Сайт вебмастера

Dependency Injection простыми словами

20-12-2021Время чтения ~ 9 мин.PHP/ООП 6197

В продолжение предыдущей статьи «Dependency injection (внедрение зависимости)» решил добавить ещё несколько примеров. К этому вопросу я вернулся из-за того, что в Albireo потребовалось сделать небольшой сервис-контейнер и я опять окунулся в эту тему.

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

Для начала определимся с основами. Есть понятие «внедрение зависимости». Это означает, что какой-то класс для своей работы будет использовать другой класс.

class A {}
 
class B {
	public function __construct(A $varA) {}
}

Здесь класс «B» принимает в качестве параметра класс «А» — то есть внедряется зависимость. Теперь, если нужно создать объект класса «B» вначале нужно создать объект класса «А»:

$a = new A;
$b = new B($a);

Такое внедрение происходит через конструктор. В основном именно так это и делается, хотя есть вариант создания зависимости через отдельный set-метод класса «B».

class B {
	private $a;
	
	public function __construct() {}
	
	public function set(A $varA) {
		$this->a = $varA; 	
	}
}

Соответственно меняется код инстанцирования:

$a = new A;
$b = new B;
$b->set($a);

Запомним этот момент, а пока рассмотрим понятие контейнера.

Контейнер объектов

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

function getData() {
	$cache = new FileCache();
	...
} 

Если нужно поменять алгоритм кэширования, то придётся менять код этой функции:

function getData() {
	// $cache = new FileCache();
	$cache = new MemcachedCache();
	...
} 

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

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

Именно такой вариант описывает стандарт PSR-11, где есть два метода: get() — для получения и has() — для проверки существования объекта в контейнере.

Работа с классами будет уже примерно так:

// получаем контейнер
$services = Services\Services::getInstance();
 
// получаем нужный класс
$cache = $services->get('Cache\Cache'); // указывается полное имя класса

Или этот же код в одну строчку:

$cache = Services\Services::getInstance()->get('Cache\Cache');

Именно так работает контейнер в Albireo. Конечно же он очень простой, но для фреймворка этого пока достаточно. Заметьте, что мы указываем класс в полном виде, что в нашем случае не решает задачи. Поэтому для каждого класса в контейнере можно придумать псевдоним — короткое имя/метку.

Вот так это работает в Albireo на примере класса кэша:

// где-то в конфигурации создаём псевдоним
Services\Services::getInstance()->setAlias('cache', Cache\Cache::class);
 
// а потом где-то в коде используем:
$cache = Services\Services::getInstance()->getAlias('cache');

В других фреймворках это может быть чуть по другому:

$cache = Services\Services::getInstance()->get('cache');

Но здесь возникает вопрос — а как в этом случае хранилище узнает с какими меткам хранятся классы?

Самый простой способ — это явно прописать в классе контейнера методы, которые вызывают нужный класс. Например в CodeIgniter 4 можно вызвать метод контейнера logger(), вместо того, чтобы указывать класс CodeIgniter\Log\Logger. Для системных классов это может сработать, но не будет работать для своих классов. Поэтому поступают по другому.

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

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

Такие зависимости называют сервисами, а хранилище — локатор сервисов (или служб).

Локатор сервисов (service locator)

По сути service locator — это самое обычное хранилище, но с завязкой на какую-то конфигурацию. Это может быть массив, либо php-класс, где прописываются нужные связи.

Работать с таким локатором достаточно просто (пример из CodeIgniter):

$cache= \Config\Services::cache();

Если стоит задача добавить свой сервис (читай метод), то нужно его добавить в файле конфигурации Config\Services.php. Таким образом service locator хорошо подходят для случаев, когда нужно «отвязаться» от конкретного класса, как в изначальном примере с кэшем. В функции мы работаем именно с сервисом, но в конфигурации можем указать любой подходящий класс.

// где-то в конфигурации
$container = new Container();
$container->set('db', new Database($options));
 
// используем в функции
$db = $container->get('db');

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

Контейнер Dependency Injection

В начале статьи я показал что такое внедрение зависимости. Теперь, представьте себе, что мы создали service locator и добавили в него класс «B». Проблема в том, что он зависит от класса «А», о чём наше хранилище не в курсе. Если хранилище попытается создать объект класса «B», то он будет неверно работать — вначале нужно каким-то образом создать «А».

В самом простом варианте в get-метод контейнера можно указать дополнительный параметр, который и будет передаваться в создаваемый класс. Пример из Albireo:

$a = new A;
$my = Services\Services::getInstance()->get('B', $a); // указываем имя класса и параметр

Но теперь представим себе, что у нас будет ещё какая-то зависимость у класса «A», как в таком случае передавать параметры?

В PHP существует только один способ автоматизировать этот процесс — использовать Reflection API — с его помощью можно получить информацию о php-классах.

Я долго не мог разобраться в чём же разница между Service Locator и Dependency Injection Container и в итоге для себя решил, что первый не может автоматически определять зависимости. Поэтому DI Container — это Service Locator + Reflection API. Из-за того, что многие фреймворки начинали с простого Service Locator, но потом добавили Reflection, при этом сохранили старое название и происходит постоянная путаница в этих понятиях.

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

