Пишите телеграмм ботов? Ваша производительность разработки желает лучшего? Ищите чего-то нового? Тогда прошу под кат.
Идея заключается в следующем: слямзить архитектуру 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;
Arrays.stream(bean.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);
if (controller == null) controller = container.getControllerMap().get("");
}
return controller != null ? controller : new FakeBotApiMethodController();
}
}
Постскриптум
Telegram развивается очень стремительно. Используя ботов мы можем организовывать свои магазины, давать команды различным своим интернет-вещам, организовывать блог-каналы и многое многое другое. А самое главное, что всё это в едином приложении!
Ссылки:
Автор: PqDn