Так уж повелось, что программисты закрепляют удачные решения в виде шаблонов проектирования. По шаблонам существует множество литературы. Классикой безусловно считается книга Банды четырех «Design Patterns» by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides" и еще, пожалуй, «Patterns of Enterprise Application Architecture» by Martin Fowler. Лучшее из того, что я читал с примерами на PHP – это «PHP Objects, Patterns and Practice» by Matt Zandstra. Так уж получилось, что вся эта литература достаточно сложна для людей, которые только начали осваивать ООП. Поэтому у меня появилась идея изложить некоторые паттерны, которые я считаю наиболее полезными, в сильно упрощенном виде. Другими словами, эта статья – моя первая попытка интерпретировать шаблоны проектирования в KISS стиле.
Сегодня речь пойдет о том, какие проблемы могут возникнуть с инициализацией объектов в ООП приложении и о том, как можно использовать некоторые популярные шаблоны проектирования для решения этих проблем.
Пример
Современное ООП приложение работает с десятками, сотнями, а иногда и тысячами объектов. Что же, давайте внимательно посмотрим на то, каким образом происходит инициализация этих объектов в наших приложениях. Инициализация объектов – это единственный аспект, который нас интересует в данной статье, поэтому я решил опустить всю «лишнюю» реализацию.
Допустим, мы создали супер-пупер полезный класс, который умеет отправлять GET запрос на определенный URI и возвращать HTML из ответа сервера. Чтобы наш класс не казался чересчур простым, пусть он также проверяет результат и бросает исключение в случае «неправильного» ответа сервера.
class Grabber
{
public function get($url) {/** returns HTML code or throws an exception */}
}
Создадим еще один класс, объекты которого будут отвечать за фильтрацию полученного HTML. Метод filter принимает в качестве аргументов HTML код и CSS селектор, а возвращает он пусть массив найденных элементов по заданному селектору.
class HtmlExtractor
{
public function filter($html, $selector) {/** returns array of filtered elements */}
}
Теперь, представим, что нам нужно получить результаты поиска в Google по заданным ключевым словам. Для этого введем еще один класс, который будет использовать класс Grabber для отправки запроса, а для извлечения необходимого контента класс HtmlExtractor. Так же он будет содержать логику построения URI, селектор для фильтрации полученного HTML и обработку полученных результатов.
class GoogleFinder
{
private $grabber;
private $filter;
public function __construct()
{
$this->grabber = new Grabber();
$this->filter = new HtmlExtractor();
}
public function find($searchString) { /** returns array of founded results */}
}
Вы заметили, что инициализация объектов Grabber и HtmlExtractor находится в конструкторе класса GoogleFinder? Давайте подумаем, насколько это удачное решение.
Конечно же, хардкодить создание объектов в конструкторе не лучшая идея. И вот почему. Во-первых, мы не сможем легко подменить класс Grabber в тестовой среде, чтобы избежать отправки реального запроса. Справедливости ради, стоит сказать, что это можно сделать при помощи Reflection API? Т.е. техническая возможность существует, но это далеко не самый удобный и очевидный способ.
Во-вторых, та же проблема возникнет, если мы захотим повторно использовать логику GoogleFinder c другими реализациями Grabber и HtmlExtractor. Создание зависимостей жестко прописано в конструкторе класса. И в самом лучшем случае у нас получится унаследовать GoogleFinder и переопределить его конструктор. Да и то, только если область видимости свойств grabber и filter будет protected или public.
И последний момент, каждый раз при создании нового объекта GoogleFinder в памяти будет создаваться новая пара объектов-зависимостей, хотя мы вполне можем использовать один объект типа Grabber и один объект типа HtmlExtractor в нескольких объектах типа GoogleFinder.
Я думаю, что вы уже поняли, что инициализацию зависимостей нужно вынести за пределы класса. Мы можем потребовать, чтобы в конструктор класса GoogleFinder передавались уже подготовленные зависимости.
class GoogleFinder
{
private $grabber;
private $filter;
public function __construct(Grabber $grabber, HtmlExtractor $filter)
{
$this->grabber = $grabber;
$this->filter = $filter;
}
public function find($searchString) { /** returns array of founded results */}
}
Если мы хотим предоставить другим разработчикам возможность добавлять и использовать свои реализации Grabber и HtmlExtractor, то стоит подумать о введении интерфейсов для них. В данном случае это не только полезно, но и необходимо. Я считаю, что если в проекте мы используем только одну реализацию и не предполагаем создание новых в будущем, то стоит отказаться от создания интерфейса. Лучше действовать по ситуации и сделать простой рефакторинг, когда в нем появится реальная необходимость.
Теперь у нас есть все нужные классы и мы можем использовать класс GoogleFinder в контроллере.
class Controller
{
public function action()
{
/* Some stuff */
$finder = new GoogleFinder(new Grabber(), new HtmlExtractor());
$results = $finder->find('search string');
/* Do something with results */
}
}
Подведем промежуточный итог. Мы написали совсем немного кода, и на первый взгляд, не сделали ничего плохого. Но… а что если нам понадобится использовать объект типа GoogleFinder в другом месте? Нам придется продублировать его создание. В нашем примере это всего одна строка и проблема не так заметна. На практике же инициализация объектов может быть достаточно сложной и занимать до 10 строк, а то и более. Так же возникают другие проблемы типичные для дублирования кода. Если в процессе рефакторинга понадобится изменить имя используемого класса или логику инициализации объектов, то придется вручную поменять все места. Я думаю, вы знаете как это бывает :)
Обычно с хардкодом поступают просто. Дублирующиеся значения, как правило, выносятся в конфигурацию. Это позволяет централизованно изменять значения во всех местах, где они используются.
Шаблон Registry.
Итак, мы решили вынести создание объектов в конфигурацию. Давайте сделаем это.
$registry = new ArrayObject();
$registry['grabber'] = new Grabber();
$registry['filter'] = new HtmlExtractor();
$registry['google_finder'] = new GoogleFinder($registry['grabber'], $registry['filter']);
Нам остается только передать наш ArrayObject в контроллер и проблема решена.
class Controller
{
private $registry;
public function __construct(ArrayObject $registry)
{
$this->registry = $registry;
}
public function action()
{
/* Some stuff */
$results = $this->registry['google_finder']->find('search string');
/* Do something with results */
}
}
Можно дальше развить идею Registry. Унаследовать ArrayObject, инкапсулировать создание объектов внутри нового класса, запретить добавлять новые объекты после инициализации и т.д. Но на мой взгляд приведенный код в полной мере дает понять, что из себя представляет шаблон Registry. Этот шаблон не относится к порождающим, но он в некоторой степени позволяет решить наши проблемы. Registry – это всего лишь контейнер, в котором мы можем хранить объекты и передавать их внутри приложения. Чтобы объекты стали доступными, нам необходимо их предварительно создать и зарегистрировать в этом контейнере. Давайте разберем достоинства и недостатки этого подхода.
На первый взгляд, мы добились своей цели. Мы перестали хардкодить имена классов и создаем объекты в одном месте. Мы создаем объекты в единственном экземпляре, что гарантирует их повторное использование. Если изменится логика создания объектов, то отредактировать нужно будет только одно место в приложении. Как бонус мы получили, возможность централизованно управлять объектами в Registry. Мы легко можем получить список всех доступных объектов, и провести с ними какие-нибудь манипуляции. Давайте теперь посмотрим, что нас может не устроить в этом шаблоне.
Во-первых, мы должны создать объект перед тем как зарегистрировать его в Registry. Соответственно, высока вероятность создания «ненужных объектов», т.е. тех которые будут создаваться в памяти, но не будут использоваться в приложении. Да, мы можем добавлять объекты в Registry динамически, т.е. создавать только те объекты, которые нужны для обработки конкретного запроса. Так или иначе контролировать это нам придется вручную. Соответственно, со временем поддерживать это станет очень тяжело.
Во-вторых, у нас появилась новая зависимость у контроллера. Да, мы можем получать объекты через статический метод в Registry, чтобы не передавать Registry в конструктор. Но на мой взгляд, не стоит этого делать. Статические методы, это даже более жесткая связь, чем создание зависимостей внутри объекта, и сложности в тестировании (вот неплохая статья на эту тему).
В-третьих, интерфейс контроллера ничего не говорит нам о том, какие объекты в нем используются. Мы можем получить в контроллере любой объект доступный в Registry. Нам тяжело будет сказать, какие именно объекты использует контроллер, пока мы не проверим весь его исходный код.
Factory Method
В Registry нас больше всего не устраивает то, что объект необходимо предварительно инициализировать, чтобы он стал доступным. Вместо инициализации объекта в конфигурации, мы можем выделить логику создания объектов в другой класс, у которого можно будет «попросить» построить необходимый нам объект. Классы, которые отвечают за создание объектов называют фабриками. А шаблон проектирования называется Factory Method. Давайте посмотрим на пример фабрики.
class Factory
{
public function getGoogleFinder()
{
return new GoogleFinder($this->getGrabber(), $this->getHtmlExtractor());
}
private function getGrabber()
{
return new Grabber();
}
private function getHtmlExtractor()
{
return new HtmlFiletr();
}
}
Как правило делают фабрики которые отвечают за создание одного типа объектов. Иногда фабрика может создавать группу связанных объектов. Мы можем использовать кэширование в свойство, чтобы избежать повторного создания объектов.
class Factory
{
private $finder;
public function getGoogleFinder()
{
if (null === $this->finder) {
$this->finder = new GoogleFinder($this->getGrabber(), $this->getHtmlExtractor());
}
return $this->finder;
}
}
Мы можем параметризировать метод фабрики и делегировать инициализацию другим фабрикам в зависимости от входящего параметра. Это уже будет шаблон Abstract Factory.
Если появится необходимость разбить приложение на модули, мы можем потребовать, чтобы каждый модуль предоставлял свои фабрики. Мы можем и дальше развивать тему фабрик, но думаю что суть этого шаблона понятна. Давайте посмотрим как мы будем использовать фабрику в контроллере.
class Controller
{
private $factory;
public function __construct(Factory $factory)
{
$this->factory = $factory;
}
public function action()
{
/* Some stuff */
$results = $this->factory->getGoogleFinder()->find('search string');
/* Do something with results */
}
}
К преимуществам данного подхода, отнесем его простоту. Наши объекты создаются явно, и Ваша IDE легко приведет Вас к месту, в котором это происходит. Мы также решили проблему Registry и объекты в памяти будут создаваться только тогда, когда мы «попросим» фабрику об этом. Но мы пока не решили, как поставлять контроллерам нужные фабрики. Тут есть несколько вариантов. Можно использовать статические методы. Можно предоставить контроллерам самим создавать нужные фабрики и свести на нет все наши попытки избавиться от копипаста. Можно создать фабрику фабрик и передавать в контроллер только ее. Но получение объектов в контроллере станет немного сложнее, да и нужно будет управлять зависимостями между фабриками. Кроме того не совсем понятно, что делать, если мы хотим использовать модули в нашем приложении, как регистрировать фабрики модулей, как управлять связями между фабриками из разных модулей. В общем, мы лишились главного преимущества фабрики – явного создания объектов. И пока все еще не решили проблему «неявного» интерфейса контроллера.
Service Locator
Шаблон Service Locator позволяет решит недостаток разрозненности фабрик и управлять созданием объектов автоматически и централизованно. Если подумать, мы можем ввести дополнительный слой абстракции, который будет отвечать за создание объектов в нашем приложении и управлять связями между этими объектами. Для того чтобы этот слой смог создавать объекты для нас, мы должны будем наделить его знаниями, как это делать.
Термины шаблона Service Locator:
- Сервис (Service) — готовый объект, который можно получить из контейнера.
- Описание сервиса (Service Definition) – логика инициализации сервиса.
- Контейнер (Service Container) – центральный объект который хранит все описания и умеет по ним создавать сервисы.
Любой модуль может зарегистрировать свои описания сервисов. Чтобы получить какой-то сервис из конейнера мы должны будем запросить его по ключу. Существует масса вариантов реализации Service Locator, в простейшем варианте мы можем использовать ArrayObject в качестве контейнера и замыкания, в качестве описания сервисов.
class ServiceContainer extends ArrayObject
{
public function get($key)
{
if (is_callable($this[$key])) {
return call_user_func($this[$key]);
}
throw new RuntimeException("Can not find service definition under the key [ $key ]");
}
}
Тогда регистрация Definitions будет выглядеть так:
$container = new ServiceContainer();
$container['grabber'] = function () {
return new Grabber();
};
$container['html_filter'] = function () {
return new HtmlExtractor();
};
$container['google_finder'] = function() use ($container) {
return new GoogleFinder($container->get('grabber'), $container->get('html_filter'));
};
А использование, в контроллере так:
class Controller
{
private $container;
public function __construct(ServiceContainer $container)
{
$this->container = $container;
}
public function action()
{
/* Some stuff */
$results = $this->container->get('google_finder')->find('search string');
/* Do something with results */
}
}
Service Container может быть очень простым, а может быть очень сложным. Например, Symfony Service Container предоставляет массу возможностей: параметры (parameters), области видимости сервисов (scopes), поиск сервисов по тегам (tags), псевдонимы (aliases), закрытые сервисы (private services), возможность внести изменения в контейнер после добавления всех сервисов (compiller passes) и еще много чего. DIExtraBundle еще больше расширяет возможности стандартной реализации.
Но, вернемся к нашему примеру. Как видим, Service Locator не только решает все те проблемы, что и предыдущие шаблоны, но и позволяет легко использовать модули с собственными определениями сервисов.
Кроме того, на уровне фреймворка мы получили дополнительный уровень абстракции. А именно, изменяя метод ServiceContainer::get мы сможем, например, подменить объект на прокси. А область применения прокси-объектов ограниченна лишь фантазией разработчика. Тут можно и AOP парадигму реализовать, и LazyLoading и т.д.
Но, большинство разработчиков, все таки считают Service Locator анти-паттерном. Потому что, в теории, мы можем иметь сколько угодно т.н. Container Aware классов (т.е. таких классов, которые содержат в себе ссылку на контейнер). Например, наш Controller, внутри которого мы можем получить любой сервис.
Давайте, посмотрим, почему это плохо.
Во-первых, опять же тестирование. Вместо того, чтобы создавать моки только для используемых классов в тестах придется делать мок всему контейнеру или использовать реальный контейнер. Первый вариант не устраивает, т.к. приходится писать много ненужного кода в тестах, второй, т.к. он противоречит принципам модульного тестирования, и может привести к дополнительным издержкам на поддержку тестов.
Во-вторых, нам будет трудно рефакторить. Изменив любой сервис (или ServiceDefinition) в контейнере, мы будем вынуждены проверить также все зависимые сервисы. И эта задача не решается при помощи IDE. Отыскать такие места по всему приложению будет не так-то и просто. Кроме зависимых сервисов, нужно будет еще проверить все места, где отрефакторенный сервис получается из контейнера.
Ну и третья причина в том, что бесконтрольное дергание сервисов из контейнера рано или поздно приведет к каше в коде и излишней путанице. Это сложно объяснить, просто Вам нужно будет тратить все больше и больше времени, чтобы понять как работает тот или иной сервис, иными словами полностью понять что делает или как работает класс можно будет только прочитав весь его исходный код.
Dependency Injection
Что же можно еще предпринять, чтобы ограничить использование контейнера в приложении? Можно передать в фреймворк управление созданием всех пользовательских объектов, включая контроллеры. Иными словами, пользовательский код не должен вызывать метод get у контейнера. В нашем примере мы cможем добавить в контейнер Definition для контроллера:
$container['google_finder'] = function() use ($container) {
return new Controller(Grabber $grabber);
};
И избавиться от контейнера в контроллере:
class Controller
{
private $finder;
public function __construct(GoogleFinder $googleFinder)
{
$this->finder = $finder;
}
public function action()
{
/* Some stuff */
$results = $this->finder->find('search string');
/* Do something with results */
}
}
Такой вот подход (когда доступ к Service Container не предоставляется клиентским классам) называют Dependency Injection. Но и этот шаблон имеет как преимущества, так и недостатки. Пока у нас соблюдается принцип единственной ответственности, то код выглядит очень красиво. В-первую очередь, мы избавились от контейнера в клиентских классах, благодаря чему их код стал намного понятнее и проще. Мы легко можем протестировать контроллер, подменив необходимые зависимости. Мы можем создавать и тестировать каждый класс независимо от других (в том числе и классы контроллеров) используя TDD или BDD подход. При создании тестов мы сможем абстрагироваться от контейнера, и позже добавить Definition, когда нам понадобится использовать конкретные экземпляры. Все это сделает наш код проще и понятнее, а тестирование прозрачнее.
Но, необходимо упомянуть и об обратной стороне медали. Дело в том, что контроллеры – это весьма специфичные классы. Начнем с того, что контроллер, как правило, содержит в себе набор экшенов, значит, нарушает принцип единственной ответственности. В результате у класса контроллера может появиться намного больше зависимостей, чем необходимо для выполнения конкретного экшена. Использование отложенной инициализации (объект инстанцианируется в момент первого использования, а до этого используется легковесный прокси) в какой-то мере решает вопрос с производительностью. Но с точки зрения архитектуры создавать множество зависимостей у контроллера тоже не совсем правильно. Кроме того тестирование контроллеров, как правило излишняя операция. Все, конечно, зависит от того как тестирование организовано в Вашем приложении и от того как вы сами к этому относитесь.
Из предыдущего абзаца Вы поняли, что использование Dependency Injection не избавляет полностью от проблем с архитектурой. Поэтому, подумайте как Вам будет удобнее, хранить в контроллерах ссылку на контейнер или нет. Тут нет единственно правильного решения. Я считаю что оба подхода хороши до тех пор, пока код контроллера остается простым. Но, однозначно, не стоит создавать Conatiner Aware сервисы помимо контроллеров.
Выводы
Ну вот и пришло время подбить все сказанное. А сказано было немало… :)
Итак, чтобы структурировать работу по созданию объектов мы можем использовать следующие паттерны:
- Registry: Шаблон имеет явные недостатки, самый основной из которых, это необходимость создавать объекты перед тем как положить их в общий контейнер. Очевидно, что мы получим скорее больше проблем, чем выгоды от его использования. Это явно не лучшее применение шаблона.
- Factory Method: Основное достоинство паттерна: объекты создаются явно. Основной недостаток: контроллеры должны либо сами беспокоиться о создании фабрик, что не решает проблему хардкода имен классов полностью, либо фреймворк должен отвечать за снабжение контроллеров всеми необходимыми фабриками, что будет уже не так очевидно. Отсутствует возможность централизованно управлять процессом создания объектов.
- Service Locator: Более «продвинутый» способ управлять созданием объектов. Дополнительный уровень абстракции может быть использован, чтобы автоматизировать типичные задачи встречающиеся при создании объектов. Например:
class ServiceContainer extends ArrayObject { public function get($key) { if (is_callable($this[$key])) { $obj = call_user_func($this[$key]); if ($obj instanceof RequestAwareInterface) { $obj->setRequest($this->get('request')); } return $obj; } throw new RuntimeException("Can not find service definition under the key [ $key ]"); } }
Недостаток Service Locator в том, что публичный API классов перестает быть информативным. Необходимо прочитать весь код класса, чтобы понять, какие сервисы в нем используются. Класс, который содержит ссылку на контейнер сложнее протестировать.
- Dependency Injection: По сути мы можем использовать тот же Service Container, что и для предыдущего паттерна. Разница в том, как этот контейнер используется. Если мы будем избегать создания классов зависимых от контейнера, мы получим четкий и явный API классов.
Это не все, что я хотел бы рассказать о проблеме создания объектов в PHP приложениях. Есть еще паттерн Prototype, мы не рассмотрели использование Reflection API, оставили в стороне проблему ленивой загрузки сервисов да и еще много других нюансов. Статья получилась не маленькая, потому закругляюсь :)
Я хотел показать, что Dependency Injection и другие паттерны не так уж и сложны, как принято считать.
Если говорить о Dependency Injection, то существуют и KISS реализации этого паттерна, например Pimple, который занимает всего пару сотен строк вместе с комментариями. И есть такие монстры, как Symfony Dependency Injection Component. Базовый принцип один и тот же, а вот набор предоставляемых возможностей несопоставим.
Что же, надеюсь было интересно и вы не зря потратили время на чтение статьи.
Have fun!
P.S. Большое спасибо, всем кто нашел время и откликнулся на мою просьбу о помощи. Мне важно было знать Ваше мнение ;)
Автор: tyomo4ka