Гайдлайн разработчика Проекта Э

December 20, 2023


Этот микропост является практически полной копией (за исключением небольших правок во имя NDA) Confluence-страницы "Руководство разработчика Проекта Э".

Общая структура системы

Систему следует разрабатывать в соответствии с принципами Эргономичного подхода.

В частности необходимо придерживаться эргономичной структуры информационных систем:

erogonomic programs structure v2 overview.drawio

Физически декомпозиция осуществляется по средствам пакетов.

Общее правило: рекомендуемый размер пакета составляет 3-6 элементов (файлов или подпакетов). Для пакетов меньшего размера стоит рассмотреть возможность их включения в родительский пакет, для пакетов большего размера стоит рассмотреть возможность выделения подпакетов. Пакеты с 10 и более элементами допустимы только в исключительных случаях.

Здесь мы начнём с третьего приближения - устройство Бэка.

Устройство Бэка

erogonomic programs structure v2 backend.drawio

На самом верхнем уровне код реализации бэка делится на три части:

  1. Приложение
  2. Ядро
  3. Платформа

Приложение и платформа

Приложение и платформа содержат реализацию "cross-cutting concerns" - функциональности общей для всех/многих модулей ядра. Разница между ними в том, что приложение содержит функциональность заточенную собственно под приложение, а платформа - функциональность, которую потенциально можно переиспользовать в других проектах. Ещё один критерий разделения кода приложения и платформы - то, что вызывается фреймворком - попадает в приложение, то что вызывается ядром - в платформу.

Пример функциональности приложения: точка входа, авторизация, метрики для мониторинга.

Пример функциональности платформы: фреймворк обработки ошибок, реализация RPC через RabbitMQ, АПИ хранилища изображений.

Особых гайдлайнов разработки приложения и платформы пока нет - просто стараемся писать в соответствии с общепринятыми лучшими практиками.

Ядро

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

Устройство ядра

erogonomic programs structure v2 core.drawio

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

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

Модули одного уровня вложенности должны образовывать ацикличный направленный граф.

Так же важно отметить, что на этом уровне продолжает действовать общий гайдлайн на рекомендуемое количество элементов в пакете (количество верхнеуровневых модулей системы) равное 3-6 и не более 10 модулям.

Дальнейшее чтение

Декомпозиция на базе эффектов:

Похожие подходы к компонентной архитектуре:

Устройство (под)модуля ядра

erogonomic programs structure v2 module.drawio

По определению, модуль логически делится как минимум на интерфейс и реализацию.

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

Наконец, если модуль "исполняемый" (должна быть возможность запустить его обособленно от остальной системы, в тестах, например), то он должен содержать класс Spring-конфигурации, для конструирования графа объектов модуля в рантайме.

Типовые элементы реализации модулей

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

  • api
    • dtos - классы DTO АПИ модуля
    • events - классы событий модуля
    • model - классы сущностей и объектов-значений из DDD, в случае если они "выставлены" в АПИ
    • *Exception - файл с иерархией исключений модуля
    • *Service - класс с интерфейсом модуля
  • internal
    • domain - классы репозитория и сущности модуля и, при наличии, DAO
    • submodule1 - код реализации подмодуля
    • Submodule2.kt - код реализации подмодуля
    • *ServiceImpl - класс реализации интерфейса модуля
    • *Props - класс конфигурационных параметров модуля
  • ports
    • *Controller - Spring MVC контроллер и обработчик ошибок
    • *Listener - Spring RabbitMQ слушател
  • *Config - Spring-конфигурация модуля

Помимо типовых видов элементов (особенно в подпакете internal), вполне допускаются нетиповые элементы.

В пакет api допускается помещать декларативные интерфейсы с аннотациями специфичными для реализации, по которым в рантайме будут сгенерированы реализации, например интерфейсы Spring Data JDBC репозиториев, MyBatis мапперов, Spring 6 декларативных HTTP клиентов и т.п.

Физически модуль это всегда пакет. Но содержимое этого пакета зависит от размера модуля и варьируется от одного класса, до набора подпакетов с разделением реализации на подмодули.

Примеры модулей

Модуль в одном классе

В случае если код модуля тривиальный и он либо "выставляется" через один порт, либо не "выставляется" вообще, то допустимо реализовать его в виде одного класса. Пример - http-эндпоинт, который конвертирует xlsx в нетипизированный json:

  • xlsx
    • XlsxService
Модуль в одном пакете

