понедельник, 25 марта 2013 г.

DI Паттерны. Service Locator

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

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

Описание

Суть паттерна Сервис Локатор сводится к тому, что вместо создания конкретных объектов («сервисов») напрямую с помощью ключевого слова new, мы будем использовать специальный «фабричный» объект, который будет отвечать за создание, а точнее «нахождение» всех сервисов.

image

// Статический "локатор"
public static class ServiceLocator
{ 
   
public static object GetService(Type
type) {}
   
public static
T GetService<T>() {} } // Сервис локатор в виде интерфейса public interface IServiceLocator {
    T GetService<T>(); }

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

Назначение

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

Применимость

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

Однако между DI контейнером и его использованием в виде Сервис Локатора существует тонкая грань. По всем правилам, использование контейнера должно быть ограничено минимальным количеством мест. В идеале, в приложении должна быть лишь одна точка, где производится вызов метода container.Resolve(); этот код должен находиться либо в точке инициализации приложения (так называемый Composition Root), либо максимально близко к ней.

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

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

class EditEmployeeViewModel
{ 
   
private Employee
_employee;
   
private IServiceLocator
_serviceLocator;
   
public EditEmployeeViewModel(IServiceLocator
serviceLocator)
    {
        _serviceLocator = serviceLocator;
    }
private void
OkCommandHandler()
    {
        ValidateEmployee(_employee);
       
var repository = _serviceLocator.GetService<IRepository
>();
        repository.Save(_employee);
    }
private void ValidateEmployee(Employee employee) {} }

И хотя использование Сервис Локатора является довольно популярным, у него есть ряд фатальных существенных недостатков.

Недостатки Сервис Локатора

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

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

Неясный контракт класса

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

В результате, между классом и его клиентом существует формальный или неформальный «контракт», который выражается в виде предусловий (требований к клиенту) и постусловий (гарантий выполнения работы). Однако если класс принимает экземпляр сервис локатора, или, хуже того, использует глобальный локатор, то этот контракт, а точнее требования, которые нужно выполнить клиенту класса, становятся неясными:

class EditEmployeeViewModel
{
    private IServiceLocator _serviceLocator; 
   
public EditEmployeeViewModel(IServiceLocator
serviceLocator)
    {
        _serviceLocator = serviceLocator;
    }
}

Как понять клиенту данного класса, что от него требуется для того, чтобы данный объект выполнил свою часть работы? Какие «предусловия» класса EditEmployeeViewModel? Наличие в сервис локаторе IRepository, ILogger, IEMailSender, ISomethingElse? Чтобы понять это нам придется проанализировать исходный код этого класса, что совсем не просто, а иногда еще и невозможно.

Кроме того, вполне возможно, что EditEmployeeViewModel не выполняет всю работу самостоятельно, а делегирует часть обязанностей другому классу, например, DialogEditViewModel. И уже этот класс получает нужную зависимость у Сервис Локатора для сохранения сотрудника. В результате, чтобы понять требования класса EditEmployeeViewModel придется проанализировать исходный код не только этого класса, но и всех зависимых классов.

image

Если рассматривать это решение с точки критериев хорошего дизайна, то его дизайн явялется отвратительным, поскольку является жестким, хрупким и неподвижным. Внесение изменение в одной части системы (например, в нашем классе DialogEditViewModel или в любом классе, который он использует) приведет к необходимости изменения логики инициализации локатора. И такая «логическая связанность» приведет к непредвиденным ошибкам во время исполнения, поскольку рано или поздно мы забудем обновить одну часть системы, при изменении другой части и получим тот самый «эффект бабочки», которого мы так хотим избежать. Наконец, этот класс невероятно сложно использовать повторно, поскольку очень сложно понять, что же должно находиться в Локаторе для его нормальной работы.

Неопределенная сложность класса

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

Когда наш класс использует сервис локатор, то стабильность класса становится неопределенной. Наш класс, теоретически, может использовать что угодно, поэтому изменение любого класса (или интерфейса) в нашем проекте может затронуть произвольное количество классов и модулей.

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

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

Отсутствие строгой типизации

Сервис локатор можем содержать несколько способов получения (или поиска) зависимостей:

class Locator
{ 
   
// Не строготипизированное получение сервиса     public object Resolve(Type
type) { }
   
// Якобы строготипизированное получение сервиса     public T Resolve<T>() { } }

Многие склонны считать, что метод Resolve, возвращающий object является «слабо типизированным», в то время, как обобщенный метод Resolve обеспечивает строгую типизацию.

Как правильно писал Эрик Липперт, проблема здесь в том, что понятие строгой типизации весьма размыто и даже в Вики есть десяток разных определений. Да, обобщенный метод Resolve устраняет необходимость приведения типов (это приведение находится внутри этого метода;)), но ведь отсутствие явного приведения типов не является единственным свойством строгой типизации.

Главное преимущество строгой типизации – это раннее определение логических ошибок в приложении. При наличии строгой типизации мы переносим момент определения ошибок с этапа выполнения, на этап компиляции. Вот с этой точки зрения, обе версии метода Resolve являются одинаково «слабо типизированными», поскольку тот факт, что наш код компилируется совсем не означает, что он будет вести себя корректным образом во время исполнения.

Смягчаем проблему

Сейчас должно быть понятно, что от Сервис Локатора стоит держаться подальше, но что делать, если наше приложение уже активно его использует и избавиться от него не так и просто?

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

class EditEmployeeViewModel
{
    private void OkCommandHandler() 
    {
        ValidateEmployee(_employee);
       
var repository = _serviceLocator.GetService<IRepository
>();
        repository.Save(_employee);
    }
}

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

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

class EditEmployeViewModel
{ 
   
private readonly IRepository
_repository;
   
private readonly ILogger
_logger;
   
private readonly IMailSender
_mailSender;
   
private readonly IServiceLocator
_locator;
   
public EditEmployeViewModel(IServiceLocator
locator)
    {
        _locator = locator;
        _repository = locator.GetService<
IRepository
>();
        _mailSender = locator.GetService<
IMailSender
>();
        _logger = locator.GetService<
ILogger>();
    } }

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

Заключение

Самое страшное в Сервис Локаторе то, что он дает видимость хорошего дизайна. У нас никто не знает о конкретных классах, все завязаны на интерфейсы, все «нормально» тестируется и «расширяется». Но когда вы попробуете использовать ваш код в другом контексте или когда кто-то попробует использовать его повторно, вы с ужасом поймете, что у вас есть дикая «логическая» связанность, о которой вы и не подозревали.

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

Дополнительные ссылки

Service Locator is an Anti-Pattern by Mark Seemann

Серия статей об управлении зависимостями
  1. Управление зависимостями
  2. Наследование vs Композиция vs Агрегация
  3. DI Паттерны. Constructor Injection
  4. DI Паттерны. Property Injection
  5. DI Паттерны. Method Injection
  6. Критерии плохого дизайна
  7. Аксиома управления зависимостями
  8. Service Locator

