Практики внедрения зависимостей

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

Примерное время вдумчивого чтения: 50 минут

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

Сервисы без зависимостей

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

Не стоит путать сервисы для контейнера с сервисами в SOA или микросервисами в одноимённой архитектуре. Те сервисы представляют из себя полноценные приложения. В статьях про контейнер мы говорим просто про более мелкие вспомогательные процедуры или классы.

Например, возьмём ту же функцию хэширования паролей из прошлых примеров:

$hash = function ($password) {
    return password_hash($password, PASSWORD_ARGON2I)
}
 
echo $hash($password1);
echo $hash($password2);

Такие функции-сервисы можно записать и в виде объектов-функций:

class PasswordHasher 
{
    public function __invoke($password) {
        return password_hash($password, PASSWORD_ARGON2I);
    }
}
 
$hash = new PasswordHasher();
 
echo $hash($password1);
echo $hash($password2);

Либо вместо __invoke сделать обычный динамический метод и явно вызывать его:

class PasswordHasher 
{
    public function hash($password) {
        return password_hash($password, PASSWORD_ARGON2I);
    }
}
 
$hasher = new PasswordHasher();
 
echo $hasher->hash($password1);
echo $hasher->hash($password2);

Суть от этого не поменяется. Это будет та же функция-сервис, но записанная в виде обычного класса.

Обычно примитивные функции самодостаточны, то есть всё делают сами на основе переданных данных и ничего больше не требуют. Но иногда бывает не так.

Зависимости функций и методов

Не все сервисы самодостаточны. Некоторым часто нужно помимо обрабатываемых данных передать другие вспомогательные вещи.

Мы уже рассмотрели пример с параметрами конфигурации. К нашей функции можно добавить параметр $cost, который будет определять сложность хэширования:

$hash = function ($password, $cost) {
    return password_hash($password, PASSWORD_ARGON2I, [
        'memory_cost' => $cost,
    ]);
}
 
echo $hash($password1, 16);
echo $hash($password2, 4);

Или то же в виде класса:

class PasswordHasher 
{
    public function hash($password, $cost) {
        return password_hash($password, PASSWORD_ARGON2I, [
           'memory_cost' => $cost,
        ]);
    }
}
 
$hasher = new PasswordHasher();
 
echo $hasher->hash($password1, 16);
echo $hasher->hash($password2, 4);

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

В итоге получилось, что эта функция теперь принимает не только входящие данные в виде пароля, но и параметр конфигурации $cost. Но функции могут быть нужны при работе не только конфигурационные параметры.

Функции или классу для своей работы иногда могут потребоваться другие функции или классы. Тогда можно сказать, что в коде одна функция (или класс, или модуль) зависит от другой функции (или класса, или модуля). В программировании как раз такие вещи называют зависимостями.

Например, внутри функции a мы можем вызывать любую функцию b:

function a($value) {
    return b($value) * 2
}
 
function b($value) {
    return $value + 3;
}
 
echo a($value1);
echo a($value2);

Или то же самое с классами:

class A 
{
    public function a($value) {
        $b = new B();
        return $b->b($value) * 2;
    }
}
 
class B 
{
    public function b($value) {
        return $value * 3;
    }
}
 
$a = new A();
 
echo $a->a($value1);
echo $a->a($value2);

В любом случае, функции a или классу A для работы нужны функция b или класс B.

Но когда мы создаём сервисы через контейнер, мы там также работаем и с параметрами вроде $cost. Поэтому в наших примерах мы будем рассматривать работу как с зависимыми сервисами, так и параметрами конфигурации. А сейчас разберёмся с внедрением.

Внедрение зависимостей

Что у нас за внедрение? Куда и зачем?

В коде выше мы жёстко вписали использование одной функции внутри другой:

function a($value) {
    return b($value) * 2
}

Так и в классе мы сами создали объект $b и вызвали его метод:

