Гексагональная архитектура для Node.js-приложения, или Как сделать код более поддерживаемым

Kate

Administrator
Команда форума
Привет, меня зовут Андрей, я Engineering Manager в компании Uptech. В этой статье хочу рассказать об одном из архитектурных подходов для создания приложений — гексагональной архитектуре. Рассмотрим пример ее использования для создания Node.js-приложения.

Если ищете способ улучшить поддерживаемость и тестируемость кода, тогда эта статья для вас.

TLDR. Разделяйте бизнес-логику и любые внешние зависимости — будьте счастливы.

Перед тем как начать, давайте разберемся, какие проблемы нужно решить. Основное — сделать код более поддерживаемым. А именно: быстро добавлять новые фичи, легко менять одни зависимости на другие и тестировать код. Фактически минимизировать технический долг в долгосрочной перспективе. Гексагональная архитектура при правильном использовании может удовлетворить наши желания.

Рассмотрим типичное приложение:

image3_M2iss6v.png


В более общем случае:

image4_PSRCnGL.png


Вроде все хорошо, но что обычно происходит в реальной жизни?

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

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

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

Порты и адаптеры​

Если посмотрим на описанный выше флоу, увидим, что роуты — это просто точки «входа» к бизнес-логике, а база данных и другие сторонние сервисы — «выходы» из приложения. И таких точек «входа» и «выхода» может быть много. Заметив это, Алистер Коберн в 2005 году предложил подход к архитектуре приложений, который назвал «гексагональная архитектура», или «архитектура портов и адаптеров».

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

Наша основная цель — это изоляция бизнес-логики. Но нужен способ взаимодействия с ней. Порт служит именно для этой цели. Порт — описанная спецификация взаимодействия уровня логики с любыми внешними зависимостями. Это просто интерфейс, по которому бизнес-логика вызывается или сама вызывает внешние зависимости, без каких-либо деталей имплементации. Для большинства языков порт — это интерфейс и DTO (Data transfer object), связанные с этими интерфейсами. Порты принадлежат уровню бизнес-логики.

После того как описали процесс взаимодействия с бизнес-логикой, стоит раскрыть логику взаимодействия с конкретными сторонними сервисами и соединить их вместе. Для этой цели служат адаптеры. Адаптер — конкретная имплементация работы с другими сервисами. Эти сервисы бывают двух типов по отношению к «ядру» приложения. Те, которые логику вызывают — HTTP-роуты, Socket-соединения. Их называют первичными (Primary) адаптерами. И те, которых бизнес-логика сама вызывает: база данных, платежные программы, сервисы уведомлений и так далее. Их называют вторичными (Secondary) адаптерами.

Основная разница между ними в том, как они взаимодействуют с уровнем бизнес-логики. Primary-адаптеры втягивают в себя уровень логики и вызывают его напрямую. Они фактически говорят нашему приложению, что делать. А Secondary-адаптеры имплементируют порт и после этого инжектятся в уровень логики с помощью Dependency injection.

Выглядит это приблизительно так:

image5_Pbu0VqO.png


Выглядит красиво, но рассмотрим на примере. Спроектируем небольшое приложение календаря.

Первичный адаптер. HTTP endpoint​

Наш календарь имеет обычный REST API и взаимодействует с внешним миром через HTTP. HTTP endpoint — самый простой пример первичного адаптера. Контроллер, который работает с вашим фреймворком и обрабатывает HTTP-запросы.

В данном случае метод CreateEvent обрабатывает POST-запросы по роуту /event, парсит все параметры запроса и вызывает eventCreationService, который есть частью слоя с бизнес-логикой. Он принимает на вход только значения и ничего не знает об особенностях транспорта, что его вызвал, таких как тело запроса или заголовки.

Таким образом мы убираем зависимость бизнес-логики от транспортного протокола и фреймворка:

@injectable()
@JsonController('/event')
export class EventController {
// ...
@Post()
public async createEvent(
@Body() input: EventCreationInput,
@CurrentUser({required: true}) userId: number
): Promise<EventResponse> {
return await this.eventCreationService.createEvent(input, userId);
}
// ...
}

Порт. Сервис нотификаций​

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

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

Порт принадлежит сервису и используется на уровне бизнес-логики. В сервисе следует заинжектить конкретную имплементацию интерфейса. Это рассмотрим ниже.

export interface NotificationService {
sendNotifications(message: NotificationMessage, userTokens: string[]): Promise<void>;
}

Вторичный адаптер. Сервис нотификаций​

Теперь осталось описать, как именно оправлять нотификации с помощью конкретного провайдера. Для этого пишем вторичный адаптер. Он имплементирует описанный выше интерфейс NotificationService. В нем с использованием Firebase SDK имплементируем отправку нотификаций через конкретный сервис. Таким образом мы изолируем зависимость на Firebase SDK в одном месте нашего приложения. Адаптер нужно зарегистрировать в DI-контексте, чтобы потом заинжектить его в сервисе с бизнес-логикой.

@injectable()
export class FCMNotificationAdapter implements NotificationService {
private readonly fcmApp: admin.app.App;

constructor() {
this.fcmApp = admin.initializeApp({
credential: config.firebase.authJson
});
}

public async sendNotifications(message: MessageData, tokens: string[]): Promise<void> {
const payload = {
tokens,
notification: message.notification,
data: {
data: JSON.stringify(message.data)
}
};

const response = await this.fcmApp.messaging().sendMulticast(payload);
}
}

