Часть 9. Расцепляем фичи через события

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

Вертикальные слайсы

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

Для маленьких приложений преимущества такого разделения не особо заметны и не то чтобы перекрывают затраты на «нарезку» по слайсам. Но для более объёмных проектов это может оказаться полезно, особенно для «микрофронтендов», где каждая фича может быть отдельным приложением, написанным на отличающемся стеке технологий.

Связать фичи прямыми вызовами в микрофронтендах, конечно, можно, но это сильно ограничивает свободу разработки и нивелирует смысл микрофронтендов. (Командам придётся синхронизировать работу и ждать друг друга, чтобы опубликовать серьёзные изменения в проекте.)

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

Зацепление через адаптер

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

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

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

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

Юзкейс отправляет лишь один сигнал во внешний мир, на который другие фичи реагируют самостоятельно
Юзкейс отправляет лишь один сигнал во внешний мир, на который другие фичи реагируют самостоятельно

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

Шина событий

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

// core/ports.output
type PublishRefreshed = (rates: ExchangeRates) => void;

Эти сообщения будут прилетать в некий «хаб», который будет эти сообщения пересылать всем заинтересованным частям приложения. «Заинтересованность» будет выражаться в подписке модулей на конкретные события, которые им нужно обработать. Этот «хаб» мы и будем называть шиной.

Наша самописная шина будет предоставлять 3 интерфейса: для публикации событий, подписки на них и отписки. Эти интерфейсы будут общими для всех модулей, поэтому мы положим их в Shared Kernel:

// shared/kernel

export type PublishEvent = (event: InternalEvent, data: EventPayload) => void;
export type SubscribeTo = (event: InternalEvent, handler: EventHandler) => void;
export type Unsubscribe = (event: InternalEvent, handler: EventHandler) => void;

Также в Shared Kernel мы опишем типы событий, которые могут возникнуть в разных частях приложения:

// shared/kernel

type ConverterEvent = 'RatesRefreshed';

// Maybe in the future, we'll also have:
// type NotesEvent = "NoteCreated"
// type UserEvent = "SessionExpired"

export type InternalEvent = ConverterEvent; /* | NotesEvent | UserEvent | etc */

Реализация может сильно отличаться в зависимости от требований. Для нашего приложения мы возьмём небольшую библиотеку, которая сделает почти всё за нас:

// shared/infrastructure/bus

import mitt from 'mitt';

const emitter = mitt<Record<InternalEvent, Optional<string>>>();

export const publishEvent: PublishEvent = emitter.emit;
export const subscribeTo: SubscribeTo = emitter.on;

Расцепляем фичи

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

// converter/refreshRates

type Dependencies = {
	// ...
	publishRefreshed: PublishRefreshed;
};

export const createRefreshRates =
	({ publishRefreshed /* ... */ }: Dependencies): RefreshRates =>
	async () => {
		// ...
		publishRefreshed(rates);
	};

Сам адаптер перепишем так, чтобы он не вызывал конкретную фичу, а дёргал метод публикации в шине:

// converter/infrastructure/bus

import type { PublishEvent } from '~/shared/kernel';
import type { PublishRefreshed } from '../../core/ports.output';

export const createPublisher =
	(publish: PublishEvent): PublishRefreshed =>
	(rates) => {
		const noteContent = JSON.stringify(rates, null, 2);
		publish('RatesRefreshed', noteContent);
	};

// converter/infrastructure/bus.composition

export const publishRefreshed: PublishRefreshed = createPublisher(publishEvent);

Зарегистрируем публикатор в юзкейсе:

// converter/refreshRates.composition

import { publishRefreshed } from '../../infrastructure/bus';

export const refreshRates: RefreshRates = withAnalytics(
	createRefreshRates({
		fetchRates,
		readConverter,
		saveConverter,
		publishRefreshed
	})
);

…И обновим тесты:

// converter/refreshRates.test

// ...
const publishRefreshed = vi.fn();
const refreshRates = createRefreshRates({ publishRefreshed /*...*/ });

describe('when called', () => {
	// ...

	it('calls a message bus with the rates refreshed event', async () => {
		await refreshRates();
		expect(publishRefreshed).toHaveBeenCalledWith(rates);
	});
});

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

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

Подписка на событие

Внутри фичи заметок создадим механизм подписки на событие:

// notes/infrastructure/bus

import { subscribeTo, unsubscribeFrom } from '~/shared/infrastructure/bus';
import { createNote } from '../../core/createNote';

const subscribe = () => subscribeTo('RatesRefreshed', createNote);
const unsubscribe = () => unsubscribeFrom('RatesRefreshed', createNote);

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

export const useBus = () => {
	useEffect(() => {
		subscribe();
		return unsubscribe;
	}, []);
};

И объявим подписку в фиче:

export function Notes() {
	useBus();
	// return ...
}

Использовать хуки опять же необязательно, потому что подписка на шину не зависит от фреймвока и в целом от UI.

Но ведь это же… Redux? 🤨

Принцип работы подозрительно напоминает Redux. Если не вдаваться в подробности, то ментальная модель и правда почти совпадает: события — это экшены, шина — это стор, подписки — подписки.

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

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

Однако, на «шину» похож не только на Redux, а скорее любой инструмент, который помогает настроить «более-менее расцепленное» общение между модулями. Атомарные сторы, обзёрверы — если захотеть, всё это можно свести к событийной модели.

Смысл идеи именно в слабом зацеплении и общении через контракты (события, сообщения, экшены и т.д.), поэтому «заимствование» идей в таких инструментах получается само собой 🙃

События и DDD

В DDD события занимают ещё более важное место — они помогают координировать работу разных ограниченных контекстов и служат результатом работы бизнес-процессов.

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

Handle Command:       Perform Use Case:        Publish Event:
RefreshButtonClick -> [ FetchRates        ] -> RatesRefreshed
                      [ -> ReadConverter  ]
                      [ -> LookupRate     ]
                      [ -> CalculateQuote ]
                      [ -> SaveConverter  ]

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

DDD в его «каноничном» виде нужен далеко не всегда, но идея о грамотном разделении границ поддоменов, представлении процессов как последовательности преобразований и событий может помочь при проектировании приложения.

В следующий раз

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

Ссылки

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

Архитектура и микрофронтенды

Расцепление через события и сообщения

DDD и «догматизм»

Прочее

Другие части серии