Приветствую всех неравнодушных! Я являюсь руководителем разработки в компании DD Planet, и сегодня, наконец-то, дошли руки написать продолжение статьи
Во второй части статьи мы рассмотрим, как с помощью DDD структурировать домены, выстроить иерархию сервисов, настроить зависимости и внедрить DI-подход.
Продолжаем переводить архитектуру на DDD, и первым делом мы регистрируем пространство имен в файле init.php.
Внимание, копнем в этом моменте поглубже!
Сколько бы проектов, основанных на DDD, я ни изучал, каждый раз сталкивался с тем, что есть теория, которую мы все знаем, но практика у каждого своя, к примеру:
1 вариант. Автор делает доменную логику, отделяя ядро и общие независимые модули в отдельную структуру.
2 вариант. Автор выкладывает всю логику первого слоя на корневом уровне. Таким образом, первый слой будет состоять из наибольшего количества директорий.
3 вариант. Архитектура, основанная на фичах, где каждая фича живёт своей жизнью и полностью содержит в себе архитектурные наборы, — такой вариант хорош для распиливания на микросервисы.
Ссылки для тех, кому интересно почитать
По итогу мы видим, что деление на слои есть, а вот в каком порядке — это не столь важно. Тут приведу пример, где в одном домене собрано вообще все, и это, надо признать, та же самая архитектура, как и в пункте 3 выше:
Какой напрашивается вывод? Нет правильной схемы — есть лишь иерархия, основанная на логике проекта.
Создадим нашу основную архитектуру
local
App
Application — сам Битрикс является этим слоем. Нам он не понадобится.Domains — сюда складываем логику проекта.
Infrastructure — тут у нас будут лежать сервисы для интеграций.
Shared (Lib) — тут мы будем складывать сервисы, общие как для проекта, так и легко переносимые на другие проекты, и всякие хелперы, не завязанные на бизнес-логику.
Сервисный подход:
Для упрощения дальнейшей поддержки и доработок нужно сделать так, чтобы все разработчики действовали по аналогии, довольно сильно ограничивая свою фантазию относительно структуры, но не ограничиваясь в возможностях.
Таким образом, нам понадобится сервисный подход, который позволит одинаково обращаться к совершенно разным системам, а именно:
Layer\ServiceName\ServiceNameService::create($serviceId)->module()->methods()
Пример:
App\Domains\Order\OrderService::create()->repository()->getElementById($id);
App\Infrastructure\Http\HttpService::create(HTTPEnums::GUZZLE->value)->client()->send(array $body, Url $url)
Практика
1. В первую очередь смерджим наш composer.json в папке local с тем, который у нас в ядре, при помощи merge-plugin, чтобы не лезть в папку bitrix с доработками.
{ "version": "1.0.0", "name": "name", "description": "description", "authors": [ { "name": "name", "email": "email" } ], "extra": { "installer-paths": { "modules/{$name}/": [ "type:bitrix-module" ] }, "merge-plugin": { "require": [ "../bitrix/composer-bx.json" ] } }, "require": { "php": ">=8.2", "psr/container": "2.0", "psr/http-client": "^1.0", "psr/http-message": "^1.0", "psr/log": "^3.0", "...": "...", }, "config": { "vendor-dir": "../bitrix/vendor", "allow-plugins": { "composer/installers": true, "php-http/discovery": true } }, "require-dev": { "phpunit/phpunit": "11.3.0" }, "scripts": { "test": "phpunit" } }
Затем зарегистрируем нашу архитектуру, используя собранный набор вендоров в файле init.php, подключение классов будет автоматически, согласно PSR-4:
use Bitrix\Main\Application; use Bitrix\Main\Loader; require(Application::getDocumentRoot() . "/bitrix/vendor/autoload.php"); /***** * Подключение архитектурных классов. *****/ try { Loader::registerNamespace( "App", Loader::getDocumentRoot() . "/local/App" ); } catch (LoaderException $e) { LoggerFacade::app()->error('Error include namespace: ' . $e->getMessage()); throw new \Exception($e->getMessage()); }
Слой домена: тут каждый домен будет являть из себя полный набор необходимых ему для жизни функций, чтобы в дальнейшем можно было легко начать распил на микросервисы.
Каждый доменный сервис, для единого подхода с другими слоями, в которых подключение сервисов идет через паттерн «абстрактная фабрика», будет подключаться через паттерн «простая фабрика» и через нее возвращать сам себя.
Создаем интерфейс для сервисов домена:
<?php namespace App\Domains\Contracts; interface BaseServiceInterface { /** * Method create service by key. * @return mixed */ public static function create(): self;
И применяем к нашему классу:
<?php namespace App\Domains\Order\Services; use App\Domains\Contracts\BaseServiceInterface; use App\Domains\Order\Services\Exchange\ExchangeService; use App\Domains\Order\Services\Repository\OrderRepository; use App\Shared\DI\Container; use Bitrix\Main\ArgumentException; readonly class OrderService implements BaseServiceInterface { /** * @throws ArgumentException */ public static function create(): self { return (new Container())->get(self::class); } public function __construct( private OrderRepository $repository, private ExchangeService $exchangeService ) { } public function repository(): OrderRepository { return $this->repository; } public function exchange(): ExchangeService { return $this->exchangeService; } }
Теперь мы можем обратиться к сервису и вызвать его дочерние классы, которые мы в него внедрили через паттерн DI
public function __construct( private OrderRepository $repository, private ExchangeService $exchangeService )
Так, мы можем получить доступ к методу по пути OrderService::create()->repository()->getDealRepositoryByDealId($id);
Но если заметили, в методе create() используется какой-то Container()? Так вот, если мы вернем DI-инъекцию классов, то они будут требовать объявления передаваемых объектов, и нам придется прописывать инициализацию для всей структуры в каждой зависимости, на каждом уровне, что очень кропотливо.
return new self(new OrderRepository(), new ExchangeService());
Если брать symfony и laravel, то там это решается при помощи хелперов app(). Вот выдержка из документаций:
If you are using it as in your example — you’ll get no profit. But Laravel container gives much more power in this resolving that you cannot achieve with simple instantiating objects.
Binding Interface — you can bind specific interface and it’s implementation into container and resolve it as interface. This is useful for test-friendly code and flexibility — cause you can easily change implementation in one place without changing interface. (For example use some Countable interface everywhere as a target to resolve from container but receive it’s implementation instead.)
Dependency Injection — if you will bind class/interface and ask it as a dependecy in some method/constructor — Laravel will automatically insert it from container for you.
Conditional Binding — you can bind interface but depending on the situation resolve different implementations.
Singleton — you can bind some shared instance of an object.
Resolving Event — each time container resolves smth — it raises an event you can subscribe in other places of your project.
And many other practises… You can read more detailed here http://laravel.com/docs/5.1/container
В нашем случае мы вручную создадим динамический контейнер, который нам вернет инстанс со всеми иерархическими объявлениями:
<?php namespace App\Shared\DI; use Bitrix\Main\ArgumentException; use Exception; use Psr\Container\ContainerExceptionInterface; use Psr\Container\ContainerInterface; use Psr\Container\NotFoundExceptionInterface; use ReflectionClass; use ReflectionException; /** * Класс для генерации контейнеров. * @package App\Shared\DI */ class Container implements ContainerInterface { private array $objects = []; /** * @inheritDoc */ public function has(string $id): bool { return isset($this->objects[$id]) || class_exists($id); } /** * Метод возвращает инстанс класса по его ключу. * * @param string $id * @return mixed * @throws ArgumentException */ public function get(string $id): mixed { try { return isset($this->objects[$id]) ? $this->objects[$id]() // "Старый подход" : $this->prepareObject($id); // "Новый" подход } catch (ContainerExceptionInterface|NotFoundExceptionInterface|Exception $exception) { throw new ArgumentException($exception->getMessage()); } } /** * @throws Exception */ private function prepareObject(string $class): object { try { $classReflector = new ReflectionClass($class); // Получаем рефлектор конструктора класса, проверяем - есть ли конструктор // Если конструктора нет - сразу возвращаем экземпляр класса $constructReflector = $classReflector->getConstructor(); if (empty($constructReflector)) { return new $class; } // Получаем рефлекторы аргументов конструктора // Если аргументов нет - сразу возвращаем экземпляр класса $constructArguments = $constructReflector->getParameters(); if (empty($constructArguments)) { return new $class; } // Перебираем все аргументы конструктора, собираем их значения $args = []; foreach ($constructArguments as $argument) { // Получаем тип аргумента $argumentType = $argument->getType()->getName(); // Получаем сам аргумент по его типу из контейнера $args[$argument->getName()] = $this->get($argumentType); } // И возвращаем экземпляр класса со всеми зависимостями return new $class(...$args); } catch (ReflectionException|ContainerExceptionInterface|NotFoundExceptionInterface $e) { throw new Exception($e->getMessage()); } } }
Таким образом, все внедренные зависимости в сервис всегда будут одним и тем же объектом, и мы сможем изменять значения в дочерних классах, при этом в самих классах, используя зависимости, к примеру:
CurlService::create()->client()->setUrl($url);
CurlService::create()->actions()->send($message);
Так как в CurlActions такой же инстанс подключается через иерархическую зависимость:
readonly class CurlActions implements ActionsInterface { /** * @throws ArgumentException * @throws SystemException */ public function __construct( private CurlClient $client ) { $this->client->init(); }
2. Инфраструктурные сервисы
Раз мы заговорили о CURL, он является типичным представителем инфраструктуры, но легко заменяется тем же Guzzle, и большинство сервисов инфраструктуры легко взаимозаменяемы, как, например, разные провайдеры СМС.
Тут нам понадобится фабрика, построенная на интерфейсах, а не конкретной реализации. К примеру, у curl и guzzle должен быть метод init(), метод setBody() и метод send(). А вот их конкретная реализация нас мало интересует.
Подготавливаем родительский интерфейс для сервиса:
<?php namespace App\Infrastructure\Contracts; interface BaseServiceInterface { /** * Method create service by key. * @param int $typeId ID сервиса. * @return mixed */ public static function create(int $typeId): mixed; }
Через $typeId
будем передавать, какой именно тип сервиса мы хотим получить. Чтобы разработчики не запутались в том, какие сервисы как называются, создаем константы в Enums:
<?php namespace App\Infrastructure\Enums\Http; enum ServicesEnums : int { case CURL = 1; case GUZZLE = 2; }
И интерфейс для класса с инъекциями, который он будет возвращать:
<?php namespace App\Infrastructure\Contracts\Http; interface ServiceInterface { public function client(): ClientInterface; public function repository(): RepositoryInterface; public function actions(): ActionsInterface; }
Создаем сервис и в него передаем тип:
<?php namespace App\Infrastructure\Services\Http; use App\Infrastructure\Contracts\BaseServiceInterface; use App\Infrastructure\Contracts\Http\HttpRequestInterface; use App\Infrastructure\Contracts\Http\ServiceInterface; use App\Infrastructure\Enums\Http\ServicesEnums; use App\Infrastructure\Enums\Logger\TypeEnums; use App\Infrastructure\Services\Http\Curl\CurlService; use App\Infrastructure\Services\Http\Guzzle\GuzzleService; use App\Shared\DI\Container; use Exception; class HttpService implements BaseServiceInterface { /** * @throws Exception */ public static function create(int $typeId = ServicesEnums::CURL->value): ServiceInterface { return match ($typeId) { ServicesEnums::CURL->value => (new Container())->get(CurlService::class), ServicesEnums::GUZZLE->value => (new Container())->get(GuzzleService::class), default => throw new Exception('Unexpected match value'), }; } /** * Логирование обмена данными. * * @param HttpRequestInterface $context * @param TypeEnums $type Источник логов. * @return void */ public static function log(HttpRequestInterface $context, TypeEnums $type = TypeEnums::ORDERS): void { $method = debug_backtrace()[1]['function']; $message = "Http: {$context->method}::{$method} [{$context->id_entity}]"; LoggerFacade::app('Http' . DIRECTORY_SEPARATOR . $context->service . DIRECTORY_SEPARATOR . $type->name . DIRECTORY_SEPARATOR )->info($message, (array)$context); LoggerFacade::elk()->info($message, (array)$context); } }
Создаем классы для каждого сервиса по отдельности и возвращаем общие для сервиса интерфейсы:
Каждый сервис предоставляет функциональность через инъекции:
<?php namespace App\Infrastructure\Services\Http\Curl; use App\Infrastructure\Contracts\Http\RepositoryInterface; use App\Infrastructure\Contracts\Http\ServiceInterface; use App\Infrastructure\Services\Http\Curl\Actions\CurlActions; use App\Infrastructure\Services\Http\Curl\Client\CurlClient; readonly class CurlService implements ServiceInterface { public function __construct( private CurlClient $client, private CurlActions $actions, ) { } public function client(): CurlClient { return $this->client; } public function repository(): RepositoryInterface { // TODO: Implement repository() method. } public function actions(): CurlActions { return $this->actions; } }
В итоге получается, что мы имеем набор контрактов для каждого сервиса, чтобы эти сервисы имели не реализацию, а описание структуры.
Таким образом, мы сможем обращаться уже по тому пути, который приводился в примере выше:
App\Infrastructure\Http\HttpService::create(HTTPEnums::GUZZLE->value)->client()->send( $body, $url)
Итог:
По результату, у нас получилась архитектура
local\App\Domains\<Name>\NameService.php
local\App\Domains\<Name>\<SubDir>\SubDirClass.php
local\App\Infrastructure\Services\<Name>\NameService.php
local\App\Infrastructure\Services\<Name>\<SubDir>\SubDirClass.php
local\App\Shared\Services\<Name>\NameService.php
local\App\Shared\Services\<Name>\<SubDir>\SubDirClass.php
И мы можем выстраивать архитектуру на любую глубину, к примеру:
App\Shared\Services\ManagementService::create()->filesystem()->modules()->psr()->actions()->registerSplByMask(mask: $mask, baseDir : 'local')
Плюс, перенос между проектами папки Infrastructure и Shared происходит простым копированием этих директорий.
ссылка на оригинал статьи https://habr.com/ru/articles/869428/
Добавить комментарий