class A 
{
    public function a($value) {
        $b = new B();
        return $b->b($value) * 2;
    }
}

Здесь понятно, что происходит. Но при таком жёстком подходе есть несколько неудобств:

Во-первых, если зависимость b имеет побочные эффекты (ходит в базу данных или сторонние API), то непонятно, как нам сделать изолированный юнит-тест для функции a или класса A.

Во-вторых, если у зависимости $b появятся свои конфигурационные параметры и зависимости, то тоже непонятно, что с ними делать.

Например, зависимостью может являться класс Db, обращающийся к базе данных и имеющий свои параметры подключения.

Тогда для создания объекта нам придётся передавать все его параметры в метод:

class A 
{
    public function a($host, $port, $user, $password) {
        $db = new Db($host, $port, $user, $password);
        return $db->query('SELECT COUNT (*) FROM users');
    }
}

что не очень удобно.

Либо использовать глобальные константы:

class A 
{
    public function a() {
        $db = new Db(DB_HOST, DB_PORT, DB_USER, DB_PASSWORD);
        return $db->query('SELECT COUNT (*) FROM users');
    }
}

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

Кстати, такая же жёсткость будет при использовании не очень полезного паттерна Singleton, если мы будем использовать глобально доступный общий объект подключения:

class A 
{
    public function a() {
        $db = Db::getInstance();
        return $db->query('SELECT COUNT (*) FROM users');
    }
}

У него такие же проблемы, так как чаще всего он внутри будет использовать те же константы. Плюс к тому, если самому классу Db потребуются свои зависимости, то их тоже придётся делать синглтонами. Не получится работать с несколькими хранилищами и будут проблемы подмены синглтона на заглушки в тестах. По таким причинам Singleton теперь считают антипаттерном, от которого только проблемы.

И в третьих, при таком подходе с созданием зависимостей внутри метода у нас каждый объект будет создавать новый экземпляр Db со своим подключением к базе данных. А это медленно и неэкономно.

Как решить эти проблемы? Достаточно легко, если вместо жёсткого вписывания создания зависимостей внутри метода мы будем внедрять этот зависимый объект снаружи в этот метод:

class A 
{
    public function a(Db $db) {
        return $db->query('SELECT COUNT (*) FROM users');
    }
}

или сначала передать в конструктор и потом использовать в методе:

class A 
{
    private Db $db;
 
    public function __conscruct(Db $db) {
        $this->db = $db;
    }
 
    public function a() {
        return $this->db->query('SELECT COUNT (*) FROM users');
    }
}

И аналогично в примере с функцией мы можем одну функцию передавать в другую:

function a($value, $b) {
    return $b($value) * 2
}
 
$b = function ($value) {
    return $value + 3;
}
 
echo a($value1, $b);
echo a($value2, $b);

Это и есть внедрение заранее созданных зависимостей снаружи вместо создания их же внутри.

Так наши функция a и класс A избавились от сложного кода по контролю создания своих зависимостей. Этот контроль изнутри функции или объекта ушёл наружу в вызывающий код. Можно сказать, что произошла инверсия управления зависимостями.

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

Теперь в коде приложения мы по очереди создаём все сервисы, внедряя их друг в друга.

$db = new Db($host, $port, $user, $password);
$a = new A($db);
 
echo $a->a();

Так мы можем создать всего один экземпляр подключения $db и использовать его во всех сервисах.

Использование контейнера

Нам может быть лень создавать у себя всю пачку объектов и передавать их друг в друга вручную.

Этот процесс можно автоматизировать, сделав для создания и внедрения сервисов друг в друга сервис-контейнер. Это штука, которая выглядит примерно так:

$container = new Container();
 
// Объявляем параметры
$container->set('config', [
    'db' => [
        'host' => '...',
        'port' => ...,
        'username' => '...',
        'password' => '...',
     ],
]);
 
