Итак, начнём с заготовки блока BE Proxy, который впоследствии должен вытеснить серверную часть изначального приложения.
На первом этапе это будет всего несколько бизнес-сущностей, но нам необходимо заложить базовые архитектурные принципы, чтобы проект можно было легко масштабировать. За основу возьмём принципы чистой архитектуры (clean architecture).
Что такое чистая архитектура и почему стоит следовать её принципам?
Во-первых, стоит отметить, что принципы чистой архитектуры – это не догма и не следует, узнав о любом шаблоне проектирования, незамедлительно применять его во всех проектах. Разные задачи требуют разного подхода к их решению, однако идеи, заложенные в принципах чистой архитектуры, дают неоспоримые преимущества для решения задач проектирования масштабируемого API. Среди преимуществ можно выделить:
- низкую связанность компонентов;
- устойчивость к изменениям бизнес-логики;
- разделение приложения на логические блоки;
- удобство сопровождения и расширения (всё-таки это в некотором роде «стандарт», принципы которого понятны другим разработчикам);
- независимость от фреймворка/UI/БД/внешнего сервиса (можно легко перейти с MS SQL Server на Postgres или с UI на ASP.NET на React и т.д.).
Принципы чистой архитектуры
Можно сказать, что принципы чистой архитектуры имеют «domain first» подход, т.е. предлагают строить приложение вокруг бизнес-логики.
Разделение на уровни:
Рис. № 1
- уровень домена (domain) – содержит сущности, отвечающие за работу с данными;
- уровень приложения (application) – выполняет бизнес-логику и координирует работу между уровнями представления и домена;
- уровень инфраструктуры – связь с внешними системами (БД, API etc);
- уровень представления – взаимодействие с пользователем.
Зависимости внутри уровней
Направление зависимостей соответствует направлению стрелок на рисунке № 1. Один из способов это сделать – инверсия зависимостей (Dependency Inversion Principle).
Чистая зависимость
Принцип «чистой зависимости» говорит о том, что более высокоуровневые модули не должны зависеть от низкоуровневых. Оба модуля должны зависеть от абстракций. Абстракции не зависят от деталей, а детали зависят от абстракций.
Use case
Принцип, определяющий входы и выходы пользовательских сценариев.
Приведем набившую оскомину диаграмму, на которой представлено более подробное описание слоев и направление связей.
Рис. № 2
Слева изображен общий взгляд на разделение по слоям:
- Entities. Сущности, содержащие самую важную логику. Critical Business Rules – правила и законы, по которым работает система и без автоматизацииCritical Business Data – данные, необходимые для работы Critical Business RulesИ два этих понятия формируют понятие Entity. Entity – объект, который хранит в себе Critical Business Rules и необходимые для их работы данные (Critical Business Data);
- Use Cases. Бизнес-процессы – объекты, манипулирующие Entity;
- Controllers/Presenters/Gateways. Слой взаимодействия с внешним миром;
- UI/DB/devices. Внешний мир (например, база данных).
На диаграмме представлены зависимости исходного кода, они идут от внешних слоёв к внутренним.
Справа показана диаграмма зависимостей:
- Controller – уровень, с которым общаются внешние клиенты;
- Use Case Input Port – интерфейс, представляющий бизнес-процессы;
- Use Case Interactor – реализация Use Case Input Port;
- Use Case Output Port – интерфейс для представления результатов бизнес-процесса;
- Presenter – реализация Use Case Output Port.
Через всю диаграмму от Controller к Presenter идёт розовая стрелка – поток управления.
Пробуем применить эти данные к нашей задаче!
Пример реализации
Начнём с малого: пока ограничимся работой с деревом объектов и версиями изменения этого дерева (это наши Entity). Добавляем таблицы в БД. Важно понимать, что записи из dcc_objects не будут удаляться, чтобы по версиям можно было восстановить дерево на конкретную дату. Удаляемые объекты будут помечаться is_deleted = true.
Рис. № 3
Реализовывать будем с помощью .net core Web API. Приведём диаграмму архитектуры приложения ASP.NET Core в соответствии с принципами чистой архитектуры.
Рис. № 4
Создаём проект, разбиваем его на папки (соответствующие слоям) и добавляем соответствующие проекты.
Рис. № 5
Ядро (core):
- Nicotech.Dcc.Domain. Слой Domain, в котором мы создаём наши Entity (DccTreeObj, DccTreeVersion, DccTreeChange), которые соответствуют таблицам созданным в БД. Никаких Critical Business Rules в них пока нет, только Critical Business Data.
- Nicotech.Dcc.App. Слой Application, в котором реализованы Use case-ы.
Рис. № 6
В отличие от Domain, где нет ничего кроме Critical Business Data, слой Application чуть интереснее.
- Инверсия зависимостей
То, о чём мы говорили, обсуждая направление связей. Мы не должны засорять наше ядро, поэтому тут мы создаём интерфейс IDccDbContext, а реализация инфраструктурных зависимостей уже расположена в своём слое (папка Infrastructre).
- Command and Query Responsibility Segregation (CQRS)
CQRS – архитектурный паттерн, при котором код, изменяющий состояние, отделяется от кода только читающего состояние. Одна из причин развития CQRS – несимметричное распределение нагрузки и сложности бизнес-логики на чтение и запись. Большинство бизнес-правил и валидаций располагается в write-подсистеме при этом читают данные, как правило, чаще. Подобное разделение позволяет по-разному подойти к оптимизации. В том числе могут быть использованы разные модели данных (оптимизированные под запись для команд и под чтение для запросов). Так же такая система может улучшить безопасность и упростить разработку. В проекте App есть папка CQRS, разбитая на папки по сущностям, которые в свою очередь разделены на команды и запросы. Это позволяет быстрее искать необходимую команду или запрос.
- Медиатор
Медиатор – поведенческий шаблон проектирования, позволяющий уменьшить связанность множества классов между собой, благодаря перемещению этих связей в один класс-посредник.
Выглядит он так:
Рис. № 7
Итак, зачем нам медиатор и куда его встроить? С медиатором интерфейс ядра для UI слоя представляет из себя набор запросов и команд, которые обрабатывают хендлеры. В свою очередь каждый хендлер представляет отдельный use case. И располагать его нужно на границе между UI и Application.
Рис. № 8
Реализовывать отдельно шаблон своими силами – бесполезный труд, т.к. есть готовый Nuget-пакет MediatR. Всё. что нужно, это только реализовать интерфейс IRequest для команды/запроса и IRequestHandler для хендлера. Рассмотрим на примере команды создания новой версии.
Команда
Хендлер
Другие команды и запросы принципиально не отличаются и выполнены по тем же принципам, поэтому приводить отдельно каждую реализацию не имеет смысла.
Слой Infrastructure
Рис. № 9
В этом слое мы храним реализацию интерфейса IDccDbContext, которую мы определили в слое Application. В качестве ORM используем EF, реализация выглядит довольно банально.
DccTreeObjConf, DccTreeVersionConf, DccTreeChangeConf – классы, реализующие IEntityTypeConfiguration для каждой из трёх Entity, которые мы определили. Для удобства здесь же храним методы расширения для DI-контейнера.
Слой Presentation
В слое презентации также пока не так много интересного. Как уже говорилось раннее, мы расположили Mediator на границе UI и Application. Поэтому создадим базовый контроллер, в котором будет доступ к медиатору.
Теперь всё, что нам нужно, - это просто отправлять запросы/команды медиатору.
Красота! Никаких зависимостей, каждый занимается своим делом!
Ещё один немаловажный момент на будущее: нельзя, чтобы браузер кэшировал запросы к данному эндпойнту, чтобы корректно синхронизировать состояние дерева с клиентской БД (как это будет показано в следующей части). Поэтому говорим нашему контроллеру, чтобы он этого не делал.
Базовые методы в эндпойнт добавлены, вернёмся к теме backend-a в статье про создание и распределение микрозадач.
Рис. № 10