Если весь код модуля умещается в 6 файлах, то все эти файлы можно оставить в корневом пакете модуля:

  • models
    • DeviceModel
    • DeviceModelsController
    • DeviceModelsRepo
    • DeviceModelsService
    • UpdateDeviceModelsRequest
Модуль с логическими подпакетами

В противном случае, на верхнем уровне остаются пакеты api, internal и ports и класс Spring-конфигурации (пакет ports и класс конфигурации - при наличи):

  • firmwares
    • api
      • Firmware
      • FirmwaresException
      • FirmwaresService
      • FirmwareState
      • GetFirmwaresRequest
    • internal
      • FirmwareFile
      • FirmwareInfo
      • FirmwareInfosRepo
      • FirmwareFilesRepo
      • FirmwaresRepo
      • FirmwaresServiceImpl
    • ports
      • FirmwaresController
    • FirmwaresConf

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

Группировка функционально связанных модулей

Если в пакете интерфейса модуля набирается более 6 файлов или в интерфейсе модуля набирается более 6 операций или здравый смысл (и Диаграмма Эффектов) указывает на то, что в модуле есть относительно изолированные части, то сам модуль необходимо/можно? разбить на два подмодуля на основе эффектов или "здравого смысла". При этом корневой модуль должен иметь файл конфигурации, который импортирует файлы конфигураций подмодулей и, при необходимости, определяет общие Spring-бины. Подмодули могут иметь собственные конфигурации, если их необходимо запускать по отдельности (в тестах, например).

Пример:

  • devices
    • firmwares
    • models
    • DevicesConfig

Пример из TSP:

  • feed_provider
    • dgis
    • yandex

В этом случае два технически независимых модуля реализуют общую функциональность "предоставление фида".

Подмодуль реализации в пакете

Если в пакете реализации модуля набирается более 6 файлов, то реализацию модуля надо декомпозировать на подмодули на основе эффектов или "здравого смысла":

  • email
    • api
      • EmailNotificationsService
      • EmailTemplate
    • internal
      • email_sender
        • api
          • Email
          • EmailSender
        • impl_sendgrid
          • SendGridEmailSender
          • SendGridProps
        • EmailSenderConfig
      • EmailNotificationsServiceImpl
      • EmailTemplates
      • SelfInfoProps
      • SupportContactsProps
    • ports
      • FeedbackController
    • EmailNotificationsConfig

Подмодуль реализации в классе

Если реализация подмодуля ограничивается 4 классами и 200 строками, его можно оформить в виде одного файла:

  • reports
    • api
      • ReportGenerator
    • internal
      • PdfGenerator.kt
        • PdfGenerator
        • ReportBody
    • ports
      • ReportsController
    • ReportsConfig

Устройство реализации (под)модуля ядра

erogonomic programs structure v2 module impl.drawio

Модули ядра состоят из традиционных для DDD и чистой архитектуры блоков:

  1. сущности и объекты-значения, объединённые в агрегаты - описывают модель данных модуля и содержат бизнес-логику ограниченную рамками одного агрегата
  2. репозитории агрегатов - реализуют абстракцию изменяемой коллекции агрегатов
  3. сервисы домена - реализуют бизнес-логику затрагивающую несколько агрегатов
  4. сервисы приложения - отвечают за поток данных между репозиториями и агрегатами и сервисами домена
  5. технические сервисы (клиенты) - абстрагируют внешние системы (старый бэк, SendGrid, Партнёр1, Партнёр2) внутри системы
  6. контроллеры - отвечают за адаптацию HTTP-запросов в вызовы методов сервисов приложения

На абстрактные блоки из иллюстрации, эти блоки мапятся следующим образом:

  1. Оркестрация - сервисы приложения плюс контроллеры в качестве адаптеров сервисов к HTTP-интерфейсу
  2. Ввод/вывод - репозитории и технические сервисы
  3. Модель данных и чистые трансформации - агрегаты, сущности, объекты-значения, сервисы домена