Inversion of Control​

Обратите внимание: чтобы сделать вторичные адаптеры независимыми от бизнес-логики, используем Dependency injection (DI). Получается, что зависимости направлены к Application core. Работает принцип Inversion of Control сразу на уровне архитектуры.

Организация Application core​

На этом этапе классическая гексагональная архитектура, описанная Алистером Коберном, заканчивается. А у нас большая часть приложения остается не организованной. Собственно, это «гексагон» с бизнес-логикой — Application core. Я поделюсь нашим подходом, как мы это делали. Для нас это неплохо сработало. Но фактически организация кора не является стандартизированной, поэтому эта часть полностью на ваше усмотрение.

Для организации бизнес-логики мы объединили подход слоевой архитектуры (Layered Architecture) и DDD (Domain Driven Design).

Вот что из этого вышло. Следуя слоевому подходу, мы разделили Application core на два слоя: Application и Domain. Domain отвечает за бизнес-логику, в нем хранятся доменные модели и сервисы, которые отражают бизнес-процессы. Это базовый слой, он не зависит от других частей приложения.

Слой Application связывает доменную логику с внешними сервисами. Application-уровень описывает полноценные пользовательские сценарии (use case). Здесь живут порты, через которые происходит взаимодействие с адаптерами. Запросы в базу и на другие сторонние сервисы вызываются из этого уровня.

Сервис Аpplication-уровня работает приблизительно так: он инжектит в себя вторичные адаптеры, не зная ничего о конкретной имплементации этих сервисов. В данном примере EventRepository и NotificationService — это порты, описанные на доменном уровне, для которых из DI-контекста инжектится конкретная имплементация взаимодействия с внешними сервисами.

@injectable()
export class EventCreationService {
private eventRepo: EventRepository;
private notificationService: NotificationService;

constructor(
@inject(EventRepositoryType) eventRepo: EventRepository,
@inject(NotificationServiceType) notificationService: NotificationService,
) {
this.eventRepo = eventRepo;
this.notificationService = notificationService;
}

public async createEvent(input: EventCreationInput, userId: number): Promise<EventResponse> {
const newEvent = Event.fromObject({
...input,
creatorId: userId
});

const savedEvent = await this.eventRepo.save(newEvent);

const message = {
title: `You invited to ${event.title}`,
body: 'Wanna participate?',
data: {eventId: event.id}
};

await this.notificationService.sendNotifications(message, tokens);

return savedEvent;
}
}

Второй шаг организации Application core — это разделение на компоненты по доменной области. В каждом из них лежат сервисы и модели, которые тесно связаны между собой, но слабо зависимы от других частей приложения. Такой себе Bounding Сontext из DDD. Примеры таких компонентов: User, Reminder, Event и так далее.

В итоге получаем такую схему:

image2_scyW50x.png


Для уменьшения связности между компонентами можно использовать event-driven подход и реализовать их взаимодействие на основании ивентов.

В результате мы получаем архитектуру, которая позволяет иметь маленькую связность между компонентами и не зависеть на внешние сервисы. Теперь посмотрим, как это тестировать.

Тесты​

У нас было 3 вида тестов, каждый отвечал за свою часть архитектуры.

Unit-тесты​

Классические unit-тесты для каждого сервиса. С их помощью мы протестировали бизнес-логику в Аpplication core. Они достаточно легковесны и быстро исполняются.

Integration-тесты​

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

Acceptance-тесты​

Когда мы проверили бизнес-логику и интеграции с внешними сервисами, осталось убедиться, как система работает целиком. В этом помогут Acceptance-тесты. Они тестируют полный пользовательский сценарий, но с замокаными вторичными адаптерами. При этом выполняются быстро, так как не взаимодействуют с сетью. Из минусов — тесты могут быть очень большими для сложных сценариев, поэтому их написание и поддержка потребует много усилий. Чтобы сбалансировать усилия и пользу, мы выработали правило: один Acceptance-тест для одного пользовательского сценария и только для основного успешного кейса.

image1_2iawa9D.png


Звучит это все хорошо, а в чем подвох, спросите вы?

Проблемы​

Гексагональная архитектура — не серебряная пуля и к тому же имеет ряд проблем, с которыми придется разбираться.

Транзакции​

Транзакция — это абстракция уровня базы данных, которая используется на уровне бизнес-логики. Поэтому получаем прямую зависимость на базу данных на уровне Аpplication. А это то, чего хотелось бы избежать. Не все базы данных поддерживают транзакции, поэтому абстрагировать их не выйдет. Транзакции иногда могут требовать сложной логики роллбеков.

Оптимизация​

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

Валидация​

Не очевидно, где именно должны быть валидации? С одной стороны, валидация — это часть бизнес-логики и должна жить в Аpplication core. С другой стороны, слои должны быть максимально независимы со своей валидацией на каждом уровне. Или достаточно проверить так где они приходят — на первичных адаптерах? Тут нет правильного ответа, выбирайте то, что больше подойдет вашему приложению.

Выводы​

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

 
Сверху