Чтобы вам удобнее было взаимодействовать с сервисом и API, я подготовил yaml-спецификацию с описанием всех endpoint'ов, типов данных и обязательности полей. Взаимодействовать с ней можно одним из способов:
- Получение спецификации от самого приложения.
- Локально запустить приложение через IntellijIdea, либо сбилдив проект в jar.
- Перейти в браузере по адресу вида:
http://localhost:{PORT}/specification/build-trigger-specification.html
где PORT, собственно - порт, на котором запустились. Например:
http://localhost:8080/specification/build-trigger-specification.html
- В открывшейся странице можно посылать запросы к сервису через "Try it out", данные будут предзаполнены по примерам.
Надо будет только слегка их скорректировать, см. раздел
Создание / редактирование триггера
.
- Скопировать содержимое из
/resources/specification/build-trigger-specification.yaml
и вставить в онлайн-редактор Swagger, по адресу: https://editor.swagger.io. Чисто для того, чтобы ознакомиться с API, но запросы оттуда не поделаешь, придётся использовать Postman.
Если взаимодействовать с сервисом на 100%, понадобится:
- Postgres.
Выбрал в качестве СУДБ.
Если адрес, логин, пароль не дефолтные, настроить в
datasource-master.properties
. - Docker Desktop. Используется для интеграционных тестов, Testcontainers через него запускает БД для тестов. Также через него я запускаю Rabbit MQ.
- Rabbit MQ.
Выбрал в качестве MQ, т.к., на мой взгляд, проще конфигурируется.
Если креды не дефолтные, настроить в
rabbit-mq.properties
. - JDK. В своём проекте я установил уровень 11, т.к. более привычен и распространён, в отличие от 17.
- Тестовый репозиторий на GitHub. Чтобы была возможность потестировать функционал на настоящем репозитории.
Чтобы создать или обновить триггер, идём на /trigger/create
или /trigger/update
.
Передаём все обязательные параметры. Если выбран тип Vcs
, дополнительно нужно передать необязательный vcsTriggerData
.
Если выбран Scheduled
, дополнительно передаём scheduledTriggerData
.
- При процессинге триггера могут случиться исключительные ситуации. Например, недоступен удалённый репозиторий или ветка, которую указали
при создании триггера, не найдена в нём. В таком случае обработка завершается с ошибкой, в логи пишется WARNING, а следующее время
исполнения высчитывается относительно константы
TriggerProperties.nextExecutionDelayOnError
. Что здесь можно улучшить: не просто откладывать исполнение с константой, а иметь счётчик попыток, и только при достижении определённого порога откладывать надолго. - То же самое может произойти при попытке отправить сообщение в MQ - система может быть недоступна. Сейчас кейс с ошибкой просто логируется. Тут нужно глобально определиться со стратегией, что мы считаем успешной обработкой: отправили сообщения по всем веткам, хотя бы по одной и т.д. А также понять, что делать если часть отправилась успешно, а часть - нет: отправлять ещё раз, кажется, нехорошо, значит, надо хранить данные об этом.
- Входная точка - контроллер
TriggerController
. Не содержит никакой логики, делегирует обработку исполнителю команд. Таким образом, взаимодействие с endpoint'ами шаблонизировано, за обработку клиентских запросов отвечают классы с окончанием...Command
. Для исполнения запросов завёл отдельный thread pool, чтобы повысить throughput. Классы, описывающие эту логику, находятся в packagecom.jetbrains.buildtrigger.command.*
- За управление триггерами отвечает
com.jetbrains.buildtrigger.trigger.service.BuildTriggerManager
. Этот сервис является связующим звеном между БД и бизнес-логикой. - Для CRUD-операций использовал Spring Data JPA + Hibernate.
См. класс
com.jetbrains.buildtrigger.trigger.dao.TriggerRepository
, расширяющийJpaRepository
.
- Перед первым сохранением сразу высчитывается время следующей обработки -
nextExecutionTime
, согласно переданным настройкам и типу триггера. - После каждого обновления
nextExecutionTime
пересчитывается.
- Чтобы с определённой периодичностью выявлять триггеры, требующие обработки, использовал стандартный
@Scheduled
. За это отвечает классcom.jetbrains.buildtrigger.trigger.scheduling.UnprocessedTriggersDetectorTask
, единственная ответственность которого - отрабатывать с периодичностью, заданной вscheduling.properties
, и делегировать обработкуBuildTriggerManager
. Использует отдельный пул потоков, чтобы в дополнение ко многонодовости из условия задачи повысить производительность. - Синхронизация во многопоточной и многонодовой среде.
В кастомных методах
TriggerRepository
берётся пессимистическая row-level блокировка на обрабатываемый триггер, чтобы гарантировать уникальность обработки и избежать конфликта доступа для модифицирующих CRUD-операций (обновление, удаление триггера). Блокировка берётся двумя способами: средствами Postgres (методfetchUnprocessedWithLock()
) и средствами JPA (методfetchForUpdateById()
). В первом методе использовал нативный запрос, т.к. средствами JPA не было возможность вытащить одну запись (LIMIT 1), а тащить List<> не хотелось.
fetchUnprocessedWithLock()
используется для обработки триггеров.fetchForUpdateById()
используется в CRUD-операциях, требующих модификации.
- Непосредственно обработка триггеров происходит через общий интерфейс:
com.jetbrains.buildtrigger.trigger.service.processing.TriggerProcessor
. ПоSOLID
, чтобы быть более гибкими, если появятся новые типы триггеров.
- С удалённым репозиторием взаимодействуем через VGit без локального сохранения репозитория, т.к. в данном случе это не нужно.
- Обработчик триггеров Vcs идёт в удалённый репозиторий, получает список веток и последних коммитов.
- Если в локальном хранилище ещё нет информации о последнем коммите ветки, то коммит первично будет сохранён в БД, сборка проведена не будет, т.к. мы только начали отслеживать состояние ветки.
- Если локальные данные о последнем коммите отличаются от данных из удалённого репозитория, то обновляем у себя информацию и инициируем сборку.
- Иначе, если коммиты совпадают, сборка проведена не будет.
- Обработчик триггеров Scheduled идёт в удалённый репозиторий, получает список веток.
- Если данные о ветке присутствуют в удалённом репозитории, инициируется сборка.
- Иначе билд для ветки проведён не будет.
- Если выполняются все условия: для триггера
VCS
найдено различие в коммите, а для триггераSCHEDULED
- ветка найдена в репозитории, то пушим событие в очередь Rabbit MQ. Роутинг описан вrabbit-mq.properties
иcom.jetbrains.buildtrigger.config.RabbitMqConfiguration
. За недостаточностью входных данных, он примерный, также, как и структура отправляемого сообщения. - После окончания обработки время следующего исполнения триггера -
nextExecutionTime
обновляется согласно текущим настройкам.
- Для высчитывания следующего времени исполнения используется стратегия -
интерфейс
com.jetbrains.buildtrigger.trigger.service.executiontime.NextExecutionTimeStrategy
.
- Следующее время исполнения высчитывается согласно заданным настройкам:
ExecutionByTimeData
.- В случае
IntervalType
==FIXED_RATE
к текущей дате прибавляется значение параметраfixedRateInterval
- В случае
IntervalType
==SCHEDULED
высчитывается относительно текущей даты, через параметрcron
- В случае
- Тесты. Тесты написаны на языке Kotlin.
- Unit-тесты располагаются в директории
test
- Интеграционные тесты располагаются в директории
slowTest
. В этой директории не всё - сами тесты, т.к. потребовалось настроить окружение для возможности прогона интеграционных тестов. Если интересны сами тесты, их нужно искать по окончанию...Test
.
- Система сборки -
Gradle
. ВslowTest.gradle
сконфигурированы интеграционные тесты, вbuild.gradle
- общая конфигурация и зависимости.
Хочется всё сделать красиво, и я по максимуму старался так сделать. Поэтому проект получился немного большим. Тем не менее в рамках формата тестового задания и чтобы уложиться в заявленный срок, не всё сделано/продумано идеально и реализовано. Есть множество мыслей о том, что можно было бы доработать, ниже - лишь малая часть, первое, что пришло на ум:
- Api-классы хорошо бы либо вынести в библиотеку. И либо генерить спецификацию на основании классов, либо классы - на основании .yaml-спецификации.
Но для простоты оставил в директории
com.jetbrains.buildtrigger.trigger.api.*
. - Для полноценного CRUD добавить endpoint findAll, для получения списка триггеров
- Для интеграционных тестов поднять тестовый RabbitMQ
- Дописать ещё тестов, сейчас покрытие не полное
- Подумать над нормализацией таблицы
build_trigger
, вынесение в отдельные @Entity вместо хранения в JSON данных триггеров Vcs и Scheduled, как я это сделал для веток - они находятся в таблице branch. В текстовом JSON-формате храню в целях экономии времени и потому, что в данном бизнес-кейсе я не увидел потребности в обратном. - Вынести данные, связанные с процессингом триггера, в отдельную таблицу.
Сейчас это только колонка next_processing_date. В отдельную таблицу можно добавить:
current_attempts_count
- текущее количество попыток обработки. Завести вTriggerProperties
лимит на попытки и откладывать исполнение на более долгий срок.lock_expiration_date
- сейчас берём лок только в рамках транзакции, что не очень гибко и удобно, заметил это, когда дорабатывал тесты. А также на неудобность намекает название метода -BuildTriggerManager.detectAndProcess
. Собственно, в этой колонке можно сохранять дату, по которую действует лок. Эту дату мы сохраним, когда зафетчим триггер с пессимистической блокировкой. И при фетче надо также будет учитывать колонку lock_expiration_date.
- Валидация данных при создании триггера:
- Сейчас в запросе на создание или обновление триггера можно передать и
vcsTriggerData
иscheduledTriggerData
, что не совсем корректно, хоть и ничего не ломает - Валидировать cron, что он в правильном формате
- Валидировать формат переданного repositoryUrl
- Прикрутить Jsr-303 валидатор, чтобы если есть ошибки в формате полей, не возвращался стандартный текст в SC400
- Возможно, прикрутить какие-то кастомные валидации, как в случае кейса с
vcsTriggerData
иscheduledTriggerData
.
- Сейчас в запросе на создание или обновление триггера можно передать и
- Смотреть, чтобы при создании триггера уже не существовало с таким же типом и расписанием
- Credentials от Git, по-моему, не должны принимать здесь, должны получать из другого места. Предполагаю, это должен быть другой сервис или, как минимум, другая таблица. А ещё не хранить пароль в незашифрованном виде.
- Уточнить данные, которые пушатся в RabbitMQ, и, при необходимости, расширить. Сейчас они сформированы примерно, в виду неполноты требований. То же касается и роутинга.
- Написать более серьёзное решение для задач, выполняющихся периодически.
Это должна быть настраиваемая библиотека, которая в отдельной таблице хранит некий контекст с данными, периодически к ней обращается
и выполняет задачи произвольного формата с периодичностью, как и обычный
@Scheduled
. - Аналогично предыдущему пункту, только библиотека, которая по состоянию контекста в БД умеет "дожимать" бизнес-логику произвольного формата.
Т.е. мы имеем:
- Конфигурация и бизнес-логика описана в коде
- Состояние процесса хранится в БД. В любой момент времени мы можем обратиться к ней и посмотреть, на какой стадии находимся. В зависимости от параметров и состояния, либо повторить попытку, не начиная всё с начала, либо завершить процесс.
- И так далее, ведь нет предела совершенству :)