Однако относительно DDD и чистой архитектуры на эти блоки накладываются дополнительные ограничения функциональной архитектуры:

  1. Сущности и агрегаты реализуются в виде неизменяемых структур данных
  2. Вся бизнес-логика помещается в агрегаты и сервисы домена и реализуется в чистом функциональном стиле (без побочных эффектов)
    1. При этом чистая снаружи функция вполне может использовать мутабельное локальное состояние, при необходимости
  3. Циколматическая сложность методов сервисов приложения должна стремиться к 1 - не содержать условий и циклов.
    1. Методы сервисов приложения могут содержать защитные выражения, для прерывания потока данных в случае ошибок
    2. Хорошей метафорой метода сервиса приложения является Railway-Oriented Programming - хэппи-пас метод должен быть прямым как железная дорога из пункта А (запрос) в пункт Б (ответ и эффекты), со съездами на альтернативный путь в случае ошибок.
  4. Методы репозиториев и технических сервисов должны быть реализованы либо в декларативном стиле (Spring Data) либо так же быть прямыми, как железная дорога
    1. Если в методе технического сервиса, помимо ввода-вывода, требуется какая-то обработка, то это сервис надо выделить в отдельный (под)модуль и реализовать его в соответствии с той же базовой структурой - оркестрация, ввод/вывод, трансформации
  5. Идеальный метод контроллера должен быть просто плейсхолдером для аннотаций Spring MVC. Соответственно его тело - просто проброс вызова в метод сервиса приложения. При необходимости методы контроллера могут содержать логику по адаптации параметров или ответов под HTTP и код вторичного роутинга - выбор метода сервиса для вызова в зависимости от параметров запросы, который не получается реализовать средствами Spring MVC.

Дальнейшее чтение

Функциональная архитектура:

  1. Принципы юнит-тестирования, раздел "6.3. Функциональная архитектура
  2. Domain Modeling Made Functional, глава "A Functional Architecture"
  3. Immutable architecture
  4. Boundaries by Gary Bernhardt
  5. Structured Design, глава "8. THE MORPHOLOGY OF SIMPLE SYSTEMS"
  6. Are we there yet

DDD:

Чистая архитектура:

Обработка ошибок

Стратегия работы с ошибками следующая:

  1. Каждый модуль объявляет собственную sealed-иерархию исключений
  2. Каждый модуль с помощью @ControllerAdvice (привязанный к пакету) объявляет собственный обработчик ошибок, который обязательно мапит все свои исключения на HTTP ответ. Так же этот обработчик может мапить исключения других модулей.
  3. Если при обработке запроса модуля вылетает ошибка не замапленная в его обработчике ошибок, то она уходит в GenericWebExceptionHandler и там превращается в 500ку

Тестирование

Терминология

Пользовательский тест - Это тест, который проверяет систему на соответствие определённому требованию. Источником "вдохновения" такого теста являются бизнес-требования и, соответственно, такой тест (его суть и корректность) можно свободно обсудить с конечным пользователем.

Девелоперский тест - Это тест, который проверяет корректность работы отдельного элемента кода - группы классов, класса или функции. Источником "вдохновения" такого теста является разработчик и, соответственно такие тесты не получится обсудить с конечным пользователем.

Внешний тест - Тест, который взаимодействует с системой через её внешний интерфейс (HTTP в нашем случае). Внешние тесты запускают всё приложение целиком в режиме, максимально приближенном к боевому.

Внутренний тест - Тест, который взаимодействует с системой через прямой вызов методов объектов системы. Внутренние тесты запускают только необходимые части приложения (Spring-конфиги отдельных модулей) и инфраструктуры. Внутренние девелоперские тесты рекомендуется писать без использования Spring вообще.

Принципы

см. xUnit Test Patterns, глава 5 "Principles of Test Automation"

  1. Пишите сначала тесты (Write the Tests First)
  2. Предпочитайте тестирование через публичный интерфейс (Use the Front Door First)
  3. Передавайте намерение (Communicate Intent)
  4. Не меняйте систему для тестов (Don’t Modify the SUT)
  5. Делайте тесты независимыми друг от друга (Keep Tests Independent)
  6. Минимизируйте пересечение тестов (Minimize Test Overlap)
  7. Не вносите тестовую логику в продовый код (Keep Test Logic Out of Production Code)

Стратегия

Глобально стратегия тестирования следующая:

  1. При реализации нового эндпоинта, разработчик должен написать новый или дополнить существующий внешний пользовательский тест
    1. На усмотрение разработчика или ревьюевра, функциональность может быть покрыта дополнительными тестами. При этом предпочтение отдаётся внутренним пользовательским тестам, с переходом к девелоперским тестам только в том случае, если "достать" нужный кусок тестируемого кода через пользовательский тест "слишком сложно"
  2. При фиксе бага, разработчик начинает с теста, демонстрирующего отклонение фактического результата от ожидаемого. Рекомендуется делать этот тест внутренним пользовательским, переходя к внешним или девелоперским только в случае необходимости.
  3. Не надо стесняться удалять девелоперские тесты, если они мешают рефакторингу.