// Объявляем сервис Db, подтягивая параметры
$container->set(Db::class, function (Container $container) {
     $config = $container->get('config')['db'];
     return new Db(
         $config['host'],
         $config['port'],
         $config['username'],
         $config['password'],
     );
});
 
// Объявляем сервис A и внедряем в него Db
$container->set(A::class, function (Container $container) {
    $db = $container->get(Db::class);
    return new A($db);
});

В него мы присваиваем все наши параметры и в нём объявляем фабрики для каждого сервиса, где друг за другом программируем все внедрения зависимых сервисов.

И теперь где-то в коде нашего приложения достаём из него любой сервис вроде A. И контейнер нам запуская эти фабрики рекурсивно соберёт наш объект, поместив в него объект подключения к базе данных. И всё будет работать:

$a = $container->get(A::class);
 
echo $a->a();

Но обычно мы сами в своих проектах контейнер не дёргаем. Вместо нас это делает сам фреймворк, когда из контейнера достаёт нужный контроллер. Поэтому мы просто объявляем зависимости в конструкторе контроллера и всё подтягивается из контейнера.

При этом для экономии ресурсов контейнер не дублирует сервисы. Он сохраняет у себя в приватном массиве уже созданные объекты и возвращает их же при повторных вызовах. В итоге все сервисы вроде Db в нашем проекте будут созданы контейнером только в одном экземпляре.

Это и будет у нас так называемый сервис-контейнер или контейнер внедрения зависимостей (Dependency Injection Container, DIC). Его как раз удобно использовать для создания сервисов вместо использования синглтонов и хранения параметров в константах.

Более умные контейнеры поддерживают автоматическое детектирование зависимостей. Так называемый autowiring. В них нам не нужно вручную вписывать код создания объекта из класса A, который принимает в конструктор только другой сервис:

$container->set(A::class, function (Container $container) {
    $db = $container->get(Db::class);
    return new A($db);
});

Умный контейнер при вызове get(A::class) автоматически спарсит типы параметров конструктора этого класса с помощью рефлексии и сам подтянет туда нужные сервисы. Так мы сэкономим кучу кода, так как нам нужно будет вписать код создания только для сервисов с нетипичными конструкторами, которые принимают скалярные параметры вроде нашего $cost. Все остальные конструкторы будут парситься автоматически.

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

Способы внедрения

Мы поняли, что нам нужно просто одни сервисы передавать в другие. Какие у нас есть способы? Рассмотрим каждый вариант.

Инъекция в метод

Например, если внутри функции $a мы захотим вызывать любую функцию $b и использовать настройку $c, то можем предоставить возможность передать это всё в $a вторым и третьим аргументами:

$a = function ($value, $b, $c) {
    return $b($value) * $c;
}
 
$b = function ($value) {
    return $value + 3;
}
 
$c = 42;
 
echo $a($value1, $b, $c);
echo $a($value2, $b, $c);

Или то же самое с классами:

class A 
{
    public function a($value, B $b, int $c) {
        return $b->b($value) * $c;
    }
}
 
class B 
{
    public function b($value) {
        return $value * 3;
    }
}
 
$a = new A();
$b = new B();
$c = 42;
 
echo $a->a($value1, $b, $c);
echo $a->a($value2, $b, $c);

В простейшем случае мы все зависимости можем напрямую передавать в метод вместе с обрабатываемыми данными. Этот подход называют инъекцией в метод (Method Injection).

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

Инъекция в конструктор

Если наш параметр $cost фиксированный (то есть достаётся из конфигурации приложения и в процессе работы приложения не меняется), то каждый раз его передавать будет неудобно:

echo $hash($password1, $config['cost']);
echo $hash($password2, $config['cost']);
echo $hash($password3, $config['cost']);
echo $hash($password4, $config['cost']);

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

Как мы приводили пример в предыдущей статье, для передачи конфигурационных параметров мы можем обернуть функцию $hash в фабрику createHash. И она создаст и вернёт функцию, использующую внешнюю область видимости с нашим параметром. То есть вернёт функцию-замыкание:

