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

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

  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.

Рис. 1. Архитектура сервиса
Рис. 1. Архитектура сервиса

Ядром всей системы является 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.

Помимо уже имеющегося функционала по получению отчёта о прохождении автотестов и запуска их из бота, текущая версия обросла дополнительными фичами. Давайте рассмотрим подробнее каждую из них.

Тэгание ответственных лиц

Рис. 2. Тэгание ответственных пользователей
Рис. 2. Тэгание ответственных пользователей

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

Перезапуск автотестов

При получении неудовлетворительных результатов зачастую имеется необходимость запустить джоб с упавшими тестами. Особенно это характерно для UI-тестов, так как процент успешных среди них достаточно невысок (по причине их низкой стабильности), по сравнению, например с API-тестами. Нам хотелось сократить время этих рутинных операций, чтобы осуществлять перезапуск можно было по нажатию кнопки из мессенджера. Поэтому, если сборка неуспешная, формируется клавиатура с двумя дополнительными кнопками: «Перезапуск упавших тестов» и «Перезапуск сборки».

Рис. 3. Перезапуск автотестов
Рис. 3. Перезапуск автотестов

Вся информация о том, какой джоб перезапустить, хранится в callback кнопке (более подробно её рассмотрим в главе Пользовательский сценарий). Тelegram имеет ограничение на количество передаваемых символов в callback. Мы решили данную проблему, создав дополнительную таблицу в базе данных, в которой хранится эта информация, а в callback «зашиваем» лишь id этой записи.

Статистика выполнения автотестов

Еще одна из полезных фичей, которая появилась в текущей версии, — получение статистики по результатам прохождения автотестов.

Рис. 4. Статистика выполнения автотестов
Рис. 4. Статистика выполнения автотестов

После получения результатов автотестов Jenkins-adapter записывает данные в БД. По заданному расписанию информация отправляется в Async-serverAsync-server отправляет данное сообщение в нужный клиент, а он доставляет сообщение пользователю, подписавшемуся на рассылку статистики.

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

Так как взаимодействовать с ботом могут только зарегистрированные пользователи, было важно реализовать функционал, который бы позволял осуществлять сбор всей необходимой информации для регистрации  пользователя. Для этого был создан функционал диалога с ботом. Каждый желающий получить доступ к системе просит своего коллегу, уже имеющего доступ, дать ему реферальный код. Он необходим для выполнения команды, инициирующей запуск диалога с ботом.

Рис. 5. Диалог с ботом
Рис. 5. Диалог с ботом
Рис. 6. Запрос на регистрацию
Рис. 6. Запрос на регистрацию

Запрос на регистрацию нового пользователя отправляется администраторам бота после завершения диалога.

Это в разы сокращает время на регистрацию новых пользователей в системе, так как нет необходимости лично проводить опрос каждого претендента на доступ.

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

Для демонстрации пользовательского сценария заполним данными ранее созданную таблицу 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»

Рис. 7. Уведомление о старте билда
Рис. 7. Уведомление о старте билда

При нажатии пользователем кнопки «Android» боту отправится команда /start_build 6. В данной команде аргумент — это id заранее заготовленного пресета (описывается в первой части статьи). Данная команда инициирует выполнение метода  handle обработчика StartBuildHandler. Обработчик отправит REST API запрос в Jenkins-adapter, который, в свою очередь, отправит запрос со всеми параметрами в Jenkins. Произойдёт запуск build, после чего пользователю отправится уведомление о том, что его build поставлен в очередь.

Рис. 8. Результат выполнения автотестов
Рис. 8. Результат выполнения автотестов

По окончании выполнения теста Jenkins-adapter сформирует отчёт и пользователю придёт уведомление с его результатами.

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

Опыт использования мессенджеров в работе нам показался весьма положительным, поэтому со временем наш сервис стал обрастать функционалом, далеко выходящим за рамки автотестирования.

Отпуска

Одним из первых появился функционал по работе с отпусками сотрудников. В User-manager добавили возможность задать принадлежность пользователя организационной команде, даты и вид отсутствия, реализовали механизм оповещения об отпусках пользователей в команде по подписке. Теперь руководители своевременно получают информацию об отсутствующих сотрудниках. Им также поступает информация о тех сотрудниках, кого не будет на рабочем месте в ближайшее время.

Рис. 9. Списки отсутствующих и планируемых отпусков
Рис. 9. Списки отсутствующих и планируемых отпусков

Своевременное получение данной информации существенно упрощает планирование и управление командой.

Интеграция с Jira

Следующим функционалом, никак не связанным с автотестированием, стало взаимодействие с Jira. Зачастую сотрудники забывают списывать время, поэтому мы решили реализовать функционал, который бы присылал пользователю регулярные уведомления о количестве списанных им часов на задачи. 

По аналогии с Jenkins-adapter, вся работа с Jira была вынесена в отдельный микросервис Jira-adapter. По заданному расписанию происходит отправка REST API запроса к Jira с целью получения информации о затраченном времени за прошлую неделю. После получения данной информации происходит формирование отчёта в HTML-формате.  

Рис. 10. Отчёт по списанию времени
Рис. 10. Отчёт по списанию времени

Заключение

В итоге, после внедрения всех технических решений, описанных в данной статье, новая версия, по сравнению со старой, имеет следующие преимущества:

  • микросервисная архитектура

  • есть возможность осуществить в кратчайшие сроки любые интеграции — всё ограничивается лишь нашей фантазией

Проект по разработке сервиса дал нам интересный и полезный опыт. Кроме того, мы значительно прокачали свои навыки:

  • разработки на Spring Boot

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

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

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

Комментарии (0)