Сервис-контейнер: 4 способа управления зависимостями

Сервис контейнер: управление зависимостями

Сервис-контейнер — довольно сложная тема, и я вижу, как многие пытаются понять, чем же он занимается. Для меня это тоже было сложно, так как, в основном, большинство рассказывают о том, «как» использовать контейнер. В этой же статье я хочу объяснить «почему» и «когда» контейнер может помочь нам с нашими зависимостями.

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

class UserStatsCsvExporter implements UserStatsExporterContract
{
    public function export(int $userId)
    {
         // Загружаем статистику пользователя...
         // Экспортируем файл...
    }
}

Внутри контроллера мы создаем новый экземпляр класса и вызываем метод экспорта.

class ExportController extends Controller
{
    public function handle()
    {
        $userStatsExporter = new UserStatsCsvExporter();

        return $userStatsExporter->export(12);
    }
}

Класс экспортера является зависимостью нашего контроллера, но, как вы можете видеть, мы можем справиться с этим сами. Зачем же нам нужна помощь сервис-контейнера в управлении нашей зависимостью? И ответ таков: наш метод handle не должен отвечать за создание класса экспортера. Его единственная обязанность — вызвать метод экспорта. Таким образом, мы применяем принцип инверсии управления.

1. Авторазрешение

Вместо создания экземпляра в методе handle мы можем внедрить его. Это можно сделать как внутри конструктора контроллера, так и с помощью самого метода в Laravel. Это называется внедрение в метод (method-injection).

public function handle(UserStatsCsvExporter $userStatsExporter)
{
    return $userStatsExporter->export(12);
}

Используя контроль типа (type-hinting) сервисного класса, мы получаем его экземпляр внутри метода handle, который можем использовать. Интересно, что это уже работает и без того, чтобы указать Laravel как создать экземпляр класса. Это работает, потому что под капотом мы используем сервис-контейнер. В частности, функцию контейнера — авторазрешение (auto-resolving).

Laravel, через PHP Reflection API, может найти нужную нам зависимость из контроля типа и автоматически создать ее для нас. Это очень круто.

Но это еще не все. Что, если у нашего класса экспортера есть своя зависимость?

class UserStatsCsvExporter implements UserStatsExporterContract
{

    /** @var Translator */
    private $translator;

    public function __construct(Translator $translator)
    {
        $this->translator = $translator;
    }

    public function export(int $userId)
    {
        // Загружаем статистику пользователя...
        // Эксмпортируем файл...
    }
}

Я добавил конструктор, в котором есть зависимость от класса Translator. Прекрасно, что код нашего контроллера все еще работает. Таким образом, функция авторазрешение в Laravel достаточно умна, чтобы позаботиться о зависимости нашей зависимости. Круто же?

Это работает, пока наши зависимости это простые классы, которые Laravel может создавать самостоятельно. Авторазрешение перестает работать, когда нам нужно передать определенные значения нашим классам.

2. Привязка к контейнеру

Внутри класса Translator я добавил новый конструктор, который определяет переменную language при инициализации этого класса.

class Translator
{
    /** @var string */
    private $language;

    public function __construct(string $language)
    {
        $this->language = $language;
    }
    
    public function translate(string $word)
    {
        // Перевести слово...
    }
}

Теперь Laravel не имеет ни малейшего понятия о том, что здесь проходит, и именно поэтому автоматическое разрешение больше не работает. Это тот случай когда нам нужно явно указать Laravel как создать наш экземпляр экспорта и его зависимости. И лучшее место для написания этого кода внутри сервис-провайдера (service provider).

Примечание. Сервис-провайдеры — главное место для настройки вашего приложения. Мы используем их для регистрации сервисов или компонентов Laravel.

Я создал нового сервис-провайдера для нашего класса экспорта.

class UserStatsExporterProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->bind(UserStatsCsvExporter::class, function() {
           return new UserStatsCsvExporter(new Translator(config('app.locale')));
        });
    }
}

В каждом сервис-провайдере у нас есть доступ к сервис-контейнеру (service container) через $this->app. Это центральное место для определения, как создать новый экземпляр нашего экспортера. Новая переменная language теперь загружается из файла конфигурации. Информация о том, как создать этот экземпляр, сохраняется внутри экземпляра сервис-контейнера. Это означает, что каждый раз, когда нам нужен этот класс, мы можем запросить его у сервис-контейнер, и нам больше не придется писать код для повторного создания экземпляра.

Если хотите, вы можете продампить контейнер при помощи dd(app()) в вашем контроллере. Под свойством bindings вы найдете массив, который теперь содержит наш класс Exporter.

3. Привязка к интерфейсам

Вы, наверное, уже видели, что наш класс CSV экспортера реализует интерфейс. Потому, что у нас уже есть класс для экспорта в XML наряду с CSV-версией. Он также реализует интерфейс. Возможно, мы захотим изменить реализацию, которую мы используем для экспорта пользовательской статистики, переведя её на XML.

Конечно, мы могли бы заменить контроль типа в класса экспорта на XML версию.

public function handle(UserStatsXmlExporter $userStatsExporter)
{
    return $userStatsExporter->export(12);
}

А затем изменить код в сервис-провайдере.

public function register()
{
    $this->app->bind(UserStatsXmlExporter::class, function() {
       return new UserStatsXmlExporter(new Translator(config('app.locale')));
    });
}

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

public function handle(UserStatsExporterContract $userStatsExporter)
{
    return $userStatsExporter->export(12);
}

Таким образом, мы говорим, что нам все равно, внедряется класс для экспорта в CSV или в XML. Для нас важно то, что он реализует интерфейс экспортера.

Чтобы это заработало, нам необходимо забиндить название интерфейса внутри нашего сервис-провайдера.

public function register()
{
    $this->app->bind(UserStatsExporterContract::class, function() {
       return new UserStatsXmlExporter(new Translator(config('app.locale')));
    });
}

Теперь у нас осталось только одно место где нужно менять код, если нужно переключить реализацию экспортера. Это сервис-провайдер.

4. Расшаривание экземпляра

Последняя особенность сервисного контейнера, о которой я хочу поговорить сегодня, — это расшаривание. Если мы сделаем два экземпляра нашего класса экспорта, то увидим, что у них два разных ссылочных идентификатора. Это означает, что мы создали два отдельных экземпляра одного и того же класса.

public function handle(UserStatsExporterContract $userStatsExporter)
{
    dd(app(UserStatsExporterContract::class), app(UserStatsExporterContract::class));
    
    return $userStatsExporter->export(12);
}

Примечание. Как вы можете видеть, мы использовали вспомогательный метод app() для получения экземпляров непосредственно из контейнера, а не при помощи внедрения зависимости.

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

public function register()
{
    $this->app->singleton(UserStatsExporterContract::class, function() {
       return new UserStatsXmlExporter(new Translator(config('app.locale')));
    });
}

Сервис контейнер Ларавел

Вы можете видеть, теперь идентификаторы совпадают. И есть две причины, когда это имеет смысл:

1. Хранение состояния

Когда вы храните некоторую информацию внутри экземпляра, которая все еще должна быть там, когда другая часть вашего приложения захочет получить её, то тогда имеет смысл создать синглтон.

2. Производительность

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

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

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

Заключение

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

Автор: Christoph Rumpel
Перевод: Demiurge Ash