function createHash($cost)
{
    return function (string $password) use ($cost): string {
        return password_hash($password, PASSWORD_ARGON2I, [
            'memory_cost' => $cost,
        ]);
    }
}
 
$hash = createHash(16);
 
echo $hash($password1);
echo $hash($password2);

В случае использования функций-объектов мы для передачи параметра можем привычно использовать конструктор:

class PasswordHasher
{
    private intost;
 
    public function __construct(intost) {
        $this->сost = $сost;
    }
 
    public function hash(string $password): string {
        return password_hash($password, PASSWORD_ARGON2I, [
            'memory_cost' => $this->cost,
        ]);
    }
}
 
$hasher = new PasswordHasher(16);
 
echo $hasher->hash($password1);
echo $hasher->hash($password2);

То есть, чтобы каждый раз не передавать в функцию или процедуру все зависимости в виде параметров конфигурации и вспомогательных функций, мы можем воспользоваться фабрикой, в которую будем передавать все эти аргументы:

function createA($b, $c) {
    return function a($value) use ($b, $c) {
        return $b($value) * $c;
    }
}
 
$b = function ($value) {
    return $value * 3;
}
 
$c = 42;
 
$a = createA($b, $c);
 
echo $a($value1);
echo $a($value2);

Или в случае с классами мы можем то же самое реализовать передачей зависимостей в конструктор:

class A 
{
     private $b;
     private $c;
 
     public function __construct(B $b, int $c) {
         $this->c = $c;
         $this->b = $b;
     }
 
    public function a($value) {
        return $this->b->b($value) * $this->c;
    }
}
 
class B 
{
    public function b($value) {
        return $value * 3;
    }
}
 
$b = new B();
$c = 42;
 
$a = new A($b, $c);
 
echo $a->a($value1);
echo $a->a($value2);

Так мы можем один раз создать наш сервис и потом вызывать его метод более удобно.

Такой подход называют инъекцией зависимости в конструктор (Constructor Injection).

Это удобнее вместо того, чтобы каждый раз передавать все параметры в метод.

Инъекция в сеттер необязательных зависимостей

Иногда при написании компонентов бывает такое, что некоторые зависимости могут быть опциональными (необязательными).

Например, если мы используем некий сервис для отправки электронных писем, то в простейшем случае такой сервис может в конструктор принимать конфигурацию с параметрами подключения к почтовому серверу:

class Mailer
{
    private Config $config;
 
    public function __construct(Config $config) {
        $this->config = $config;
    }
 
    public function send($message) {
        ...
    }
}

Или если этот мейлер умеет работать с разными протоколами отправки сообщений, то мы можем выделить отдельный класс или интерфейс Transport для работы с нужным транспортом:

class Mailer
{
    private Transport $transport;
 
    public function __construct(Transport $transport) {
        $this->transport = $transport;
    }
 
    public function send(Message $message) {
        ...
        $this->transport->send($message);
        ...
    }
}

И теперь мы используем этот мэйлер в своём проекте:

// Создание и конфигурирование
$transport = new SmtpTransport(
    $host, $port, $username, $password, $encryption
);
$mailer = new Mailer($transport);
 
// Использование
$mailer->send($message1);
$mailer->send($message2);

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

И если логирование нужно сделать необязательным, то программисты часто выбирают решение принимать обязательные зависимости в конструктор, а для необязательных – сделать отдельные сеттеры вроде метода setLogger для присваивания логгера в приватное поле $logger:

class Mailer
{
    private Transport $transport;
    private ?Logger $logger = null;
 
    public function __construct(Transport $transport) {
        $this->transport = $transport;
    }
 
    public function setLogger(Logger $logger) {
        $this->logger =  $logger
    }
 
    public function send($message) {
        if ($this->logger !== null) {
              $this->logger->info('Sending message');
        }
        $this->transport->send($message);
    }
}