Структура директорий с тестами

  • tests.cases - собственно тесты
    • app - тесты, затрагивающие несколько модулей и "cross-cutting concerns", специфичные для приложения (авторизация, например)
    • core - тесты модулей приложения
      • devices - тесты сфокусированные на коде модуля устройств
        • infra - вспомогательный код, для тестов модуля устройств
        • external - внешние пользовательские тесты
        • internal - внутренние пользовательские тесты
        • unit - внутренние девелоперские тесты
          • таких тестов у нас буквально 5 штук на весь проект сейчас
      • profile - тесты сфокусированные на коде модуля профиля
      • и т.д.
    • platform - тесты модулей платформы
  • tests.infra - общий вспомогательный код вспомогательный код

Связь функциональной архитектуры с типами тестов/выбор типа теста

erogonomic programs structure v2 tests.drawio

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

Внешние тесты имеют максимальную устойчивость к рефактирнгу и показательность, но их сложно писать (в частности сетапать фикстуру) и они долго выполняются.

Юнит-тесты (чистых фукнций) писать тривиально и выполняются они мнгновенно, но даже 100% зелённых тестов не гарантирует, что система хотя бы запустится и они зачастую будут требовать доработки при рефакторинге.

Внутренние тесты, являются компромиссом по всем четырём аспектам - скорость запуска, сложность сетапа, показательность и устойчивость к рефакторингу.

У нас, зачастую, "бизнес-логика" выглядит так:

val profileId = getProfileIdForTag(userId)
val currentTags = tagsRepo.getTagsByProfileIdFilteredByNewTagsIds(profileId, ids)
                    .ifEmpty { throw NoChangeableTags() }

// Внимание, бизнес-логика начинается!
val tagsToDelete = currentTags.filter { it.id in ids }
// Внимание, бизнес-логика окончена!
tagsRepo.deleteAll(tagsToDelete)

return tagsToDelete

Очевидно, что для хэппи-паса этой операции нам более чем достаточного одного внешнего теста.

С другой стороны, время от времени у нас бывает и так - <ссылка на GitLab с классом бизнес-логики на 300+ строк, где функции - чистые>.

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

Наконец, есть случаи, когда нам надо проверить дополнительные ветки кода интеграции с внешними системами. Например, фильтрацию с мультивыбором хочется проверить и тестом на пустой список и на 2 элемента. В этом случае, нам не надо проверять интеграцию с Tomcat и Spring MVC (она уже проверена внешним тестом) и мы считаем интерфейс сервиса практически таким же стабильным, как и REST API, поэтому такие тесты можно написать внутренними, чтобы немного сэкономить времени на их написании и запуске.

Стратегия сетапа фикстуры БД

  1. При инициализации контейнера, все БД пересоздаются целиком (файл db/db-init.sql, исполняется в Containers.kt)
  2. Первый тест, который использует БД запускает Flyway, который создаёт схему и наполняет таблицы ссылочными данными (наши стандартные миграции в src/main/resources/db/migration)
  3. Каждый тест запускает общие инит-скрипты баз, которые удаляют все нессылочные данные (те, что вставлены вне миграций)
    1. Внешние тесты делают это с помощью @CleanupDb
    2. Внутренние тесты делают это с помощью DbInitializer.executeScripts
    3. Разница в том, что внутренние тесты поднимают не все конфиги и там не будет всех датасорсов от которых зависит CleanupDb. Теоретически, мы можем нагенерять пачку Cleanup\*Db, которые зависит только от датасорсов соответствующего модуля, но с <один из разработчиков> почему-то договорились так. Можем передоговориться.
  4. Каждый тест запускает собственные скрипты, которые вставляют нужные тесту данные с помощью DbInitializer.executeScripts

Т.е. КАЖДЫЙ ТЕСТ должен ПОЛНОСТЬЮ почистить РАБОЧИЕ ДАННЫЕ и САМ вставить МИНИМАЛЬНЫЙ набор нужных ему данных.

TDD

Разработку рекомендуется вести в TDD-стиле на базе внешних пользовательских тестов.

