Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
На связи Сергей Кондитеров и Сергей Бушмелев, ведущие инженеры по автоматизации тестирования в компании РТЛабс. Это вторая часть статьи «Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования». Как и обещали, в данной статье мы расскажем о том, как масштабировали наш сервис, как развивали функциональность автотестов и как в итоге вышли за рамки обычного репорт-бота.
На первом этапе нами был создан бот. Он работал стабильно, пользователи получали нужные отчёты, но в какой-то момент пришла мысль, что мы привязаны к одному мессенджеру. Хотелось получить возможность осуществлять интеграцию с другими мессенджерами. В связи с этим было принято решение перевести наш монолитный сервис на микросервисную архитектуру, в которой каждый микросервис будет заниматься решением своих узконаправленных задач.
Архитектура
Основные компоненты:
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.
Ядром всей системы является 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 этой записи.
Статистика выполнения автотестов
Еще одна из полезных фичей, которая появилась в текущей версии, — получение статистики по результатам прохождения автотестов.
После получения результатов автотестов Jenkins-adapter записывает данные в БД. По заданному расписанию информация отправляется в Async-server. Async-server отправляет данное сообщение в нужный клиент, а он доставляет сообщение пользователю, подписавшемуся на рассылку статистики.
Регистрация пользователей
Так как взаимодействовать с ботом могут только зарегистрированные пользователи, было важно реализовать функционал, который бы позволял осуществлять сбор всей необходимой информации для регистрации пользователя. Для этого был создан функционал диалога с ботом. Каждый желающий получить доступ к системе просит своего коллегу, уже имеющего доступ, дать ему реферальный код. Он необходим для выполнения команды, инициирующей запуск диалога с ботом.
Запрос на регистрацию нового пользователя отправляется администраторам бота после завершения диалога.
Это в разы сокращает время на регистрацию новых пользователей в системе, так как нет необходимости лично проводить опрос каждого претендента на доступ.
Пользовательский сценарий
Для демонстрации пользовательского сценария заполним данными ранее созданную таблицу 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»
При нажатии пользователем кнопки «Android» боту отправится команда /start_build 6
. В данной команде аргумент — это id заранее заготовленного пресета (описывается в первой части статьи). Данная команда инициирует выполнение метода handle
обработчика StartBuildHandler
. Обработчик отправит REST API запрос в Jenkins-adapter, который, в свою очередь, отправит запрос со всеми параметрами в Jenkins. Произойдёт запуск build
, после чего пользователю отправится уведомление о том, что его build
поставлен в очередь.
По окончании выполнения теста Jenkins-adapter сформирует отчёт и пользователю придёт уведомление с его результатами.
Функционал, не связанный с автотестами
Опыт использования мессенджеров в работе нам показался весьма положительным, поэтому со временем наш сервис стал обрастать функционалом, далеко выходящим за рамки автотестирования.
Отпуска
Одним из первых появился функционал по работе с отпусками сотрудников. В User-manager добавили возможность задать принадлежность пользователя организационной команде, даты и вид отсутствия, реализовали механизм оповещения об отпусках пользователей в команде по подписке. Теперь руководители своевременно получают информацию об отсутствующих сотрудниках. Им также поступает информация о тех сотрудниках, кого не будет на рабочем месте в ближайшее время.
Своевременное получение данной информации существенно упрощает планирование и управление командой.
Интеграция с Jira
Следующим функционалом, никак не связанным с автотестированием, стало взаимодействие с Jira. Зачастую сотрудники забывают списывать время, поэтому мы решили реализовать функционал, который бы присылал пользователю регулярные уведомления о количестве списанных им часов на задачи.
По аналогии с Jenkins-adapter, вся работа с Jira была вынесена в отдельный микросервис Jira-adapter. По заданному расписанию происходит отправка REST API запроса к Jira с целью получения информации о затраченном времени за прошлую неделю. После получения данной информации происходит формирование отчёта в HTML-формате.
Заключение
В итоге, после внедрения всех технических решений, описанных в данной статье, новая версия, по сравнению со старой, имеет следующие преимущества:
микросервисная архитектура
есть возможность осуществить в кратчайшие сроки любые интеграции — всё ограничивается лишь нашей фантазией
Проект по разработке сервиса дал нам интересный и полезный опыт. Кроме того, мы значительно прокачали свои навыки:
разработки на Spring Boot
разработки чат-ботов
проектирования баз данных
проектирования масштабируемых систем