Пишете телеграмм ботов? Ваша производительность разработки желает лучшего? Ищете чего-то нового? Тогда прошу под кат.



Идея заключается в следующем: слямзить архитектуру spring mvc и перенести на telegram api.
Выглядеть должно как-то так:


@BotController
public class SimpleOkayController {
    @BotRequestMapping(value = "/ok")
    public SendMessage ok(Update update) {
        return new SendMessage()
                .setChatId(update.getMessage().getChatId())
                .setText("okay bro, okay!");
    }
}

или


Пример с бинами
@BotController
public class StartController {
    @Autowired
    private Filter shopMenu;

    @Autowired
    private PayTokenService payTokenService;

    @Autowired
    private ItemService itemService;

    @BotRequestMapping("/shop")
    public SendMessage generateInitMenu(Update update) {
            return  new SendMessage()
                    .setChatId(update.getMessage().getChatId().toString())
                    .setText("Товары моего магазинчика!")
                    .setReplyMarkup(shopMenu.getSubMenu(0L, 4L, 1L)); // <--
    }

    @BotRequestMapping(value = "/buyItem", method = BotRequestMethod.EDIT)
    public List<BotApiMethod> bayItem(Update update) {
        ....................
        Item item = itemService.findById(id); // <--

        return Arrays.asList(new EditMessageText()
                .setChatId(update.getMessage().getChatId())
                .setMessageId(update.getMessage().getMessageId())
                .setText("Подтвердите ваш выбор, в форме ниже"),

                new SendInvoice()
                        .setChatId(Integer.parseInt(update.getMessage().getChatId().toString()))
                        .setDescription(item.getDescription())
                        .setTitle(item.getName())
                        .setProviderToken(payTokenService.getPayToken())
                        ........................
                        .setPrices(item.getPrice())
        );
    }

}

Это даёт следующие преимущества:


  • Не надо писать кастомную логику для выбора обработчика сообщения от пользователя
  • Возможность инжектить разлиные бины в наш @BotController
  • Как следствие из предыдущих двух пунктов — существенное сокращение объемов кода
  • Потенциально (хотя я этого еще не сделал) аргументы кастомного метода обработчика, могут быть выражены в виде тех аргументов, которые действительно нужны!
  • Возможность создавать серьезные энтерпрайз решения, используя spring

Давайте теперь посмотрим как это можно завести в нашем проекте


Аннотации
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Component
public @interface BotController {
    String[] value() default {};
}

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface BotRequestMapping {
    String[] value() default {};
    BotRequestMethod[] method() default {BotRequestMethod.MSG};
}

Создаем свой контейнер обработчиков в виде обычной HashMap


Контейнер
public class BotApiMethodContainer {
    private static final Logger LOGGER = Logger.getLogger(BotApiMethodContainer.class);

    private Map<String, BotApiMethodController> controllerMap;

    public static BotApiMethodContainer getInstanse() {
        return Holder.INST;
    }

    public void addBotController(String path, BotApiMethodController controller) {
        if(controllerMap.containsKey(path)) throw new BotApiMethodContainerException("path " + path + " already add");
        LOGGER.trace("add telegram bot controller for path: " +  path);
        controllerMap.put(path, controller);
    }

    public BotApiMethodController getBotApiMethodController(String path) {
        return controllerMap.get(path);
    }

    private BotApiMethodContainer() {
        controllerMap = new HashMap<>();
    }

    private static class Holder{
        final static BotApiMethodContainer INST = new BotApiMethodContainer();
    }
}

В контейнере будем хранить контроллеры обертки (для пары @BotController и @BotRequestMapping)


Контроллер обертка
public abstract class BotApiMethodController {
    private static final Logger LOGGER = Logger.getLogger(BotApiMethodController.class);

    private Object bean;
    private Method method;
    private Process processUpdate;

    public BotApiMethodController(Object bean, Method method) {
        this.bean = bean;
        this.method = method;

        processUpdate = typeListReturnDetect() ? this::processList : this::processSingle;
    }

    public abstract boolean successUpdatePredicate(Update update);

    public List<BotApiMethod> process(Update update) {
        if(!successUpdatePredicate(update)) return null;

        try {
            return processUpdate.accept(update);
        } catch (IllegalAccessException | InvocationTargetException e) {
            LOGGER.error("bad invoke method", e);
        }

        return null;
    }

    boolean typeListReturnDetect() {
        return List.class.equals(method.getReturnType());
    }

    private List<BotApiMethod> processSingle(Update update) throws InvocationTargetException, IllegalAccessException {
        BotApiMethod botApiMethod = (BotApiMethod) method.invoke(bean, update);
        return botApiMethod != null ? Collections.singletonList(botApiMethod) : new ArrayList<>(0);
    }

