Архитектура приложения стартапа. Взгляд с высоты птичьего полета
Общая часть
Введение
Приветствую всех читателей Хабра.
Немного разбросал текущие дела и пришло время для написания следующего поста в моем запланированном цикле статей:
Стартап первый, или как я входил в it. - история, почему пришел в it. И какой путь мне пришлось пройти.
Стартап второй, или как мы становились программистами - история, которая рассказывает путь нашей команды. Как и почему мы пришли к разработке собственного фреймворка.
Архитектура приложения стартапа. Взгляд с высоты птичьего полета - собственно, эта статья.
Архитектура приложения стартапа, domain-layer.
Архитектура приложения стартапа, app-layer. Часть 1.
Архитектура приложения стартапа, app-layer. Часть 2.
Архитектура приложения стартапа, Фронтэнд.
Процесс разработки, на примере реализации UseCase.
Дисклеймер
Хотелось бы предупредить, что все решения которые я буду предоставлять в данной и последующих статьях принимались командой из четырех человек. Все они не имели опыта работы, некоторые не имеют соответствующего образования и являются самоучками.
Лично я, когда начали работать над первым стартапом (когда собственно и сформировалась данная команда) по уровню профессионального развития был на следующей ступеньке:
знал синтаксис языка и мог писать код который выполнялся;
понимал что значат слова DRY, магические числа, глобальная константа и старался придерживаться соответствующих принципов;
старался думать по ООП-шному и программировать в соответствии с ООП, но чувствовал, что еще учиться и учиться;
старался, чтобы классы и методы были ответственны за решение одной задачи, но не особо в этом преуспел;
читая книги встречал SOLID из которого было понятен только первый принцип;
думал, зачем люди придумали инопланетное понятие - интерфейс;
про изучение более высокоуровневых понятий само собой еще не мечталось;
Остальные члены команды были примерно на том же уровне.
Исходя из этого, хотелось бы позаботиться о читателе. И если у вас:
портится настроение при виде плохого кода;
начинаются головные боли, когда вы видите неудачные конструкции и решения;
понесет желудок, когда наблюдаете неверно выбранные и примененные алгоритмы или шаблоны;
руки автоматически тянутся к клаве или мышке, с желанием узнать адрес (об этом вроде дядюшка Боб писал) того программиста который смеет засорять просторы любимого Хабра, чтобы причинить ему что то противоправное.
То прошу вас, воздержитесь от дальнейшего чтения этого и последующих статей. Хотелось бы, чтобы и с вами и со мной все было в порядке.
Почему такие сложности?
Почему же мы сели за написание своего фреймворка?
Если говорить с позиции владельца продукта который понимает разработку, то продукт характеризуется следующими характеристиками:
амбициозная цель стартапа, которая предполагает параллельную работу многих команд в будущем;
сложная предметная область, которая требует соответствующего решения и уровня квалификации от разработчиков.
Исходя из этого, я понимал что рано или поздно надо будет решать вопрос с технической компетенцией команды.
Но что если этот стартап не взлетит? Умные книги говорят, что на старте стартапа первым делом нужно получить ответ на вопрос: “Нужен ли этот продукт рынку?”.
Да, это так. Но скажу с позиции руководителя компании. Я пришел в it надолго и понимаю, что основная сила компании в долгосрочной перспективе кроется в умениях как каждого отдельного члена команды, так и команды в целом. Мне всегда хотелось видеть в своей команде опытных специалистов. И мы в определенный момент приняли решение, глубже изучить основы разработки сложных программ. Предыдущая статья, как раз история об этом.
Итогом этого пути является тот результат, которым мы хотим поделиться. Работа над фреймворком не закончена, впереди еще много работы. Но каркас есть и можно предметно говорить о качестве принимаемых решений.
Четырем неподготовленным умам пришлось изучать довольно сложные штуки, такие как Clean architecture, DDD, CQRS, EventSourcing. Само собой, каждый понял все что скрывается за этими терминами по-своему. Пришлось объединять все это понимание в одно общее решение. Это другое, не менее сложное действо уже командной работы, тоже было преодолено. И на ваш суд выкладывается продукт всей этой работы.
Общее требования к фреймворку
Давайте обсудим что мы хотели реализовать в своем фреймворке и почему.
Что | Почему |
Должен соответствовать Clean architecture + DDD | Предметная логика не зависит от внешних фреймворков. Выделение в отдельный слой всей предметной логики, позволяет лучше контролировать сложность приложения в целом. При моделировании, проектировании и реализации предметного слоя нет необходимости отвлекаться на устройство слоя приложения. |
Поддерживать микросервисную архитектуру | Уменьшает сложность реализации предметной области физически разделяя поддомены. Позволяет в будущем параллельную и независимую разработку несколькими командами. Позволяет использовать различные технологии. |
Поддерживать CQRS | Разделяет запросы чтения и изменения с целью повышения быстродействия, отказоустойчивости и оптимизации ресурсов. |
Поддерживать EventSourcing | Сохранение истории изменения состояния предметной области в виде описания событий, дает возможность получения ответов на большее количество вопросов. В случае некорректной обработки данных и получения невалидного состояния системы, позволяет позднее получить валидное состояние системы по уже сохраненным событиям. |
Максимальная унификация фронтэнда и бэкенда в связи с использованием одного языка программирования | 100% владение командой модели и кода предметной области. Упрощает переход программистов с бэкенда во фронтэнд и обратно. Позволяет переиспользовать код. Уменьшает bus factor. |
Конечно за реализацию всего вышеприведенного придется заплатить сложностью кода, дороговизной реализации сценариев использования, производительностью.
Стек технологии:
Бэкенд: TypeScript + NestJS + TypeORM (Postgres)
Фронтенд: TypeScript + Angular
NestJS используется только для обработки HTTP запросов, в будущем возможен переход на Express.
Соглашения
Целью данной статьи является ознакомление с принятыми решениями в общих контурах всего фреймворка. Будут описываться модули, слои, компоненты и их взаимодействие на довольно высоком уровне. Реализация будет рассматриваться в следующих статьях.
Стрелки во всех диаграммах обозначают зависимости, если это не оговорено специально.
Мы предполагаем, что читатель знаком, хотя бы частично с используемыми практиками и технологиями.
Мы не ставим своей целью ознакомить вас полностью с нашим кодом. Нас больше интересует освещение принятых решений. Поэтому оставляем за собой право опускать в статьях некоторые детали, которые мы считаем излишними. Например, мы можем указать не все модули или упростить иерархию наследования классов.
Здесь и в последующих постах я буду давать свое понимание шаблонов, технологии, терминов. Если будут цитаты с книг или статей, то это будет явно оговариваться.
Вводные правила
ООП
В разработке принято ООП как основная парадигма программирования. Рекомендовалось все компоненты проектировать в данной парадигме.
Избегали использования декораторов, потому что не было уверенности в использовании “к месту”. Меня немного напрягало, что использование декораторов довольно сильно упрощает реализацию в некоторых местах. Было ощущение, что его используют “потому что так проще”. Придерживание ООП стиля по крайней мере заставляет задумываться о:
что за классы должны быть и как их назвать;
за что они ответственны;
как они должны взаимодействовать с другими;
частью какого слоя они являются;
С декораторами мне было сложнее отвечать на эти вопросы и поэтому было принято вышеприведенное решение. Я боялся, что если будем часто баловаться декораторами, то потеряем контроль над сложностью. Как вы считаете, насколько оправдано введение этого ограничения с условием, что архитектурная и продуктовая части приложения в будущем может сильно разрастаться? Команда считала это ограничение излишним.
В фреймворке хоть и принята парадигма ООП, но все же присутствуют функции. В некоторых местах это казалось обоснованным. Я определил следующие критерии, когда можно допустить использование функции:
функция довольно часто используется;
очень маловероятно, что “рядом” будут появляться подобные функции;
Например, если есть новая функция для работы с датой, то все таки его лучше определить в объект DateUtility, есть вероятность, что появятся другие функции для работы с датой. В нашем фреймворке присутствует функции failure, success для возврата ответа. Маловероятно, что появится третий вариант ответа.
Обработка ошибок
Ответы при общении между компонентами производятся через ответ со статусом обработки. При этом:
при положительном ответе, в теле ответа лежит ожидаемый ответ;
при отрицательном ответе, в теле ответа лежит экземпляр ошибки;
Исключения (Exceptions) используются только с применением шаблона Assertion (Утверждение). В этом случае, обработчик запросов бэкенда возвращает ответ с объектом ошибки InternalError. Это правило не распространяется на исключение “Оптимистичной блокировки”.
При необходимости компоненты могут внутри использовать исключения, но в наружу они обязаны передавать стандартный ответ.
Бэкенд, фронтэнд
Бэкенд - ответственен за реализацию логики предметной части.
Фронтэнд - ответственен за взаимодействие с пользователем.
Из этого следуют следующие решения:
бекэнд обязан предоставлять однозначный ответ с результатом обработки запроса, но не более;
фронтэнд обязан сам определить что и как должен показать конечному пользователю, для этого:
определять тексты сообщения и показывать их конечному пользователю в соответствии с локализацией;
реализовать перевод времени в соответствии с временными зонами;
Слои и модули
Для дальнейшего изложения материала есть необходимость в определении нашего единого языка терминами принятыми в нашем фреймворке:
active - это запросы, которые приводят к изменению состояния системы. Обычно их называют command-запросы. Термин command используется для других целей, поэтому пришлось искать альтернативные названия;
passive - это запросы, которые не приводят к изменению состояния системы, т.е. query-запросы;
модуль - у этого термина два понятия:
репозитории в GitHub;
в коде, это объект который представляет определенный модуль поддомена;
поддомен - часть предметной области которая выведена как отдельная обособленная область. Состоит из нескольких модулей, подробнее ниже;
Модули
Зависимости между модулями устанавливаются как зависимости между npm пакетами.
Модули предназначены для физического разделения кода согласно их ответственности. Модули в общем делятся на две категории:
архитектурный - где хранится весь код нашего фреймворка;
конкретный - где хранится код конкретного приложения (стартапа).
В следующем рисунке приведены модули из которых состоит наш фреймворк, а также какие модули могут создаваться для конкретного приложения.
Давайте разберем, что это за модули и за что они ответственны.
Думаю с модулями фреймворка не нужно объяснений. Тут имена говорят за что они ответственны.
ConcreteApp* - конкретное приложение стартапа. Хранит логику которые уникальны именно для данного приложения.
ConcreteModule# - модуль отдельного поддомена предметной области. Каждый поддомен может объявлять следующие модули:
Frontend: логика поддомена в части фронтэнда;
Contracts: контракты, которые данный поддомен объявляет для других поддоменов и фронтэнда;
Common: логика бэкенда, которая обрабатывает и active и passive запросы.
Command: логика бэкенда, которая обрабатывает только active запросы;
Query: логика бэкенда, которая обрабатывает только passive запросы.
Модули Command, Query - это соответствующие их названиям модули согласно технологии CQRS. Если для конкретного поддомена классический CQRS излишен, то данный поддомен разрабатывается как обобщенный модуль Common обрабатывающий все запросы.
Зависимости между поддоменами, устанавливаются на модуль Contracts.
Добавим в наш единый язык следующий термин:
микросервис - модуль Common, Command или Query определенного поддомена в момент выполнения;
Микросервисы
Как было объявлено: у нас микросервисная архитектура. На данный момент мы сконцентрировались на модульном монолите. То есть, на начальном этапе, все микросервисы будут запускаться в одном сервере. Но это должны быть настоящие микросервисы с общением через шину. А шина будет просто классом. Это позволит нам не задумываться на начальном этапе насчет инфраструктуры. А переход в будущем должен быть довольно безболезненным и быстрым.
Размер микросервиса
Какой должен быть размер микросервиса (или по DDD-шному, размер поддомена)? Я пока не читал книги про микросервисы. Но в одной статье было приведено, что размер микросервиса в их проекте часто равна размеру агрегата. Лично мне это кажется излишним разделением. Считаю, что размер микросервиса должен быть достаточно большим, чтобы инкапсулировать более крупную бизнес логику. По моим интуитивно-рассудительным прикидкам, оптимальным видится размер 5-20 сценариев использования. Интересно было бы узнать мнение читателя.
Состояние системы
Из-за того, что при CQRS состояние системы фактически хранится в двух местах, то был вопрос: “А что является состоянием системы? Когда мы считаем что состояние системы изменилось?”. Было принято следующее решение:
состоянием системы в поддомене, является состояние микросервиса Query;
если бэкенд поддомена реализован через Common тип микросервиса, то он с точки зрения состояния считается как Query микросервис;
если поддомен реализован по технологии CQRS, то:
микросервис Command использует свое состояние как состояние системы, если ему нужно состояние других поддоменов, то он обращается в микросервис Query;
микросервис Command после изменения своего состояния обязан выпустить в шину событие с типом CommandEvent;
каждый микросервис Query обязан подписаться на события микросервиса Command и привести свое состояние в соответствие с состоянием Command обработав каждое событие;
состояния микросервисов между поддоменами обновляется через обработку события типа QueryEvent;
Как это выглядит в деле (в данной диаграмме, стрелки являются потоком выполнения):
CommandModule1 публикует событие типа CommandEvent, на которое может подписываться только QueryModule1;
QueryModule1 обработав событие, публикует событие типа QueryEvent, которая предназначена для микросервисов других поддоменов;
CommonModule4 обработав событие публикует в шину свое событие, и оно всегда типа QueryEvent;
Слои
Все модули кроме CommonCore состоят из четырех слоев (которые фактически являются корневыми папками).
Слой - это крупномасштабная абстракция которая делит модуль на горизонтальные слои (уровни). Слои позволяют концентрироваться на решении определенных задач согласно ответственности данного слоя.
Компоненты слоя могут импортировать компоненты своего и внутренних слоев. Если слой использует компонент который должен быть реализован в одном из внешних слоев, то он инвертирует зависимость, объявив соответствующий интерфейс.
Слой | Ответственность |
Domain (доменный, предметный слой) | Слой предназначен для описания предметной логики. |
Application (слой приложения) | Слой описывает как работает наше приложение. |
Infrastructure (слой инфраструктуры) | Слой в котором используются сторонние технологии (фреймворки), например фреймворк БД. Правило: Если какому либо компоненту приходится импортировать компонент из внешней библиотеки или это делает кто-то из его предков, то он обязан быть в инфраструктурном слое. |
Config (Конфигурационный слой) | Предназначен для конфигурирования и начальной инициализации приложения. Концептуально является частью слоя приложения, но компонентам данного слоя разрешено импортировать компоненты инфраструктурного слоя. |
Далее в статье будут описываться только часть бэкенда. Фронтэнд будет описан в соответствующей статье.
Доменный слой
Как было представлено выше в таблице, слой предназначен для описания бизнес-логики приложения, это по другому еще называют предметной моделью, предметной логикой. Задача фреймворка: предоставить все инструменты для этого.
Доменный слой делится на два подслоя:
Domain (Домен) - слой для моделирования предметной области в пределах отдельных агрегатов.
DomainService (Доменный сервис) - слой для моделирования взаимодействия нескольких агрегатов.
Domain - Доменные объекты
Для моделирования бизнес логики в фреймворке предусмотрено следующая иерархия классов.
BaseObject - родоначальник всей иерархии объектов предметной области.
ValueObject (Объект-значение) - объект предметной области, не имеющий уникальности. Нельзя менять состояние (атрибуты) объект-значения. Если это необходимо, то необходимо создать новый объект-значение. Объект-значения равны (являются одним и тем же объектом), если они относятся к одному типу и значения их атрибутов равны.
Entity (Сущность) - объект предметной области, имеющий уникальность (идентификатор) в пределах агрегата. Сущность может менять свое состояние. Сущности равны (являются одним и тем же объектом), если они относятся к одному типу и их идентификаторы равны.
AggregateRoot (Корневой узел) - Сущность, но с глобальной идентичностью. Корневой узел тесно переплетается с таким понятием как агрегат. Приведу цитату из глоссария синей книги по DDD: “Агрегат (Aggregate) - совокупность ассоциированных друг с другом объектов, воспринимаемых с точки зрения изменения данных как единое целое. Внешние ссылки возможны только на один объект АГРЕГАТА, именуемый корневым (root). В границах АГРЕГАТА действует определенный набор правил согласования и единообразия.”
Фактически, агрегат это иерархия из объектов значений и сущностей, где главным (точкой входа) является сущность которая называется корневым узлом. Экземпляр агрегата (корневого узла) можно получить в репозитории (хранилище) по идентификатору корневого узла, поэтому этот идентификатор называется глобальным.
Еще одним из особенностей и обязанностей корневого узла является проверка инвариантов агрегата. Т.е. корневой узел обязан не допускать получение экземпляра в не валидном состоянии.
Валидацию входных от пользователя значений мы решили вынести на уровень приложения. Это позволит агрегату сосредоточиться на проверках инвариантов требовании предметного слоя. Т.е. корневой узел не будет проверять является ли числом значение age, он будет считать, что к нему приходят валидные значения.
При этом встал вопрос, как нам отличить что должно проверяться на входе, а что агрегат должен проверять сам. Как определить? Нужно определить правило. Наше правило на данный момент звучит так: “Валидации на уровне приложения относится: проверка типа данных (string, number), формата данных (соответствие например regex), диапазона данных (положительное число, список). При этом для валидации у валидатора должны быть все данные, он не может выполнять какие либо запросы, куда либо (например выполнить запрос в БД, чтобы получить список объектов)”. За проверку других случаев ответственен сам агрегат.
DomainEvent (Доменное событие) - объект события, сообщает что состояние предметной области изменилось. Изменение состояния агрегата приводит к появлению (регистрации в агрегате) доменного события.
Object-DTO
Обмен доменными объектами между микросервисами и фронтэндом производится через object-dto объекты.
Как мы видим добавились два новых типа объекта: AppError, DomainError.
Error (Ошибка) - предназначена для сообщения клиенту об ошибке обработки запроса. Фактически, объект ошибки является объектом-значением. Но было принято решение описывать их сразу как объект dto. Причина в том, что объекты наследники BaseObject приходится описывать два раза. Первый раз, как объект предметного слоя, второй раз, его object-dto представление. Так как объекты ошибок не имеют своей логики, было принято решение описывать их сразу в dto представлении.
DomainErrorObjectDTO - объект моделирования предметной области. Описывает ошибки предметной области.
AppErrorObjectDTO - описывает ошибки слоя приложения.
DomainService - Доменный сервис
DomainService (Доменный сервис) - объект, предназначенный для определения логики взаимодействии нескольких агрегатов в пределах одного микросервиса. Подробнее в разделе: “Domain, моделирование”.
Saga - Сага
Saga (Сага) - объект, предназначен для определения логики взаимодействия сценариев использования разных микросервисов. Подробнее в разделе: “Domain, моделирование”.
Данный объект еще не реализован в фреймворке, существование этого объекта пока на стадии проектных предположений.
Domain, техническая часть
Репозиторий (Repository) - объект, который представляет абстракцию хранилища для объектов предметного слоя.
Каждый корневой узел обязан иметь репозитории. Так как, технология сохранения не является частью логики предметной области, то агрегат инвертирует зависимость через объявление интерфейса репозитория.
В процессе разработки в команде было обсуждение, где должен быть объявлен репозиторий. Я был за то, чтобы репозитории был объявлен в слое приложения. Моя логика была основана на следующем:
предметному слою не надо знать, что он должен где то храниться;
слой приложения знает, что есть запросы и надо сохранить состояние между запросами.
Но участниками команды было приведено обоснованное возражение. Есть вероятность, что корневому узлу может все таки понадобится объект репозитория. Например, если мы захотим использовать шаблон “Спецификация” описанный в книгах DDD. Пришлось согласиться.
Factory (Фабрика) - это объект, который знает как создавать агрегат. Если вам нужен экземпляр агрегата, то вам нужно обратиться к фабрике. Фабрика создается для каждого корневого узла.
Domain, моделирование
Итого, со всеми объектами предметного слоя мы познакомились. Давайте на довольно абстрактном уровне посмотрим как этим предполагается пользоваться.
Агрегат
Основным строительным материалом при моделировании предметного слоя на мой взгляд является агрегат. Как было сказано, агрегат это группа объектов которые в совокупности решают определенную бизнес задачу.
Допустим, что мы хотим сделать софт для автомастерской. В приложении смоделирован агрегат Автомобиль. Агрегат автомобиля имеет свою структуру вложенных объектов: двигатель, шасси, ходовая часть, шины и т.д. Каждый из этих объектов может иметь свою иерархию объектов.
Сразу видно, что у нас получится супер-агрегат. Если мы захотим поменять масло в двигателе, то мы начнем с автомобиля и в нашем распоряжении имеются очень много других объектов которые не относятся к данной операции и данному узлу. Более того, если мы меняем масло и изменяем состояние агрегата, то мы заблокируем доступ ко всему агрегату (автомобилю). Это означает, что другие пользователи не могут работать с этим агрегатом на изменение. А это уже не эффективно. Следовательно, надо чтобы наше приложение состояло из более мелких агрегатов.
Возникает вопрос: “Насколько мелко надо дробить?”. Это как раз и задача программиста, решить каким должен быть размер агрегата и что он должен включать в себя.
Агрегат должен быть достаточно большим, чтобы инкапсулировать и решать определенную бизнес-задачу. В то же время должен быть достаточно мелким, чтобы обеспечивать простоту понимания агрегата и отвечать техническим требованиям приложения (все таки есть у этого слоя зависимость от приложения:).
Из этого следует, что наше приложение скорее всего будет состоять из множества агрегатов.
А что, если нам понадобится менять состояние нескольких агрегатов одновременно? Или, если говорить техническим языком, есть необходимость в изменении состояния нескольких агрегатов за один раз, за один сценарий использования. Можно слою приложения (UseCase) поочередно вызывать методы у агрегатов. Но это означает, что логика предметной области начала просачиваться в слой приложения.
Рассмотрим это на следующем примере. Представим, что есть агрегаты Engine и CarCard (карточка авто). Есть сценарий использования: замена масла в двигателе. Когда она выполнится, то необходимо чтобы в агрегате Engine атрибут масла была отмечена как новая, а в агрегате CarCard обновилась запись следующей замены масла. Каждый агрегат отвечает только за себя. Как же быть? И тут вступает в работу объект DomainService.
DomainService
Как видно из названия - это сервис, а значит, не имеет состояния. Его задача предоставить метод для слоя приложения и выполнить скоординированную работу над группой агрегатов изменив их состояния. При этом она может работать только с агрегатами в пределах микросервиса. Данный объект дает новый уровень абстракции и позволяет более гибко моделировать предметную область.
А что делать, если Автомобиль оказался настолько большим и сложным, что мы решили разбить его на несколько микросервисов? И перед нами стоит задача обеспечить совместное выполнение предметной логики в пределах нескольких микросервисов. Тут уже нам поможет самая крупная и неповоротливая артиллерия предметного моделирования - Saga.
Saga
Сразу оговорюсь, что я не читал книг про микросервисы. Не читал углубленно статьи про Saga. Дальнейшие умозаключения основаны на отдельных обрывках информации.
Итого, паттерн Сага в моем понимании отвечает за последовательное или параллельное выполнение различных сценариев использования (UseCase) различных микросервисов. Вообще думая о об этом паттерне, я пришел к следующим выводам;
Сага, так же как и Корневой узел должен хранить свое состояние между вызовами;
Сага, так же как и Корневой узел должен иметь глобальный идентификатор и версионность (про версионность в следующем посте);
Сага запускает UseCase-ы. А с пониманием, что за каждым UseCase скрывается определенный метод предметного слоя, то мы понимаем что она последовательно или параллельно запускает различные методы предметного слоя.
Исходя из этого, объект Saga мной определен в предметный слой и предположительно является наследником AggregateRoot. Saga отличается тем, что в своем состоянии сохраняет статусы выполнения UseCase-ов и не имеет вложенной структуры объектов. В остальном, я не вижу различий. А что думаете вы?
Слой приложения
UseCase - Сценарий использования
Относится к слою приложения, но ответственен за корректное выполнение предметной логики. При этом ему позволено выполнять passive запросы в другие микросервисы (через объекты-фасады этих микросервисов). Обычная последовательность в его теле:
запросить данные в других микросервисах, если это необходимо;
получить в репозитории или в фабрике экземпляр агрегата;
вызвать метод предметного слоя и вернуть ответ.
Handler - Обработчик запроса
Можно сказать, что объект Handler формирует каркас всего приложения. При этом он связан с UseCase один на один, то есть для каждого UseCase необходимо определить свой Handler. UseCase не знает про свой Handler. Для выполнения своей работы, ему позволено знать многое про устройство приложения. Что он знает:
что он ответственен обработать запрос, вызвав UseCase;
что запросы могут выполнять AnonymousUser, User, ModuleCaller;
что запросы делятся на вызовы Call (Active или Passive) и события Event, при этом Call должен вернуть ответ со статусом выполнения, а Event не возвращает ответа;
что Call запросы надо проверить на валидацию входных данных;
что есть Active и Passive запросы;
что если это Active запрос, то надо сохранить изменения;
что если это Active запрос, то возможно нужна публикация события в шину сообщений;
что приложение имеет микросервисную архитектуру;
что микросервисы могут быть типа Common, Command или Query;
Может возникнуть мысль, что этот объект не отвечает принципу единственной ответственности. Но это не так, он ответственен за обработку запроса. Вся его работа делится на три основных этапа:
подготовительные действия до выполнения UseCase;
выполнение UseCase;
завершающие UseCase действия.
Соответственно, он знает логику обработки запроса. А для выполнения специализированных задач он применяет делегирование. Рассмотрим как выглядит объектная модель Handler-а и его окружения.
Порядок обработки Handler-ом запроса:
выполняет проверку, кто вызывает (AnonymousUser, User, ModuleCaller) и можно ли ему выполнить этот запрос. Что то похожее на разрешения;
выполняет валидацию входных данных, делегируя эту задачу UseCaseValidator-у;
создает экземпляр UnitOfWork;
выполняет UseCase;
сохраняет изменения в состоянии системы, делегируя это UnitOfWork;
публикует событие об изменении состояния системы:
формирует сообщение события для шины сообщений, делегируя эту задачу EventMessageFormer-у;
Публикует сообщение, делегируя эту задачу DomainEventPublisher-у;
формирует ответ, используя при необходимости BaseObjectMapper;
выполняет проверку, кто вызывает (AnonymousUser, User, ModuleCaller) и можно ли ему выполнить этот запрос. Что то похожее на разрешения;
выполняет валидацию входных данных, делегируя эту задачу UseCaseValidator-у;
создает экземпляр UnitOfWork;
выполняет UseCase;
сохраняет изменения в состоянии системы, делегируя это UnitOfWork;
публикует событие об изменении состояния системы:
формирует сообщение события для шины сообщений, делегируя эту задачу EventMessageFormer-у;
Публикует сообщение, делегируя эту задачу DomainEventPublisher-у;
формирует ответ, используя при необходимости BaseObjectMapper;
Конечно же некоторые пункты, в определенных видах запроса будут пропускаться, здесь приведен самый сложный вид запроса - active запрос.
Вроде должно быть понятно за что ответственны классы, поэтому не буду углубляться. Тем более будут еще посты где будет рассматриваться реализация этих классов.
Остановлюсь более подробно на том, что еще не реализовано и требует этого в будущем. Это:
разрешения, сейчас очень упрощенная версия. Похоже в продукте необходимо будет реализовать разрешения, которые могут меняться динамически. Это усложняет задачу и мы решили это оставить на потом;
доставка изменений до фронтэнда.
Во первых, согласно CQRS, active запросы не должны возвращать экземпляр объекта в теле ответа. Фронтэнд должен его получить после обновления Query модуля.
Во вторых, необходимо доставлять изменившиеся объекты не только до клиента сделавшего запрос, а всем, кому интересен данный объект;
Bus, BusAdapter
Bus (Шина сообщений) - ответственен за гарантированную доставку сообщений до всех подписчиков. При этом это абстракция, которая не является частью приложения. Это что то внешнее, например RabbitMQ. Но выше приводилось, на момент написания поста во фреймворке не предполагается работа с внешней системой.
Все общение между микросервисами будет происходить по шине сообщений.
Для того чтобы при каждой смене технологии доставки сообщений нам не пришлось глубоко изменять код, мы добавили абстракцию под названием BusAdapter.
BusAdapter (Адаптер шины сообщений) - объявляет единообразный интерфейс для работы с шиной сообщений, а конкретные реализации адаптируют работу с определенной технологией.
Controller
Абстракция, обязанность которого быть посредником между технологией связи и Handler-ом. Имеет следующую иерархию.
HttpController - ответственен за получение запроса и возврат ответа в соответствии с HTTP протоколом.
BusController - ответственен за трансформацию сообщения шины в тип понятный Handler-у и обратную трансформацию ответа Handler-а в формат шины сообщений.
ModuleFacade
Каждый модуль (микросервис) имеет свой объект ModuleFacade. Соответственно, команда работающая над поддоменом обязана разработать и положить в модуль Contracts объект ModuleFacade Предназначен для простого доступа и вызова сценариев использования данного модуля. Фасад за кулисами использует Шину сообщений для выполнения запросов.
Запросы и общение через шину сообщений
Давайте рассмотрим диаграмму классов на этом уровне:
К Handler-у запросы могут попадать двумя путями. Через внешние HTTP запросы и внутренние запросы приложения через шину сообщений. На самом деле для Handler-а без разницы кто вызывает, он отвечает стандартно для всех клиентов. А уже контроллеры конвертируют ответ в соответствующий формат.
При этом контроллер внешнего запроса обязан присвоить идентификационный номер запроса и передать его обработчику. Он нужен для отслеживания цепочки запросов распространившихся по микросервисам.
Для получения внутренних запросов (общение между микросервисами), соответствующие Handler-ы в момент инициализации приложения подписываются в BusAdapter.
Поток выполнения между BusAdapter-ом и Handler-ом проходит через BusController. Для этого BusAdapter передает экземпляр Handler-а BusController-у.
DomainEventPublisher получив от Handler-а сообщение о событии публикует его через BusAdapter.
UseCase может обратиться к другим микросервисам с passive запросом через соответствующий ModuleFacade, который в свою очередь выполняет запрос через шину.
В будущем предполагается, что Saga через ModuleFacade будет выполнять active запросы.
Инфраструктурный слой
Обязанность инфраструктурного слоя - реализация обязанностей компонентов используя внешние библиотеки и фреймворки. На момент написания в этом слое реализовываются следующие компоненты:
Repository (TypeORM);
TransactionManager (использует UnitOfWork, TypeORM);
BusAdapter (Пока собственная реализация);
HttpController (NestJS);
библиотека jsonwebtoken для работы jwt-токенами.
Описание реализации этого слоя не входит в планы этих статей.
Конфигурационный слой
Как было описано выше, слой предназначен для конфигурации и инициализации приложения. Начнем с диаграммы:
Module - объектное представление микросервиса;
ModuleConfig - конфигурация микросервиса;
ModuleResolver - разрешает получение экземпляров в пределах микросервиса. Аналог dependency injection;
Application - объектное представление приложения;
ServerConfig - конфигурация для запуска сервера;
Поток инициализации:
ServerConfig создает экземпляр Application передав список конструкторов Module которые определяют какие микросервисы работают на этом сервере;
Application при инициализации создает экземпляры микросервисов;
происходит инициализация каждого микросервиса;
На данный момент ServerConfig не управляет запуском NestJS и внедрением Resolver-ов в список провайдеров (DI фреймворка). Хотелось бы, чтобы запуск экземпляра ServerConfig приводил к запуску сервера. Но это скорее всего потом. На данный момент каждый резолвер в NestJS приходится прописывать явно.
Заключение
Обратная связь
В начале статьи я приводил в какой стадии компетенции мы находились, когда только начинали. Я видел в этом проблему. И мы пошли путем улучшения своих профессиональных навыков и за это конечно пришлось заплатить высокую цену.
Насколько компетентными нам удалось стать? Что бы мы могли улучшить? Что мы не знаем и не учли? Насколько хорошо нам удалось овладеть новым языком программирования со статической типизацией? Мысль о данных статьях появилась, в связи с желанием получить обратную связь от других специалистов. Остальные цели, описанные в первой статье хоть и являются важными, но идут бонусом. И они сильно повлияли на обоснованность выделения ресурсов для написания статей. Будем рады оценке и критике.
В нашей команде принято следующее правило. Если видишь проблему, то:
необходимо озвучить, что за проблему ты видишь;
необходимо озвучить, почему ты считаешь эту проблему важной;
желательно предложить вариант(ы), как можно решить эту проблему;
Приглашаю к такому же формату обсуждений.
Блог-дневник
Я еще веду блог стартапа в формате видеодневника.
Про что блог?
Само собой, блог о стартапе. И я в нем планирую рассказывать, что мы делаем, почему мы это делаем и возможно как мы это делаем. Но в глубине блог о другом.
Я в прошлой статье писал, что в какой то момент из жизни ушел страх. Страх неудачи и безденежья. Я и раньше чувствовал глубокое удовлетворение от того чем занимаюсь, от того пути которое я выбрал. Но после этого события, все многократно усилилось. Как будто после заточения, тебя освободили. Фактически так это и было. Состояние тревоги о судьбе стартапа которое я испытывал время от времени, организовывало вокруг меня невидимые стены. И вдруг, свобода… Точно уже не помню, но кажется именно после этого появилась мысль о блогинге. Хотелось, чтобы люди вставали на путь и испытывали подобное.
Что такое путь в моем понимании? Путь - это вызов, движение и развитие. Когда мы хотим получить новый опыт, новый результат и движемся в этом направлении, развиваясь для достижения результата. Путь, это мастерство созидания. Созидания нового.
Решил рассказывать о своем пути. О том, что это дает мне и другим. О том, что в пути нет ничего, абсолютно ничего плохого. Есть только хорошее. Есть только движение и развитие. И этот блог об этом.
Не планирую рассказывать про профессиональные навыки. Если вы хотите прокачать свои технические навыки в it, то здесь не про это.
Но я буду рассказывать про все остальное. Про стартап, основанной на миссии. Как я строю в своих стартапах корпоративную культуру. Какие проблемы встречаются на пути и что нам нужно сделать, чтобы преодолеть их. Я планирую рассказывать про то, какие личные навыки мне помогают идти по пути. Как я их получил и развил. И т.д.
Если честно, то у меня нет точного видения, что я буду рассказывать и в каком формате. Это будет определяться и уточняться по ходу, буду слушать свой внутренний компас. Предполагаю, что на контент будет сильно влиять обратная связь. Но что я точно знаю, это миссию блога: “Вдохновляю найти свой путь и идти по нему”. И хорошо бы мне, рассказывая о своем пути не забывать об этом.
При этом, я не являюсь экспертом по стартапам или бизнесу. При этом, у меня нет опыта выстраивания высокоуровневой корпоративной культуры и создания agile команд. При этом я не знаю что ожидает стартап в будущем. Но это не важно. Важно, что есть желание идти. Идти по своему пути.
Приглашаю. Возможно вы найдете что то полезное. Или дадите ценную обратную связь.
Блог ведется в платформах instagram, youtube. Найти можно, произнеся заклинание: “Startup Uralsk” в соответствующих платформах.
Заключение в заключении
На этом все, всей командой ждем обратной связи. Единственная просьба, если будете ставить минус, известите нас, что не так. Это конечно не обязательно, но качественная обратная связь - залог развития.