Локатор служб

Локатор служб (англ. service locator) — это шаблон проектирования, используемый в разработке программного обеспечения для инкапсуляции процессов, связанных с получением какого-либо сервиса с сильным уровнем абстракции.

Подробнее с шаблоном можно ознакомиться в статье википедии

Концепция

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

История 1

Предположим что у нас есть некоторый класс \Vendor\Currency\Converter который отвечает за конвертацию валюты. Мы передаем в него из какой валюты ($fromCurrency) и сколько ($fromValue) мы хотим конвертировать в целевую валюту ($toCurrency) на определенную дату ($date), а в ответ он возвращает конвертированное значение валюты.

Схематично опишем его как:

namespace Vendor\Currency;

use \Bitrix\Main\Type\Date;

class Converter
{
	/**
	 * Conver $fromValue $fromCurrency
	 *
	 * @param      float                   $fromValue     Income value (example 100.0)
	 * @param      string                  $fromCurrency  Income currency (example 'RUB', 'EUR', etc)
	 * @param      string                  $toCurrency    Result currency (example 'RUB', 'EUR', etc)
	 * @param      \Bitrix\Main\Type\Date  $date          Conversion date, default - now date
	 *
	 * @return     float                   Converted result
	 */
	public function convert( float $fromValue, string $fromCurrency, string $toCurrency, Date $date = null): float
	{
		$converted = 0.0;
		
		// Go to database: get currency rate.
		// Calculate summary and save to $converted

		return $converted;
	}
}

В таком случае пример его использования будет выглядеть следующим образом:

use \Vendor\Currency\Converter;

$converter = new Converter();

$convertedValue = $converter->convert(100.0, 'EUR', 'RUB');

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

use \Vendor\Currency\Converter;

$converter = new Converter();

$convertedValue = $converter->convert(1.0, 'EUR', 'RUB'); //  1 query to "EUR -> RUB" currenct
$convertedValue = $converter->convert(2.0, 'EUR', 'RUB'); // +1 query to "EUR -> RUB" currenct
$convertedValue = $converter->convert(3.0, 'EUR', 'RUB'); // +1 query to "EUR -> RUB" currenct

Итого на выполнение указанного выше фрагмента кода мы выполнил 3 запроса в базу данных для получения одних и тех же данных. Этой ситуации хотелось бы избежать и сделать это мы можем путем добавления кеширующей прослойки. Внесем изменения в наш код.

namespace Vendor\Currency;

use \Bitrix\Main\Type\Date;

class Converter
{

	protected $currencyCache = [];

	/**
	 * Conver $fromValue $fromCurrency
	 *
	 * @param      float                   $fromValue     Income value (example 100.0)
	 * @param      string                  $fromCurrency  Income currency (example 'RUB', 'EUR', etc)
	 * @param      string                  $toCurrency    Result currency (example 'RUB', 'EUR', etc)
	 * @param      \Bitrix\Main\Type\Date  $date          Conversion date, default - now date
	 *
	 * @return     float                   Converted result
	 */
	public function convert( float $fromValue, string $fromCurrency, string $toCurrency, Date $date = null): float
	{
		$converted = 0.0;

		$date = $date || new Date();

		$rate = $this->getCurrencyRate($fromCurrency, $toCurrency, $date);

		$converted = $fromValue * $rate;

		return $converted;
	}

	/**
	 * Return currency rate $fromCurrency to $toCurrency by date $date.
	 * Use local cache
	 *
	 * @param      string                  $fromCurrency  Income currency (example 'RUB', 'EUR', etc)
	 * @param      string                  $toCurrency    Result currency (example 'RUB', 'EUR', etc)
	 * @param      \Bitrix\Main\Type\Date  $date          Conversion date
	 *
	 * @return     float|int               Currency Date
	 */
	protected function getCurrencyRate(string $fromCurrency, string $toCurrency, Date $date): float
	{
		// If same - return 1
		if ( $fromCurrency == $toCurrency )
		{
			return 1.0;
		}

		$cacheKey = sprintf('%s_%s_%s', $fromCurrency, $toCurrency, $date->format('Ymd'));

		if ( array_key_exists($cacheKey, $this->currencyCache) )
		{
			return $this->currencyCache[$cacheKey];
		}

		// Go to database, load currency, save to $this->currencyCache[$cacheKey]
		
		return $this->currencyCache[$cacheKey];
	}
}

