Как стать автором
Обновить

Битрикс: от модулей к сервисам 2

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

Приветствую всех неравнодушных! Я являюсь руководителем разработки в компании DD Planet, и сегодня, наконец-то, дошли руки написать продолжение статьи

Битрикс: от модулей к сервисам
Приветствую всех не равнодушных! Хочу поделиться с вами историей о том, как мы рефакторили код проек...
habr.com

Во второй части статьи мы рассмотрим, как с помощью DDD структурировать домены, выстроить иерархию сервисов, настроить зависимости и внедрить DI-подход.

Продолжаем переводить архитектуру на DDD, и первым делом мы регистрируем пространство имен в файле init.php.

Внимание, копнем в этом моменте поглубже!

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

1 вариант. Автор делает доменную логику, отделяя ядро и общие независимые модули в отдельную структуру.

2 вариант. Автор выкладывает всю логику первого слоя на корневом уровне. Таким образом, первый слой будет состоять из наибольшего количества директорий.


   

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

Ссылки для тех, кому интересно почитать

Domain Driven Design на практике
Эванс написал хорошую книжку с хорошими идеями. Но этим идеям не хватает методологической основы. Оп...
habr.com
Clean Architecture, DDD, гексагональная архитектура. Разбираем на практике blog на Symfony
Всем привет! Давайте знакомиться ;) Я Аня, и я php разработчик. Основной стек - Magento. С недавних ...
habr.com

По итогу мы видим, что деление на слои есть, а вот в каком порядке — это не столь важно. Тут приведу пример, где в одном домене собрано вообще все, и это, надо признать, та же самая архитектура, как и в пункте 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.

  1. 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.)

  2. 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.

  3. Conditional Binding - you can bind interface but depending on the situation resolve different implementations.

  4. Singleton - you can bind some shared instance of an object.

  5. 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 происходит простым копированием этих директорий.

Теги:
Хабы:
+2
Комментарии0
2

Публикации

Истории

Работа

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