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

от автора

Введение

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

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/796223/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *