Как стать автором
Поиск
Написать публикацию
Обновить

Практический пример декомпозиции монолитного PHP приложения

Уровень сложностиСредний
Время на прочтение26 мин
Количество просмотров12K

Введение

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

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

  1. Медленная скорость разработки. Это происходит из-за высокой связанности (coupling) кода, что включает в себя:

    1. слишком запутанный код (есть важная разница между сложным (complex) и запутанным (complicated) кодом).

    2. части кода слишком зависимы друг от друга, что приводит к более высокой вероятности возникновения конфликтов и повышает сложность построения надлежащего CI\CD процесса.

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

  2. Монолит как единая точка отказа является бизнес-риском.

  3. Горизонтальное масштабирование приложения становится более сложным.

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

Теперь, когда мы определили проблему и цель, давайте разработаем высокоуровневый план.

Описание приложения

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

Приложение для доставки еды
Приложение для доставки еды

Типичный 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

Высокоуровневый план

Теперь проясним то, что именно мы называем «связанным кодом» в контексте монолитной архитектуры:

  1. Не смотря на то, что в нашем коде присутствует разделение на так называемые service classes, они взаимодействуют друг с другом посредством прямых вызовов методов.

  2. Более того, даже если мы изменим способ взаимодействия сервисных классов друг с другом, мы все равно не сможем извлечь, потому что он использует классы других сервисов напрямую (через ключевое слово use).

  3. Наконец, все service classes имеют доступ ко всем данным в базе, а значит, нет границ между ними с точки зрения данных.

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

Для этого введем определение модуля. Мы будем называть часть нашего кода модулем тогда и только тогда, когда он будет удовлетворять двум условиям:

  1. Разделение кода. Модули не используют классы других модулей напрямую. Когда модулю необходимо вызвать другой модуль, используется service client.

  2. Разделение данных. Каждый модуль использует собственную базу данных.

Разберем подробнее:

  • Модули не используют классы других модулей напрямую. Чтобы обеспечить соблюдение этого правила, мы будем использовать библиотеку под названием 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) каждой таблицы по очереди: в коде конфигурируется ещё одно подключение к базе данных, и запись происходит в оба хранилища, одновременно, а чтение - только из старой. Параллельно нам нужно разработать и запустить скрипт для переноса существующих данных из старой базы в новую. В этой статье мы не будем останавливаться на деталях реализации горячей миграции (это заслуживает отдельной статьи), но типичный алгоритм следующий:

  1. Найти в коде места использования переносимой таблицы.

  2. Для чтения оставить старое соединение.

  3. Для записи (вставка, изменение, удаление) отправлять запросы в обе базы данных.

  4. Тем временем разработать и запустить скрипт, который перенесет существующие данные из старой базы в новую.

  5. После завершения миграции, зарелизить финальный PR, который будет использовать только новое соединение и для записи, и для чтения.

  6. На последнем этапе можно удалить старую таблицу.

Моменты, которые следует учитывать:

  1. Joins. Поскольку мы перемещаем таблицы в отдельные базы, операция JOIN между ними больше не будет возможна. Такие места придется переписывать в отдельные запросы.

  2. Foreign keys. СУБД больше не сможет обеспечить консистентность внешних ключей, поскольку таблицы находятся в отдельных базах данных. Если ваш продукт опирается на такую логику, ее придется реализовать на уровне кода приложения. Стоит отметить, что в highload проектах часто приходится отказываться от внешних ключей, поскольку у них есть свои проблемы.

  3. 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 мы получим отчет о зависимостях между нашими модулями:

Отчет Deptrac
Отчет 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, в котором будет проверяться отстутствие новых зависимостей.

Чистый отчет Deptrac
Чистый отчет Deptrac

Полный пример кода после этого шага можно увидеть здесь.

Переход от 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 диаграммы:

Create order action: синхронное взаимодействие
Create order action: синхронное взаимодействие

Этот flow мы превратим в следующую (я выделил места, которые станут асинхронными):

Create order action: асинхронное взаимодействие
Create order action: асинхронное взаимодействие

То же самое касается действия «Change delivery status»:

Change delivery status action: синхронное взаимодействие
Change delivery status action: синхронное взаимодействие

Чтобы сделать межсервисную связь асинхронной в этом flow, требуется лишь небольшое изменение:

Change delivery status action: асинхронное взаимодействие
Change delivery status action: асинхронное взаимодействие

Шаги реализации включают в себя следующее:

  • Введение 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. Таким образом, мы имеем ряд преимуществ:

  1. Producers отправляют сообщения в broker без необходимости указывать routing key и не зная, кто именно получит сообщение. Благодаря этому наши сообщения больше похожи на events, чем на background tasks, что уменьшает связанность сервисов.

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

Наконец, мы можем создать наше первое сообщение и изменить способ взаимодействия наших сервисов:

# 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 и принятия решений. Поэтому я предлагаю рассматривать эту статью не как руководство к действию, а как практический пример, показывающий, что этот процесс может (и должен) быть итеративным и предсказуемым. Самой большой проблемой таких архитектурных изменений часто является не само изменение, а обоснование и построение надлежащего процесса, который позволяет инженерам и другим вовлеченным сторонам верить в успех и сохранять фокус. Надеюсь, эта статья дает хотя бы отдаленное понимание как это может выглядить.

Спасибо за внимание и удачи!

Теги:
Хабы:
Всего голосов 28: ↑28 и ↓0+28
Комментарии6

Публикации

Ближайшие события