    private List<BotApiMethod> processList(Update update) throws InvocationTargetException, IllegalAccessException {
        List<BotApiMethod> botApiMethods = (List<BotApiMethod>) method.invoke(bean, update);
        return botApiMethods != null ? botApiMethods : new ArrayList<>(0);
    }

    private interface Process{
        List<BotApiMethod> accept(Update update) throws InvocationTargetException, IllegalAccessException;
    }
}

Теперь когда у нас есть данная кодовая база возникает вопрос: как Spring заставить автоматически наполнять контейнер, чтобы мы могли им пользоваться?


Для этого реализуем специальный бин — BeanPostProcessor. Это дает возможность отлавливать бины во время их инициализации. Наши контроллеры имеют scope по умолчанию — синглтон, значит инициализироваться они будут со стартом контекста!


TelegramUpdateHandlerBeanPostProcessor
@Component
public class TelegramUpdateHandlerBeanPostProcessor implements BeanPostProcessor, Ordered {
    private static final Logger LOGGER = Logger.getLogger(TelegramUpdateHandlerBeanPostProcessor.class);

    private BotApiMethodContainer container = BotApiMethodContainer.getInstanse();
    private Map<String, Class> botControllerMap = new HashMap<>();

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        Class<?> beanClass = bean.getClass();
        if (beanClass.isAnnotationPresent(BotController.class))
            botControllerMap.put(beanName, beanClass);
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if(!botControllerMap.containsKey(beanName)) return bean;

        Object original = botControllerMap.get(beanName); 
        Arrays.stream(original.getClass().getMethods())
                .filter(method -> method.isAnnotationPresent(BotRequestMapping.class))
                .forEach((Method method) -> generateController(bean, method));
        return bean;
    }

    private void generateController(Object bean, Method method) {
        BotController botController = bean.getClass().getAnnotation(BotController.class);
        BotRequestMapping botRequestMapping = method.getAnnotation(BotRequestMapping.class);

        String path = (botController.value().length != 0 ? botController.value()[0] : "")
                    + (botRequestMapping.value().length != 0 ? botRequestMapping.value()[0] : "");

        BotApiMethodController controller = null;

        switch (botRequestMapping.method()[0]){
            case MSG:
                controller = createControllerUpdate2ApiMethod(bean, method);
                break;
            case EDIT:
                controller = createProcessListForController(bean, method);
                break;
            default:
                break;
        }

        if (controller != null) {
            container.addBotController(path, controller);
        }
    }

    private BotApiMethodController createControllerUpdate2ApiMethod(Object bean, Method method){
        return new BotApiMethodController(bean, method) {
            @Override
            public boolean successUpdatePredicate(Update update) {
                return update!=null && update.hasMessage() && update.getMessage().hasText();
            }
        };
    }

    private BotApiMethodController createProcessListForController(Object bean, Method method){
        return new BotApiMethodController(bean, method) {
            @Override
            public boolean successUpdatePredicate(Update update) {
                return update!=null && update.hasCallbackQuery() && update.getCallbackQuery().getData() != null;
            }
        };
    }

    @Override
    public int getOrder() {
        return 100;
    }
}

Инициализируем контекст, в котором прописаны все наши бины и — вуаля! Подбирать обработчики для сообщений можно, например, так:


Выбор обработчика
public class SelectHandle {
    private static BotApiMethodContainer container = BotApiMethodContainer.getInstanse();

    public static BotApiMethodController getHandle(Update update) {
        String path;
        BotApiMethodController controller = null;

        if (update.hasMessage() && update.getMessage().hasText()) {
            path = update.getMessage().getText().split(" ")[0].trim();
            controller = container.getControllerMap().get(path);
            if (controller == null) controller = container.getControllerMap().get("");

        } else if (update.hasCallbackQuery()) {
            path = update.getCallbackQuery().getData().split("/")[1].trim();
            controller = container.getControllerMap().get(path);
        }

        return controller != null ? controller : new FakeBotApiMethodController();
    }
}

Постскриптум
Telegram развивается очень стремительно. Используя ботов мы можем организовывать свои магазины, давать команды различным своим интернет-вещам, организовывать блог-каналы и многое многое другое. А самое главное, что всё это в едином приложении!


Ссылки:


