Локатор служб
Table of Contents
Локатор служб (англ. 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()
);
}
]);
Это пример лаконичного и просто обьявление своего сервиса, который однако имеет ряд недостатков:
- Поскольку мы размещаем код сервиса прямо в файле
kernel.php
мы рискуем получить одну из тех ошибок, которую мы бы хотели избежать вinit.php
- раздувание кода. - Поскольку сервисом может быть много и каждый выполняется излолированно - для предотвращения ‘распухания’ и ошибок рекомендуется использовать полное наименование класса и не использовать
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
). - Состояния когда сервис дополнительно должен быть сконфигурирован в вызываемом месте быть не должно.