Способ реализации мультитенантности (multitenancy, мультиарендности) зависит от сферы деятельности, в которой мы работаем. В некоторых случаях нам может хватать одной общей базы данных, а в некоторых нам могут потребоваться отдельные базы данных для полной изоляции. У нас может быть всего пара арендаторов (tenants, тенантов) или же наоборот сотни, нам может потребоваться замедлить или ускорить производительность для конкретного арендатора. Все это создает уникальную среду, в которой мультитенантность является не только техническим вопросом, но и вопросом бизнес‑логики.
В своей предыдущей статье я рассказывал о том, как проще всего создавать мультитенантные системы на Laravel с помощью Ecotone. А в этой статье мы сделаем то же самое, но для Symfony Framework.
Для каждого сценария, рассматриваемого в данной статье, будет приведена ссылка на соответствующую реальную демонстрацию. Таким образом, мы будем не только обсуждать сам сценарий, но и сможем ссылаться на реальный пример.
Данная статья представляет из себя практическое руководство, после изучения которого вы узнаете, как и зачем следует применять мультитенантность для различных сценариев в вашем проекте. Если вас интересует теория, лежащая в основе мультитенантной архитектуры, я рекомендую сначала прочитать эту статью Михала Кузея (Michał Kurzeja).
Отправка сообщений в отдельные базы данных для каждого арендатора
Предположим, мы занимаемся продажами через интернет и у нас есть два арендатора, у каждого из которых своя отдельная база данных (стратегия «DB per Tenant»).
Первое, что должно происходить в системе онлайн‑коммерции, — это, конечно же, регистрация нового клиента, и именно на этом мы сейчас и сосредоточимся.
Процесс регистрации нового клиента будет проходить следующим образом:
Делаем мы это посредством отправки сообщения‑команды регистрации пользователя (Register Customer) нашему обработчику команд (Command Handler).
Мы отправим команду Register Customer
с помощью командной шины в обработчик команд (Command Handler), который сохранит нового клиента в базе данных. Сложность заключается в том, что мы хотим сохранить клиента в базе данных, связанной с конкретным арендатором.
Начнем с установки Ecotone для Symfony:
composer require ecotone/symfony-starter
Эта команда обеспечит нам интеграцию Ecotone с Symfony и предоставит нам необходимый инструментарий для работы с базами данных.
Назначение соединений для конкретных арендаторов
В нашем примере мы будем использовать Doctrine ORM. Поскольку каждый арендатор будет иметь собственное соединение с базой данных, нам нужно будет начать с определения конфигураций Doctrine для каждого арендатора (doctrine.yaml).
Конфигурации Doctrine с отдельной базой данных для каждого арендатора:
doctrine:
dbal:
connections:
tenant_a_connection:
url: '%env(resolve:TENANT_A_DATABASE_URL)%'
charset: UTF8
tenant_b_connection:
url: '%env(resolve:TENANT_B_DATABASE_URL)%'
charset: UTF8
orm:
entity_managers:
tenant_a_connection:
connection: tenant_a_connection
mappings:
(Здесь мы назначаем соединение)
tenant_b_connection:
connection: tenant_b_connection
mappings:
(Здесь мы назначаем соединение)
Когда соединения определены, мы можем настроить, как они будут сопоставляться с именами арендаторов. Для этого мы используем метод конфигурации Ecotone, помеченный атрибутом ServiceContext
.
Сопоставление имени каждого арендатора с конкретным менеджером сущности арендатора (Tenant’s Entity Manager):
<?php
final readonly class EcotoneConfiguration
{
#[ServiceContext]
public function multiTenantConfiguration(): MultiTenantConfiguration
{
return MultiTenantConfiguration::create(
tenantHeaderName: 'tenant',
tenantToConnectionMapping: [
'tenant_a' => SymfonyConnectionReference::createForManagerRegistry('tenant_a_connection'),
'tenant_b' => SymfonyConnectionReference::createForManagerRegistry('tenant_b_connection')
],
);
}
}
Вот, собственно, и все. Теперь Ecotone будет знать, как данное имя арендатора соотносится с данным соединением. Таким образом, всякий раз, когда мы будем отправлять какое‑либо сообщение (команду/запрос/событие), он будет знать, какое соединение следует использовать.
Обработчик команд мильтитенантной системы
Для нашей Multi‑Tenant системы мы будем использовать CQRS от Ecotone. Благодаря этому нам будет доступно большое количество встроенных функций, которые мы сможем использовать для создания мультитенатных систем.
Давайте определим наш обработчик команды Register Customer.
Обработчик команды Register Customer, получение команды и сохранение сущности Doctrine ORM:
<?php
final readonly class CustomerService
{
#[CommandHandler]
public function handle(
RegisterCustomer $command,
// Внедрение менеджера объектов (ObjectManager) для текущего арендатора
#[MultiTenantObjectManager] ObjectManager $objectManager
): void
{
$objectManager->persist(Customer::register($command));
}
}
Обработчик команды Register Customer, получение команды и сохранение сущности Doctrine ORM
Как вы можете видеть, обработчик команд не представляет из себя ничего сложного. Это просто метод, выполняющий бизнес-логику, помеченный PHP-атрибутом. Наш обработчик команд принимает класс Command и сохраняет клиента с помощью Doctrine ORM. Этот код будет отлично работать и в среде с одним арендатором.
Однако сложность заключается в том, что нам нужно использовать отдельный менеджер объектов/сущностей (ObjectManager/EntityManager) для конкретного арендатора, поскольку каждый из них имеет собственное подключение к базе данных.
Добавляя атрибут #[MultiTenantObjectManager]
, мы указываем Ecotone внедрить менеджер объектов для последнего активированного арендатора. Таким образом, мы получаем возможность хранить наших клиентов в базе данных нужного арендатора, а наш код остается практически не затронут деталями реализации мультиарендности.
Ecotone использует атрибуты для создания декларативных конфигураций, которые позволяют нашему бизнес-коду не зависеть от мультитенантной среды. Таким образом, мы можем разрабатывать приложение так, будто у нас всего один арендатор, но при этом система сможет по умолчанию работать с несколькими.
Давайте определим класс команды RegisterCustomer
:
<?php
final readonly class RegisterCustomer
{
public function __construct(public int $customerId, public string $name)
{
}
}
Класс команды — это простой POPO (Plain Old PHP Object), он не расширяет и не реализует никаких специфических классов фреймворка. Команда содержит все данные, необходимые для регистрации клиента.
Ecotone будет управлять флашем и очисткой наших менеджеров объектов/сущностей по умолчанию после выполнения обработчика команд. Таким образом, наш код упрощается, поскольку все, что нам нужно сделать, это вызвать persist для конкретной сущности, и все готово.
Мультитенантная шина сообщений
После внедрения обработчика команд в наш код, мы можем отправлять ему команды для конкретных арендаторов. Мы будем выполнять заданную команду в контексте конкретного арендатора:
<?php
final readonly class CustomerController extends Controller
{
public function __construct(private CommandBus $commandBus) {}
public function placeOrder(Request $request): Response
{
$this->commandBus->send(
new RegisterCustomer($request->get('name')),
metadata: [
// Это заголовок сообщения, который будет использоваться для сопоставления в арендатором
'tenant' => 'tenant_a'
]
);
return new Response(200);
}
}
Здесь мы отправляем команду по командной шине (Command Bus) и передаем имя арендатора в метаданных (заголовках сообщений). Таким образом Ecotone поймет, что мы выполняем данный обработчик команд в контексте базы данных конкретного арендатора. Обычно мы на этом моменте уже знаем имя арендатора на основе HTTP-домена или сессии пользователя.
Мы определили мультитенантный обработчик команд и мы можем сделать то же самое для обработчиков запросов (Query Handlers), отвечающих за получение данных и событий. Более подробно мы рассмотрим обработчики событий в следующем разделе этого руководства.
В принципе, это все, что нам нужно для хранения клиентов в мультитенантной среде. В принципе, наш код будет работать как для одного, так и для нескольких арендаторов, поскольку он полностью независим от деталей реализации мультитенантной архитектуры. Теперь давайте проверим другие сценарии, которые могут нам встретиться в наших мультитенантных системах.
Демо-реализацию можно найти по этой ссылке.
Разделение на общие и индивидуальные базы данных
У нас может быть бизнес-модель, в которой по умолчанию мы предоставляем для всех арендаторов одну общую базу данных, но если клиент купит премиум-подписку, то он получит доступ к собственной.
Чтобы реализовать такой сценарий, Ecotone предоставляет соединение по умолчанию. Таким образом, если для заданного имени арендатора не существует привязки к определенной базе данных, будет использовано соединение по умолчанию.
Использование соединения по умолчанию для основной группы арендаторов и отдельных соединений для остальных:
<?php
final readonly class EcotoneConfiguration
{
#[ServiceContext]
public function multiTenantConfiguration(): MultiTenantConfiguration
{
return MultiTenantConfiguration::create(
tenantHeaderName: 'tenant',
tenantToConnectionMapping: [
'tenant_a' => SymfonyConnectionReference::createForManagerRegistry('tenant_a_connection'),
'tenant_b' => SymfonyConnectionReference::createForManagerRegistry('tenant_b_connection')
],
// Предоставление соединения по умолчанию
SymfonyConnectionReference::create('tenant_default_connection')
);
}
}
Доступ к текущему арендатору в обработчике сообщений
Для определенных сценариев нам может потребоваться знать контекст арендатора, с которым в данный момент работает система. Например, у данного арендатора может быть магазин, торгующий предметами роскоши, в котором доставка должна осуществляться сразу же после оформления заказа, в то время как для других арендаторов время доставки не имеет такого важного значения.
В случае Ecotone все, что мы отправляем в заголовке сообщений (метаданные), доступно нам на уровне обработчика сообщений. Таким образом, в зависимости от ситуации мы можем игнорировать или получать доступ к текущим метаданным. А поскольку мы отправляем имя арендатора через заголовки сообщений, в случае необходимости мы можем получить к нему доступ:
<?php
final readonly class OrderService
{
public function __construct(private FastDelivery $fastDelivery) {}
#[CommandHandler]
public function handle(
PlaceOrder $command,
#[Header('tenant')] $tenantName
)
{
if ($this->fastDelivery->isEnabled($tenantName)) {
// Обработка заказа для премиум-арендатора
}else {
// Обычный процесс размещения заказа
}
}
}
Атрибут Header указывает, к какому заголовку сообщения мы хотим получить доступ. В нашем случае мы хотим получить доступ к заголовку tenant, который мы отправили ранее через командную шину.
Мы можем получить доступ к любому заголовку сообщения в наших обработчиках сообщений. Это означает, что какие бы метаданные мы ни передали команде/запросу/событию (например, идентификатор пользователя, роль пользователя, HTTP-домен, с которого был сделан запрос, и т. д.), мы сможем получить к ним доступ в случае необходимости.
Подключение дополнительной логики при переключении между арендаторами
Если у нас уже есть готовое мультитенантное приложение, то, скорее всего, мы используем какие-то пользовательские библиотеки или интеграции. В таких случаях может потребоваться выполнение некоторого кода при активации или деактивации конкретного арендатора.
Ecotone дает нам возможность подключения дополнительной логики при переключении арендаторов. Например, 0н может предоставить соединение, которое будет активировано, и имя арендатора.
<?php
final readonly class HookTenantSwitchSubscription
{
/** Подключение к активации арендатора */
#[OnTenantActivation]
public function whenActivated(string|ConnectionReference $tenantConnectionName, #[Header('tenant')] $tenantName): void
{
echo sprintf("HOOKING into flow: Tenant name %s is about to be activated\n", $tenantName);
}
/** Подключение к деактивации арендатора */
#[OnTenantDeactivation]
public function whenDeactivated(string|ConnectionReference $tenantConnectionName, #[Header('tenant')] $tenantName): void
{
echo sprintf("HOOKING into flow: Tenant name %s is about to be deactivated\n", $tenantName);
}
}
Чтобы выполнить некоторый код при активации/деактивации арендатора, достаточно пометить данный метод атрибутом OnTenantActivation
или OnTenantDeactivation
. Таким образом, просто пометив данный метод атрибутом, мы можем подключиться к потоку и выполнить необходимую нам логику.
Демо-реализацию можно найти по этой ссылке.
Ecotone придерживается декларативной конфигурации. Это означает, что в основном мы лишь указываем, чего хотим добиться, помечая методы атрибутами. Таким образом, мы сможем сосредоточиться на бизнес логике системы, а не на ее конфигурации и настройках.
Распространение событий
Когда клиент зарегистрирован, нам может понадобиться запустить какие-нибудь дополнительные действия, например, отправить письмо с приветственным сообщением. Для таких ситуаций мы можем определить события (Events) и обработчики событий (Event Handlers).
Запуск обработчика события отправки уведомления, когда клиент зарегистрирован:
<?php
final readonly class CustomerService
{
#[CommandHandler]
public function handle(
RegisterCustomer $command,
// Атрибут Reference извлекает сервис из DI -контейнера
#[Reference] EventBus $eventBus,
#[MultiTenantObjectManager] ObjectManager $objectManager,
): void
{
$objectManager->persist(Customer::register($command));
$eventBus->publish(new CustomerWasRegistered($command->customerId));
}
#[EventHandler]
public function sendNotificationWhen(
CustomerWasRegistered $event,
#[Header('tenant')] $tenant,
#[Reference] NotificationSender $notificationSender,
#[MultiTenantObjectManager] ObjectManager $objectManager,
)
{
$customer = $objectManager->getRepository(Customer::class)->find($event->customerId);
$notificationSender->sendWelcomeNotification($customer, $tenant);
}
}
Когда клиент зарегистрирован, мы публикуем сообщение о событии (Event Message) CustomerWasRegistered
с помощью шины событий (Event Bus), и тогда все методы, помеченные обработчиком события, которые подписались на него (первый параметр указывает на событие, на которое мы подписались), будут выполнены.
Как вы можете видеть, в Ecotone мы можем получить доступ к заголовку сообщения tenant в нашем обработчике событий. Это происходит благодаря возможностям Ecotone по распространению метаданных.
Демо-реализацию можно найти по этой ссылке.
Распространение контекста и метаданных
Ecotone по умолчанию автоматически распространяет все заголовки сообщений. В результате сохраняется контекст данного арендатора. В нашем случае отправка уведомления будет происходить в контексте того же арендатора, что и регистрация клиента:
Метаданные автоматически распространяются от команды к опубликованному событию.
Таким образом, мы можем получить доступ к имени арендатора и в обработчиках событий:
<?php
#[EventHandler]
public function audit(
CustomerWasRegistered $event,
AuditRepository $auditRepository,
#[Header('tenant')] string $tenantName
)
{
$auditRepository->store($event, $tenantName);
}
Какие бы метаданные мы ни передали в начале потока (например, команду Register Customer
), мы сможем получить к ним доступ в любом синхронном или асинхронном подпотоке (например, в обработчиках события Customer was Registered
). Это означает, что мы можем легко передавать данные, которые не имеют прямого отношения к команде регистрации клиента, и получать к ним доступ в контексте, в котором они могут потребоваться. Например, мы можем передать HTTP-домен, IP-адрес в метаданных и получить к ним доступ в обработчике событий, которому они могут понадобится.
Асинхронные события
По умолчанию обработчик событий работает в синхронном режиме, но мы можем выполнять обработчики событий и асинхронно. Ecotone предоставляет набор интеграций для асинхронной обработки, таких как RabbitMQ, Redis, каналы баз данных (Database Channels), а также мы можем использовать Symfony Messenger Transport.
Если мы хотим использовать каналы баз данных, то это означает, что мы ожидаем, что сообщения для конкретного арендатора будут храниться в базе данных данного арендатора. Для этого мы будем использовать канал сообщений базы данных Ecotone, поскольку он обеспечивает необходимую поддержку для реализации мультитенантности.
Давайте пометим наш обработчик событий как асинхронный.
Создание асинхронного обработчика событий с помощью атрибута Asynchronous
:
<?php
final readonly class CustomerService
{
#[CommandHandler]
public function handle(
RegisterCustomer $command,
#[MultiTenantObjectManager] ObjectManager $objectManager,
#[Reference] EventBus $eventBus
): void
{
$objectManager->persist(Customer::register($command));
$eventBus->publish(new CustomerWasRegistered($command->customerId));
}
#[Asynchronous('notifications')]
#[EventHandler(endpointId: 'notificationSender')]
public function sendNotificationWhen(
CustomerWasRegistered $event,
#[Header('tenant')] $tenant,
#[Reference] NotificationSender $notificationSender,
#[MultiTenantObjectManager] EntityManager $objectManager,
)
{
$customer = $objectManager->getRepository(Customer::class)->find($event->customerId);
$notificationSender->sendWelcomeNotification($customer, $tenant, $objectManager->getConnection());
}
}
Этот обработчик событий теперь будет обрабатываться как асинхронный (в фоновом режиме), а сообщение о событии будет отправлено в канал сообщений "notifications". Поэтому давайте определим этот канал как очередь базы данных (Database Queue):
<?php
final readonly class EcotoneConfiguration
{
#[ServiceContext]
public function databaseChannel(): DbalBackedMessageChannelBuilder
{
return DbalBackedMessageChannelBuilder::create('notifications');
}
}
Это все, что нам нужно сделать, чтобы настроить данный обработчик событий для асинхронной обратботки. Теперь всякий раз, когда будет выполняться наш обработчик событий, сообщение о событии будет сначала отправляться в очередь базы данных для данного арендатора, а затем будет обрабатываться асинхронно.
Все, что нам нужно сделать, это поместить атрибут
Asynchronous
над обработчиком событий, и Ecotone теперь будет считать, что этот обработчик должен выполняться асинхронно. Точно так же можно поступить и с обработчиками команд.
Запуск асинхронного потребителя сообщений
Когда мы публикуем сообщение в асинхронный канал сообщений (в нашем случае в очередь базы данных), нам понадобится потребитель для того, чтобы потребить, т.е. получить и выполнить, наше сообщение.
Для этого мы запустим Message Consumer с помощью встроенной консольной команды ecotone:run
:
bin/console ecotone:run notifications
Эта команда запустит отдельный процесс «поглощения» сообщений, который будет получать и выполнять наши сообщения, приходящие в канал «notifications».
Поскольку мы работаем в мультитенантной среде и наш канал «notifications» — это очередь в базе данных, это означает, что для каждого арендатора может существовать отдельная база данных со своей собственной очередью. Получается нам необходимо учесть, что для каждого арендатора при таком подходе будет свой потребитель со своим процессом.
В зависимости от сферы, в котором мы работаем, у нас могут быть сотни арендаторов, поэтому запуск сотен птребителей сообщений может быть далеко не лучшим решением. Для таких ситуаций Ecotone по умолчанию использует стратегию Round‑Robin (или алгоритм кругового обслуживания) для обработки всех сообщений арендаторов с помощью одного процесса. Это означает, что мы будем получать сообщения от каждого арендатора по порядку:
Этот способ работает по умолчанию, нам не нужно совершать для этого никаких дополнительных действий. Если мы хотим ускорить обработку сообщений, мы можем запустить несколько таких процессов.
На самом деле мы можем полностью контролировать данный процесс, и, например, замедлить потребление сообщений от арендатора, который их производит слишком много, или ускорить потребление сообщений для премиум-арендатора. Однако об этом мы расскажем уже в другой статье статье.
Стратегия Round-Robin хороша тем, что позволяет работать с одним процессом, который может управлять несколькими арендаторами. Однако Ecotone позволяет нам гораздо больше, поскольку позволяет определять собственные стратегии потребления, чтобы замедлить или ускорить потребление сообщений для определенных арендаторов. Это позволяет полностью настроить систему в соответствии с потребностями нашего бизнеса.
Демо-реализацию можно найти по этой ссылке.
Транзакции в базе данных и паттерн Outbox
Нам может понадобиться реализовать транзакции базы данных, чтобы сделать систему более устойчивой к сбоям. Конечно, в нашем случае мы хотим, чтобы транзакция запускалась для базы данных конкретного арендатора.
Ecotone начнет транзакцию для базы данных конкретного арендатора автоматически, когда мы выполним команду. Это происходит по умолчанию благодаря модулю Dbal, который был установлен вместе с Symfony Starter. Поэтому дополнительная настройка нам не потребуется. Подробнее о настройке транзакций вы можете прочитать в документации.
И когда мы публикуем события асинхронно в очередь базы данных, это также будет покрыто транзакцией. Таким образом, в случае сбоя мы можем быть уверены, что все данные сохранятся.
Этот процесс работает как паттерн Outbox, который мы получаем в мультитенантной системе из коробки ;) Вместе с этим Ecotone предоставляет так называемые комбинированные каналы сообщений (combined Message Channels), где сообщения могут быть автоматически перемещены из базы данных в брокер сообщений (например, RabbitMQ, Redis, SQS). Таким образом, фактическая обработка сообщений будет осуществляться потребителями брокеров сообщений (их мы и будем масштабировать), а не потребителями базы данных.
Dbal-интерфейс
Модуль Dbal предоставляет бизнес-интерфейс — простой способ написания запросов к базе данных, скрывающий технические аспекты за абстракцией.
<?php
interface PersonService
{
#[DbalWrite('INSERT INTO persons VALUES (:personId, :name, DEFAULT)')]
public function register(int $personId, string $name): void;
#[DbalWrite('UPDATE persons SET name = :name WHERE person_id = :personId')]
public function changeName(int $personId, string $name): int;
}
Мы определяем интерфейс того, чего хотим добиться, а Ecotone заботится о том, как это сделать. Это означает, что все, что нам нужно сделать, это написать интерфейс, а реализация будет доставлена и зарегистрирована в нашем контейнере зависимостей.
Бизнес-интерфейсы при вызове из наших обработчиков сообщений (команд/запросов/событий) будут автоматически наследовать соединение арендатора.
Если вы хотите узнать больше об использовании бизнес-интерфейсов на основе Dbal, прочтите эту статью.
Отправка команд непосредственно в модель
Ecotone поддерживает отправку команд прямо в нашу сущность Doctrine ORM. Таким образом, нет необходимости писать какой-либо делегирующий код. Это, конечно, работает и в мультитенантной архитектуре:
<?php
#[Aggregate]
#[Entity]
class Customer
{
#[CommandHandler]
public static function register(RegisterCustomer $command): static
{
$self = new self();
$self->customerId = $command->customerId;
$self->name = $command->name;
return $self;
}
(...)
}
Как можно увидеть на примере выше, мы создали статический фабричный метод. Так мы сообщаем Ecotone, что этот фабричный метод register
будет создавать нового клиента. После того как этот метод будет выполнен, Ecotone вызовет менеджер сущностей (Entity Manager) для данного арендатора, чтобы сохранить его в правильной базе данных.
Таким образом, нам больше не нужно писать подобный код.
Класс CustomerService
больше не нужен, когда мы помечаем наши модели как агрегат:
<?php
final readonly class CustomerService
{
#[CommandHandler]
public function handle(
RegisterCustomer $command,
// Внедрение менеджера объектов (ObjectManager) для текущего арендатора
#[MultiTenantObjectManager] ObjectManager $objectManager
): void
{
$objectManager->persist(Customer::register($command));
}
}
Со стороны контроллера ничего не меняется, мы по-прежнему отправляем команды так же, как и раньше:
<?php
final readonly class CustomerController extends Controller
{
public function __construct(private CommandBus $commandBus) {}
public function placeOrder(Request $request): Response
{
$this->commandBus->send(
new RegisterCustomer($request->get('name')),
metadata: [
// Это заголовок сообщения, который будет использоваться для сопоставления с арендатором
'tenant' => 'tenant_a'
]
);
return new Response(200);
}
}
Что важно, он также работает для экшен-методов, что в некоторых сценариях позволяет нам полностью отказаться от классов команд:
<?php
#[Aggregate]
#[Entity]
class Customer
{
#[CommandHandler("customer.ban")]
public function ban(): void
{
$this->is_blocked = true;
}
(...)
Затем мы можем выполнить командную шину, как показано ниже:
<?php
final readonly class CustomerController extends Controller
{
public function __construct(private CommandBus $commandBus) {}
public function placeOrder(Request $request): Response
{
$this->commandBus->sendWithRouting(
// Ключ маршрутизации обработчика команд
"customer.ban",
metadata: [
// Специальный заголовок Ecotone, который сообщает, какой экземпляр сущности должен быть выполнен
"aggregate.id" => $request->get("customerId")
'tenant' => 'tenant_a'
]
);
return new Response(200);
}
Достаточно передать aggregate.id
в метаданных, чтобы указать, на каком экземпляре Customer мы хотим выполнить метод. Если вы хотите узнать больше по этой теме, вы можете прочитать об использовании агрегатов в Doctrine ORM в этой статье.
Демо-реализацию можно найти по этой ссылке.
Event Sourcing
Когда нам нужно создать различные представления или провести проверку изменений в нашей системе, мы можем использовать для этого паттерн Event Sourcing (порождение событий).
Ecotone поставляется с полной поддержкой Event Sourcing, что позволяет нам в кратчайшие сроки развернуть готовое к работе в продакшене Event Sourced приложение для мультитенантных систем.
<?php
#[EventSourcingAggregate]
final class Product
{
#[CommandHandler]
public static function register(RegisterProduct $command): array
{
return [
new ProductWasRegistered($command->productId, $command->name)
];
}
(...)
Поток работает так же, как и для агрегатов Doctrine ORM, которые мы рассматривали ранее. Разница в том, что Event Sourced агрегаты, возвращают классы событий, а не изменяют внутреннее состояние.
Автоматическая настройка
Конечно, нам нужно место, где будут храниться события для конкретного арендатора, и для этого мы используем хранилище событий (Event Store) в базе данных арендатора.
Ecotone позаботится о сериализации и десериализации событий, настройке хранилища событий в базе данных арендатора (встроенная поддержка PostgreSQL, MySQL, MariaDB), а также поможет нам с настройкой проекций модели чтения (Read Model Projections).
Проекции моделей чтения
Проекции используются для создания различных представлений на основе событий. Каждая проекция может быть отдельной таблицей или набором таблиц в базе данных, которые создаются динамически:
<?php
#[Projection(name: 'registered_products', fromStreams: Product::class)]
final readonly class RegisteredProductList
{
const TABLE_NAME = 'registered_products';
#[ProjectionInitialization]
public function initialize(#[MultiTenantConnection] Connection $connection): void
{
$schemaManager = $connection->createSchemaManager();
if ($schemaManager->tablesExist(self::TABLE_NAME)) {
return;
}
$schemaManager->createTable(
new Table(self::TABLE_NAME, [
new Column('product_id', Type::getType(Types::GUID)),
new Column('name', Type::getType(Types::STRING)),
new Column('registered_at', Type::getType(Types::DATETIME_IMMUTABLE))
])
);
}
(...)
}
Всякий раз, когда будет опубликовано событие, будет запущена соответствующая проекция. Ecotone на основе метаданных поймет, с каким арендатором оно связано, и инициализирует проекцию (если этого не произошло раньше).
После инициализации будет запущен обработчик событий нашей проекции:
<?php
#[Projection(name: 'registered_products', fromStreams: Product::class)]
final readonly class RegisteredProductList
{
#[EventHandler]
public function whenProductWasRegistered(
ProductWasRegistered $event,
#[Header('timestamp')] int $occurredAt, // Получаем доступ к обработчику событий,
#[MultiTenantConnection] Connection $connection
): void
{
$connection->insert(
self::TABLE_NAME,
[
'product_id' => $event->productId->toString(),
'name' => $event->name,
'registered_at' => date('Y-m-d H:i:s', $occurredAt)
]
);
}
(...)
По умолчанию все это будет происходить синхронно, что значительно упрощает работу с Event Sourcing. Однако в случае необходимости мы можем переключить наши проекции на асинхронный режим.
Вы можете прочитать документацию если хотите узнать больше о Event Sourcing.
Демонстрационную реализацию можно найти по этой ссылке.
Заключение
В этой статье мы рассмотрели способ создания мультитенантных Symfony-приложений, используя при этом код, независимый от мультитенантности среды. Такой способ создания приложений упрощает разработку и поддержку приложений, поскольку написанный нами код может работать как в однопользовательской, так и в мультитенантной среде без каких-либо изменений.
Ecotone позаботится о распространении контекста. Таким образом, не имеет значения, является ли код синхронным или асинхронным, поскольку контекст арендатора, в котором выполняется действие, будет сохранен.
Однако если мы переходим к асинхронной обработке и фоновым задачам, мы можем столкнуться с необходимостью использования более продвинутых решений на базе очередей. Это может произойти, если мы захотим, например, изменить скорость обработки сообщений для разных арендаторов, или обезопасить наши данные при сбое в работе системы. Ecotone предоставляет такую возможность, однако эта тема заслуживает отдельной статьи.
В заключение рекомендуем обратить внимание на открытые уроки, которые пройдут в рамках курса по Symfony:
28 января в 20:00: «Symfony Messenger и Kafka как транспорт».
В результате урока научитесь использовать Symfony Messenger в связке с Kafka, а также эффективно масштабировать консьюмеры в Symfony. Записаться13 февраля в 20:00: «Генерируем API-клиент без помощи ChatGPT».
На уроке узнаете, как генерировать API-клиент на базе спецификации Open API и как использовать его в своих приложениях. Записаться