В индустрии сложилось устойчивое мнение, что Service Locator является анти-паттерном. Из wiki:
Стоит заметить, что в некотором случае локатор служб фактически является анти-шаблоном.
В этой публикации я рассматриваю тот случай, когда, на мой взгляд, Service Locator анти-шаблоном не является.
Вот что пишут в интернетах по поводу Локатора:
Некоторые считают Локатор Служб анти-паттерном. Он нарушает принцип инверсии зависимостей (Dependency Inversion principle) из набора принципов SOLID. Локатор Служб скрывает зависимости данного класса вместо их совместного использования, как в случае шаблона Внедрение Зависимости (Dependency Injection). В случае изменения данных зависимостей мы рискуем сломать функционал классов, которые их используют, вследствие чего затрудняется поддержка системы.
Service Locator идёт рука об руку с DI настолько близко, что некоторые авторы (Mark Seemann, Steven van Deursen) специально предупреждают:
Service Locator is a dangerous pattern because it almost works. ... There’s only one area where Service Locator falls short, and that shouldn’t be taken lightly.
Т.е., Локатор чертовски хорош и работает почти как надо, но есть один момент, который всё портит. Вот он:
The main problem with Service Locator’s the impact of reusability of the classes consuming it. This manifests itself in two ways:
* The class drags along the Service Locator as a redundant Dependency.
* The class makes it non-obvious what its Dependencies are.
Т.е., шаблон уменьшает возможности переиспользования кода некоторого класса по двум причинам: во-первых, из-за лишней зависимости класса от самого Локатора, во-вторых, становится неочевидным, какие зависимости используются классом.
Другими словами, вот так создавать объекты и внедрять в них зависимости благословляется:
public function __construct(IDep1 $dep1, IDep2 $dep2, IDep3 $dep3)
{
$this->dep1 = $dep1;
$this->dep2 = $dep2;
$this->dep3 = $dep3;
}
а вот так - нет:
public function __construct(ILocator $locator)
{
$this->locator = $locator;
$this->dep1 = $locator->get(IDep1::class);
$this->dep2 = $locator->get(IDep2::class);
$this->dep3 = $locator->get(IDep3::class);
}
При этом внедрение зависимостей через свойства (акцессоры) теми же авторами признаётся кошерным в некоторых случаях (например, когда внедряемые зависимости опциональны или задаются по умолчанию в конструкторе и могут быть переопределены впоследствии):
Property Injection should only be used when the class you’re developing has a good Local Default, and you still want to enable callers to provide different implementations of the class’s Dependency. It’s important to note that Property Injection is best used when the Dependency is optional. If the Dependency is required, Constructor Injection is always a better pick.
Если мы перепишем наш класс с Локатором в таком виде:
public function __construct(ILocator $locator = null)
{
if ($locator) {
$this->dep1 = $locator->get(IDep1::class);
}
}
public function setDep1(IDep1 $dep1)
{
$this->dep1 = $dep1;
}
то, а) мы делаем его независимым от наличия Локатора (например, в тестовой среде), б) явным образом выделяем зависимости в setter'ах (также можно аннотировать, документировать, ставить префиксы и решать проблему "неочевидности" зависимостей любым другим доступным способом, вплоть до Ctrl+F по ключу "$locator->get" в коде).
Вот мы и подошли к тому моменту, когда, на мой взгляд, использование Локатора оправдано. В комментах к статье "Какое главное отличие Dependency Injection от Service Locator?" коллега @symbix резюмировал тему статьи так:
SL работает по принципу pull: конструктор "вытягивает" из контейнера свои зависимости.
DI работает по принципу push: контейнер передает в конструктор его зависимости.
Т.е., по сути дела, DI-контейнер объектов может использоваться и как Service Locator:
// push deps into constructor
public function __construct(IDep1 $dep1, IDep2 $dep2, IDep3 $dep3) {}
// pull deps from constructor
public function __construct(IContainer $container) {
if ($container) {
$this->dep1 = $container->get(IDep1::class);
$this->dep2 = $container->get(IDep2::class);
$this->dep3 = $container->get(IDep3::class);
}
}
Как мы уже отметили выше, первый способ считается допустимым, второй - анти-паттерн. Но к чему приводит применение первого способа в промышленных объёмах? К тому, что, чтобы запустить приложение, мы должны при создании заинжектить в конструктор приложения его зависимости, а в их конструкторы - зависимости зависимостей и т.д. по всей иерархии. Т.е., чтобы объект приложения был хотя бы создан, мы должны по цепочке создать все зависимости, упомянутые во всех конструкторах, даже если мы собираемся всего лишь получить справку о параметрах запуска приложения в консольном режиме.
"Анти-паттерн" Service Locator же позволяет нам "вытягивать" из контейнера нужные нам зависимости по мере обращения к ним:
class App {
/** @var \IContainer */
private $container;
/** @var \IDep1 */
private $dep1;
public function __construct(IContainer $container = null) {
$this->container = $container;
}
private function initDep1() {
if (!$this->dep1) {
$this->dep1 = $this->container->get(IDep1::class);
}
return $this->dep1;
}
public function run() {
$dep1 = $this->initDep1();
}
public function setDep1(IDep1 $dep1) {
$this->dep1 = $dep1;
}
}
Итого, приведённый выше код:
может быть использован без контейнера в конструкторе за счёт возможности внедрения зависимости через setter (например, в тестах);
зависимости явно описываются через набор private-методов с префиксом
init
;иерархия зависимостей не тянется при создании экземпляра данного класса, а создаётся по мере использования.
В таком варианте использования паттерн Service Locator вызывает во мне положительные эмоции и не вызывает отрицательных. Ну если только за малым исключением - при внедрении зависимостей в конструктор (режим "push") DI-контейнер знает, для какого класса создаются зависимости и может внедрять различные имплементации одного и того же интерфейса на основании внутренних инструкций. В режиме "pull" у контейнера нет информации для кого он создаёт зависимости, нужно её дать:
$this->dep1 = $this->container->get(IDep1::class, self::class);
Вот в таком варианте Service Locator становится очень даже "pattern" без всяких "anti".
Послесловие
В комментах к публикации благодаря коллегам @lair и @Maksclubпришёл к выводам, что проблема отложенного внедрения зависимостей при создании объектов решается в рамках DI-парадигмы, если соответствующий язык программирования поддерживает generic'и или проксирование. В случае с PHP, в котором generic'и отсутствуют, необходима дополнительная кодогенерация (автоматом - github.com/Ocramius/ProxyManager, или вручную).
Таким образом, у предложенного решения (внедрение DI-контейнера в качестве Service Locator'а) всё ещё остаётся ниша - проекты на языках без generic'ов или проксирования, в которых нежелательна дополнительная кодогенерация. Но в подавляющем большинстве случаев лучше использовать "чистый" DI.
lair
Вот только зависимость от самого типа локатора вы никуда не дели. И с ней потребителям придется иметь дело, даже если они не хотят.
Это несколько неправда. Современные DI-контейнеры прекрасно умеют инициализацию по требованию.
Ну то есть то, что чтобы запустить объект в тестах, вам надо посмотреть (по коду класса), какие зависимости надо ему положить в локатор — это не вызывает у вас отрицательных эмоций?
А количество бойлерплейта, которое нужно написать, чтобы добавить одну зависимость?
(я уж молчу про всякие мелочи типа потокобезопасности или однократной инициализации зависимостей, предполагаю, что вы их просто опустили для простоты, а не забыли)
flancer Автор
В PHP так можно.
Значит это всё-таки несколько правда и старые DI-контейнеры плохо умеют инициализацию по требованию. И приведите, пожалуйста, пример современного DI-контейнера, который "прекрасно умеет инициализацию по требованию". Желательно на PHP или JS — это мои активные языки на данный момент.
Зачем вообще локатор/контейнер при тестах? Инжектите зависимости через setter'ы (или конструктор). И да, чтобы тестировать класс нужно залезть в его код. И нет, не обязательно лезть в код, если у вас есть описание публичных интерфейсов класса и ожидаемое поведение.
А это оборотная сторона медали. За всё нужно чем-то платить. Кстати, бойлерплейтить по-новому нужно только те зависимости, которые должны pull'аться в рабочем режиме. Остальные нужно бойлерплейтить по-старому.
Нет, не забыл. Я про них даже не думал. В PHP в web-приложениях вообще один поток. Но можно сделать и много. Но обычно не делают. А что, pull-способ использования контейнера менее потокобезопасен, чем push-способ? И это, что за зверь такой "однократная инициализация зависимостей"? Каким образом вы предполагаете в конструктор заталкивать зависимости многократно?
Daemos
А если мы интеграционный тест пишем и нам не очень хочется писать кастомную логику инициализации всего дерева требуемых объектов?
То, что так можно, не означает, что так нужно делать. Код с тайп-хинтами лучше анализируется статически, предотвращает ошибки еще до запуска и тем более деплоя.
flancer Автор
Тогда не пишите кастомную логику, используйте обычную. Это конкретно коллега lair хотел запустить конкретный объект в конкретных тестах.
Полностью согласен. Если класс предполагается использовать в рамках проекта, где интерфейс
IContainer
определён, то лучше его использовать. Но если очень сильно хочется отвязаться от этого интерфейса, то PHP это позволяет.lair
А вы говорите про паттерн, или про его реализацию в конкретном языке?
Autofac. Но вообще — любой, который умеет регистрировать и отдавать
Func<...>
.Угу. Какие сеттеры надо вызвать, а какие — не обязательно?
А говорите, нет отрицательных эмоций.
… но при этом вы упоминаете консольные приложения.
Не "pull-способ", а ваша реализация. У вас есть сеттеры (и прочие методы), они (по умолчанию для генеричного кода) не потокобезопасны. "Честный" DI делается в конструкторе, конструктор (по тому же умолчанию) потокобезопасен.
В конкструктор как раз никак. А вот в ваши свойства их можно запихнуть несколько раз. Это выгодное отличие constructor injection от property injection.
flancer Автор
И. У меня чисто утилитарные намерения.
А этот Autofac инициализирует зависимости, внедряемые через конструктор или через свойства/акцессоры? Вот как-то я не вижу, каким образом при создании объекта в языках со статической типизацией можно реализовать инициализацию зависимостей по требованию в этом случае:
Возможно, это особенности .Net. Например, в JS я могу в контейнере использовать Proxy и проинициализировать зависимости
$depX
при первом обращении к нему, но в PHP, насколько мне известно, такого сделать нельзя. Не могли бы вы привести пример кода (или ссылку на него), где Autofac инициализирует по требованию зависимости конструктора?Все. Вы же тестируете класс.
Я не испытываю отрицательные эмоции, когда в магазине оплачиваю покупку молока.
Консольные приложения могут быть однопоточные.
Не знаю, что такое "генеричный" код, но сеттеры для зависимостей исключительно на ваше усмотрение. Если вы хотите делать "переносимый код". Если не хотите, можно и без сеттеров — все зависимости тянуть через контейнер в конструкторе. Будет потокобезопасность и не будет переносимости.
В таком случае просто не используйте внедрение через setter'ы/свойства в коде приложения — будет вам однократная инициализация зависимостей. Используйте внедрение через setter'ы/свойства только для тестов. Опасаетесь за других разрабов в команде — уберите вообще возможность внедрять что-либо через setter'ы/свойства и тестируйте через инициализацию внедряемого в конструктор контейнера, если для вас это приоритет.
Я не говорю, что все и всегда должны применять описанный мной способ (в том числе вы, и в том числе в .Net). Я лишь хочу увидеть весомые аргументы, почему лично мне не стоит применять этот способ в PHP или в JS. И пока не увидел. В том числе и от вас.
lair
Ну то есть первое условие, при котором "Service Locator анти-шаблоном не является" — это "у меня PHP". Так?
И так, и так. Предпочтительно через конструктор.
Очень просто:
вообще все? У классов может быть много свойств, и не все из них нужны в конкретном тесте.
Но зачем, если можно просто сделать честный DI, и не вбрасывать контейнер? В чем выигрыш от использования сервис-локатора при таких ограничениях?
Аргументов, почему лично вам не стоит применять этот способ в PHP или JS, я вам и не дам. Я обсуждаю заявленную в заголовке тему "а анти-паттерн ли service locator". Пока что по всему получается, что как был антипаттерном, так и остался. Просто, как и любой антипаттерн, он иногда все еще имеет свои достоинства и область применения.
flancer Автор
Нет.
Полагаю, что Lazy — это такой же прокси, как и Ocramius/ProxyManager. Т.е., создание объекта теперь также привязано к контейнеру, как и в моём случае с внедрением самого контейнера (я не говорю, что это плохо — это то, чем приходится платить). Такой вопрос — Lazy также физически генерирует код на диске/в памяти, как и Ocramius/ProxyManager?
Тестируйте те, которые нужны.
Генерация зависимостей по требованию без использования Lazy-прокси. Если Lazy-прокси физически генерирует код на диске — то вот и выигрыш. А если нет — то и выигрыша нет, проксирование лучше. В JS, по крайней мере, подход с прокси точно можно применить, там это на лету делается. А вот в PHP — без кодогенерации не уверен, что можно.
Вот я и набросал, кмк, область, где Service Locator имеет право на жизнь.
lair
Нет, вы неправильно полагаете. Это стандартный класс .net, не имеющий никакого прокси.
Нет, не привязано.
Никакого контейнера не нужно, зависимость только на стандартную системную библиотеку и, собственно, потребляемую зависимость.
Нет, зачем? Это обычный класс со ссылкой внутри.
А в чем проблема с использованием
Lazy<T>
илиFunc<T>
?Или, подождите, дайте я угадаю… в языках, на которые вы ссылаетесь, нет дженериков?
flancer Автор
Ага.
flancer Автор
Судя по документации, нет. Очень похоже, что работает прозрачно, без дополнительной кодогенерации. В таком случае у вас в .Net уже есть контейнер, который решает проблему отложенной инициализации через проксирование зависимостей. Для вас (и остальных пользователей Autofac) описанный мною вариант с Service Locator'ом бесполезен.
Maksclub
Берем github.com/Ocramius/ProxyManager и готово :)
docs.laminas.dev/laminas-servicemanager/lazy-services (использует proxy manager)
symfony.com/doc/current/service_container/lazy_services.html (использует proxy manager)
php-di.org/doc/lazy-injection.html (использует proxy manager)
flancer Автор
В-о-о-т! Это то, что я и искал!!! За это спасибо!
Я правильно понимаю, что этот Proxy генерирует код для проксируемых объектов с сохранением его на диске?