Что изменилось?

Мы добавили метод getCurrencyRate который возвращает отношение между валютами $fromCurrency и $toCurrency на дату $date. Затем мы изменили метод convert чтобы он выполнял получение валюты из нашего нового метода и возвращал результат. И наконец мы добавили локальное кеширование (свойство currencyCache) чтобы не бегать каждый раз в базу данных, если такой курс валюты у нас уже был запрошен.

Как в итоге сейчас выглядит конвертация валюты? Ровно точно так же как на первом фрагменте:

use \Vendor\Currency\Converter;

$converter = new Converter();

$convertedValue = $converter->convert(1.0, 'EUR', 'RUB'); //  1 query to "EUR -> RUB" currenct
$convertedValue = $converter->convert(2.0, 'EUR', 'RUB'); // no query, but works!
$convertedValue = $converter->convert(3.0, 'EUR', 'RUB'); // no query, but works!

Так в чем же тогда проблема? Лишних запросов не выполняется, код работает быстро и кажется все хорошо. Именно “кажется” - например в рамках кода у нас бывают несколько разных мест, в которых мы производим конвертацию:


use \Vendor\Currency\Converter;

$myData = [
	'ProductsSum'      => 100,
	'ProductsCurrency'   => 'EUR',
	'ProductsTotalInRub' => 0.0,
	'ServiceValue'      => 100,
	'ServiceCurrency'   => 'EUR',
	'ServiceTotalInRub' => 0.0,
];

calculateProductsTotal($myData);
calculateServiceTotal($myData);

function calculateProductsTotal( &$data )
{
	$converter = new Converter();

	$data['ProductsTotalInRub'] = $converter->convert($data['ProductsSum'], $data['ProductsCurrency'], 'RUB');
}

function calculateServiceTotal( &$data )
{
	$converter = new Converter();

	$data['ServiceTotalInRub'] = $converter->convert($data['ServiceValue'], $data['ServiceCurrency'], 'RUB');
}

Тогда в каждом из этих мест будет создана своя переменная с конвертером и у каждого конвертора будет свой локальный кеш. Запросов в базу данных будет меньше, но избежать мы их не сможем, а еще к тому же у нас постоянно в памяти будут висеть локальные кешы, которыми мы более не воспользуемся.

Как можно этого избежать? Перенести сервис конвертации в локатор сервисов (см как это работает в статье про описание ниже) Таким образом наш код получит небольшие изменения:

use \Bitrix\Main\DI\ServiceLocator;

$myData = [
	'ProductsSum'      => 100,
	'ProductsCurrency'   => 'EUR',
	'ProductsTotalInRub' => 0.0,
	'ServiceValue'      => 100,
	'ServiceCurrency'   => 'EUR',
	'ServiceTotalInRub' => 0.0,
];

calculateProductsTotal($myData);
calculateServiceTotal($myData);

function calculateProductsTotal( &$data )
{
	$converter = ServiceLocator::getInstance()->get('vendor.currency.manager');

	$data['ProductsTotalInRub'] = $converter->convert($data['ProductsSum'], $data['ProductsCurrency'], 'RUB');
}

function calculateServiceTotal( &$data )
{
	$converter = ServiceLocator::getInstance()->get('vendor.currency.manager');

	$data['ServiceTotalInRub'] = $converter->convert($data['ServiceValue'], $data['ServiceCurrency'], 'RUB');
}

В указанном случае у нас происходит ровно 1 получение валюты “EUR -> RUB” и каждый метод использует общий кеш.

История 2

