Bitrix D7 ServiceLocator: регистрация сервисов, DI и типичные ошибки

В больших проектах на Битрикс быстро появляется зоопарк: «сервис уведомлений» создаётся в трёх местах, конфиг читается через Option::get прямо из шаблона, а в тестах невозможно подменить внешний API. D7 даёт нормальный путь — Bitrix\Main\DI\ServiceLocator.

Ниже — как зарегистрировать сервис, как достать его из кода, чем locator отличается от «глобального синглтона на коленке», и какие грабли ловят чаще всего на проде.

Зачем ServiceLocator, а не new MyService()

Прямое создание объектов в обработчиках событий и компонентах работает, пока сервис простой. Как только появляются зависимости (логгер, HTTP-клиент, репозиторий), код начинает дублировать инициализацию. Locator решает три задачи:

  • одна точка регистрации (модуль или local/php_interface);
  • ленивое создание — объект появляется только при первом запросе;
  • подмена реализации в тестах или на стейдже без правок бизнес-логики.

Связанные темы на сайте: EventManager, HttpClient, старые события, Composer в Битрикс.

Минимальная регистрация в local/

Для проектной логики удобно регистрировать сервисы в /local/php_interface/init.php (или отдельном файле, который init подключает):

<?php
use Bitrix\Main\DI\ServiceLocator;
use Bitrix\Main\Loader;

Loader::registerAutoLoadClasses(null, [
    'Local\\Service\\Notifier' => '/local/php_interface/lib/Service/Notifier.php',
    'Local\\Service\\OrderExporter' => '/local/php_interface/lib/Service/OrderExporter.php',
]);

$locator = ServiceLocator::getInstance();

$locator->addInstanceLazy('local.notifier', [
    'className' => \\Local\\Service\\Notifier::class,
]);

$locator->addInstanceLazy('local.order.exporter', static function () {
    $http = new \\Bitrix\\Main\\Web\\HttpClient([
        'socketTimeout' => 5,
        'streamTimeout' => 10,
    ]);

    return new \\Local\\Service\\OrderExporter(
        ServiceLocator::getInstance()->get('local.notifier'),
        $http
    );
});

addInstanceLazy не создаёт объект сразу — фабрика или класс вызываются при первом get(). Это важно: тяжёлый клиент к внешней системе не должен подниматься на каждом хите админки.

Класс сервиса без «магии»

Держите сервис обычным PHP-классом. Без статических синглтонов внутри — иначе locator теряет смысл:

<?php
namespace Local\Service;

use Bitrix\Main\Web\HttpClient;

final class OrderExporter
{
    public function __construct(
        private Notifier $notifier,
        private HttpClient $http
    ) {
    }

    public function export(int $orderId): void
    {
        $payload = $this->buildPayload($orderId);

        $response = $this->http->post(
            'https://erp.example/api/orders',
            $payload
        );

        if ($this->http->getStatus() >= 400) {
            $this->notifier->error('ERP reject: ' . $orderId);
            throw new \RuntimeException('ERP export failed');
        }

        $this->notifier->info('Order exported: ' . $orderId);
    }

    private function buildPayload(int $orderId): array
    {
        // выборка заказа через D7 ORM / Sale — здесь упрощено
        return ['id' => $orderId];
    }
}

Как достать сервис из кода

В обработчике события, агенте или контроллере:

<?php
use Bitrix\Main\DI\ServiceLocator;

$exporter = ServiceLocator::getInstance()->get('local.order.exporter');
$exporter->export((int)$orderId);

Проверка наличия — через has(). Если сервиса нет, get() бросит исключение; ловить «на всякий случай» в каждом вызове не нужно — лучше упасть на стейдже, чем молча пропустить экспорт.

$locator = ServiceLocator::getInstance();

if (!$locator->has('local.order.exporter')) {
    throw new \LogicException('Service local.order.exporter is not registered');
}

Регистрация из модуля

Если сервис живёт в своём модуле, регистрируйте его при подключении модуля — в include.php или через событие OnPageStart / автозагрузку модуля. Идентификатор лучше неймспейсить именем модуля:

<?php
// /local/modules/vendor.tools/include.php
use Bitrix\Main\DI\ServiceLocator;

$locator = ServiceLocator::getInstance();

