Пишете телеграмм ботов? Ваша производительность разработки желает лучшего? Ищете чего-то нового? Тогда прошу под кат.
Идея заключается в следующем: слямзить архитектуру 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 по умолчанию — синглтон, значит инициализироваться они будут со стартом контекста!
@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)
Noiwex
13.08.2017 12:47-14Зачем это на Java писать? Есть гораздо лучшие средства для этого, например, Node.js.
alex4321
13.08.2017 13:21Я, конечно, больше по питону, но уж лучше жаба, чем жабоскрипт :-)
з.ы. а если серьёзно — какие плюсы тут даст нода, которые не дадут другие средства? Единственное, что приходит в голову — асинхронщина, так её куда только не завезли
nebachadnezzair
13.08.2017 15:12+2Сомнительное решение.
1. В методе TelegramUpdateHandlerBeanPostProcessor.postProcessAfterInitialization аргумент bean — это прокся, у которой на методах уже нет никаких аннотаций.
Решение: заменить обращения к bean на botControllerMap.get(beanName)
2. TelegramUpdateHandlerBeanPostProcessor вызывается в середине процесса настройки бинов. Сохраняемый «контроллер» не факт, что полностью настроен.
Решение: наполнять контейнер только после инициализации всего спринга (например, через ApplicationListener). Или сохранять не конкретный «контроллер», а его beanName, чтобы в момент обработки брать настроенный «контроллер» из контекста спринга.
3. Если «контроллер» будет иметь скоуп отличный от синглтона, то это решение тоже работать не будет, так как его создание будет происходить не при старте спринга, а когда-то.
Решение: наполнять контейнер на этапе BeanFactoryPostProcessor, сохраняя тройки path-beanName-methodPqDn Автор
13.08.2017 15:241) Спасибо за замечание, поправлю. Хотя в данном примере АОП не используется и соответственно бины в прокси не оборачиваются
2) На самом деле для нашего случая в каком месте будут обрабатываться наши бины неважно.
Также я специально реализовал интерфейс Ordered, в методе которого я указал низкий приоритет, чтобы TelegramUpdateHandlerBeanPostProcessor обрабатывал бины в конце.
3) Наверно для данной целевой области не имеет смысла. Ну а решить эту таску всегда можно при необходимости.
eugenehr
14.08.2017 11:00+1Очень даже Spring- и Java- way. Я бы в дополнение к этому добавил еще новый Spring Scope для привязки бинов к пользователям и поддержки stateful диалогов.
SpringConfig.javapublic 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); } }
Virviil
14.08.2017 13:20Я не пишу на Java, но
TelegramUpdateHandlerBeanPostProcessor
это шутка такая?
Это набирать вообще реально? Или в Java это так и надо делать...Borz
14.08.2017 17:15в нормальных IDE достаточно писать только заглавные буквы, чтобы тебе IDE предложила подставить полное название, поэтому нет заморочек на "надо имя класса покороче"
ruslanys
14.08.2017 19:59Меня единственного беспокоит вопрос сценариев? Каждый раз открываю статью про Telegram API и надеюсь, что в ней хоть капельку затронут систему сценариев.
На мой взгляд, бот, который получает одну команду и по ней сразу формирует ответ — это чрезмерно тривиальная задача. И пусть та же задача обернута здесь в зеленую упаковку Spring MVC, тем не менее, задача остается тривиальной, а бот примитивным.
Вопрос открыт: Как спроектировать гибкую систему сценариев?
Так, чтобы клиент выбрал сценарий (а программист без труда его составил), и ему предоставлялось целое дерево всевозможных вопросов, каждый из которых зависит от предыдущего и обладает полным контекстом ответов.
А в итоге, например, вызывается финальный метод, который программист должен переопределить, в котором будет получен финальный результат — ответы на все вопросы сценария.
У меня есть идеи на этот счет. Например, использование DSL от Groovy или Kotlin для описания сценариев, но вопрос все еще открыт.
Быть может, когда-нибудь закончу, да напишу статью.
А пока — было бы здорово выслушать мысли людей, подискутировать по действительно проблемной теме.
ExplosiveZ
Чувак, ну ты вообще бешеный.
vershinin
А чем плохо-то? На спринге так и пишут. Многие, правда, из спринга знают только MVC-шные аннотации да DI, но чтобы не лезть в дикую императивщину, кто-то должен сделать за вас этот FizzBuzz.