Практическое применение аннотации в Java на примере создания Telegram-бота
Рефлексия в Java — это специальное API из стандартной библиотеки, которая позволяет получить доступ к информации о программе во время выполнения.
Большинство программ так или иначе пользуются рефлексией в различных его видах, ведь его возможности трудно уместить в одной статье.
Многие ответы заканчиваются на этом, но что более важно, это понимание вообще концепции рефлексии. Мы гонимся за короткими ответами на вопросы, чтобы успешно пройти собеседование, но не понимаем основы — откуда это взялось и что именно понимать под рефлексией.
В этой статье мы коснемся всех этих вопросов применительно к аннотациям и на живом примере увидим как использовать, находить и писать свою.
Рефлексия
Я считаю, что ошибочно будет думать, что рефлексия в Java ограничивается лишь каким-то пакетом в стандартной библиотеке. Поэтому предлагаю рассмотреть его как термин, не привязывая конкретному пакету.
Reflection vs Introspection
Наряду с рефлексией также есть понятие интроспекции. Интроспекция — это способность программы получить данные о типе и других свойствах объекта. Например, это instanceof
:
if (obj instanceof Cat) {
Cat cat = (Cat) obj;
cat.meow();
}
Это очень сильный метод, без чего Java не была бы такой, какая она есть. Тем не менее дальше получения данных он не уходит, и в дело вступает рефлексия.
Некоторые возможности рефлексии
Если говорить конкретнее, то рефлексия — это возможность программы исследовать себя во время выполнения и с помощью неё изменять своё поведение.
Поэтому пример, показанный выше, является не рефлексией, а лишь интроспекцией типа объекта. Но что же тогда является рефлексией? Например, создание класса или вызов метода, но весьма своеобразным способом. Ниже приведу пример.
Представим, что у нас нет никаких знаний о классе, который мы хотим создать, а лишь информация, где он находится. В таком случае мы не можем создать класс очевидным путём:
Object obj = new Cat(); // а куда кошка пропала?
Воспользуемся рефлексией и создадим экземпляр класса:
Object obj = Class.forName("complete.classpath.MyCat").newInstance();
Давайте также через рефлексию вызовем его метод:
Method m = obj.getClass().getDeclaredMethod("meow");
m.invoke(obj);
От теории к практике:
import java.lang.reflect.Method;
import java.lang.Class;
public class Cat {
public void meow() {
System.out.println("Meow");
}
public static void main(String[] args) throws Exception {
Object obj = Class.forName("Cat").newInstance();
Method m = obj.getClass().getDeclaredMethod("meow");
m.invoke(obj);
}
}
Поиграть с ним можно в Jdoodle.
Несмотря на простоту, в этом коде происходит довольно много сложных вещей, и зачастую программисту не хватает лишь просто использования getDeclaredMethod and then invoke
.
Вопрос #1
Почему в invoke методе в примере сверху мы должны передавать экземпляр объекта?
Далее углубляться я не буду, так как мы уйдём далеко от темы. Вместо этого я оставлю ссылку на статью старшего коллеги Тагира Валеева.
Аннотации
Важной частью языка Java являются аннотации. Это некоторый дескриптор, который можно повесить на класс, поле или метод. Например, вы могли видеть аннотацию @Override
:
public abstract class Animal {
abstract void doSomething();
}
public class Cat extends Animal {
@Override
public void doSomething() {
System.out.println("Meow");
}
}
Задумывались ли вы, как оно работает? Если не знаете, то, прежде чем читать дальше, попробуйте догадаться.
Типы аннотаций
Рассмотрим вышеприведённую аннотацию:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
@Target
— указывает к чему применима аннотация. В данном случае, к методу.
@Retention
— длительность жизни аннотации в коде (не в секундах, разумеется).
@interface
— является синтаксисом для создания аннотаций.
Если с первым и последним все более менее понятно (подробнее см. @Target
в документации), то @Retention
давайте разберем сейчас, так как он поможет разделить аннотации на несколько типов, что очень важно понимать.
Эта аннотация может принимать три значения:
В первом случае аннотация запишется в байт-код вашего кода, но не должна сохраняться виртуальной машиной во время выполнения.
Во втором случае аннотация будет доступна и во время выполнения, благодаря чему мы сможем её обработать, например получить все классы, которые имеют данную аннотацию.
В третьем случае аннотация будет удалена компилятором (её не будет в байт-коде). Обычно это бывают аннотации, которые полезны только для компилятора.
Возвращаясь к аннотации @Override
, мы видим, что она имеет RetentionPolicy.SOURCE
что в общем-то логично, учитывая, что он используется только компилятором. В рантайме эта аннотация действительно ничего полезного не дает.
SuperCat
Попробуем добавить свою аннотацию (это здорово нам пригодится во время разработки).
abstract class Cat {
abstract void meow();
}
public class Home {
private class Tom extends Cat {
@Override
void meow() {
System.out.println("Tom-style meow!"); // <---
}
}
private class Alex extends Cat {
@Override
void meow() {
System.out.println("Alex-style meow!"); // <---
}
}
}
Пусть у нас в доме будет два котика: Том и Алекс. Создадим аннотацию для суперкотика:
@Target(ElementType.TYPE) // чтобы использовать для класса
@Retention(RetentionPolicy.RUNTIME) // хотим чтобы наша аннотация дожила до рантайма
@interface SuperCat {
}
// ...
@SuperCat // <---
private class Alex extends Cat {
@Override
void meow() {
System.out.println("Alex-style meow!");
}
}
// ...
При этом Тома мы оставим обычным котом (мир несправедлив). Теперь попробуем получить классы, которые были аннотированы данным элементом. Было бы неплохо иметь такой метод у самого класса аннотации:
Set> classes = SuperCat.class.getAnnotatedClasses();
Но, к сожалению, такого пока метода нет. Тогда как нам найти эти классы?
ClassPath
Это параметр, который указывает на пользовательские классы.
Надеюсь, вы с ними знакомы, а если нет, то спешите изучить это, так как это одна из фундаментальных вещей.
Итак, узнав, где хранятся наши классы, мы сможем их загрузить через ClassLoader и проверить классы на наличие данной аннотации. Сразу приступим к коду:
public static void main(String[] args) throws ClassNotFoundException {
String packageName = "com.apploidxxx.examples";
ClassLoader classLoader = Home.class.getClassLoader();
String packagePath = packageName.replace('.', '/');
URL urls = classLoader.getResource(packagePath);
File folder = new File(urls.getPath());
File[] classes = folder.listFiles();
for (File aClass : classes) {
int index = aClass.getName().indexOf(".");
String className = aClass.getName().substring(0, index);
String classNamePath = packageName + "." + className;
Class> repoClass = Class.forName(classNamePath);
Annotation[] annotations = repoClass.getAnnotations();
for (Annotation annotation : annotations) {
if (annotation.annotationType() == SuperCat.class) {
System.out.println(
"Detected SuperCat!!! It is " + repoClass.getName()
);
}
}
}
}
Не советую использовать это в вашей программе. Код приведён только для ознакомительных целей!
Этот пример показателен, но используется только для учебных целей из-за этого:
Class> repoClass = Class.forName(classNamePath);
Дальше мы узнаем, почему. А пока разберём по строчкам весь код сверху:
// ...
// пакет в котором мы сейчас находимся
String packageName = "com.apploidxxx.examples";
// Загрузчик классов, чтобы получить наши классы из байт-кода
ClassLoader classLoader = Home.class.getClassLoader();
// com.apploidxxx.examples -> com/apploidxxx/examples
String packagePath = packageName.replace('.', '/');
URL urls = classLoader.getResource(packagePath);
File folder = new File(urls.getPath());
// Наши классы в виде файлов
File[] classes = folder.listFiles();
// ...
Чтобы разобраться, откуда мы берём эти файлы, рассмотрим JAR-архив, который создаётся, когда мы запускаем приложение:
├───com
│ └───apploidxxx
│ └───examples
│ Cat.class
│ Home$Alex.class
│ Home$Tom.class
│ Home.class
│ Main.class
│ SuperCat.class
Таким образом, classes
— это только наши скомпилированные файлы в виде байт-кода. Тем не менее File
— это ещё не загруженный файл, мы только знаем, где они находятся, но мы пока по-прежнему не можем видеть, что находится внутри них.
Поэтому загрузим каждый файл:
for (File aClass : classes) {
// имя файла, на самом деле, Home.class, Home$Alex.class и тд
// поэтому нам нужно избавиться от .class и получить путь к файлу
// как к объекту внутри Java
int index = aClass.getName().indexOf(".");
String className = aClass.getName().substring(0, index);
String classNamePath = packageName + "." + className;
// classNamePath = com.apploidxxx.examples.Home
Class> repoClass = Class.forName(classNamePath);
}
Всё, что сделано ранее, было только для того, чтобы вызвать этот метод Class.forName, который загрузит необходимый нам класс. Итак, финальная часть — это получение всех аннотаций, использованных на класс repoClass, а затем проверка, являются ли они аннотацией @SuperCat
:
Annotation[] annotations = repoClass.getAnnotations();
for (Annotation annotation : annotations) {
if (annotation.annotationType() == SuperCat.class) {
System.out.println(
"Detected SuperCat!!! It is " + repoClass.getName()
);
}
}
output: Detected SuperCat!!! It is com.apploidxxx.examples.Home$Alex
И готово! Теперь, когда у нас есть сам класс, то мы получаем доступ ко всем методам рефлексии.
Рефлексируем
Как и в примере сверху, мы можем просто создать новый экземпляр нашего класса. Но перед этим разберём несколько формальностей.
- Во-первых, кошкам надо где-то жить, поэтому им нужен дом. В нашем случае они не могут существовать без дома.
- Во-вторых, создадим список суперкотов.
List superCats = new ArrayList<>();
final Home home = new Home(); // дом, где будут жить наши котики
Итак, обработка обретает финальную форму:
for (Annotation annotation : annotations) {
if (annotation.annotationType() == SuperCat.class) {
Object obj = repoClass
.getDeclaredConstructor(Home.class)
.newInstance(home);
superCats.add((Cat) obj);
}
}
И снова рубрика вопросов:
Вопрос #2
Что будет, если мы пометим@SuperCat
класс, который не наследуется отCat
?Вопрос #3
Почему нам нужен конструктор, который принимает тип аргументаHome
?
Подумайте пару минут, а затем сразу разберём ответы:
Ответ #2: Будет ClassCastException
, так как сама аннотация @SuperCat
не гарантирует того, что класс, помеченный этой аннотацией, наследует что-то или имплементирует.
Вы можете проверить это, убрав extends Cat
у Alex. Заодно вы убедитесь в том, насколько полезной может быть аннотация @Override
.
Ответ #3: Кошкам нужен дом, потому что они являются внутренними классами. Всё в рамках спецификации The Java Language Specification глава 15.9.3.
Тем не менее вы можете избежать этого, просто сделав эти классы статическими. Но при работе с рефлексией вы часто будете сталкиваться с такого рода вещами. И вам на самом деле не нужно для этого досконально знать спецификацию Java. Эти вещи достаточно логичны, и можно додуматься самому, почему мы должны передавать в конструктор экземпляр родительского класса, если он non-static
.
Подведём итоги и получим: Home.java
package com.apploidxxx.examples;
import java.io.File;
import java.lang.annotation.*;
import java.lang.reflect.InvocationTargetException;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface SuperCat {
}
abstract class Cat {
abstract void meow();
}
public class Home {
public class Tom extends Cat {
@Override
void meow() {
System.out.println("Tom-style meow!");
}
}
@SuperCat
public class Alex extends Cat {
@Override
void meow() {
System.out.println("Alex-style meow!");
}
}
public static void main(String[] args) throws Exception {
String packageName = "com.apploidxxx.examples";
ClassLoader classLoader = Home.class.getClassLoader();
String packagePath = packageName.replace('.', '/');
URL urls = classLoader.getResource(packagePath);
File folder = new File(urls.getPath());
File[] classes = folder.listFiles();
List superCats = new ArrayList<>();
final Home home = new Home();
for (File aClass : classes) {
int index = aClass.getName().indexOf(".");
String className = aClass.getName().substring(0, index);
String classNamePath = packageName + "." + className;
Class> repoClass = Class.forName(classNamePath);
Annotation[] annotations = repoClass.getAnnotations();
for (Annotation annotation : annotations) {
if (annotation.annotationType() == SuperCat.class) {
Object obj = repoClass
.getDeclaredConstructor(Home.class)
.newInstance(home);
superCats.add((Cat) obj);
}
}
}
superCats.forEach(Cat::meow);
}
}
output: Alex-style meow!
Так что не так с Class.forName
?
Сам он как раз-таки делает всё, что от него нужно. Тем не менее мы его используем неправильно.
Представьте себе, что вы работаете над проектов в котором 1000 и больше классов (всё-таки на Java пишем). И представьте, что вы будете загружать каждый класс, который найдёте в classPath. Сами понимаете, что память и остальные ресурсы JVM не резиновые.
Способы работы с аннотациями
Если бы не было другого способа работать с аннотациями, то использование их в качестве меток класса, как, например, в Spring, было бы весьма и весьма спорным.
Но всё же Spring вроде работает. Неужели из-за них моя программа такая медленная? К сожалению или к счастью, нет. Spring работает исправно (в этом плане), потому что использует несколько иной способ для работы с ними.
Прямо в байт-код
Все (надеюсь) так или иначе имеют представление, что такое байт-код. В нём хранится вся информация о наших классах и их метаданных (в том числе аннотаций).
Пора время вспомнить наш RetentionPolicy
. В прошлом примере мы смогли найти эту аннотацию, потому что указали, что она является рантаймовой аннотацией. Следовательно, она должна храниться в байт-коде.
Так почему бы нам её просто не прочитать (да, из байт-кода)? Но здесь я не буду реализовывать программу для её чтения из байт-кода, так как это заслуживает отдельной статьи. Впрочем, вы сами можете это сделать — это будет отличной практикой, которая закрепит материал статьи.
Для ознакомления с байт-кодом вы можете начать с моей статьи. Там я описываю базовые вещи байт-кода с программой Hello World! Статья будет полезна, даже если вы не собираетесь напрямую работать с байт-кодом. В нем описываются фундаментальные моменты, которые помогут ответить на вопрос: почему именно так?
После этого добро пожаловать на официальную спецификацию JVM. Если вы не хотите разбирать байт-код вручную (по байтам), то посмотрите в сторону таких библиотек, как ASM и Javassist.
Reflections
Reflections — библиотека с WTFPL лицензией, которая позволяет делать с ней всё, что вы захотите. Довольно быстрая библиотека для различной работы с classpath и метаданными. Полезным является то, что она может сохранять информацию о уже некоторых прочитанных данных, что позволяет сэкономить время. Можете покопаться внутри и найти класс Store.
package com.apploidxxx.examples;
import org.reflections.Reflections;
import java.lang.reflect.InvocationTargetException;
import java.util.Optional;
import java.util.Set;
public class ExampleReflections {
private static final Home HOME = new Home();
public static void main(String[] args) {
Reflections reflections = new Reflections("com.apploidxxx.examples");
Set> superCats = reflections
.getTypesAnnotatedWith(SuperCat.class);
for (Class> clazz : superCats) {
toCat(clazz).ifPresent(Cat::meow);
}
}
private static Optional toCat(Class> clazz) {
try {
return Optional.of((Cat) clazz
.getDeclaredConstructor(Home.class)
.newInstance(HOME)
);
} catch (InstantiationException |
IllegalAccessException |
InvocationTargetException |
NoSuchMethodException e)
{
e.printStackTrace();
return Optional.empty();
}
}
}
spring-context
Я бы рекомендовал использовать библиотеку Reflections, так как внутри она работает через javassist, что свидетельствует о том, что используется чтение байт-кода, а не его загрузка.
Тем не менее существует множество других библиотек, работающих схожим образом. Их достаточно много, но сейчас я хочу разобрать лишь один из них — это spring-context
. Он, может быть, лучше первого, когда вы разрабатываете бота в среде Spring. Но и здесь есть пара нюансов.
Если ваши классы — это по сути managed beans, то есть находятся в контейнере Spring, то вам незачем повторно их сканировать. Вы просто можете получить доступ к этим бинам из самого контейнера.
Другое дело — если вы хотите, чтобы ваши помеченные классы были бинами, тогда вы можете сделать это вручную через ClassPathScanningCandidateComponentProvider
, который работает через ASM.
Опять же, довольно редко вы должны будете использовать именно такой метод, но как вариант его стоит рассмотреть.
Я писал на нём бота для ВК. Вот репозиторий, с которым вы можете ознакомиться, но писал я его давно, а когда зашёл посмотреть, чтобы вставить ссылку в статью, то увидел, что через VK-Java-SDK я получаю сообщения с неинициализированными полями, хотя раньше всё работало.
Самое забавное, что я даже версию SDK не менял, поэтому, если вы найдёте, в чем была причина, — буду благодарен. Тем не менее загрузка самих команд работает нормально, и это именно то, на что вы можете посмотреть, если хотите увидеть пример работы со spring-context
.
Команды в нём представляют из себя вот что:
@Command(value = "hello", aliases = {"привет", "йоу"})
public class Hello implements Executable {
public BotResponse execute(Message message) throws Exception {
return BotResponseFactoryUtil.createResponse("hello-hello",
message.peerId);
}
}
Примеры кода с аннотацией SuperCat
вы можете найти в этом репозитории.
Практическое применение аннотаций в создании Телеграм-бота
Всё это было довольно длинным, но необходимым вступлением для работы с аннотациями. Далее, мы будем реализовывать бота, но цель статьи — это не мануал к его созданию. Это практическое применение аннотаций. Здесь могли быть что угодно: от консольных приложений до этих же самых ботов для вк, телеги и прочего.
Также здесь осознанно не будут выполнятся какие-то сложные проверки. Например, до этого в примерах не было никаких чеков на null или правильной обработки ошибок, не говоря уже об их логировании.
Все это делается для упрощения кода. Поэтому, если вы будете брать код из примеров, то не ленитесь доработать его, так вы лучше его поймете и настроите под свои нужды.
Мы будем использовать библиотеку TelegramBots с MIT лицензией для работы с API телеграма. Вы же можете использовать любую другую. Я выбрал её, потому что она могла работать как «c» (имеет версию со стартёром), так и «без» спринг-бута.
Собственно, я не хочу также усложнять код, добавляя какую-то абстракцию, если хотите, то можете сделать что-то универсальное, но подумайте, стоит ли оно того, поэтому для этой статьи мы часто будем использовать конкретные классы из этих библиотек, привязывая наш код к ним.
Reflections
Первый бот на очереди — это бот, написанный на библиотеке reflections, без Spring. Будем разбирать не всё подряд, а лишь основные моменты, в особенности нас интересует обработка аннотаций. До разбора в статье вы сами можете разобраться в его работе в моём репозитории.
Во всех примерах будем придерживаться того, что бот состоит из нескольких команд, причём эти команды мы не будем загружать вручную, а просто будем добавлять аннотации. Вот пример команды:
@Handler("/hello")
public class HelloHandler implements RequestHandler {
private static final Logger log = LoggerFactory
.getLogger(HelloHandler.class);
@Override
public SendMessage execute(Message message) {
log.info("Executing message from : " + message.getText());
return SendMessage.builder()
.text("Yaks")
.chatId(String.valueOf(message.getChatId()))
.build();
}
}
@Retention(RetentionPolicy.RUNTIME)
public @interface Handler {
String value();
}
В этом случае параметр /hello
будет записан в value
в аннотации. value — это что-то вроде аннотации по умолчанию. То есть @Handler("/hello")
= @Handler(value = "/hello")
.
Также добавим логгеры. Их мы будем вызвать либо до обработки запроса, либо после, а также комбинировать их:
@Retention(RetentionPolicy.RUNTIME)
public @interface Log {
String value() default ".*"; // regex
ExecutionTime[] executionTime() default ExecutionTime.BEFORE;
}
default` означает, что значение будет применено, если не будет указан `value
@Log
public class LogHandler implements RequestLogger {
private static final Logger log = LoggerFactory
.getLogger(LogHandler.class);
@Override
public void execute(Message message) {
log.info("Just log a received message : " + message.getText());
}
}
Но также мы можем добавить параметр, чтобы логгер срабатывал при определённых сообщениях:
@Log(value = "/hello")
public class HelloLogHandler implements RequestLogger {
public static final Logger log = LoggerFactory
.getLogger(HelloLogHandler.class);
@Override
public void execute(Message message) {
log.info("Received special hello command!");
}
}
Или срабатывал после обработки запроса:
@Log(executionTime = ExecutionTime.AFTER)
public class AfterLogHandler implements RequestLogger {
private static final Logger log = LoggerFactory
.getLogger(AfterLogHandler.class);
@Override
public void executeAfter(Message message, SendMessage sendMessage) {
log.info("Bot response >> " + sendMessage.getText());
}
}
Или и там, и там:
@Log(executionTime = {ExecutionTime.AFTER, ExecutionTime.BEFORE})
public class AfterAndBeforeLogger implements RequestLogger {
private static final Logger log = LoggerFactory
.getLogger(AfterAndBeforeLogger.class);
@Override
public void execute(Message message) {
log.info("Before execute");
}
@Override
public void executeAfter(Message message, SendMessage sendMessage) {
log.info("After execute");
}
}
Мы можем делать такое, так как executionTime
принимает массив значений. Принцип работы прост, поэтому приступим к обработке этих аннотаций:
Set> annotatedCommands =
reflections.getTypesAnnotatedWith(Handler.class);
final Map commandsMap = new HashMap<>();
final Class requiredInterface = RequestHandler.class;
for (Class> clazz : annotatedCommands) {
if (LoaderUtils.isImplementedInterface(clazz, requiredInterface)) {
for (Constructor> c : clazz.getDeclaredConstructors()) {
//noinspection unchecked
Constructor castedConstructor =
(Constructor) c;
commandsMap.put(extractCommandName(clazz),
OBJECT_CREATOR.instantiateClass(castedConstructor));
}
} else {
log.warn("Command didn't implemented: "
+ requiredInterface.getCanonicalName());
}
}
// ...
private static String extractCommandName(Class> clazz) {
Handler handler = clazz.getAnnotation(Handler.class);
if (handler == null) {
throw new
IllegalArgumentException(
"Passed class without Handler annotation"
);
} else {
return handler.value();
}
}
По сути, мы просто создаём мапу с именем команды, которую берём из значения value
в аннотации. Исходный код здесь.
То же самое делаем с Log, только логгеров с одинаковыми паттернами может быть несколько, поэтому мы чуть меняем нашу структуру данных:
Set> annotatedLoggers = reflections.getTypesAnnotatedWith(Log.class);
final Map> commandsMap = new HashMap<>();
final Class requiredInterface = RequestLogger.class;
for (Class> clazz : annotatedLoggers) {
if (LoaderUtils.isImplementedInterface(clazz, requiredInterface)) {
for (Constructor> c : clazz.getDeclaredConstructors()) {
//noinspection unchecked
Constructor castedConstructor =
(Constructor) c;
String name = extractCommandName(clazz);
commandsMap.computeIfAbsent(name, n -> new HashSet<>());
commandsMap
.get(extractCommandName(clazz))
.add(OBJECT_CREATOR.instantiateClass(castedConstructor));
}
} else {
log.warn("Command didn't implemented: "
+ requiredInterface.getCanonicalName());
}
}
На каждый паттерн приходится несколько логгеров. Остальное всё так же.
Теперь в самом боте нам нужно настроить executionTime
и перенаправлять запросы на эти классы:
public final class CommandService {
private static final Map commandsMap
= new HashMap<>();
private static final Map> loggersMap
= new HashMap<>();
private CommandService() {
}
public static synchronized void init() {
initCommands();
initLoggers();
}
private static void initCommands() {
commandsMap.putAll(CommandLoader.readCommands());
}
private static void initLoggers() {
loggersMap.putAll(LogLoader.loadLoggers());
}
public static RequestHandler serve(String message) {
for (Map.Entry entry : commandsMap.entrySet()) {
if (entry.getKey().equals(message)) {
return entry.getValue();
}
}
return msg -> SendMessage.builder()
.text("Команда не найдена")
.chatId(String.valueOf(msg.getChatId()))
.build();
}
public static Set findLoggers(
String message,
ExecutionTime executionTime
) {
final Set matchedLoggers = new HashSet<>();
for (Map.Entry> entry:loggersMap.entrySet()) {
for (RequestLogger logger : entry.getValue()) {
if (containsExecutionTime(
extractExecutionTimes(logger), executionTime
))
{
if (message.matches(entry.getKey()))
matchedLoggers.add(logger);
}
}
}
return matchedLoggers;
}
private static ExecutionTime[] extractExecutionTimes(RequestLogger logger) {
return logger.getClass().getAnnotation(Log.class).executionTime();
}
private static boolean containsExecutionTime(
ExecutionTime[] times,
ExecutionTime executionTime
) {
for (ExecutionTime et : times) {
if (et == executionTime) return true;
}
return false;
}
}
public class DefaultBot extends TelegramLongPollingBot {
private static final Logger log = LoggerFactory.getLogger(DefaultBot.class);
public DefaultBot() {
CommandService.init();
log.info("Bot initialized!");
}
@Override
public String getBotUsername() {
return System.getenv("BOT_NAME");
}
@Override
public String getBotToken() {
return System.getenv("BOT_TOKEN");
}
@Override
public void onUpdateReceived(Update update) {
try {
Message message = update.getMessage();
if (message != null && message.hasText()) {
// run "before" loggers
CommandService
.findLoggers(message.getText(), ExecutionTime.BEFORE)
.forEach(logger -> logger.execute(message));
// command execution
SendMessage response;
this.execute(response = CommandService
.serve(message.getText())
.execute(message));
// run "after" loggers
CommandService
.findLoggers(message.getText(), ExecutionTime.AFTER)
.forEach(logger -> logger.executeAfter(message, response));
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
Лучше всего узнать код самому и посмотреть в репозитории, а ещё лучше открыть его через IDE. Этот репозиторий подходит для начала работы и ознакомления, но в качестве бота он недостаточно хорош.
Во-первых, между командами не хватает достаточного уровня абстракции. То есть с каждой команды вы можете вернуть только SendMessage
. Это можно побороть, использовав более высокий уровень абстракции, например BotApiMethodMessage
, но и это на самом деле не решит все проблемы.
Во-вторых, сама библиотека TelegramBots
, как мне кажется, не особо ориентирована на такую работу (архитектуру) бота. Если же вы будете разрабатывать бота именно на этой библиотеке, то можете использовать Ability Bot
, который указан в wiki самой библиотеки. Но очень хочется увидеть полноценную библиотеку с такой архитектурой. Поэтому можете начать писать свою библиотеку!
Спринговый бот
Это приобретает больше смысла при работе с экосистемой спринга:
- Работа через аннотации не нарушает общей концепции работы спринг-контейнера.
- Мы можем не создавать команды сами, а получить их из контейнера, пометив наши команды как бины.
- Получаем отличный DI от спринга.
Вообще использование спринга в качестве каркаса для бота — это тема отдельного разговора. Ведь многие могут подумать, что это слишком тяжело для бота (хотя, скорее всего, они и на Java ботов не пишут).
Но я думаю, что, спринг — это хорошая среда не только для энтерпрайза/веб-приложений. Просто он содержит в себе очень много как официальных, так и пользовательских библиотек для своей экосистемы (под спрингом имеется в виду Spring Boot).
И, самое главное, он позволяет реализовать очень много паттернов различными способами, предоставляемыми контейнером.
Реализация
Что ж, приступим к самому боту.
Поскольку мы пишем на спринговом стеке, то мы можем не создавать свой контейнер команд, а воспользоваться уже существующим в спринге. Их можно не сканировать, а получить из IoC контейнера.
Более самостоятельные разработчики могут сразу приступить к чтению кода.
Здесь же я разберу именно чтение команд, хотя в самом репозитории есть ещё пара интересных моментов, которые вы можете рассмотреть самостоятельно.
Реализация очень похожа на бота через Reflections, поэтому аннотации те же.
ObjectLoader.java
@Service
public class ObjectLoader {
private final ApplicationContext applicationContext;
public ObjectLoader(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
public Collection
CommandLoader.java
public Map readCommands() {
final Map commandsMap = new HashMap<>();
for (Object obj : objectLoader.loadObjectsWithAnnotation(Handler.class)) {
if (obj instanceof RequestHandler) {
RequestHandler handler = (RequestHandler) obj;
commandsMap.put(extractCommandName(handler.getClass()), handler);
}
}
return commandsMap;
}
В отличие от прошлого примера здесь уже используется более высокий уровень абстракции для интерфейсов, что, конечно же, хорошо. Также нам не нужно самим создавать экземпляры команд.
Подведём итоги
Только вам решать, что лучше подойдёт под вашу задачу. Я разобрал условно три случая для примерно похожих ботов:
- Reflections.
- Spring-Context (без Spring).
- ApplicationContext из Spring.
Тем не менее я могу дать вам совет, основываясь на своём опыте:
- Подумайте, нужен ли вам Spring. Он даёт мощный IoC контейнер и возможности экосистемы, но за всё приходится платить. Обычно я рассуждаю так: если нужны база данных и быстрый старт, то Spring Boot вам нужен. Если же бот достаточно прост, то можно обойтись и без него.
- Если же вам не нужны сложные зависимости, то смело используйте Reflections.
Реализация, например, JPA без Spring Data мне кажется довольно трудоёмкой задачей, хотя вы также можете посмотреть на альтернативы в виде micronaut или quarkus, но о них я только наслышан и не имею достаточного опыта, чтобы что-то советовать относительно этого.
Если вы приверженец более чистого подхода с нуля даже без JPA, то посмотрите на этого бота, который работает через JDBC через ВК и Телеграм.
Там вы увидите много записей вида:
PreparedStatement stmt = connection.prepareStatement("UPDATE alias SET aliases=?::jsonb WHERE vkid=?");
stmt.setString(1, aliases.toJSON());
stmt.setInt(2, vkid);
stmt.execute();
Но код имеет двухгодичную давность, так что не советую оттуда брать все паттерны. И вообще я бы не рекомендовал таким заниматься вовсе (работу через JDBC).
Также лично мне не особо нравится работать напрямую с Hibernate. Я уже имел печальный опыт писать DAO
и HibernateSessionFactoryUtil
(те, кто писал, поймут, о чём я).
Что касается самой статьи, я пытался писать кратко, но достаточно, чтобы, имея в руках только эту статью, вы могли начать разработку. Всё-таки это не глава в книге, а статья на Хабре. Глубже изучить аннотации и вообще рефлексию вы сможете сами, например, создавая того же бота.
Всем удачи! И не забывайте о промокоде HABR, который дает дополнительную скидку 10% к той, что указана на баннере.
КУРСЫ