Пример цикла реализация требования "Система должна позволять загружать новые прошивки":

  1. Создаётся тест, который выполняет вызов метода создания прошивки
  2. Тест запускается и падает
  3. Создаётся контроллер, принимающий вызов
  4. Тест запускается и проходит
  5. В тест добавляется извлечение ИДа из ответа
  6. Тест запускается и падает
  7. Реализуется код сохранения прошивки
  8. Тест запускается и проходит
  9. В тест добавляется вызов метода получения прошивки по ИД
  10. Тест запускается и падает
  11. В контроллер добавляется метод, который принимает вызов и возвращает стаб-объект
  12. Тест запускается и проходит
  13. В тест добавляется проверка корректности полей прошивки
  14. Тест запускается и падает
  15. Реализуется код получения прошивки по ИД
  16. Тест запускается и проходит
  17. Требование реализовано

Именование (v0.0.1)

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

Имена остальных тестов можно формулировать как требование к поведению или функциональности системы: System should provide users ability to login (Система должна предоставлять пользователям возможность входа в систему).

Ещё один допустимый вариант - констатация факта о системе: User can login into system (Пользователь может залогиниться в систему).

Практически никогда имя теста не должно содержать слово "test", пример плохого имени: Test login (Протестировать логин).

Структура методов тестов

Тест должен состоять из трёх стандартных блоков - Given, When, Then.

В теле теста, эти блоки отмечаются соответствующими комментариями.

Во внешних тестах, рекомендуется использовать RestAssured-ные Given-When-Then только в блоке When, а доп. запросы, для сетапа фикстуры и врефикации оборачивать во вспомогательные методы.

Дальнейшее чтение

Общие руководящие принципы разработки

  1. Код должен обладать высокой функциональной связанностью и низкой сцепленностью
  2. KISS
  3. YAGNI
  4. DRY
  5. В коде должны отсутствовать циклы в зависимостях
  6. Объекты и модули должны скрывать детали своей реализации, а клиентский код не должен полагаться на детали реализации
  7. Модули следует рассматривать как объекты
  8. В кодировании следует отдавать предпочтение функциональному стилю - неизменяемые структуры данных и чистые функции
    1. Но без фанатизма
  9. В кодировании следует придерживаться практик чистого кода, если это не приведёт к нарушению остальных принципов

Дальнейшее чтение

  1. Simple Made Easey (есть русские человечьи субтитры)
  2. On the criteria to be used in decomposing systems into modules
  3. Information Distribution Aspects of Design Methodology
  4. Structured Design, главы "6. COUPLING" и "7. COHESION"
  5. Practical Guide to Structured Systems Design, главы "6: Coupling" и "7: Cohesion"
  6. Applying UML and Patterns, главы, разделы "Information Expert" (применяется на уровне модуля), "Low Coupling", "High Cohesion", "Information Hiding"
  7. Структура И Интерпретация Компьютерных Программ, глава "3. Модульность, объекты и состояние"
  8. Clean Code
  9. Clojure Applied, глава "6. Creating Components"
  10. Implementation Patterns
  11. Clean Architecture, Часть 4 "COMPONENT PRINCIPLES"
  12. Ted Kaminski
  13. Enterprise Craftsmanship

Работа с Git

Именование веток

Имена веток формируются по шаблону <jira-issue-code>/<short-descr> - например EWS-584/add-spring-profiles. Можно заводить по несколько веток на одну задачу.

Сообщения коммитов

Используется соглашение, вдохновлённое Conventional Commits

Используемые типы коммитов:

  • build: Изменения сборки (в том числе зависимостей) проекта
  • chore: Мелкие непонятные изменения (исравление проблем после мёржа/ребейза, забытые изменения и т.п.)
  • ci: Изменения в скриптах CI
  • docs: Изменения только в документации
  • env: Изменения в дев-окружении проекта (добавление/исправление ран-конфигов, скриптов, конфигов тулов, локальный докер-файлов и т.п.)
  • feat: Изменения добавляющие новую функциональность
  • fix: Изменения исправляющие баг
  • ops: Изменения связанные с эксплуатацией проекта (дополнительные параметры конфигурации, логи, метрики, мониторинг и т.п.)
  • perf: Изменения улучшающие прозводительность
  • refactor: Рефакторинг
  • review: Изменения по требованию ревьювера
  • style: Мелкие стилистические изменения (форматирование)
  • test: Изменения затрагивающие только тесты

Коммиты пишутся на грамотном русском языке.

В отличие от Conventional Commits, заголовок сообщение должен содержать после типа через "/" номер основной задачи в рамках которых он выполнен.

Пример сообщения без тела:

feat/EWS-115: Реализован метод POST /logout

Тело опционально, но его стоит написать, если коммит содержит какие-то неочевидные/необычные решения или вызван какими-то неочевидными/необычными обстоятельствами или мотивами.