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

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

Приветствую всех неравнодушных! Я являюсь руководителем разработки в компании 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 происходит простым копированием этих директорий.

Источник: https://habr.com/ru/articles/869428/


Интересные статьи

Интересные статьи

visionOS вывел типографику на новый уровень, где решающую роль играет пространственное мышление. В отличие от традиционных дисплеев, текст должен быть разборчивым на разном расстоянии и под разными уг...
Как и бит, кубит допускает два собственных состояния, обозначаемых |0> и |1> (обозначения Дирака), но при этом может находиться и в их суперпозиции. В общем случае его волновая функция имеет ви...
Представляю вашему вниманию цикл статей по работе с пока еще мало знакомым многим битрикс-разработчикам инструментом оперирования данными с CRM Битрикс24 через абстрактные фабрики. В первой статье рас...
Одной из «киллер-фич» 12й версии Битрикса была объявлена возможность отдавать статические файлы из CDN, тем самым увеличивая скорость работы сайта. Попробуем оценить практический выигрыш от использова...
Согласно многочисленным исследованиям поведения пользователей на сайте, порядка 25% посетителей покидают ресурс, если страница грузится более 4 секунд.