21 комментарий:

  1. Сейчас работаем с PHP проектом на Symfony, и там эта проблема как раз актуальна. Всё построено вторым способом: передаём в конструктор serviceContainer $container и расщепляем в защищённые поля.

    Перечислять по 6-8 параметров в аргументах конструктора и конфигурационном файле или обрамлять сеттерами не очень хочется. А иначе протащить до самого низа логгер, мэйлер и SMS-мессенджер было бы не так просто.

    ОтветитьУдалить
  2. В таком случае я бы посоветовал как минимум следующее: протаскивайте инфраструктуру как угодно, но протаскивайте бизнес-зависимости явно.

    Я бы предпочел вообще для инфраструктурных зависимостей Ambient Context, а для бизнес-зависимостей - конструктор.

    ОтветитьУдалить
  3. Знаешь, согласен. Локатор - антипаттерн. Но посмотри с другой стороны. Имеем код с жесткими зависимостями ( в конструкторе через new). Или же код с Сервис Локатором. Чтобы ты предпочел? Вариант с рефакторингом через DI не предлагать (больше 10 мегабайт исходников). Как минимум такой код можно тестировать. С чем согласен - второй путь использования (без ленивости) МНОГО лучше. Поэтому лучше рефакторить от первого варианта использования ко второму, а потом, если будет время - перейти на DI нормальный.

    ОтветитьУдалить
  4. Самый глупый вопрос из всех возможных... а чем вы рисуете диаграммы?

    ОтветитьУдалить
  5. @eugene: как по мне, Сервис Локатор возможен как временное решение на 0-й итерации, но поскольку временные решения очень часто становятся постоянными, то я бы не рисковал с таким подходом.
    Кроме того, если уже взялись за легаси код и решили инвестировать в его улучшение, то нет смысла менять жесткое решение на решение с сервис-локатором. Мы можем поменять шило на мыло.

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

    В целом, я понимаю, что подход с локатором может быть не плохим, но, опять таки, меня беспокоит то, что это может превратиться в постоянное решение.

    ОтветитьУдалить
  6. Не :). Никто не спорит, что лучше DI. Просто мне довелось видеть код, в котором был как раз локатор. И кстати, очень утверждали, что это DI в полный рост :). И в чем его преимущество, что он был довольно неплохо покрыт тестами. Так вот с моей точки зрения, код с локатором, лучше чем код с жесткими связями (хотя бы из-за тестирования). Никто не ставил под сомнение, что рефакторить надо в сторону DI. Я сказал, что легаси код с локатором, лучше чем легаси код с жесткими связями. Ну и как от этого кода с локатором идти в сторону DI. Как по мне самая ценная часть в этой статье - именно о том, чтобы заменять in place сервисы на получение сервисов в конструкторе. Это уже ОЧЕНЬ серьезный шаг к DI. Ну а дальше - сломать парадигму и НЕ ОСТАВИТЬ временное решение. Для легаси кода, а это наш случай, я бы предпочел именно step by step. Если сразу менять использование in place получение сервисов на Constructor Injection велик риск получить не работающую программу. Перенос сервисов в конструктор и убирание ленивости уже может внести ошибки (например, часть кода вообще никогда не работала и фейловый сервис не срабатывал) а не "ленивый" вызов в конструкторе может все сломать. А если добавить еще и формирование Composition Root. В общем, ИМХО за один шаг такое делать нельзя. Ну или можно, если ты ковбой :)

    ОтветитьУдалить
  7. Жень, я слегка троллил:) Я согласен с твоими рассуждениями, тем более я знаю, что в твоих руках использование локатора будет именно вынужденной мерой. (тут я не тролю!)

    Второй главный вывод (помимо того, как бороться с уже существующим кодом, завязанным на локатор) такой: стараться не писать новый код, завязанный на локатор, выдавая его за "тестабельное" решение и "слабую свзанность".

    ОтветитьУдалить
    Ответы
    1. Не могли бы вы дать конкретные отличия DI от service locator? Тут много пишут о какой то тонкости, но я ее к сожалению так и не увидел в ваших постах.

      Удалить
    2. DI - это общая концепция, которая заключается в передаче требуемых зависимостей определенному типу. Сервис локатор - это использование контейнера нпосредственно бизнес-объектами.

      Удалить
    3. Я не знаю знакомы ли вы с Zend Framework 2,
      но там как раз используются оба этих паттерна на сколько я знаю.
      По умолчанию вы можете забивать service locator фабриками или анонимными функциями которые в свою очередь возвращают вам сформированный объект,
      вы как клиент не знаете каким образом был сформирован данный объект.
      Вы просто работаете с полученным объектом.
      Также в данном фреймворке есть возможность самому формировать через Di объекты, управляя зависимостями (что заинжектить в нужный нам класс и как).

      На сколько я понял отличие этих подходов - Service Locator - Ты знаешь где конкретно взять тот или иной объект. DI же сам производит поиск необходимых частей для сбора объектов
      т.е ты не в курсе откуда он их берет, (поиск по интерфейсу, хинтам итд).

      Так вот прав ли я ???

      Удалить
    4. Сервис локатор - это использование контейнера напямую и все. DI - это передача зависимостей через конструктор, свойства или методы. Если в качестве зависимости передается сам контейнер и класс сам выгребает нужные ему зависимости, то такий тип (или паттерн) инверсии управления будет называться сервис локатором.
      З.ы. С зенд фреймворком не знаком.

      Удалить
    5. Сергей спасибо за ответы, с вашего позволения последний пример:


      это где то в классах инициализации

      /**
      * Get service config
      */
      public function getServiceConfig()
      {
      return array(
      'factories' => array(
      'Application\Model\ModelManager' => function($serviceManager)
      {
      // инжектим необходимые зависимости в конструктор
      return new Model\ModelManager($serviceManager->
      get('Zend\Db\Adapter\Adapter'), $serviceManager->get('Cache\Static'));
      },
      ...
      )
      )
      }

      // а здесь юзаем контейнер, в данном примере получаем модель

      class AclAdministrationController extends AbstractAdministrationController
      {
      /**
      * Model instance
      * @var object
      */
      protected $model;

      /**
      * Get model
      */
      protected function getModel()
      {
      if (!$this->model) {
      $this->model = $this->getServiceLocator() // сам контейнер заинжектен где то в базовом обстрактном классе
      ->get('Application\Model\ModelManager');
      }

      return $this->model;
      }
      ....
      }

      Отсюда получаем, что мы используем DI в инициализации объектов, но сам паттерн называется ServiceLocator????
      Честно не могу понять соли. Вопрос нафига нам использовать DI без контейнеров, а если юзаем контейнеры они называются Service Locator и еще подходят по некоторым характеристикам на Анти Паттерн.

      PS. Спасибо за терпение, я бы себя уже послал :)


      Удалить
    6. Александр, все верно.

      > Честно не могу понять соли.
      Самый простой вариант понять соль DI - вспомнить о классических паттернах, построенных на его основе. Стратегия передается в конструктор или метод, команда - аналогично. Декоратор - чистый constructor injection. Во всех этих паттернах не используется сервис локатор, но при этом польза от DI есть: мы получаем слабосвязанный дизайн.

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

      Удалить
    7. в этой статье я нашел ответ на свои вопросы - http://www.loosecouplings.com/2011/01/dependency-injection-using-di-container.html

      Удалить
    8. Основа это - Dependency Injection != using a DI container

      Удалить
  8. Очень хорошая статья :) Только недавно задумывался над этим вопросом, и статья подкрепила и расширила мои мысли на счет этого паттерна. Использование сервис локатора вполне оправдано, когда есть либо отложенная инициализация либо пере-инициализация отельных кусков функционала без перезапуска системы. Например через сервис локатор была реализована инициализация драйверов для терминального ПО (человек подключает к COM-порту новое устройство, в терминале подгружается новый драйвер).
    Если взять специфический случай, например инициализация чего-то тяжелого (и лучше чтобы это была Lazy иницализация), то я бы передавал, типизированный Lazy инициализатор:
    public interface IConcreteLazy
    {
    IConcreteComponent GetInstance();
    }
    На счет инфраструктуры я также согласен с Сергеем, лучше использовать Ambient Context, нежели везде резолвить и "прикапывать" инстанс логгера по типу ILog _logger = Locator.Resolve, получается что логгер является состоянием объекта или типа, что не совсем верно.
    Так же для меня было бы странным видеть в бизнес типе (пусть этот тип использует какой-нить провайдер) зависимость от какого-то там локатора, опять таки при модульном тестировании мне надо сначала создать мок на локатора, только потом передать ему мок на провайдер, в действительности мок на локатор мне совсем не нужен.



    ОтветитьУдалить
  9. Спасибо, за статью! Как всегда, очень полезно. Сергей, подскажите пожалуйста, разве нельзя избавиться от проблемы: "...В этом случае мы жертвуем «ленивостью» получения зависимостей..." - воспользовавшись Lazy ?

    ОтветитьУдалить
    Ответы
    1. Роман, да, в случае с Lazy мы вернем ленивость, но тут есть проблема: ленивость по своей природе имеет свои плюсы и минусы. В качестве плюсов: мы не получаем потенциально дорогую в создании зависимость, если эта зависимость не нужна. А в плане минуса: если произойдет ошибка при получении зависимости, то она (ошибка) произойдет в произвольный момент работы объекта, а не в момент конструирования.

      Удалить
    2. Вроде бы, это понятный trade-off для ленивости. Можно не все зависимости огульно делать ленивыми, а вынести только тяжёлые, понимая о поздних ошибках. Зато все зависимости (и "сиюминутные", и ленивые) будут видны сразу в конструкторе.

      Удалить