$locator->addInstanceLazy('vendor.tools.mailer', [
    'constructor' => static function () {
        return new \Vendor\Tools\Mailer(
            \Bitrix\Main\Config\Option::get('vendor.tools', 'smtp_host')
        );
    },
]);

Имена вида vendor.tools.mailer читаются в логах и не пересекаются с чужими модулями. Не называйте сервисы просто mailer или cache.

addInstance vs addInstanceLazy

addInstance кладёт уже созданный объект. Это удобно в тестах и в CLI-скриптах, где вы сами собрали зависимости:

$locator = ServiceLocator::getInstance();
$locator->addInstance('local.notifier', new \Local\Service\NullNotifier());

На веб-хите почти всегда нужен lazy: иначе каждый запрос тянет за собой половину интеграций. Правило простое — в runtime используйте lazy, в тестах подставляйте готовый instance.

Связка с EventManager

Типичный прод-паттерн: событие только достаёт сервис и делегирует. Обработчик остаётся тонким.

<?php
use Bitrix\Main\EventManager;
use Bitrix\Main\DI\ServiceLocator;

$em = EventManager::getInstance();

$em->addEventHandler(
    'sale',
    'OnSaleOrderSaved',
    static function (\Bitrix\Main\Event $event) {
        $order = $event->getParameter('ENTITY');
        if (!$order) {
            return;
        }

        ServiceLocator::getInstance()
            ->get('local.order.exporter')
            ->export((int)$order->getId());
    }
);

Так проще тестировать экспорт отдельно от жизненного цикла заказа. Подробнее про приоритеты и отладку подписок — в статье про EventManager.

Типичные ошибки

1. Регистрация после первого get()

Если агент или ранний обработчик дергает сервис до того, как init.php успел зарегистрировать его — получите исключение. Регистрацию держите как можно раньше (init модуля / php_interface), а не «рядом с первым использованием» в шаблоне.

2. Сервис хранит состояние запроса

Locator по сути даёт долгоживущий объект на процесс PHP-FPM. Не складывайте в свойства сервиса данные текущего пользователя «навсегда» без явной очистки — на воркерах с несколькими запросами это источник странных багов. Либо делайте сервис stateless, либо передавайте контекст аргументом метода.

3. Циклические зависимости

A тянет B, B тянет A — при первом get() уйдёте в рекурсию. Ломайте цикл: вынесите общее в третий сервис или передавайте зависимость методом, а не в конструкторе.

4. Смешение Option::get и конфигурации сервиса

Читать Option::get внутри каждого метода — шум. Лучше прочитать настройки один раз в фабрике и передать в конструктор. Тогда в тесте можно подставить конфиг без БД.

Отладка: что уже зарегистрировано

Готового «красивого» списка в админке нет. Для отладки на стейдже достаточно временного лога после регистрации:

<?php
$locator = ServiceLocator::getInstance();

foreach (['local.notifier', 'local.order.exporter'] as $id) {
    AddMessage2Log(
        $id . ' => ' . ($locator->has($id) ? 'yes' : 'no'),
        'local.di'
    );
}

Если has() = no, проверьте: подключён ли модуль, не упал ли autoload класса, не оборвался ли init на фатале выше по файлу.

Когда locator не нужен

  • одноразовый скрипт миграции на 30 строк — обычный new понятнее;
  • чистая функция без зависимостей — не оборачивайте её в сервис «для красоты»;
  • значение из инфоблока / заказа — это данные, не сервис.

Locator окупается там, где есть повторяемая инфраструктура: почта, ERP, антифрод, генерация документов, очереди.

Чеклист внедрения

  1. Вынесите класс сервиса в /local/php_interface/lib или модуль.
  2. Зарегистрируйте lazy-id с префиксом проекта/модуля.
  3. В обработчиках событий оставляйте только делегирование в сервис.
  4. Конфиг читайте в фабрике, не в каждом методе.
  5. На стейдже проверьте has() и один реальный вызов.
  6. Для тяжёлых побочных эффектов (ERP, почта) добавьте идемпотентность или очередь — locator сам по себе гонки не лечит.

Итог

ServiceLocator — простой DI без внешнего контейнера. Регистрируете сервис один раз, достаёте по строковому id, подменяете в тестах. Вместе с EventManager, Result/Error и нормальной автозагрузкой это убирает половину «скрытых new» из проектного кода и делает Битрикс-проект сопровождаемым дольше, чем один релиз.