Если вы занимаетесь разработкой программного обеспечения хотя бы какое-то время, вы, скорее всего, уже натыкались на термин «dependency injection» или «внедрение зависимости». Если вы ещё только-только присоединились к миру разработки ПО, то, вероятно, пока старались избегать попыток разобраться с этой концепцией. Но что бы вам ни казалось, внедрение зависимости является отличным инструментом при разработке поддерживаемого и тестируемого кода. В сегодняшней статье автор попытается рассказать, что же такое dependency injection настолько просто, насколько он сможет.
Injection
Рассмотрим простой кусок кода:
class Photo {
/**
* @var PDO Подключение к БД
*/
protected $db;
/**
* Конструктор
*/
public function __construct()
{
$this->db = DB::getInstance();
}
}
На первый взгляд, всё просто и безобидно. Да, если не обращать внимания на тот факт, что в класс уже жёстко внедрена зависимость в виде подключения к БД. Что мы будем делать, если понадобиться хранить фотографии не в базе данных, а, скажем, на диске? Да и вообще, подумайте, с какого перепугу объект фотографии должен осуществлять изнутри себя какое-либо взаимодействие с внешним миром? Разве это не противоречит принципу разделения ответственности? Однозначно противоречит. Объект не должен знать ни о чём другом, кроме того, что касается непосредственно самого объекта.
Ключевой момент идеи разделения ответственности заключается в том, что каждый класс должен иметь максимально чёткие границы и выполнять строго определённые функции. То есть, если класс, скажем, является абстракцией фотографии, то уж точно он не должен быть ответственным за работу с базой данных или ещё чего-то в этом духе. Проще понять эту идею, если провести аналогию объектов с домашними питомцами. Например, ваша собака существует сама по себе и умеет делать определённый набор вещей самостоятельно. Но когда речь идёт о прогулке на улицу — это уже за пределами её возможностей и ответственности; решение этой задачи лежит уже в рамках ответственности её хозяина.
Итак, давайте переконструируем наш объект Photo так, чтобы можно было снаружи определять его поведение в отношении внешнего хранилища. Сделать это можно двумя способами. Давайте рассмотрим каждый из них.
Внедрение в конструкторе
class Photo {
/**
* @var PDO Подключение к БД
*/
protected $db;
/**
* Конструктор
*
* @param PDO $dbConn Подключение к БД
*/
public function __construct($dbConn)
{
$this->db = $dbConn;
}
}
$photo = new Photo($dbConn);
В приведённом примере внедрение зависимости происходит в момент создания экземпляра объекта, вместо того, чтобы жёстко внедрять зависимость в классе.
Внедрение методом
class Photo {
/**
* @var PDO Подключение к БД
*/
protected $db;
/**
* Конструктор
*/
public function __construct($dbConn) {};
/**
* Внедряет подключение к БД
*
* @param PDO $dbConn Подключение к БД
*/
public function setDB($dbConn)
{
$this->db = $dbConn;
}
}
$photo = new Photo;
$photo->setDB($dbConn);
В результате небольшого исправления объекты класса теперь не зависят от подключения к БД в момент создания. При этом сам класс стал удобней для юнит-тестирования. Ко всему прочему стало возможным теперь в любой момент менять подсистему хранения наших фотографий.
Проблема
Всплывает, однако, неприятный момент: с классом стало сложнее работать. Пользователи класса теперь должны помнить о том, как внедрять зависимости в объекты класса, прежде чем те смогут нормально функционировать. Представим, что наш фото-класс имеет больше зависимостей:
$photo = new Photo;
$photo->setDB($dbConn);
$photo->setConfig($config);
$photo->setResponse($response);
Упс. Вместо того, чтобы облегчить жизнь, мы добавили лишней сложности. Если раньше пользователь просто создавал объект и работал с ним, то теперь ему придётся изрядно потанцевать с бубном, прежде чем двигаться дальше.
Решение
Решение заключается в том, чтобы создать отдельный класс-контейнер, который будет делать за нас всю тяжёлую работу. Если вы когда-нибудь сталкивались с термином Inversion of Control (IoC) — инверсия управления, то вы, вероятно, в курсе, о чём речь.
Класс-контейнер хранит информацию обо всех зависимостях, присутствующих в проекте, и выполняет всю работу по созданию зависимых объектов, а также внедрению в них зависимостей. Можно разными способами реализовать этот подход, мы же пока ясности ради реализуем всё прямо в методах класса-контейнера:
class IoC {
/**
* @var PDO Соединение с БД
*/
protected $db;
/**
* Создаёт экземпляр Photo и внедряет зависимости
*/
public static newPhoto()
{
$photo = new Photo;
$photo->setDB(static::$db);
$photo->setConfig();
$photo->setResponse();
return $photo;
}
}
$photo = IoC::newPhoto();
Теперь всё вернулось на круги своя: одним-единственным вызовом метода класса-контейнера мы получаем полностью готовый к употреблению экземпляр объекта Photo.
Лучшим решением, всё-таки, будет более общее решение класса-контейнера:
class IoC {
/**
* @var Реестр
*/
protected static $registry = array();
/**
* Добавляет resolver
*
* @param string $name Идентификатор
* @param object $resolve Создатель объектов
* @return void
*/
public static function register($name, Closure $resolve)
{
static::$registry[$name] = $resolve;
}
/**
* Создаёт экземпляр объекта
*
* @param string $name Идентификатор resolver'a
* @return mixed
*/
public static function resolve($name)
{
if ( static::registered($name) )
{
$name = static::$registry[$name];
return $name();
}
throw new Exception('Нет у нас такого в наличии :(');
}
/**
* Определяет, зарегистрирован ли resolver
*
* @param string $name ID resolver'а
* @return bool
*/
public static function registered($name)
{
return array_key_exists($name, static::$registry);
}
}
Данный класс предоставляет возможность регистрировать резолверы (функции, выполняющие непосредственную работу по созданию объектов и внедрению зависимостей) при помощи метода
register() и позднее использовать метод
resolve() для того, чтобы получать экземпляры объектов классов. Например, зарегистрируем резолвер для нашего класса Photo:
// Добавляем резолвер 'photo' в реестр
IoC::register('photo', function() {
$photo = new Photo;
$photo->setDB('...');
$photo->setConfig('...');
return $photo;
});
И теперь там, где нам нужен экземпляр объекта, получаем его простым и понятным способом:
// Получаем экземпляр Photo, полностью готовый к работе
$photo = IoC::resolve('photo');
Теперь оба зайца убиты: зависимости вынесены за пределы класса, при этом сохранилась простота работы с ним. Немножко добавилось писанины:
// До
$photo = new Photo;
// После
$photo = IoC::resolve('photo');
Однако это совершенно ничего не стоит в сравнении с великолепной логической организацией классов, удобством использования и тестирования! В реальных приложениях, кроме того, очень полезно научить класс-контейнер создавать и возвращать Singleton-объекты.
Магические методы
Если пойти ещё дальше, желая минимизировать и упростить сам класс-контейнер, можно задействовать магические методы
__set() и
__get():
class IoC {
protected $registry = array();
public function __set($name, $resolver)
{
$this->registry[$name] = $resolver;
}
public function __get($name)
{
return $this->registry[$name]();
}
}
Теперь добавлять и использовать резолверы стало намного интуитивно-понятнее:
$c = new IoC;
$c->mailer = function() {
$m = new Mailer;
// Настраиваем объект, внедряем зависимости
return $m;
};
$mailer = $c->mailer; // И получаем экземпляр