На связи Сергей Кондитеров и Сергей Бушмелев, ведущие инженеры по автоматизации тестирования в компании РТЛабс. Это вторая часть статьи «Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования». Как и обещали, в данной статье мы расскажем о том, как масштабировали наш сервис, как развивали функциональность автотестов и как в итоге вышли за рамки обычного репорт-бота.
На первом этапе нами был создан бот. Он работал стабильно, пользователи получали нужные отчёты, но в какой-то момент пришла мысль, что мы привязаны к одному мессенджеру. Хотелось получить возможность осуществлять интеграцию с другими мессенджерами. В связи с этим было принято решение перевести наш монолитный сервис на микросервисную архитектуру, в которой каждый микросервис будет заниматься решением своих узконаправленных задач.
![](https://habrastorage.org/getpro/habr/upload_files/a95/bd2/24e/a95bd224eddfe4caffca11f7bd6bfb26.png)
-
2.1. Клиенты
2.2. Async-server
2.3. User-manager
2.4. Jenkins-adapter -
Функционал, не связанный с автотестами:
5.1. Отпуска
5.2. Интеграция с Jira
Архитектура
Микросервисы пишутся в связке Java 17 + Spring Boot, в качестве базы данных используется PostgreSQL. Взаимодействие между ними осуществляется при помощи Apache Kafka и REST API.
![Архитектура сервиса Архитектура сервиса](https://habrastorage.org/getpro/habr/upload_files/409/c2f/a20/409c2fa2059db3f5b0ceffbc0648c6a9.png)
Ядром всей системы является Async-server. Он занимается непосредственной обработкой команд, поступающих от клиентов в асинхронном режиме, и отправкой результатов обработки команд клиентам.
User-manager хранит информацию о зарегистрированных пользователях и о том, к выполнению каких команд у них есть доступ.
Адаптеры занимаются взаимодействием с такими системами, как Jenkins и Jira (Jenkins-adapter и Jira-adapter).
Когда пользователь отправляет сообщение боту, его запрос сначала попадает в один из клиентов, затем отправляется в Async-server, который, в свою очередь, пересылает его в User-manager. Если пользователь имеет требуемую роль, запрос обрабатывается, результаты обработки отправляются пользователю.
Описанная архитектура сервиса предоставляет возможность быстрой его интеграции с интересующим нас мессенджером или сервисом. Необходимо лишь реализовать очередной клиент — в случае, если мы хотим работать с новым мессенджером, или адаптер — если хотим добавить возможность взаимодействия с интересующим нас сервисом, например с Jira.
Основные компоненты
Рассмотрим более подробно каждый компонент архитектурного решения.
Клиенты
Первым звеном в работе сервиса являются клиенты. Это компоненты, которые нацелены на работу с конкретным мессенджером. Они занимаются выполнением следующих задач:
-
приём сообщений от пользователей
-
сбор информации о пользователях
-
отправка запросов на обработку сообщений пользователей в асинхронный сервис обработки
-
отправка результатов обработки сообщений пользователям
Клиенты отправляют сообщение от пользователей и всю необходимую информацию о них в Async-server. Отправляемая информация представлена в виде сущности:
@Getter @Setter public class UserMessage { //сообщение от пользователя private String text; //инициировано ли событие нажатием по кнопке private boolean callback; //id сообщения private String messageId //id чата private String chatId; //информация о пользователе private User originalUser; //из какого клиента было отправлено сообщение private Client client; //было ли сообщение из группового чата private boolean isGroupChat; //название чата private String channelName; } public enum Client { TELEGRAM; } @Getter @Setter public class User { //id пользователя в мессенджере private String id; //имя пользователя private String firstName; //фамилия пользователя private String lastName; //псевдоним пользователя private String userName; }
После обработки сообщения пользователя Async-server отправляет результат обратно в клиент в виде сущности:
@Getter @Setter public class Payload { /* указываем, какое сообщение должно быть по итогу POST - отправка обычного сообщения EDIT - исправление уже существующего DELETE - удаление для edit и delete надо указать originalId */ private SendMethod sendMethod; private String originalId; //id сообщения, в ответ на который необходимо прислать сообщение private String replyTo; //id чата, в который необходимо отправить сообщение private String chatId; //текстовое сообщение пользователю private String text; //клавиатура private String keyboard; /* указываем тип отправляемого содержимого SIMPLE - обычный текст DOCUMENT - отправка документа */ private MediaType mediaType; //имя отправляемого файла private String fileName; //файл private byte[] data; //в какой клиент осуществлять отправку private Client client; }
Async-server
Первоочередной задачей было создание легко расширяемой и легко читаемой архитектуры классов, отвечающих за обработку команд. Так как количество команд бота, которые он способен обработать, может быть неограниченным, использовать switch-case для того, чтобы узнать, какую именно команду выполнить, очевидно является плохим решением. Все когда-то встречали код, в котором блок case расстилается на десятки, а то и сотни строк! Это абсолютно неприемлемое решение для нас.
В данном кейсе нам отлично подходил функционал Spring Framework. Необходимо создать базовый класс, от которого будут наследоваться все классы, занимающиеся обработкой команд пользователя. Помимо этого, классы-наследники должны быть отмечены аннотацией @Component
. С её помощью мы даём понять spring, что нам необходимо создать bean
данного класса.
public abstract class AbstractBaseHandler { protected List<Payload> handle(UserMessage usermassage) ; }
Теперь мы можем получить все классы, являющиеся наследниками AbstractBaseHandler
, следующим образом:
@Service public class HandlerProvider { private List<AbstractBaseHandler> handlers; @Autowired public void setHandlers(List<AbstractBaseHandler> handlers) { this.handlers = handlers; } }
Первым делом spring будет искать в своём контейнере bean List<AbstractBaseHandler>
. Не обнаружив его, он найдёт всех наследников AbstractBaseHandler
, которые являются bean
, после чего «заинжектит» в наш List
.
Следующий шаг: необходимо закрепить команды бота за классами, которые занимаются их обработкой, а также возможность получения экземпляра класса в зависимости от того, какую команду отправил нам пользователь.
Один из способов решения данной задачи — использование reflection api. Для этого создали аннотацию @Command
, в которую мы передаём массив команд. Их будет обрабатывать наш класс.
@Retention(RUNTIME) @Target(TYPE) public @interface Command { /** * Возвращает список команд, поддерживаемых обработчиком * * @return список команд, поддерживаемых обработчиком */ String[] command(); }
Таким образом мы можем получить необходимый экземпляр класса, в соответствии с введённой командой пользователя:
@Service public class HandlerProvider { private List<AbstractBaseHandler> handlers; @Autowired public void setHandlers(List<AbstractBaseHandler> handlers) { this.handlers = handlers; } public void process(UserMessage usermassage) { AbstractBaseHandler = getHandler(usermassage.getText()).handle(usermassage); Дальнейшая обработка… } private AbstractBaseHandler getHandler(String text) { return handlers.stream() .filter(handler -> handler.getClass() .isAnnotationPresent(BotCommand.class)) .filter(handler -> Stream.of(handler.getClass() .getAnnotation(BotCommand.class) .command()) .anyMatch(c -> text.toLowerCase().startsWith(c)))) .findAny() .orElseThrow(UnsupportedOperationException::new); } }
Далее необходимо позаботиться о безопасности: ограничить доступ к боту для незарегистрированных пользователей. Для этого мы создали аннотацию @BotRole
, которая будет нести в себе информацию о том, какой минимальной ролью должен обладать пользователь для обращения к модулю:
@Retention(RUNTIME) @Target(METHOD) public @interface BotRole { /** * @return возвращает роль, для которой разрешено выполнение команды */ Role role(); /** * @return возвращает наименование модуля */ Module module(); }
После получения экземпляра класса из аннотации узнаём требуемую роль к модулю, отправляем запрос в User-manager и сравниваем с текущей ролью пользователя в модуле.
Таким образом мы получаем:
-
легко расширяемую и легко читаемую архитектуру классов, отвечающих за обработку команд
-
безопасность
-
интеграцию с ролевой моделью user-manager
Для вызова меню с наборами автотестов и для запуска автотестов мы имеем два отдельных класса — AutoTestsMenuHandler
и StartBuildHanlder
, соответственно:
@Component @Command(commandName = "/tests", message = "Меню запуска тестов") public class AutoTestsMenuHandler extends AbstractBaseHandler { @BotRole(role = Role.USER, module = Module.JENKINS) protected List<Payload> handle(UserMessage usermassage) { Обработка логики } }
@Component @Command(commandName = "/start_build", message = "Запуск автотестов") public class StartBuildHandler extends AbstractBaseHandler { @BotRole(role = Role.USER, module = Module.JENKINS) protected List<Payload> handle(UserMessage usermassage) { Обработка логики } }
Вся структура меню, из которого происходит запуск автотестов, хранится в БД. В нашем случае это PostgreSQL.
CREATE TABLE public.at_menu ( id int8 NOT NULL, command varchar(255) NULL, name varchar(255) NULL, parent int8 NULL, CONSTRAINT menu_pkey PRIMARY KEY (id), CONSTRAINT menu_parent FOREIGN KEY (parent) REFERENCES public.at_menu (id) );
Таблица имеет следующие столбцы:
-
command
— команда, которая будет исполняться при нажатии -
name
— название кнопки -
parent
— родительский элемент
Наличие столбца parent позволяет нам выстроить древовидную структуру. Это особенно актуально при большом количестве кнопок, так как их можно группировать в разделы и подразделы.
User-manager
Первоочередной задачей было позаботиться о безопасности, чтобы незарегистрированные пользователи не имели доступа к сервису.
Если у других компонентов появляется необходимость удостовериться в том, что пользователь зарегистрирован в системе или он обладает необходимыми правами, они обращаются в User-manager за следующей информацией:
-
роли в модулях
-
авторизованные мессенджеры
-
ID в мессенджерах
-
псевдоним в мессенджерах
Каждому пользователю присваивается связка Module + Role. Module — это сгруппированный набор определённых команд бота. Role — это одна из ниже перечисленных ролей пользователя в Module:
-
READER
-
USER
-
ADMIN
Таким способом можно разграничивать права пользователя во всех модулях сервиса.
Jenkins-adapter
В данный компонент был вынесен весь функционал по взаимодействию с Jenkins. Он непосредственно инициирует запуск джоб с помощью отправки API-запроса в Jenkins, получает результат выполнения джоб и формирует отчёт об итогах выполнения автотестов.
В ранних версиях бота вся информация о наличии новых билдов осуществлялась при помощи API-запросов. Это очень сильно нагружало Jenkins. В текущей версии бота Jenkins-adapter получает информацию о новых билдах из rss feed. Это существенно снизило нагрузку на Jenkins.
Помимо уже имеющегося функционала по получению отчёта о прохождении автотестов и запуска их из бота, текущая версия обросла дополнительными фичами. Давайте рассмотрим подробнее каждую из них.
Тэгание ответственных лиц
В ранней версии бота был недостаток: при взаимодействии с пользователями через групповые чаты отчёты могли просто потеряться среди большого количества сообщений. Ответственные за тот или иной набор тестов лица могли пропустить информацию о том, что не все автотесты были успешно пройдены. В текущей реализации при наличии неуспешных тестов бот дополнительно к отчёту тэгает ответственного пользователя.
Перезапуск автотестов
При получении неудовлетворительных результатов зачастую имеется необходимость запустить джоб с упавшими тестами. Особенно это характерно для UI-тестов, так как процент успешных среди них достаточно невысок (по причине их низкой стабильности), по сравнению, например с API-тестами. Нам хотелось сократить время этих рутинных операций, чтобы осуществлять перезапуск можно было по нажатию кнопки из мессенджера. Поэтому, если сборка неуспешная, формируется клавиатура с двумя дополнительными кнопками: «Перезапуск упавших тестов» и «Перезапуск сборки».
Вся информация о том, какой джоб перезапустить, хранится в callback кнопке (более подробно её рассмотрим в главе Пользовательский сценарий). Тelegram имеет ограничение на количество передаваемых символов в callback. Мы решили данную проблему, создав дополнительную таблицу в базе данных, в которой хранится эта информация, а в callback «зашиваем» лишь id этой записи.
Статистика выполнения автотестов
Еще одна из полезных фичей, которая появилась в текущей версии, — получение статистики по результатам прохождения автотестов.
![Статистика выполнения автотестов Статистика выполнения автотестов](https://habrastorage.org/getpro/habr/upload_files/372/0b0/b47/3720b0b47abed599d7870cb97b03c63d.png)
После получения результатов автотестов Jenkins-adapter записывает данные в БД. По заданному расписанию информация отправляется в Async-server. Async-server отправляет данное сообщение в нужный клиент, а он доставляет сообщение пользователю, подписавшемуся на рассылку статистики.
Регистрация пользователей
Так как взаимодействовать с ботом могут только зарегистрированные пользователи, было важно реализовать функционал, который бы позволял осуществлять сбор всей необходимой информации для регистрации пользователя. Для этого был создан функционал диалога с ботом. Каждый желающий получить доступ к системе просит своего коллегу, уже имеющего доступ, дать ему реферальный код. Он необходим для выполнения команды, инициирующей запуск диалога с ботом.
![Диалог с ботом Диалог с ботом](https://habrastorage.org/getpro/habr/upload_files/5c2/9b6/930/5c29b69303256b0162b28dcae8325865.png)
![Запрос на регистрацию Запрос на регистрацию](https://habrastorage.org/getpro/habr/upload_files/cb7/aad/c2e/cb7aadc2e22a6eaf0df251daeedc6ffc.png)
Запрос на регистрацию нового пользователя отправляется администраторам бота после завершения диалога.
Это в разы сокращает время на регистрацию новых пользователей в системе, так как нет необходимости лично проводить опрос каждого претендента на доступ.
Пользовательский сценарий
Для демонстрации пользовательского сценария заполним данными ранее созданную таблицу at_menu
:
INSERT INTO public.at_menu (id,command,name,parent) VALUES (1,'/tests 1','UI тесты',NULL), (2,'/tests 2','API тесты',NULL), (3,'/tests 3','Мобильные тесты',NULL), (4,'/start_build 4','Портал',1), (5,'/start_build 5', 'Портал',2), (6,'/start_build 6','Android',3), (7,'/start_build 6','IPhone',3);
Теперь представим себе следующий сценарий: зарегистрированный пользователь осуществляет запуск автотеста из бота и после его выполнения получает отчёт об итогах. Давайте рассмотрим, что происходит на системном уровне при выполнении данного сценария.
Пользователь отправляет боту команду /tests
. Данная команда инициирует вызов метода handle
обработчика AutoTestsMenuHandler
в компоненте Async-server. Команда /tests
без аргументов сообщает обработчику, что необходимо вернуть разделы меню, не имеющие родительских элементов. К БД будет выполнен запрос с условием parent = NULL
. Таким запросом мы получим стартовое меню.
После получения данных из БД мы формируем клавиатуру с кнопками. Каждая кнопка будет иметь имяname
, которое будет соответствовать столбцу name
, иcallback
(мета-информация, которую содержит в себе кнопка), которое будет соответствовать столбцуcommand
. Клавиатура представлена в виде сущностей:
@Getter @Setter public class Button { //Имя кнопки private String name; //Callback кнопки privat String callback; } @Getter @Setter public class Row { //Список кнопок в ряду private List<Button> buttons; } @Getter @Setter public class Keyboard { //Список рядов с кнопками private List<Row> rows; public String toString() { try { ObjectMapper objectMapper = new ObjectMapper(); return objectMapper.writeValueAsString(this); } catch (JsonProcessingException e) { //Обработка } } }
Сформированный экземпляр классаPayload
отправляется в один из клиентов, который, в свою очередь, формирует сообщение для пользователя и отправляет его:
//Создаём клавиатуру для Telegram private InlineKeyboardMarkup setKeyboard(Payload payload) throws JsonProcessingException { //создаём клавиатуру ObjectMapper objectMapper = new ObjectMapper(); List<List<InlineKeyboardButton>> keyboard = new ArrayList<>(); if (payload.getKeyboard() != null && !payload.getKeyboard().isEmpty()) { //Получаем клавиатуру, созданную в async-server Keyboard additionalButtons = objectMapper.readValue(payload.getKeyboard(), Keyboard.class); for (Row row : additionalButtons.getRows()) { //надо создать строку List<InlineKeyboardButton> additionalRow = new ArrayList<>(); for (Button button : row.getButtons()) { //создаём кнопку InlineKeyboardButton btn = new InlineKeyboardButton(); btn.setText(button.getName()); btn.setCallbackData(button.getCallback()); //добавляем в строку additionalRow.add(btn); } //добавляем в клавиатуру keyboard.add(additionalRow); } } InlineKeyboardMarkup inlineKeyboardMarkup = new InlineKeyboardMarkup(); inlineKeyboardMarkup.setKeyboard(keyboard); return inlineKeyboardMarkup; } //отправляем сообщение пользователю private void sendMessage(Payload payload) throws TelegramApiException { SendMessage sendMessage = new SendMessage(); sendMessage.setChatId(payload.getChatId()); try { sendMessage.setReplyMarkup(setKeyboard(payload)); } catch (JsonProcessingException e) { //обработка } sendMessage.setParseMode(ParseMode.HTML); sendMessage.setText(payload.getText()); //отправляем сообщение пользователю execute(sendMessage); }
В нашем случае пользователь получит сообщение с кнопками:
-
«UI тесты»
-
«API тесты»
-
«Мобильные тесты»
Как уже было сказано ранее, в каждую кнопку зашитcallback
. При нажатии кнопки боту отправляется команда, которая находится вcallback
. После нажатия пользователем кнопки «Мобильные тесты» боту будет отправлена команда /tests 3
. Цифра три — это аргумент команды, который является id записи в таблице. К БД будет выполнен запрос с условием parent = 3
. После этого пользователю будет отправлена клавиатура с кнопками:
-
«Android»
-
«IPhone»
![Уведомление о старте билда Уведомление о старте билда](https://habrastorage.org/getpro/habr/upload_files/3a5/213/741/3a52137417d3d4d70d8057903e73618b.png)
При нажатии пользователем кнопки «Android» боту отправится команда /start_build 6
. В данной команде аргумент — это id заранее заготовленного пресета (описывается в первой части статьи). Данная команда инициирует выполнение метода handle
обработчика StartBuildHandler
. Обработчик отправит REST API запрос в Jenkins-adapter, который, в свою очередь, отправит запрос со всеми параметрами в Jenkins. Произойдёт запуск build
, после чего пользователю отправится уведомление о том, что его build
поставлен в очередь.
![Результат выполнения автотестов Результат выполнения автотестов](https://habrastorage.org/getpro/habr/upload_files/24d/555/3f9/24d5553f930c168f5d856f516695e430.png)
По окончании выполнения теста Jenkins-adapter сформирует отчёт и пользователю придёт уведомление с его результатами.
Функционал, не связанный с автотестами
Опыт использования мессенджеров в работе нам показался весьма положительным, поэтому со временем наш сервис стал обрастать функционалом, далеко выходящим за рамки автотестирования.
Отпуска
Одним из первых появился функционал по работе с отпусками сотрудников. В User-manager добавили возможность задать принадлежность пользователя организационной команде, даты и вид отсутствия, реализовали механизм оповещения об отпусках пользователей в команде по подписке. Теперь руководители своевременно получают информацию об отсутствующих сотрудниках. Им также поступает информация о тех сотрудниках, кого не будет на рабочем месте в ближайшее время.
![Списки отсутствующих и планируемых отпусков Списки отсутствующих и планируемых отпусков](https://habrastorage.org/getpro/habr/upload_files/b00/a06/f9f/b00a06f9f550c73868e790d8f92e8ad9.png)
Своевременное получение данной информации существенно упрощает планирование и управление командой.
Интеграция с Jira
Следующим функционалом, никак не связанным с автотестированием, стало взаимодействие с Jira. Зачастую сотрудники забывают списывать время, поэтому мы решили реализовать функционал, который бы присылал пользователю регулярные уведомления о количестве списанных им часов на задачи.
По аналогии с Jenkins-adapter, вся работа с Jira была вынесена в отдельный микросервис Jira-adapter. По заданному расписанию происходит отправка REST API запроса к Jira с целью получения информации о затраченном времени за прошлую неделю. После получения данной информации происходит формирование отчёта в HTML-формате.
![Отчёт по списанию времени Отчёт по списанию времени](https://habrastorage.org/getpro/habr/upload_files/60a/a79/eb8/60aa79eb8e4e2cc7a55efca9e8ed2f7a.png)
Заключение
В итоге, после внедрения всех технических решений, описанных в данной статье, новая версия, по сравнению со старой, имеет следующие преимущества:
-
микросервисная архитектура
-
есть возможность осуществить в кратчайшие сроки любые интеграции — всё ограничивается лишь нашей фантазией
Проект по разработке сервиса дал нам интересный и полезный опыт. Кроме того, мы значительно прокачали свои навыки:
-
разработки на Spring Boot
-
разработки чат-ботов
-
проектирования баз данных
-
проектирования масштабируемых систем
ссылка на оригинал статьи https://habr.com/ru/articles/716694/
Добавить комментарий