Как еще мы бы могли усовершенствовать наш класс конвертора? Например мы могли бы хранить кеш валюты не локально, а в кеширующей среде (например redis или даже файловом кеше). В таком случае мы могли бы сделать класс отвечающий за хранение данных, который реализовывл кеширующий интерфейс:

namespace Vendor\Currency;

interface CurrencyCacheInterface
{
	public function has( string $key ): bool;

	public function get( string $key ): mixed;

	public function set( string $key, mixed $value );
}

Тогда наш код класса изменился бы следующим образом:

namespace Vendor\Currency;

use \Bitrix\Main\Type\Date;
use \Vendor\Currency\CurrencyCacheInterface;

class Converter
{

	protected $currencyCache = [];

	public function __construct( CurrencyCacheInterface $currencyCache )
	{
		$this->currencyCache = $currencyCache;
	}

	// ...

	protected function getCurrencyRate(string $fromCurrency, string $toCurrency, Date $date): float
	{
		// If same - return 1
		if ( $fromCurrency == $toCurrency )
		{
			return 1.0;
		}

		$cacheKey = sprintf('%s_%s_%s', $fromCurrency, $toCurrency, $date->format('Ymd'));

		if ( $this->currencyCache->has($cacheKey) )
		{
			return $this->currencyCache->get($cacheKey);
		}

		// Go to database, load currency, save to $value
		
		$this->currencyCache->set($cacheKey, $value)

		return $value;
	}
}

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

use \Vendor\Currency\Converter;
use \Vendor\Currency\CurrencyCacheImpementLocalCache;

$cacheInterfaceReasliser = new CurrencyCacheImpementLocalCache();
$converter = new Converter($cacheInterfaceReasliser);

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

Это еще одно преимуещство локатора сервисов от прямого создания экземпляра класса.

Как работать?

Работа с “сервисами” (или правильнее говорить элементами локатора) условно делится на несколько частей - на использование существующих сервисов и на создание собственного сервиса.

Использование

Все использование сервиса можно показать в одном фрагменте php-кода:

use \Bitrix\Main\DI\ServiceLocator;

$locator = ServiceLocator::getInstance();
if ($locator->has('someService'))
{
	$someService = $locator->get('someService');
	// .. use $someService
}

Работа с локатором осуществляется через экземпляр Singleton-класс \Bitrix\Main\DI\ServiceLocator, который можно получить через статический метод getInstance. Сам локатор имеет несколько полезных методов:

\Bitrix\Main\DI\ServiceLocator::has(string $code): bool Возвращает true, если сервис с $code был зарегистрирован. Иначе false. Параметры:

  • $code {string} - Имя сервиса.

\Bitrix\Main\DI\ServiceLocator::get(string $code): mixed Возвращает сервис, если это первое обращение, то будет выполнено создание сервиса. Если сервиса с данным кодом нет, то будет выброшено исключение, реализующее интерфейс \Psr\Container\NotFoundExceptionInterface. Параметры:

  • $code {string} - Имя сервиса.

\Bitrix\Main\DI\ServiceLocator::addInstance(string $code, $service): void Регистрация уже созданного и инициализированного сервиса. Параметры:

  • $code {string} - Имя сервиса. По этому имени будет происходить обращение к сервису.
  • $service {object} - Объект конкретного сервиса.

\Bitrix\Main\DI\ServiceLocator::addInstanceLazy(string $code, $configuration): void Регистрация сервиса с помощью конфигурации для инициализации по требованию. Параметры:

  • $code {string} - Имя сервиса. По этому имени будет происходить обращение к сервису.
  • $configuration {array} - Описание, с помощью которого сервис локатор будет создавать сервис.

Создание

Существует несколько способов описать свой сервис в Битриксе: Через файл .settings.php (далее .settings.php), через .settings.php в модуле (далее в модуле) и кодом при помощи api (далее кодом). Для сравнения преимуществ и недостатков сравним все способы при помощи таблицы сравнения:

Параметр .settings.php в модуле Кодом
Позволяют зарегистрировать сервис с параметрами
Доступны для использования сразу ⛔️
Требуют подключения модуля перед вызовом ⛔️ ⛔️
Для создания сервиса не требуется изменения системных файлов ⛔️