С таким подходом мэйлер можно либо создать как и раньше:

$mailer = new Mailer($transport);

Либо при желании включить логирование, передав ему свой логгер:

$mailer = new Mailer($transport);
 
$logger = new FIleLogger($path);
$mailer->setLogger($logger);

И всё работает успешно.

Но такой подход немного усложняет код проверками на null. Да и мы с вами договорились в нашем коде избегать сеттеров.

Можно этот класс отрефакторить.

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

class Mailer
{
    ...
 
    public function send($message) {
        if ($this->logger !== null) {
            $this->logger->info('Sending message');
        }
        $this->transport->send($message);
    }
}

Здесь всего один вызов логирования, но ситуация осложнится, если их будет много. Например, если будем логировать успех в блоке try и ошибку в блоке catch:

class Mailer
{
    ...
 
    public function send($message) {
        try {
            $this->transport->send($message);
            if ($this->logger !== null) {
                $this->logger->info('Success');
            }
        } catch (Exception $e) {
            if ($this->logger !== null) {
                $this->logger->error($e->getMessage());
            }
            throw $e;
        }
    }
}

Чтобы избавиться от таких проверок мы можем воспользоваться паттерном Null Object. А именно, по умолчанию вместо реального логгера в конструкторе создавать придуманный нами пустой ничего не делающий NullLogger, реализующий тот же интерфейс Logger:

class Mailer
{
    private Transport $transport;
    private Logger $logger;
 
    public function __construct(Transport $transport) {
        $this->transport = $transport;
        $this->logger = new NullLogger();
    }
 
    public function setLogger(Logger $logger) {
        $this->logger =  $logger
    }
 
    public function send($message) {
        $this->transport->send($message);
        $this->logger->info('Success');
    }
}

Теперь $this->logger у нас есть всегда, и мы можем убрать все проверки на null.

Во-вторых, вместо использования сеттера мы все необязательные зависимости тоже можем принимать через параметры конструктора.

В изначальном варианте с if-ами мы могли напрямую присваивать логгер или null прямо в приватное поле $this->logger в конструкторе вместо сеттера:

class Mailer
{
    ...
    private ?Logger $logger = null;
 
    public function __construct(Transport $transport, ?Logger $logger) {
        $this->transport = $transport;
        $this->logger = $logger;
    }
 
    public function send($message) {
        ...
        if ($this->logger !== null) {
            $this->logger->info('Success');
        }
    }
}

А во втором случае с NullLogger мы можем добавить в конструктор тернарный оператор с проверкой на наличие переданного логгера:

class Mailer
{
    ...
    private Logger $logger;
 
    public function __construct(Transport $transport, ?Logger $logger) {
        $this->transport = $transport;
        $this->logger = $logger ?? new NullLogger();
    }
 
    public function send($message) {
        ...
        $this->logger->info('Success');
    }
}

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

$mailer = new Mailer($transport, null);

А если с логированием, то вторым аргументом передать свой логгер:

$logger = new FIleLogger($path);
$mailer = new Mailer($transport, $logger);

При этом мы можем даже поставить для параметра значение по умолчанию как null

public function __construct(Transport $transport, ?Logger $logger = null)

Это даст возможность не указывать второй аргумент:

$mailer = new Mailer($transport);

Но такие необязательные параметры нужно всегда помещать последними. И про них можно забыть или не заметить появления новых. Поэтому желательно в своём коде избегать необязательных параметров.

Они могут быть полезны в публичных библиотеках для поддержки обратной совместимости минорных версий. Например, если мы хотим добавить логирование в минорной версии 1.1 своей библиотеки, то чтобы код ни у кого не ломался мы можем пока добавить новый необязательный параметр со значением по умолчанию. А уже потом в мажорной версии 2.0 сделаем этот параметр обязательным:

// v1.0
public function __construct(Transport $transport)
 
