Марк Симан написал замечательный пост «Service Locator нарушает инкапсуляцию». Название поста говорит само за себя о том, что он посвящён паттерну (анти-паттерну) Service Locator. Когда программист произвольно в коде вызывает IoC-контейнер для разрешения зависимости того или иного объекта — он использует Service Locator анти\паттерн. Марк рассматривает следующий пример:
public class OrderProcessor : IOrderProcessor
{
    public void Process(Order order)
    {
        var validator = Locator.Resolve<IOrderValidator>();
        if (validator.Validate(order))
        {
            var shipper = Locator.Resolve<IOrderShipper>();
            shipper.Ship(order);
        }
    }
}


Как мы видим инкапсуляция типа OrderProcessor поломана из-за двух скрытых зависимостей, которые по-тихому разрешаются в методе Process. Эти зависимости скрыты от вызывающего кода, что может привести к исключению в runtime в случае, если клиент не настроил должным образом IoC-контейнер, определив в нём необходимые зависимости. В качестве решения проблемы Марк предлагает перенести разрешение зависимостей в конструктор объекта.
public class OrderProcessor : IOrderProcessor
{
    public OrderProcessor(IOrderValidator validator, IOrderShipper shipper)
 
    public void Process(Order order)
}

Таким образом, вызывающий код будет в курсе того, что на самом деле требует объект OrderProcessor.

Однако, на мой взгляд, всё же есть сценарии, где сокрытие зависимостей можно применить. Рассмотрим WPF-приложение в котором практически любая ViewModel требует следующие зависимости: IEventAggregator, IProgress, IPromptCreator. Чтобы раскрыть смысл двух последних интерфейсов, добавлю, что реализация IProgress должна уметь принимать кусок долго выполняемого кода и показывать окно со шкалой прогресса, IPromptCreator позволяет открывать окна, запрашивающие подтверждение, согласие или отказ (модальные диалоги). Теперь представим, что есть ViewModels, которые требуют вдобавок две (а может и три) зависимости для создания модели. Вот каким образом ViewModel может начать выглядеть с таким количеством зависимостей:
public class PaymentViewModel: ICanPay
{
    public PaymentViewModel(IPaymentSystem paymentSystem, 
                            IRulesValidator rulesValidator, 
                            IEventAggregator aggregator, 
                            IProgress progress, 
                            IPromptCreator promptCreator)
    public void PayFor(Order order)
}

Какой бардак! Слишком много шума в декларации конструктора. Всего две зависимости действительно несут полезную информацию с точки зрения бизнес-логики.

Если мы, скажем, используем MEF для инъекции зависимостей, то мы можем сделать следующее:
[Export]
public class PaymentViewModel : ICanPay
{
    [Import]
    protected IEventAggregator aggregator;
    [Import]
    protected IProgress progress;
    [Import]
    protected IPromptCreator promptCreator;
 
    public PaymentViewModel(IPaymentSystem paymentSystem, 
                            IRulesValidator rulesValidator)
    {
    }
 
    public void PayFor(Order order)
    {
        //use aggreagtor, progress, promptCreator
    }
}

Мы перенесли зависимости из конструктора в декларации полей и пометили их атрибутом Import. Несмотря на то, что мы не вызываем IoC-контейнер напрямую (хотя, MEF это не IoC-контейнер в чистом виде), мы скрываем зависимости так же, как и в примере Марка. Ничего по сути не поменялось. Почему я считаю, что этот код не так уж плох? По нескольким основным причинам:
  • ViewModels не являются бизнес-сущностями, они представляют собой просто куски кода для склеивания Models и Views. Никто особо не парится насчёт выше указанных зависимостей;
  • ViewModels не являются публичным API и они не переиспользуются (в большинстве случаев);
  • С учётом двух предыдущих пунктов, члены команды могут просто договориться о том, что все ViewModels имеют эти утилитарные зависимости, и всё тут;
  • Эти утилитарные зависимости определены как protected, что позволяет в тестах создать класс ViewModel, наследующий PaymentViewModel, и заменить зависимости моками, поскольку мы будем иметь доступ к тем полям. Таким образом, мы не теряем возможность покрыть тестами PaymentViewModel. В примере Марка (где используется Service Locator) было необходимо настроить IoC-контейнер в проекте юнит-тестов для того, что замокать или застабить те зависимости, а такая практика может стать болезненной для процесса юнит-тестирования.

Заключение


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

Комментарии (24)


  1. garex
    10.11.2015 08:57
    -1

    А откуда ViewModel будет знать, что именно ложить/класть в тот же protected IProgress progress;?

    К примеру у меня два IProgress'а — один текстовый, а второй графический. И они за разное отвечают. Или вообще ещё проще — у меня этих прогрессов 5 (к примеру 5 разных процессов) — что тогда?


    1. Oxoron
      10.11.2015 09:33

      Если в один класс модели придется класть разные IProgress — вы в любом случае откажетесь от ServiceLocator, и будете инжектить нужный Progress-класс руками.
      Если говорить конкретно про IoC, при инициализации контейнера обычно получается список вида «Интерфейс I1 по дефолту реализовывать классом C1, Интерфейс I2 по дефолту реализовывать классом С2...» В вашем случае возможны пять разных реализаций, а значит, дефолта быть не может.


  1. VolCh
    10.11.2015 11:32

    Может я что-то не понимаю, но какую функцию выполняет в WPF ViewModel? Зачем в неё инжектить интерфейс модальных окон? Разве это не функция пользовтаельского интерфейса, не функция View?


    1. EngineerSpock
      10.11.2015 12:42

      Control Flow осуществляет ViewModel, поэтому и модальные диалоги ей вызывать. Это если паттерн MVVM, а вот если MVPVM, то, м.б. можно и как-то по-другому, но не уверен, у меня нет соответствующего опыта.


      1. HomoLuden
        10.11.2015 15:07
        +1

        Control Flow осуществляет ViewModel, поэтому и модальные диалоги ей вызывать. Это если паттерн MVVM


        Это не паттерн MVVM, а черти-что и лицемерие со стороны M$, ИМХО. Да Вы написали MVPVM, но для меня это не паттерн а попытка как-то обозвать (или ввести четкий термин/ярлык) решение из раздела «костыльное программирование» (я в хорошем смысле, то есть непротив), которое в свою очередь появилось из-за невозможности оставаться в рамках чистого MVVM.

        Ваша правда, что на практике не получается обойтись без управления частями UI из VM, хотя по ванильному паттерну MVVM иметь ссылку на UI внутри VM категорически запрещено.
        Более того, на практике, хранение прямой или опосредованной ссылки на UI ведет с большой вероятностью к массивным утечкам (утечет VM — полбеды; утечет VM+UI — швах). Тогда приходится еще и реализовывать вычистку нелегального барахла через IDisposable.

        Отвечая более детально на предыдущий комментарий, приведу пример.

        1. В VM есть команда удаления некоей сущности. Изменение это деструктивное и следует показать юзеру запрос «Да — Нет»
        2. Чтобы иметь возможность из VM что-то показать юзеру, заводится некий хэлпер, Expression Behavior или еще какая-то сущность, играющая роль прокси между VM и V. Кто-то обзывает ее буковкой :P.
        3. Дальше ссылка на эту прокси-сущность гоняется взад и вперед между VM'ми с целью наделить VM арендованным правом запустить тентаклю в UI.


    1. kosmos89
      10.11.2015 12:51
      +1

      Уж точно не View. Открытием окон управляет VIewModel, которые моделируют UI приложения и могут работать без View вообще. Задача View — уметь отобразить состояние ViewModel на экране. Поэтому во ViewModel так или иначе для этих целей что-то придется инжектить.


      1. HomoLuden
        10.11.2015 15:17

        Открытием окон обычно занимается некий NavigationManager. Буквально он либо дергает навигационный метод в WPF Frame, либо кладет нужного типа VM в ContentControl с пачкой <DataTemplate DataType="{x:Type SomeViewModel}.

        Ванильный MVVM вообще может означать инстанциирование ViewModel через XAML в секции Resources. Много где видел предложение инстанциировать VM'ли в ресурсах App.xaml. Получается, что в изначальной трактовке задача VM — реализовывать жизненный цикл Model, контролировать потоки данных и реализовывать ЛОГИКУ переключений частей UI. Все это следует из главного принципа «Отделяем UI от логики/кода».
        Но на практике это накладывает очень много ограничений, поэтому все сначала «говнокодят» исходя из текущих практических нужд, а потом это обзывают MVPVM.


  1. arvitaly
    10.11.2015 11:36

    Абсолютно согласен с таким подходом, конструктор тоже может быть God-object. Кстати, в JavaScript использую декораторы ES6 для DI.

    class Foo{
        @inject("bar")
        bar;
    }
    


  1. Fireball222
    10.11.2015 12:20
    +1

    Если мне не изменяет память, Марк в своей книге Dependency Injection in .NET описывал ваш подход как «внедрение зависимостей через свойства» (property injection) и допускал его использование как минимум в следующих случаях:
    1) Зависимость нужна в большинстве классов (ваш случай, еще один хороший пример это какой-нибудь ILogger)
    2) Зависимость имеет значение по умолчанию
    3) Классы зависят друг от друга

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


    1. EngineerSpock
      10.11.2015 12:40

      В реальности, с учётом того, что никто извне не использует ViewModels напрямую — да, можно сделать и публичными.


    1. VolCh
      10.11.2015 12:47

      Вот, так гораздо понятнее — сбило с толку protected


  1. EngineerSpock
    10.11.2015 12:55

    Марк Симан написал мне в блоге в комментариях, что он считает, что когда у ViewModel столько зависимостей, то нарушен SRP. Возможно, он прав. Но, что делать если в реальности три зависимости используются для создания модели, а остальные — просто утилитарные. Кто-нибудь как-то решал эту проблему более элегантным способом, чем описано в этой статье?


    1. lawliet29
      10.11.2015 13:17

      Как вариант, можно использовать Factory. В сигнатуру фабричного метода поместить все зависимости для самой модели, а утилитарные зависимости попросить в саму фабрику и передать создаваемому объекту:

      public class ViewModelFactory : IViewModelFactory
      {
          private readonly IEventAggregator _aggregator;
          private readonly IProgress _progress;
          private readonly IPromptCreator _promptCreator;
      
          public ViewModelFactory(IEventAggregator aggregator, IProgress progress, IPromptCreator promptCreator)
          {
              _aggregator = aggregator;
              _progress = progress;
              _promptCreator = promptCreator;
          }
      
          public ViewModel CreateViewModel(IPaymentSystem paymentSystem, IRulesValidator rulesValidator) 
              => new ViewModel(_aggregator, _progress, _promptCreator, paymentSystem, rulesValidator);
      }
      


      Не буду утверждать, что это более элегантно, чем Property Injection.


      1. Serg046
        10.11.2015 13:43

        Так ведь конструктор ViewModel, выходит, все равно будет содержать весь этот цирк. Это никак не решает проблему. Чисто технически инжектировать даже в 100 зависимостей конструктора не проблема. Но вод SRP будет злостно нарушен.


      1. Serg046
        10.11.2015 13:53

        А по поводу Property Injection. Мое мнение, что это такая же неявная зависимость. Тестируемость? Что мешает применить Локатор не локально, а к такому же protected-полю? Здесь из плюсов разве что читаемость, ибо сразу видно что имеет место быть внедрение, и что это неявная зависимость. Однако на мой взгляд это все отговорки.


  1. vintage
    10.11.2015 22:10
    -3

    Ничего если я встряну со своими всего лишь 10 годами опыта разработки интерфейсов?
    Инкапсуляция должна скрывать сложность реализации за простотой интерфейса. Это её основная и единственная функция. А не сокрытие данных или выпячивание зависимостей.
    То есть нет ничего плохого в том, что интерфейс скрывает зависимости, которые имеют значения по умолчанию. Вы не обязаны думать об этих зависимостях при каждом создании объекта — только тогда, когда вам действительно нужно поменять их реализацию.


    1. VolCh
      11.11.2015 07:45

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


      1. vintage
        12.11.2015 08:47

        Нет, там приходится думать не о зависимостях, а именно о среде. Просто меняем дефолтные реализации для этой среды. Пример, как сохранить инкапсуляцию и при этом иметь возможность менять дефолты извне собственно и приведён в этом топике…


        1. kosmos89
          12.11.2015 12:37

          Обычно в качестве минуса ServiceLocator упоминают знание класса логики о инфраструктуре, что делает его жестко от нее зависимым.


          1. arvitaly
            12.11.2015 12:43

            Дело не только в знании (всегда можно создать дефолтные или mock-значения), но еще и в том, что в другом месте могут не требоваться даже 10% всех функции этого локатора, как часто бывает с большими библиотеками, а тащить приходится весь багаж.


            1. vintage
              12.11.2015 13:45

              А что мешает реализовать свой локатор с урезанным функционалом? :-)


              1. arvitaly
                12.11.2015 14:29

                Ничего не мешает. Вот у меня есть библиотека, для удобства есть SIngleton с методами создания каких-либо компонентов.
                Варианты для использования моей библиотеки:
                1. Использовать этот singleton сразу, как Service Locator. Например, Bootstrap.getNewNavBar(); И всегда создавать полноценную версию этого локатора со всеми методами.
                2. Писать в каждом проекте новый Service Locator, который будет содержать только нужные методы создания компонентов…
                3. Один раз создать DI-контейнер. Инъектировать через свойства класса ссылку на тип компонента. Создавать при инициализации класса автоматически.

                Допустим у нас в ServiceLocator должен храниться сервис, который используется двумя классами. В какой момент нужно удалить этот экземпляр этого сервиса? Внутри 1 класса, внутри 2? Отслеживать все ссылки на эти классы? В конце программы?
                Мокать глобальные объекты тоже то еще удовольствие, либо обнулять ВСЕ сервисы в локаторе перед каждым тестом, либо пересоздавать его целиком заново.


                1. vintage
                  12.11.2015 20:20

                  Насколько я понял вашу проблему — она решается посредством WeakRef, либо не решается, если язык это не поддерживает. При чём тут обсуждаемая тема — не очень понятно.


  1. AlexanderG
    11.11.2015 15:56
    -1

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