В основе лежит следующий алгоритм.

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

Следующий момент — в обычном локаторе, когда «объект сервиса» ещё не создан, то он проверяется на то, что это возможно и просто создаётся в виде new и отправляется в хранилище. Но в DI-контейнере происходит дополнительная проверка, которая позволяет определить есть ли у создаваемого объекта параметры конструктора. Если есть, то получаются эти параметры и, опять же рекурсивно проверяются уже на свои зависимости.

Поэтому, когда через DI-контейнер нужно будет получить класс «B», он проверит, что у этого класса есть зависимость в виде класса «А». Потом посмотрит, что у «А» нет зависимостей, создаст объект и вернёт его в объект «B» в качестве первого параметра.

Именно так работает DI Container.

Reflection API

Уже ясно, что Reflection API — это ключевой момент. Чтобы понять как он используется, рассмотрим несколько классов.

class A {}
 
class B {
	public function __construct() {}
}
 
class F {
    public function __construct(A $varA, B $varB, C $varC, string $s = 'abc') {}
}

Первый класс не имеет конструктора. Второй имеет конструктор без параметров, а третий имеет кучу зависимостей, причём от несуществующего класса «C». В задачу DI Container будет входить разбор каждого класса. Я приведу сразу готовый кусок кода, с помощью которого можно будет понять как работает Reflection.

$class = 'A';
 
if (class_exists($class)) {
    $reflector = new \ReflectionClass($class);
    
    $constructor = $reflector->getConstructor();
    
    // если нет конструктора, то можно создать новый объект без параметров
    // if (is_null($constructor)) return $reflector->newInstance();
    
    // есть конструктор
    if ($constructor) {
        $parameters = $constructor->getParameters(); // получили его параметры
        $dependencies = []; // зависимости
  
  		// проходимся по параметрам
        foreach ($parameters as $param) {
            $depClassName= getClassName($param); // имя класса в подсказке типа
                        
            if (class_exists($depClassName)) {
                $dependencies[$param->name] = $depClassName;
            } else {
            	$dependencies[$param->name] = $depClassName . ' - not found';
            }
        }
     
        pr($dependencies);
        
        // после того, как все зависимости найдены можно создать объект с параметрами
		// return $reflector->newInstanceArgs($dependencies);
    } else {
    	pr($class . ' - constructor not found');
    }
} else {
	pr($class . ' - class not found');	
}
 
// https://www.php.net/manual/ru/reflectionparameter.getclass.php#108620
function getClassName(ReflectionParameter $param) {
    preg_match('/\[\s\<\w+?>\s([\w]+)/s', $param->__toString(), $matches);
    return isset($matches[1]) ? $matches[1] : null;
}

Функция getClassName() нужна для совместимости с PHP 8, поскольку разработчики выпилили из него методы получения имени класса. Поэтому используется такой вот «костыль». Функция pr() — это обёртка над print_r() — она есть в Albireo.

После запуска код выведет:

A - constructor not found

В переменной $class мы указываем класс, который нужно проанализировать. У класса «A» нет конструктора, поэтому $reflector->getConstructor() вернул null. Когда у класса нет конструктора, то его можно инстанцировать без параметров с помощью $reflector->newInstance().

Для класса «B» результат будет пустой массив:

Array
(
)

Это означает, что конструктор есть, но у него нет параметров. Определяется это с помощью $constructor->getParameters(). Этот метод возвращает массив доступных параметров.

И теперь класс «F»:

Array
(
    [varA] => A
    [varB] => B
    [varC] => C - not found
    [s] => string - not found
)

Мы получили список параметров и запустили по ним foreach-цикл. Уже внутри цикла получаем имя типа (класса) параметра и проверяем доступен ли этот класс. Соответственно заполняем массив $dependencies, где сохраняем найденные параметры.

Когда параметры найдены, то мы можем выполнить инстанцирование через $reflector->newInstanceArgs($dependencies) и получить готовый объект.

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

Важный момент. Обратите внимание, что параметры конструктора имеют подсказку типа. Если его не указывать, то Reflection не сможет корректно определить зависимость. То же самое происходит, если в качестве типа данных указывать не класс, а string или любой другой вариант. Как известно, в PHP динамические типы данных, но конкретно в этом случае требуется жёсткая типизация.

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

Итого

Современные Dependency Injection-контейнеры достаточно сложны, но и возможности у них большие. Лично я вряд ли рискнул бы создавать свой контейнер Dependency Injection, вместо этого взял бы какой-то готовый, например PHP-DI. И опять же, использование контейнера должно быть оправдано. Если всё делать через сервисы, то это хоть и делает код чуть меньше, но при этом теряется его смысл и понимание. Всё-таки ООП — это в первую очередь оперирование классами, а здесь мы их скрываем. Поэтому контейнеры хороши там, где в этом есть реальная потребность.

Похожие записи