// v1.1
public function __construct(Transport $transport, ?Logger $logger = null)
 
// v2.0
public function __construct(Transport $transport, ?Logger $logger)

В итоге за счёт использования параметров конструктора мы избавились от сеттеров.

Но если посмотреть внимательно на второй код, то в полученном конструкторе:

class Mailer
{
    ...
    public function __construct(Transport $transport, ?Logger $logger) {
        $this->transport = $transport;
        $this->logger = $logger ?? new NullLogger();
    }
    ...
}

мы можем избавиться даже от этого тернарного оператора, сделав параметр обязательным и не принимающим null:

class Mailer
{
    ...
    public function __construct(Transport $transport, Logger $logger) {
        $this->transport = $transport;
        $this->logger = $logger;
    }
    ...
}

Так код мэйлера значительно упростится, ведь из него пропадут все if-ы и тернарные операторы для проверки на null:

class Mailer
{
    private Transport $transport;
    private Logger $logger;
 
    public function __construct(Transport $transport, Logger $logger) {
        $this->transport = $transport;
        $this->logger = $logger;
    }
 
    public function send($message) {
        $this->transport->send($message);
        $this->logger->info('Success');
    }
}

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

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

Но тогда заглушку NullLogger нужно будет передавать из вызывающего кода:

$mailer = new Mailer($transport, new NullLogger());

Кому-то это покажется неудобным, но мы создаём Mailer всего один раз в контейнере и обычно туда всё равно передаём наш настоящий логгер:

$container->set(Mailer::class, function(Container $container) {
     $config = $container->get('config')['mailer'];
     $transport = new SmtpTransport($config['host'], ...);
 
     $logger = $container->get(Logger::class);
 
     return new Mailer($transport, $logger);
});

Так что от использования сеттера вместо конструктора мы всё равно никакого весомого выигрыша не получим.

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

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

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

Мы разобрались, что сеттеры порой используют для внедрения необязательных зависимостей. И рассмотрели, как мы можем от них избавиться. Если имеются такие сеттеры, то их используют для первоначального создания и конфигурирования сервиса в контейнере.

Инъекция в поле или свойство

Но порой вместо сеттера в каком-нибудь чужом компоненте можно встретить просто публично поле:

class Mailer implements LoggerAwareInterface
{
    public ?Logger $logger = null;
 
    public function __construct(Transport $transport) { ... }
    public function send(Message $message) { ... }
}

в которое мы можем напрямую присвоить зависимость без сеттера.

Это подход с инъекцией в поле или свойство (Property Injection)

Этот подход по написанию немного проще. Но по смыслу он никак не отличается от использования сеттера и имеет те же недостатки. И от него можно избавиться также, принимая эту зависимость в конструктор.

Помимо публичного поля некоторые контейнеры дают возможность подстановки сервисов и в приватные поля. Для этого поле можно пометить особой для этого контейнера аннотацией:

class Mailer implements LoggerAwareInterface
{
    /**
     * @autowire
     */
    private Logger $logger;
 
    public function __construct(Transport $transport) { ... }
    public function send(Message $message) { ... }
}

Но такой подход имеет ещё и дополнительные неудобства.

Во-первых, он конфликтует со статическими анализаторами кода. Например, в PHP анализатор вроде Psalm будет выдавать ошибку, что он нашёл приватное поле, которому ничего не присваивается сразу или в конструкторе.

Во-вторых, в тестах нам нужно будет использовать рефлексию, чтобы подставить стаб или мок в это поле.

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

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

Но помимо сеттеров для опциональных зависимостей можно встретить и другие.

Инъекция по интерфейсу

Порой в коде некоторых чужих компонентов или в проектах с более умным контейнером можно встретить возможность внедрять даже обязательные зависимости через сеттеры вроде такого примера:

interface LoggerAwareInterface
{
    public function setLogger(Logger $logger);
}
 
class Mailer implements LoggerAwareInterface
{
    private Logger $logger;
 