Комментарии (20)


  1. ExplosiveZ
    12.08.2017 23:23
    +7

    Чувак, ну ты вообще бешеный.


    1. vershinin
      13.08.2017 15:09
      +3

      А чем плохо-то? На спринге так и пишут. Многие, правда, из спринга знают только MVC-шные аннотации да DI, но чтобы не лезть в дикую императивщину, кто-то должен сделать за вас этот FizzBuzz.


  1. Noiwex
    13.08.2017 12:47
    -14

    Зачем это на Java писать? Есть гораздо лучшие средства для этого, например, Node.js.


    1. Borz
      13.08.2017 12:55
      +2

      точно лучше? если проект на java


    1. alex4321
      13.08.2017 13:21

      Я, конечно, больше по питону, но уж лучше жаба, чем жабоскрипт :-)

      з.ы. а если серьёзно — какие плюсы тут даст нода, которые не дадут другие средства? Единственное, что приходит в голову — асинхронщина, так её куда только не завезли


  1. TimurJD
    13.08.2017 15:10

    Лучше на Node.js?! Интересно чем же лучше?


    1. sergpank
      14.08.2017 13:40
      +2

      Чем Java, естественно ))

      привет от Армянского радио


      1. TimurJD
        14.08.2017 13:41

        Тонко)


  1. scawn
    13.08.2017 15:10

    Смысл от спринга, если все равно создаем сами синглтон?


    1. PqDn Автор
      13.08.2017 15:12
      +1

      Контейнер, который является синглтоном, можно скрыть от конечной целевой разработки


  1. nebachadnezzair
    13.08.2017 15:12
    +2

    Сомнительное решение.
    1. В методе TelegramUpdateHandlerBeanPostProcessor.postProcessAfterInitialization аргумент bean — это прокся, у которой на методах уже нет никаких аннотаций.
    Решение: заменить обращения к bean на botControllerMap.get(beanName)
    2. TelegramUpdateHandlerBeanPostProcessor вызывается в середине процесса настройки бинов. Сохраняемый «контроллер» не факт, что полностью настроен.
    Решение: наполнять контейнер только после инициализации всего спринга (например, через ApplicationListener). Или сохранять не конкретный «контроллер», а его beanName, чтобы в момент обработки брать настроенный «контроллер» из контекста спринга.
    3. Если «контроллер» будет иметь скоуп отличный от синглтона, то это решение тоже работать не будет, так как его создание будет происходить не при старте спринга, а когда-то.
    Решение: наполнять контейнер на этапе BeanFactoryPostProcessor, сохраняя тройки path-beanName-method


    1. PqDn Автор
      13.08.2017 15:24

      1) Спасибо за замечание, поправлю. Хотя в данном примере АОП не используется и соответственно бины в прокси не оборачиваются
      2) На самом деле для нашего случая в каком месте будут обрабатываться наши бины неважно.
      Также я специально реализовал интерфейс Ordered, в методе которого я указал низкий приоритет, чтобы TelegramUpdateHandlerBeanPostProcessor обрабатывал бины в конце.
      3) Наверно для данной целевой области не имеет смысла. Ну а решить эту таску всегда можно при необходимости.


  1. eugenehr
    14.08.2017 11:00
    +1

    Очень даже Spring- и Java- way. Я бы в дополнение к этому добавил еще новый Spring Scope для привязки бинов к пользователям и поддержки stateful диалогов.

    SpringConfig.java
    public class SpringConfig implements BeanFactoryPostProcessor {
    
        @Bean
        public UserScope userScope(ConfigurableListableBeanFactory beanFactory) {
            return new UserScope(beanFactory);
        }
    
        @Override
        public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
            final UserScope userScope = beanFactory.getBean(UserScope.class);
            beanFactory.registerScope(UserScope.SCOPE, userScope);
        }
    }
    


  1. Virviil
    14.08.2017 13:20

    Я не пишу на Java, но TelegramUpdateHandlerBeanPostProcessor это шутка такая?
    Это набирать вообще реально? Или в Java это так и надо делать...


    1. PqDn Автор
      14.08.2017 13:41

      К сожалению путь к простому лежит через терни сложного


    1. Borz
      14.08.2017 17:15

      в нормальных IDE достаточно писать только заглавные буквы, чтобы тебе IDE предложила подставить полное название, поэтому нет заморочек на "надо имя класса покороче"


  1. ruslanys
    14.08.2017 19:59

    Меня единственного беспокоит вопрос сценариев? Каждый раз открываю статью про Telegram API и надеюсь, что в ней хоть капельку затронут систему сценариев.

    На мой взгляд, бот, который получает одну команду и по ней сразу формирует ответ — это чрезмерно тривиальная задача. И пусть та же задача обернута здесь в зеленую упаковку Spring MVC, тем не менее, задача остается тривиальной, а бот примитивным.

    Вопрос открыт: Как спроектировать гибкую систему сценариев?
    Так, чтобы клиент выбрал сценарий (а программист без труда его составил), и ему предоставлялось целое дерево всевозможных вопросов, каждый из которых зависит от предыдущего и обладает полным контекстом ответов.
    А в итоге, например, вызывается финальный метод, который программист должен переопределить, в котором будет получен финальный результат — ответы на все вопросы сценария.

    У меня есть идеи на этот счет. Например, использование DSL от Groovy или Kotlin для описания сценариев, но вопрос все еще открыт.
    Быть может, когда-нибудь закончу, да напишу статью.
    А пока — было бы здорово выслушать мысли людей, подискутировать по действительно проблемной теме.