Рассмотрим каждый из способов создания подробнее на примере сервис конвертации валюты (Vendor\Currency\Converter) созданный в “Истории 2” в этой статье ранее.

Через .settings.php

Регистрация своих сервисов осуществляется в разделе services файла .settings.php. Пример:

// file: /bitrix/.settings.php
return [
	//...
	
	'services' => [
		'value' => [                    
			'vendor.currency.manager' => [
				'constructor' => function () {
					return new \Vendor\Currency\Converter(
						new \Vendor\Currency\CurrencyCacheImpementLocalCache()
					);
				}
			],                      
		],
		'readonly' => true,
	],	
	//...
];

Мы настоятельно не рекомендуем вносить изменения в файл /bitrix/.settings.php поскольку он не подлежит хранению в системе контроля версий. Воспользуйтесь другими способами регистрации.

В модуле

Регистрация сервиса в модуле отличается от регистрации сервиса в .settings.php только расположением этого самого .settings.php. при регистрации сервиса в модуле - файл располагается в корне модуля. Например для crm, расположения этого файла /bitrix/modules/crm/.settings.php.

Важная новость: сервисы зарегистрированные в модуле НЕ БУДУТ доступны пока модуль не будет подключен к странице (т.е. до вызова \Bitrix\Main\Loader::includeModule или его аналогов).

Через API

В рамках нашей структуры директории local описание своих сервисов происходит в файле kernel.php при помощи вызова метода addInstanceLazy.

У нас бы появился следующий код:

$serviceLocator->addInstanceLazy('vendor.currency.manager', [
	'constructor' => function () {
		return new \Vendor\Currency\Converter(
			new \Vendor\Currency\CurrencyCacheImpementLocalCache()
		);
	}
]);

Это пример лаконичного и просто обьявление своего сервиса, который однако имеет ряд недостатков:

  1. Поскольку мы размещаем код сервиса прямо в файле kernel.php мы рискуем получить одну из тех ошибок, которую мы бы хотели избежать в init.php - раздувание кода.
  2. Поскольку сервисом может быть много и каждый выполняется излолированно - для предотвращения ‘распухания’ и ошибок рекомендуется использовать полное наименование класса и не использовать use-блоки для сокращения кода.

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

// file: ...kernel.php

$serviceLocator->addInstanceLazy('vendor.currency.manager', [
	'constructor' => ['\\Vendor\\Currency\\ManagerBuilder', 'buildInstance']
]);

// file: ...classes/Vendor/Currency/ManagerBuilder.php

namespace Vendor\Currency;

use \Vendor\Currency\Converter;
use \Vendor\Currency\CurrencyCacheImpementLocalCache;

class ManagerBuilder
{
	public static function buildInstance(): Converter
	{
		return new Converter(
			new CurrencyCacheImpementLocalCache()
		);
	}
}

Рекомендации

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

  • Осозанно именуйте свои сервисы. Старайтесь чтобы сервис имел минимум 2 составные части: vendor - код компании и name - код сервиса, но не злоупотребляйте этиим - сервисы с вложенностью превышающий 4 уровня читаются тяжело.
  • При описании сервиса старайтесь использовать camelCase-нотацию.
  • Сервис это вспомогаетльный класс, не хранящий в себе состояние. Не следует хранить в нем состояние, а исключением из этого правила является кеш.
  • Если сервис хранит кеш, предусмотрите дополнительные методы: сбрасывающие кеш, а так же “флаг” позволяющий не использовать кеш.
  • В вашем коде должна быть проверка на то что сервис существует, так как сервис может и отсуствовать. Особенно при работе с сервисами которые подключаются модулями.
  • В коде регистрации сервиса не должно быть бросания исключений. Либо сервис подключен и готов к работе, либо его нет (null).
  • Состояния когда сервис дополнительно должен быть сконфигурирован в вызываемом месте быть не должно.

Полезные ссылки

Локатор сервисов