    public function setLogger(Logger $logger) {
        $this->logger = $logger;
    }
 
    ...
}

Смысл в том, что в системе объявляется какой-нибудь интерфейс с сеттером для описания нужной зависимости. А потом в умном контейнере настраивается подстановка зависимости по этому интерфейсу:

$container
    ->implements(LoggerAwareInterface::class)
    ->call('setLogger', [ reference(Logger::class) ]);

Здесь мы описываем, что у любого сервиса, который реализует этот интерфейс, нужно вызвать метод setLogger, передав в него сервис логгера.

Это так называемый подход Interface Injection, когда мы инжектим зависимости на основе интерфейсов с сеттерами.

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

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

Но в сервисах могут быть сеттеры не только для создания сервиса, но и для других целей.

Сеттеры для изменяемого состояния

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

Например, если вы подключите какой-нибудь сторонний переводчик, то многим из них обычно нужно указать локаль в конструкторе:

$container->set(Translator::class, function() {
    return new Translator('en-US');
});

И теперь можно переводить все сообщения на этот язык:

echo $translator->translate('hello');
echo $translator->translate('world');

Но если приложение будет мультиязычное, то мы уже в процессе обработки запроса можем переключать локаль на нужную для ответа на текущий HTTP-запрос:

$locale = detectLocale($request);
$translator->setLocale($locale);
 
echo $translator->translate('hello');
echo $translator->translate('world');

Например, можем делать это в HTTP-контроллере:

class HelloAction
{
     public function __construct(
         private LocaleDetector $detector;
         private Translator $translator;
     ) {}
 
    public function __invoke(Request $request): Response
    {
        $locale = $this->detector->detect($request->getHeader('Accept-Language'));
 
        if ($locale !== null) {
            $this->translator->setLocale($locale);
        }
 
        return new Response($this->translator->translate('hello'));
    }
}

Здесь мы своим вспомогательным сервисом LocaleDetector парсим нужную локаль из HTTP-заголовка в запросе и на эту локаль переключаем переводчик. А потом уже ниже переводим им свои тексты.

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

class LocaleMiddleware
{
     public function __construct(
         private LocaleDetecror $detector;
         private Translator $translator;
     ) {}
 
    public function __invoke(Request $request, callable $next): Response
    {
        $locale = $this->detector->detect($request->getHeader('Accept-Language'));
 
        if ($locale !== null) {
            $this->translator->setLocale($locale);
        }
 
        return $next($request);
    }
}

Этот посредник выполняет определение и переключение языка, а потом по цепочке вызывает следующий посредник или контроллер через $next($request). И наш контроллер теперь упростится:

class HelloAction
{
    public function __construct(private Translator $translator) {}
 
    public function __invoke(Request $request): Response
    {
        return new Response($this->translator->translate('hello'));
    }
}

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

Так что у некоторых сервисов иногда может быть общее изменяемое состояние, которое мы можем менять в процессе работы. Это так называемые stateful-сервисы.

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

Теперь мы знаем про разные способы внедрения зависимостей и про контейнер для автоматического создания сервисов.

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

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

Материалы, упоминаемые в статье:

Подписывайтесь на @elisdnru и @deworkerpro, чтобы не пропустить следующую статью и скринкасты.

Комментарии

 

Андрей

Спасибо, очень познавательно.

Ответить

 

Eric Cartman

God bless you for the article!

Sincerely yours, subscriber

Ответить

 

Sergey Vlassiuk

Очевидно, что при описании Property Injection класс Mailer не должен реализовывать LoggerAwareInterface.
Этот интерфейс из другого примера.

Ответить

 

Дмитрий Елисеев

Да, интерфейс с сеттером для Interface Injection.

Ответить

Оставить комментарий

Войти | Завести аккаунт | Войти через


(никто не увидит)





Можно использовать теги <p> <ul> <li> <b> <i> <a> <pre>