Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования. Часть 2

от автора

На связи Сергей Кондитеров и Сергей Бушмелев, ведущие инженеры по автоматизации тестирования в компании РТЛабс. Это вторая часть статьи «Мессенджеры на работе — это не прокрастинация, или как мы сделали сервис для автотестирования». Как и обещали, в данной статье мы расскажем о том, как масштабировали наш сервис, как развивали функциональность автотестов и как в итоге вышли за рамки обычного репорт-бота. 

На первом этапе нами был создан бот. Он работал стабильно, пользователи получали нужные отчёты, но в какой-то момент пришла мысль, что мы привязаны к одному мессенджеру. Хотелось получить возможность осуществлять интеграцию с другими мессенджерами. В связи с этим было принято решение перевести наш монолитный сервис на микросервисную архитектуру, в которой каждый микросервис будет заниматься решением своих узконаправленных задач.

  1. Архитектура

  2. Основные компоненты:

    2.1. Клиенты
    2.2. Async-server
    2.3. User-manager
    2.4. Jenkins-adapter

  3. Регистрация пользователей

  4. Пользовательский сценарий

  5. Функционал, не связанный с автотестами:

    5.1. Отпуска
    5.2. Интеграция с Jira

  6. Заключение

Архитектура

Микросервисы пишутся в связке 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-serverAsync-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

  • разработки чат-ботов

  • проектирования баз данных

  • проектирования масштабируемых систем


ссылка на оригинал статьи https://habr.com/ru/articles/716694/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *