Практический пример декомпозиции монолитного PHP приложения
Введение
Стоит отметить, что сам по себе монолит не является антипаттерном и может отлично работать (и часто работает) при определенных условиях, обычно - когда он выбран осознанно. Но чаще всего монолитная архитектура в проекте не потому, что люди её выбрали, а потому что проект в неё естественно эволюционировал.
Прежде всего, давайте проясним, что мы имеем в виду под "декомпозицией монолита". Какая наша конечная цель? Какие могут быть бизнес-цели, побуждающие инженеров решать эту проблему?
Медленная скорость разработки. Это происходит из-за высокой связанности (coupling) кода, что включает в себя:
слишком запутанный код (есть важная разница между сложным (complex) и запутанным (complicated) кодом).
части кода слишком зависимы друг от друга, что приводит к более высокой вероятности возникновения конфликтов и повышает сложность построения надлежащего CI\CD процесса.
технологические сдвиги (например, обновление библиотек или миграция на другой фреймворк) почти невозможны из-за высоких временных затрат.
Монолит как единая точка отказа является бизнес-риском.
Горизонтальное масштабирование приложения становится более сложным.
Учитывая вышеизложенное, мы можем сказать, что нашей конечной целью является переход к более совершенной архитектуре приложения для увеличения скорости разработки и масштабируемости, а также снижения инфраструктурных рисков.
Теперь, когда мы определили проблему и цель, давайте разработаем высокоуровневый план.
Описание приложения
В качестве примера я буду использовать приложение для доставки еды. Для простоты оно не будет содержать какой-либо реальной бизнес-логики, но будет включать несколько обращений между сервисами и к базе данных. Суть приложения проще понять по следующей диаграмме:
Типичный flow следующий: customer делает заказ (create order), совершается обращение к restaurant service, и если заказ принят, то создается сущность доставки (delivery) и ожидается курьер. Когда курьер найден, он производит действия с доставкой через цепочку изменений статуса (change delivery status): courier_assigned
-> courier_delivering
-> successful
. Таким образом, у нас есть три сервиса (customer, courier, restaurant), а также база данных, где хранятся orders и deliveries. В первоначальном состоянии, приложение выглядит как монолит с лишь минимальным разделением на сервисы посредством классов.
Ссылка на репозиторий с исходным кодом приложения: https://github.com/ilyachase/monolith-decoupling-example
Высокоуровневый план
Теперь проясним то, что именно мы называем «связанным кодом» в контексте монолитной архитектуры:
Не смотря на то, что в нашем коде присутствует разделение на так называемые service classes, они взаимодействуют друг с другом посредством прямых вызовов методов.
Более того, даже если мы изменим способ взаимодействия сервисных классов друг с другом, мы все равно не сможем извлечь, потому что он использует классы других сервисов напрямую (через ключевое слово
use
).Наконец, все service classes имеют доступ ко всем данным в базе, а значит, нет границ между ними с точки зрения данных.
В реальных приложениях, даже если мы концептуально понимаем необходимые изменения для разделения сервисов, для этого требуется много усилий. Поэтому процесс миграции должен быть итеративным и предсказуемым. С этой целью мы внедрим дополнительный шаг перед переходом к реальными сервисам - превратим наш монолит в модульный монолит.
Для этого введем определение модуля. Мы будем называть часть нашего кода модулем тогда и только тогда, когда он будет удовлетворять двум условиям:
Разделение кода. Модули не используют классы других модулей напрямую. Когда модулю необходимо вызвать другой модуль, используется service client.
Разделение данных. Каждый модуль использует собственную базу данных.
Разберем подробнее:
Модули не используют классы других модулей напрямую. Чтобы обеспечить соблюдение этого правила, мы будем использовать библиотеку под названием Deptrac. Это несложно: создается конфигурационный файл (по умолчанию
deptrac.yaml
), в нем определяются модули и далее используется исполняемый файл библиотеки для проверки соблюдения границ. Чаще всего, запуск этого файла добавляется в CI\CD (GitHub actions или что-то подобное). Важно, чтобы данная проверка была обязательной (required).Когда одному модулю необходимо вызвать другой модуль, используется service client. Идея проста: рефакторинг монолита сразу в сервисную архитектуру - обычно слишком большой скачок, поэтому мы сначала подготовим код, используя sub-requests вместо реальных HTTP-запросов. Service client - это всего лишь вспомогательный класс, который формирует запрос, отправляет его в соответствующий модуль и возвращает ответ. Мы рассмотрим его реализацию чуть позже.
Каждый модуль использует собственную базу данных. В зависимости от вашего приложения вы можете выбрать разные паттерны баз данных, и «database per service» является лишь одним из них. Мы будем использовать его в нашем примере, потому что он один из самых распространенных.
Учитывая вышесказанное, давайте рассмотрим этапы миграции архитектуры:
На последнем этапе мы не будем реализовывать полноценную event-driven архитектуру с event streams, bounded context models, outbox pattern и т.д. (поэтому, он под звездочкой). Однако, мы изменим способ коммуникации сервисов на асинхронные сообщения, т.к. это типично для приложений, претерпевающих такого рода эволюцию архитектуры, а значит, стоит рассмотреть это в реализации.
Реализация
Прежде чем мы начнем, не могу не подчеркнуть важность покрытия тестами. Конкретная реализация тестов выходит за рамки данной статьи, но в реальных приложениях первым шагом перед любыми архитектурными изменениями должно быть создание слоя тестов, который работают на near-HTTP уровне (например, Application tests в Symfony), или настоящие e2e тесты.
Переход от big ball of mud к modular monolith
Группировка файлов
Для начала, нам необходимо определить границы между частями нашего приложения. Они довольно очевидны в нашем примере (Customer, Restaurant и Courier), но на практике в реальных приложениях можно полагаться либо здравый смысл, либо на Domain Driven Design как более продвинутый подход. Не стоит делать слишком маленькие сервисы в начале, потому что это влечет за собой более высокий maintenance cost. Из статьи от Google:
We recommend that you create larger services instead of smaller services until you thoroughly understand the domain.
Когда границы определены, первым шагом будет группировка файлов в соответствующие директории, пока без распутывания зависимостей (as is). Давайте посмотрим на текущую файловую структуру:
src/
Controller/
CourierApiController.php
CustomerApiController.php
Dto/
ChangeDeliveryStatusRequest.php
CreateOrderRequest.php
Entity/
Delivery.php
Order.php
Restaurant.php
Repository/
DeliveryRepository.php
OrderRepository.php
RestaurantRepository.php
Service/
CourierService.php
CustomerService.php
RestaurantService.php
Мы создадим новый уровень директорий, репрезентирующий наши модули, а файлы, принадлежность к модулям которых не очевидна, переместим в каталог Common
:
src/
Customer/ <-- module level directory
Controller/
CustomerApiController.php
Dto/
CreateOrderRequest.php
Entity/
Order.php
Repository/
OrderRepository.php
Service/
CustomerService.php
Restaurant/ <-- module level directory
Entity/
Restaurant.php
Repository/
RestaurantRepository.php
Service/
RestaurantService.php
Courier/ <-- module level directory
Controller/
CourierApiController.php
Dto/
ChangeDeliveryStatusRequest.php
Entity/
Delivery.php
Repository/
DeliveryRepository.php
Service/
CourierService.php
Common/
Exception/
EntityNotFoundException.php
Совет: если ваша IDE поддерживает PSR namespaces, она поможет вам исправлять ссылки на классы при их перемещении. Например, PhpStorm поддерживает его из коробки, если вы синхронизируете настройки IDE с Composer.
Также нам придется немного подправить конфиги Symfony для поддержки нашей новой структуры:
# config/packages/doctrine.yaml
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
- App:
+ App\Courier:
+ is_bundle: false
+ dir: '%kernel.project_dir%/src/Courier/Entity'
+ prefix: 'App\Courier\Entity'
+ alias: App\Courier
+ App\Customer:
is_bundle: false
- dir: '%kernel.project_dir%/src/Entity'
- prefix: 'App\Entity'
- alias: App
+ dir: '%kernel.project_dir%/src/Customer/Entity'
+ prefix: 'App\Customer\Entity'
+ alias: App\Customer
+ App\Restaurant:
+ is_bundle: false
+ dir: '%kernel.project_dir%/src/Restaurant/Entity'
+ prefix: 'App\Restaurant\Entity'
+ alias: App\Restaurant
# config/routes.yaml
-controllers:
+courier_controllers:
resource:
- path: ../src/Controller/
- namespace: App\Controller
+ path: ../src/Courier/Controller/
+ namespace: App\Courier\Controller
+ type: attribute
+customer_controllers:
+ resource:
+ path: ../src/Customer/Controller/
+ namespace: App\Customer\Controller
type: attribute
Совет: в реальных приложениях этот шаг можно разделить на несколько небольших PR, поскольку технически он представляет собой простое перемещение файлов и изменение конфигурации. Таким образом, процесс станет более итеративным и предсказуемым.
Разделение баз данных
Когда файлы сгруппированы по директориями, следующим шагом является разделение базы данных. В случае нашего приложения довольно очевидно, каким будет разделение:
База
customer
, которая будет содержать таблицуorder
.База
restaurant
, которая будет содержать таблицуrestaurant
.База
courier
, которая будет содержать таблицуdelivery
.
В реальных приложениях зачастую не так просто определить границы баз данных - может потребоваться больше усилий и времени чтобы решить, какие таблицы относятся к каким модулям. Однако вне зависимости от масштаба приложения, техническая часть остается прежней — суть в том, чтобы выполнить так называемую «горячую миграцию» (hot migration) каждой таблицы по очереди: в коде конфигурируется ещё одно подключение к базе данных, и запись происходит в оба хранилища, одновременно, а чтение - только из старой. Параллельно нам нужно разработать и запустить скрипт для переноса существующих данных из старой базы в новую. В этой статье мы не будем останавливаться на деталях реализации горячей миграции (это заслуживает отдельной статьи), но типичный алгоритм следующий:
Найти в коде места использования переносимой таблицы.
Для чтения оставить старое соединение.
Для записи (вставка, изменение, удаление) отправлять запросы в обе базы данных.
Тем временем разработать и запустить скрипт, который перенесет существующие данные из старой базы в новую.
После завершения миграции, зарелизить финальный PR, который будет использовать только новое соединение и для записи, и для чтения.
На последнем этапе можно удалить старую таблицу.
Моменты, которые следует учитывать:
Joins. Поскольку мы перемещаем таблицы в отдельные базы, операция
JOIN
между ними больше не будет возможна. Такие места придется переписывать в отдельные запросы.Foreign keys. СУБД больше не сможет обеспечить консистентность внешних ключей, поскольку таблицы находятся в отдельных базах данных. Если ваш продукт опирается на такую логику, ее придется реализовать на уровне кода приложения. Стоит отметить, что в highload проектах часто приходится отказываться от внешних ключей, поскольку у них есть свои проблемы.
Transactions. После того, как таблицы разделены по нескольким базам данных, СУБД больше не сможет покрывать операции с такими таблицами транзакциями. В зависимости от бизнес-логики, такие транзакции придется либо удалить, либо переписывать, используя что-то вроде saga pattern.
Оставляя в стороне технические детали горячей миграции, давайте рассмотрим, как будет выглядеть разделение базы данных в нашем приложении. Во-первых, нам нужно ввести отдельные Entity managers и connections:
# config/packages/doctrine.yaml
doctrine:
dbal:
- url: '%env(resolve:DATABASE_URL)%'
-
- # IMPORTANT: You MUST configure your server version,
- # either here or in the DATABASE_URL env var (see .env file)
- #server_version: '15'
-
- profiling_collect_backtrace: '%kernel.debug%'
+ connections:
+ courier:
+ url: '%env(resolve:COURIER_DATABASE_URL)%'
+ customer:
+ url: '%env(resolve:CUSTOMER_DATABASE_URL)%'
+ restaurant:
+ url: '%env(resolve:RESTAURANT_DATABASE_URL)%'
orm:
- auto_generate_proxy_classes: true
- enable_lazy_ghost_objects: true
- report_fields_where_declared: true
- validate_xml_mapping: true
- naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
- auto_mapping: true
- mappings:
- App\Courier:
- is_bundle: false
- dir: '%kernel.project_dir%/src/Courier/Entity'
- prefix: 'App\Courier\Entity'
- alias: App\Courier
- App\Customer:
- is_bundle: false
- dir: '%kernel.project_dir%/src/Customer/Entity'
- prefix: 'App\Customer\Entity'
- alias: App\Customer
- App\Restaurant:
- is_bundle: false
- dir: '%kernel.project_dir%/src/Restaurant/Entity'
- prefix: 'App\Restaurant\Entity'
- alias: App\Restaurant
+ entity_managers:
+ courier:
+ report_fields_where_declared: true
+ validate_xml_mapping: true
+ connection: courier
+ mappings:
+ App\Courier:
+ is_bundle: false
+ dir: '%kernel.project_dir%/src/Courier/Entity'
+ prefix: 'App\Courier\Entity'
+ alias: App\Courier
+ customer:
+ report_fields_where_declared: true
+ validate_xml_mapping: true
+ connection: customer
+ mappings:
+ App\Customer:
+ is_bundle: false
+ dir: '%kernel.project_dir%/src/Customer/Entity'
+ prefix: 'App\Customer\Entity'
+ alias: App\Customer
+ restaurant:
+ report_fields_where_declared: true
+ validate_xml_mapping: true
+ connection: restaurant
+ mappings:
+ App\Restaurant:
+ is_bundle: false
+ dir: '%kernel.project_dir%/src/Restaurant/Entity'
+ prefix: 'App\Restaurant\Entity'
+ alias: App\Restaurant
# .env
###> doctrine/doctrine-bundle ###
-DATABASE_URL="mysql://root:${MYSQL_ROOT_PASSWORD}@db:3306/delivery_service?serverVersion=8.0.33&charset=utf8mb4"
+COURIER_DATABASE_URL="mysql://root:${MYSQL_ROOT_PASSWORD}@db:3306/courier_service?serverVersion=8.0.33&charset=utf8mb4"
+CUSTOMER_DATABASE_URL="mysql://root:${MYSQL_ROOT_PASSWORD}@db:3306/customer_service?serverVersion=8.0.33&charset=utf8mb4"
+RESTAURANT_DATABASE_URL="mysql://root:${MYSQL_ROOT_PASSWORD}@db:3306/restaurant_service?serverVersion=8.0.33&charset=utf8mb4"
###< doctrine/doctrine-bundle ###
# config/doctrine_migrations_courier.yaml
+migrations_paths:
+ 'CourierMigrations': 'src/Courier/Migrations'
# config/doctrine_migrations_customer.yaml
+migrations_paths:
+ 'CustomerMigrations': 'src/Customer/Migrations'
# config/doctrine_migrations_restaurant.yaml
+migrations_paths:
+ 'RestaurantMigrations': 'src/Restaurant/Migrations'
# migrate command example: doctrine:migrations:migrate -n --em courier --configuration config/doctrine_migrations_courier.yaml
После этого, мы сможем использовать их по необходимости в коде, например:
# src/Customer/Service/CustomerService.php
public function __construct(
private RestaurantService $restaurantService,
private CourierService $deliveryService,
- private EntityManagerInterface $entityManager
+ private EntityManagerInterface $customerEntityManager
) {
}
...
+ $this->customerEntityManager->persist($newOrder);
+ $this->customerEntityManager->flush();
Наконец, нам необходимо отрефакторить relations между entities, поскольку теперь они хранятся в отдельных базах данных и управляются разными entity managers. Самый простой способ это сделать - начать использовать значения напрямую, например:
# src/Courier/Entity/Delivery.php
namespace App\Courier\Entity;
-use App\Customer\Entity\Order;
use App\Courier\Repository\DeliveryRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity(repositoryClass: DeliveryRepository::class)]
class Delivery
{
public const STATUS_NEW = 'new';
@@ -28,9 +28,9 @@
#[Groups(['api'])]
private ?string $status = null;
- #[ORM\OneToOne(cascade: ['persist', 'remove'])]
- #[ORM\JoinColumn(nullable: false)]
- private ?Order $RelatedOrder = null;
+ #[ORM\Column(name: 'related_order_id')]
+ private ?int $relatedOrderId = null;
public function getId(): ?int
{
@@ -49,14 +49,14 @@
return $this;
}
- public function getRelatedOrder(): ?Order
+ public function getRelatedOrderId(): ?int
{
- return $this->RelatedOrder;
+ return $this->relatedOrderId;
}
- public function setRelatedOrder(Order $RelatedOrder): static
+ public function setRelatedOrderId(int $relatedOrderId): static
{
- $this->RelatedOrder = $RelatedOrder;
+ $this->relatedOrderId = $relatedOrderId;
return $this;
}
Как видите, разделение баз данных уже подталкивает наш код в направлении модульности. Это поможет нам в следующих шагах.
Полный пример кода после этого шага можно увидеть здесь.
Имплементация границ модулей
После того, как файлы перемещены в соответствующие директории, и база данных разделена, мы можем начать имплементировать более строгие границы между модулями. Для этого мы будем использовать библиотеку под названием Deptrac: определим в конфигурационном файле deptrac.yaml
модули, а затем с помощью исполняемого файла vendor/bin/deptrac
будем проверять соблюдение границ между ними. Это поможет нам в двух случаях:
Когда файлы только перемещены в соответствующие директории, мы можем определить модули в
deptrac.yaml
и запустить исполняемый файл deptrac, чтобы увидеть полный список зависимостей и спланировать работу по их рефакторингу.Когда зависимости отрефакторены, мы можем закоммитить файл
deptrac.yaml
и добавить вызов исполняемого файла deptrac в CI\CD, чтобы предотвратить появление новых зависимостей.
Давайте посмотрим, как это выглядит на практике. Во-первых, определим наши модули в deptrac.yaml
:
parameters:
paths:
- ./src
layers:
- name: Common
collectors:
- type: directory
value: src/Common/.*
- name: Courier
collectors:
- type: directory
value: src/Courier/.*
- name: Customer
collectors:
- type: directory
value: src/Customer/.*
- name: Restaurant
collectors:
- type: directory
value: src/Restaurant/.*
ruleset:
Courier:
- Common
Customer:
- Common
Restaurant:
- Common
После запуска vendor/bin/deptrac
мы получим отчет о зависимостях между нашими модулями:
Для разрешения данных зависимостей мы добавим service clients и DTO в наш код. Это будет что-то вроде SDK для наших модулей, который мы поместим в папку Common
. Смысл в том, чтобы иметь слой, где мы определяем способ взаимодействия между модулями, что в дальнейшем позволит нам легче менять этот самый способ. Для начала им будут являться прямые вызовы функций, а в дальнейшем станут HTTP-запросы и асинхронные сообщения. Также, при необходимости модуль Common
позже можно будет перенести в отдельный репозиторий и использовать в качестве библиотеки Composer.
Для реализации service clients мы будем использовать фичу Symfony под названием sub-requests которая позволит нам отправлять запросы по существующим роутам внутри приложения без запросов по сети. Идея не уникальна - например, в Node.js существует аналогичный подход с использованием mcollina/fastify-undici-dispatcher.
Стоит отметить один нюанс - sub-requests имеют небольшой оверхед по сравнению с прямыми вызовами методов. Однако, чтобы он стал хоть сколько-нибудь заметным, нужно совершать sub-requests тысячи раз в цикле, а если мы вспомним, что эти вызовы позже станут HTTP-запросами, где оверхед будет намного больше, иногда даже полезно заранее заметить такие места и отрефакторить их.
Теперь давайте введём базовый класс для всех service clients:
# src/Common/Client/AbstractSymfonyControllerResolvingClient.php
<?php
declare(strict_types=1);
namespace App\Common\Client;
use App\Common\Exception\BadPayloadException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
abstract class AbstractSymfonyControllerResolvingClient
{
public const IS_INTERNAL_REQUEST_ATTRIBUTE_KEY = 'is-internal-request';
protected readonly Serializer $serializer;
public function __construct(
private readonly HttpKernelInterface $httpKernel,
) {
$encoders = [new JsonEncoder()];
$normalizers = [new ObjectNormalizer()];
$this->serializer = new Serializer($normalizers, $encoders);
}
protected function sendServiceRequest(
string $uri,
array $query = [],
array $requestBody = [],
string $method = Request::METHOD_GET
): Response {
foreach ([$query, $requestBody] as $payload) {
$this->validatePayload($payload);
}
$request = new Request(
query: $query,
request: $requestBody,
content: json_encode($requestBody, JSON_THROW_ON_ERROR),
);
$request->setMethod($method);
$request->server->set('REQUEST_URI', $uri);
$request->attributes->set(self::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY, true);
return $this->httpKernel->handle($request, HttpKernelInterface::SUB_REQUEST);
}
private function validatePayload($data): void
{
foreach ($data as $item) {
if (is_array($item)) {
$this->validatePayload($item);
} elseif (!is_scalar($item) && !is_null($item)) {
throw new BadPayloadException();
}
}
}
}
Вы можете заметить метод validatePayload
. Его цель - запретить передачу нескалярных аргументов внутри запроса. Несмотря на то, что в случае с sub-requests это ещё будет работать, полезно добавить эту проверку сейчас, чтобы обеспечить плавный переход к HTTP-запросам в дальнейшем.
Кроме того, поскольку мы добавляем новые роуты Symfony стандартным способом, они автоматически доступны для внешних запросов, чего мы не хотим. Для этого создадим специальный Event handler:
# src/Common/EventListener/HideInternalApiListener.php
<?php
declare(strict_types=1);
namespace App\Common\EventListener;
use App\Common\Client\AbstractSymfonyControllerResolvingClient;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
#[AsEventListener(event: 'kernel.request')]
class HideInternalApiListener
{
public function onKernelRequest(RequestEvent $event): void
{
$serviceApiUrlPattern = '/service-';
$comparisonResult = strncmp($event->getRequest()->getPathInfo(), $serviceApiUrlPattern, mb_strlen($serviceApiUrlPattern));
if (0 !== $comparisonResult) {
return;
}
$secretKey = $event->getRequest()->attributes->get(AbstractSymfonyControllerResolvingClient::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY);
if (true !== $secretKey) {
throw new NotFoundHttpException();
}
}
}
Он проверяет URI запроса и, если он начинается с /service-
и не имеет специального request attribute, возвращает 404.
Теперь давайте добавим первый service client:
# src/Common/Client/RestaurantServiceClient.php
<?php
declare(strict_types=1);
namespace App\Common\Client;
use App\Common\Dto\Order;
use App\Common\Dto\Restaurant;
use RuntimeException;
class RestaurantServiceClient extends AbstractSymfonyControllerResolvingClient
{
public function getRestaurant(int $restaurantId): ?Restaurant
{
$response = $this->sendServiceRequest('/service-restaurant/restaurants/'.$restaurantId);
if (404 === $response->getStatusCode()) {
return null;
}
if (200 !== $response->getStatusCode()) {
throw new RuntimeException('Unexpected response code');
}
return $this->serializer->deserialize($response->getContent(), Restaurant::class, 'json');
}
public function acceptOrder(Order $orderDto): bool
{
$response = $this->sendServiceRequest(
uri: '/service-restaurant/order/actions/accept',
requestBody: $this->serializer->normalize($orderDto),
method: 'POST'
);
if (200 !== $response->getStatusCode()) {
throw new RuntimeException('Unexpected response code');
}
return $this->serializer->decode(data: $response->getContent(), format: 'json');
}
}
И используйте его вместо прямого вызова сервиса:
# src/Customer/Service/CustomerService.php
readonly class CustomerService
{
public function __construct(
- private RestaurantService $restaurantService,
- private CourierService $deliveryService,
- private EntityManagerInterface $entityManager
+ private RestaurantServiceClient $restaurantServiceClient,
+ private CourierServiceClient $courierServiceClient,
+ private EntityManagerInterface $customerEntityManager
) {
}
public function createOrder(CreateOrderRequest $createOrderRequest): int
{
- if (!($restaurant = $this->restaurantService->getRestaurant($createOrderRequest->getRestaurantId()))) {
+ if (!($restaurant = $this->restaurantServiceClient->getRestaurant($createOrderRequest->getRestaurantId()))) {
throw new EntityNotFoundException();
}
$newOrder = (new Order())
- ->setRestaurant($restaurant)
+ ->setRestaurantId($restaurant->getId())
->setStatus(Order::STATUS_NEW);
- if ($this->restaurantService->acceptOrder($newOrder)) {
+ $this->customerEntityManager->persist($newOrder);
+ $this->customerEntityManager->flush();
+
+ $orderDto = new OrderDto($newOrder->getId(), $newOrder->getStatus(), $newOrder->getRestaurantId(), $newOrder->getDeliveryId());
+
+ if ($this->restaurantServiceClient->acceptOrder($orderDto)) {
$newOrder->setStatus(Order::STATUS_ACCEPTED);
- $newDelivery = $this->deliveryService->createDelivery($newOrder);
- $newOrder->setDelivery($newDelivery);
+ $newDelivery = $this->courierServiceClient->createDelivery($orderDto);
+ $newOrder->setDeliveryId($newDelivery->getId());
} else {
$newOrder->setStatus(Order::STATUS_DECLINED);
}
- $this->entityManager->persist($newOrder);
- $this->entityManager->flush();
+ $this->customerEntityManager->persist($newOrder);
+ $this->customerEntityManager->flush();
return $newOrder->getId();
}
}
Готово! Таким образом, мы сократили количество зависимостей из отчета Deptrac, с 15 до 11. Это пример итеративного разрешения зависимостей, при котором мы пошагово формируем границы между сервисами. Остальные зависимости в нашем приложении разрешаются тем же образом. В реальных приложениях обычно требуется больше сил на решение того, что поместить в папку Common
— например, exceptions общего характера, DTO, helpers и т. д. Если есть сомнения, как правило, можно добавить чуть больше классов в этот неймспейс, а затем переместить в соответствующий сервис.
После того, как все зависимости отрефакторены, имеет смысл закоммитить файл конфигурации deptrac.yaml
и добавить шаг в вашем CI\CD, в котором будет проверяться отстутствие новых зависимостей.
Полный пример кода после этого шага можно увидеть здесь.
Переход от modular monolith к service-oriented architecture
После того, как модули разделены, переход к сервисам является довольно тривиальным. Ради примера я просто скопировал содержимое всего приложения в отдельные каталоги, затем удалил ненужные модули из исходного кода и исправил пару конфигов. Вот окончательная структура:
courier-service/ <-- Separate service level (complete Symfony application)
src/
Courier/ <-- Single module inside an application
...
customer-service/
src/
Customer/
...
deployment <-- Infrastructure configurations (e.g. Nginx config)
restaurant-service/
src/
Restaurant/
...
.env
docker-compose.override.yml
docker-compose.yml
Dockerfile
LICENSE
Makefile
README.md
Нет смысла перечислять все мелкие изменения в файлах конфигурации, но выделю ключевые моменты. Для начала, конфигурация Nginx для такой структуры:
# deployment/nginx/default.nginx
map $request_uri $upstream {
default invalid;
~^/api/customer/ customer-service;
~^/api/courier/ courier-service;
~^/api/restaurant/ restaurant-service;
}
upstream customer-service {
server customer-service:9000;
}
upstream courier-service {
server courier-service:9000;
}
upstream restaurant-service {
server restaurant-service:9000;
}
server {
listen 80 default_server;
root /usr/share/app/$upstream/public;
location /api/ {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass $upstream;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /usr/share/app/public$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT /usr/share/app/public;
internal;
}
location ~ \.php$ {
return 404;
}
error_log /var/log/nginx/error.log;
access_log /var/log/nginx/access.log;
}
Вы можете увидеть отдельные upstreams для каждого сервиса, а также блок выбора upstream на основе URI запроса.
Во-вторых, поскольку мы переходим от sub-requests к реальным HTTP-запросам, нам необходимо добавить symfony/http-client
в наши сервисы, а затем скорректировать базовый класс service clients, сами service clients и контроллер API сервиса:
# restaurant-service/src/Common/Client/AbstractSymfonyControllerResolvingClient.php
-abstract class AbstractSymfonyControllerResolvingClient
+abstract class AbstractHttpClient
{
- public const IS_INTERNAL_REQUEST_ATTRIBUTE_KEY = 'is-internal-request';
-
protected readonly Serializer $serializer;
public function __construct(
- private readonly HttpKernelInterface $httpKernel,
+ private readonly HttpClientInterface $client,
+ #[Autowire('%api.secret.key%')]
+ private readonly string $apiSecretKey,
) {
$encoders = [new JsonEncoder()];
$normalizers = [new ObjectNormalizer()];
@@ -32,22 +33,23 @@
array $query = [],
array $requestBody = [],
string $method = Request::METHOD_GET
- ): Response {
+ ): ResponseInterface {
foreach ([$query, $requestBody] as $payload) {
$this->validatePayload($payload);
}
- $request = new Request(
- query: $query,
- request: $requestBody,
- content: json_encode($requestBody, JSON_THROW_ON_ERROR),
+ return $this->client->request(
+ $method,
+ 'http://nginx/api/'.$this->getServiceName().$uri,
+ [
+ 'query' => $query,
+ 'body' => $this->serializer->serialize($requestBody, JsonEncoder::FORMAT),
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'X-Api-Secret' => $this->apiSecretKey,
+ ],
+ ]
);
-
- $request->setMethod($method);
- $request->server->set('REQUEST_URI', $uri);
- $request->attributes->set(self::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY, true);
-
- return $this->httpKernel->handle($request, HttpKernelInterface::SUB_REQUEST);
}
private function validatePayload($data): void
@@ -60,4 +62,6 @@
}
}
}
+
+ abstract protected function getServiceName(): string;
}
# courier-service/src/Common/Client/CustomerServiceClient.php
-class CustomerServiceClient extends AbstractSymfonyControllerResolvingClient
+class CustomerServiceClient extends AbstractHttpClient
{
public function changeOrderStatus(int $orderId, string $newOrderStatus): void
{
@@ -23,4 +23,9 @@
throw new RuntimeException('Unexpected response code');
}
}
+
+ protected function getServiceName(): string
+ {
+ return 'customer';
+ }
}
# customer-service/src/Customer/Controller/ServiceApiController.php
+#[Route('/api/customer')]
class ServiceApiController extends AbstractController
{
#[Route('/service-customer/orders', methods: 'POST')]
Поскольку мы ушли от sub-requests, то больше не можем использовать request attributes Symfony для защиты наших внутренних запросов. Необходим какой-то другой механизм. Для простоты я добавил секретный ключ, который отправляется вместе с запросом, а затем проверяется в том же HideInternalApiListener
:
# courier-service/config/services.yaml
parameters:
+ api.secret.key: '%env(API_SECRET_KEY)%'
# courier-service/.env
+API_SECRET_KEY=539afcb6-1897-4204-a552-f2c7b8fc35d2
# customer-service/src/Common/EventListener/HideInternalApiListener.php
#[AsEventListener(event: 'kernel.request')]
-class HideInternalApiListener
+readonly class HideInternalApiListener
{
+ public function __construct(
+ #[Autowire('%api.secret.key%')]
+ private string $apiSecretKey,
+ ) {
+ }
+
public function onKernelRequest(RequestEvent $event): void
{
- $serviceApiUrlPattern = '/service-';
+ $serviceApiUrlPattern = '/api/customer/service-customer/';
$comparisonResult = strncmp($event->getRequest()->getPathInfo(), $serviceApiUrlPattern, mb_strlen($serviceApiUrlPattern));
if (0 !== $comparisonResult) {
return;
}
- $secretKey = $event->getRequest()->attributes->get(AbstractSymfonyControllerResolvingClient::IS_INTERNAL_REQUEST_ATTRIBUTE_KEY);
- if (true !== $secretKey) {
+ $apiSecret = $event->getRequest()->headers->get('X-Api-Secret');
+ if ($this->apiSecretKey !== $apiSecret) {
throw new NotFoundHttpException();
}
}
Конечно, это не production-ready код, но достаточно в качестве примера. Лучшим подходом была бы реализация JWT для аутентификации внутренних запросов.
Полный пример кода после этого шага можно увидеть здесь.
Переход от service-oriented к event-driven architecture
В этой части мы изменим нашу архитектуру, чтобы она стала ближе к event-driven. Я специально использую такую формулировку, потому как уже говорилось раньше, мы не будем сильно углубляться в данную архитектуру (это выходит за рамки статьи). Вместо этого мы сосредоточимся на практических изменениях, необходимых для перехода от прямых вызовов service API к асинхронным сообщениям.
Необходимые изменения станут более понятными, если мы взглянем на следующие sequence диаграммы:
Этот flow мы превратим в следующую (я выделил места, которые станут асинхронными):
То же самое касается действия «Change delivery status»:
Чтобы сделать межсервисную связь асинхронной в этом flow, требуется лишь небольшое изменение:
Шаги реализации включают в себя следующее:
Введение async broker и consumers в нашу инфраструктуру.
Создание message-классов в нашем неймспейсе
Common
и соответствующие им message handlers.Использование message bus вместо service client для межсервисной коммуникации.
А теперь шаг за шагом. Сначала нам нужно установить компонент Symfony Messenger: composer require symfony/messenger symfony/amqp-messenger
Затем настроить его:
# other services have the same configuration except queue name and routing
framework:
messenger:
serializer:
default_serializer: messenger.transport.symfony_serializer
transports:
async:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queues:
restaurant-service: ~
buses:
default.bus:
default_middleware:
enabled: true
allow_no_handlers: true
Добавить async broker и consumers:
# docker-compose.yml
+ amqp-broker:
+ image: rabbitmq:3
+ restart: unless-stopped
...
+ courier-consumer:
+ build:
+ context: .
+ working_dir: /usr/share/app
+ restart: unless-stopped
+ command: bin/console messenger:consume async -vv
+ volumes:
+ - ./courier-service/:/usr/share/app
// consumers for the rest 2 services are added the same way
...
Существует несколько способов использования asynchronous brokers, но мы будем использовать fanout exchange (который создается Symfony по умолчанию, если не указано другое) и queue per service. Таким образом, мы имеем ряд преимуществ:
Producers отправляют сообщения в broker без необходимости указывать routing key и не зная, кто именно получит сообщение. Благодаря этому наши сообщения больше похожи на events, чем на background tasks, что уменьшает связанность сервисов.
Каждый сервис всегда получает все события и реагирует соответствующим образом. Ничего страшного, если сервис не умеет обрабатывать некоторые сообытия — сообщения можно игнорировать, а в случае необходимости, всегда можно дописать логику и начать реагировать на такие события.
Наконец, мы можем создать наше первое сообщение и изменить способ взаимодействия наших сервисов:
# customer-service/src/Common/Message/OrderCreated.php
<?php
declare(strict_types=1);
namespace App\Common\Message;
use App\Common\Dto\Order as OrderDto;
readonly class OrderCreated
{
public function __construct(private OrderDto $order)
{
}
public function getOrder(): OrderDto
{
return $this->order;
}
}
// all messages should be part of the "Common" namespace of each service
// in our case, for the sake of the example, we just copy it
// but in real application, it can (and should) be a package
# restaurant-service/src/Restaurant/MessageHandler/OrderCreatedHandler.php
<?php
declare(strict_types=1);
namespace App\Restaurant\MessageHandler;
use App\Common\Message\OrderAccepted;
use App\Common\Message\OrderCreated;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
readonly class OrderCreatedHandler
{
public function __construct(private MessageBusInterface $messageBus)
{
}
public function __invoke(OrderCreated $message)
{
// for the sake of the example, let's assume for now that the order can always be served
$this->messageBus->dispatch(new OrderAccepted($message->getOrder()));
// alternatively, we could dispatch this instead based on our business logic:
// $this->messageBus->dispatch(new OrderDeclined());
}
}
И теперь мы изменим код и начать использовать сообщения вместо вызова service clients:
# customer-service/src/Customer/Service/CustomerService.php
namespace App\Customer\Service;
-use App\Common\Client\CourierServiceClient;
use App\Common\Client\RestaurantServiceClient;
use App\Common\Dto\Order as OrderDto;
use App\Common\Exception\EntityNotFoundException;
+use App\Common\Message\OrderCreated;
use App\Customer\Dto\CreateOrderRequest;
use App\Customer\Entity\Order;
use Doctrine\ORM\EntityManagerInterface;
+use Symfony\Component\Messenger\MessageBusInterface;
readonly class CustomerService
{
public function __construct(
private RestaurantServiceClient $restaurantServiceClient,
- private CourierServiceClient $deliveryServiceClient,
- private EntityManagerInterface $customerEntityManager
+ private EntityManagerInterface $customerEntityManager,
+ private MessageBusInterface $messageBus,
) {
}
@@ -36,17 +37,11 @@
$orderDto = new OrderDto($newOrder->getId(), $newOrder->getStatus(), $newOrder->getRestaurantId(), $newOrder->getDeliveryId());
- if ($this->restaurantServiceClient->acceptOrder($orderDto)) {
- $newOrder->setStatus(Order::STATUS_ACCEPTED);
- $newDelivery = $this->deliveryServiceClient->createDelivery($orderDto);
- $newOrder->setDeliveryId($newDelivery->getId());
- } else {
- $newOrder->setStatus(Order::STATUS_DECLINED);
- }
-
$this->customerEntityManager->persist($newOrder);
$this->customerEntityManager->flush();
+ $this->messageBus->dispatch(new OrderCreated($orderDto));
+
return $newOrder;
}
Готово! Остальные места взаимодействия изменяются тем же способом. После этого мы можем отправить запрос в эндпойнт «Create order» и наблюдать, как сообщения действительно отправляются всем сервисам:
Также это видно в наших логах:
customer-service_1 | Matched route "app_customer_customerapi_createorder".
customer-service_1 | Request: "GET http://nginx/api/restaurant/service-restaurant/restaurants/2"
restaurant-service_1 | Matched route "app_restaurant_serviceapi_getrestaurant".
customer-service_1 | Sending message App\Common\Message\OrderCreated with async sender using Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpTransport
customer-consumer_1 | Received message App\Common\Message\OrderCreated
customer-consumer_1 | No handler for message App\Common\Message\OrderCreated
customer-consumer_1 | App\Common\Message\OrderCreated was handled successfully (acknowledging to transport).
courier-consumer_1 | Received message App\Common\Message\OrderCreated
courier-consumer_1 | No handler for message App\Common\Message\
courier-consumer_1 | App\Common\Message\OrderCreated was handled successfully (acknowledging to transport).
restaurant-consumer_1 | Received message App\Common\Message\OrderCreated
restaurant-consumer_1 | Sending message App\Common\Message\OrderAccepted with async sender using Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpTransport
restaurant-consumer_1 | Message App\Common\Message\OrderCreated handled by App\Restaurant\MessageHandler\OrderCreatedHandler::__invoke
restaurant-consumer_1 | App\Common\Message\OrderCreated was handled successfully (acknowledging to transport).
restaurant-consumer_1 | Received message App\Common\Message\OrderAccepted
restaurant-consumer_1 | No handler for message App\Common\Message\OrderAccepted
restaurant-consumer_1 | App\Common\Message\OrderAccepted was handled successfully (acknowledging to transport).
customer-consumer_1 | Received message App\Common\Message\OrderAccepted
courier-consumer_1 | Received message App\Common\Message\OrderAccepted
customer-consumer_1 | Message App\Common\Message\OrderAccepted handled by App\Customer\MessageHandler\OrderAcceptedHandler::__invoke
customer-consumer_1 | App\Common\Message\OrderAccepted was handled successfully (acknowledging to transport).
courier-consumer_1 | No handler for message App\Common\Message\DeliveryCreated
courier-consumer_1 | Message App\Common\Message\OrderAccepted handled by App\Courier\MessageHandler\OrderAcceptedHandler::__invoke
courier-consumer_1 | Sending message App\Common\Message\DeliveryCreated with async sender using Symfony\Component\Messenger\Bridge\Amqp\Transport\AmqpTransport
...
Кроме того, после всех изменений мы можем заметить, что как courier, так и restaurant сервису больше не нужны service clients, поскольку им не нужно напрямую взаимодействовать с другими службами. Наш код стал чуточку чище!
Полный пример кода после этого шага можно увидеть здесь.
Заключение
В этой статье мы рассмотрели трансформацию архитектуры приложения в несколько этапов. Начиная с монолита, переходя на модульный монолит, затем отдельные сервисы и заканчивая событийно-ориентированной архитектурой. Мы сосредоточились на технических деталях такого перехода.
Однако стоит отметить, что в реальных приложениях будет гораздо больше работы, больше edge cases и принятия решений. Поэтому я предлагаю рассматривать эту статью не как руководство к действию, а как практический пример, показывающий, что этот процесс может (и должен) быть итеративным и предсказуемым. Самой большой проблемой таких архитектурных изменений часто является не само изменение, а обоснование и построение надлежащего процесса, который позволяет инженерам и другим вовлеченным сторонам верить в успех и сохранять фокус. Надеюсь, эта статья дает хотя бы отдаленное понимание как это может выглядить.
Спасибо за внимание и удачи!