Service Locator (или “локатор служб”) — хорошо всем нам известный паттерн. Поскольку он был описан Мартином Фаулером, он должен быть хорошим, ведь так?

К сожалению нет, на практике это классический пример антипаттерна, который следует избегать.

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

Пример с OrderProcessor

Возьмем злободневный пример для всех, кто сталкивался с внедрением зависимостей: OrderProcessor. В рамках обработки заказа, OrderProcessor должен проверить заказ и, если все в порядке, отправить его на отгрузку. Вот пример реализации с использованием 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);
        }
    }
}

Service Locator используется в качестве альтернативы оператору new. Выглядит это следующим образом:

public static class Locator
{
    private readonly static Dictionary<Type, Func<object>>
        services = new Dictionary<Type, Func<object>>();
 
    public static void Register<T>(Func<T> resolver)
    {
        Locator.services[typeof(T)] = () => resolver();
    }
 
    public static T Resolve<T>()
    {
        return (T)Locator.services[typeof(T)]();
    }
 
    public static void Reset()
    {
        Locator.services.Clear();
    }
}

Мы можем задать Locator с помощью метода Register. “Настоящая” реализация Service Locator была бы намного более усовершенствованной, чем эта, но этот пример вполне отражает суть паттерна.

Он гибок и расширяем, и даже поддерживает подмену сервисов тестовыми дублерами, как мы вскоре увидим.

Учитывая все это, в чем же подвох?

Проблемы с API

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

Вот что мы получаем от IntelliSense в Visual Studio:

Итак, у класса есть конструктор по умолчанию. Это означает, что мы можем просто создать его новый инстанс и сразу же вызвать метод Process:

var order = new Order();
var sut = new OrderProcessor();
sut.Process(order);

Но не тут-то было, запуск этого кода внезапно вызывает KeyNotFoundException, потому что IOrderValidator не был зарегистрирован нами в Locator. Это не только неожиданно, это вполне может сбить нас с толку, если у нас нет доступа к исходному коду.

Просматривая исходный код (или используя Reflector) или копаясь в документации (фе!) мы можем, наконец, обнаружить, что нам нужно зарегистрировать инстанс IOrderValidator в Locator (совершенно несвязанным статическим классом), чтобы это все заработало.

В модульном тесте это можно сделать так:

var validatorStub = new Mock<IOrderValidator>();
validatorStub.Setup(v => v.Validate(order)).Returns(false);
Locator.Register(() => validatorStub.Object);

Что еще более раздражает, так это то, что, поскольку внутреннее хранилище Locator статично, нам нужно вызывать метод Reset после каждого модульного теста. Но хотя бы это неудобство нас преследует в основном только в модульном тестировании.

В целом, однако, мы не можем обоснованно утверждать, что такое API способствует положительному опыту разработки.

Проблемы с сопровождением

Хоть подобное использование Service Locator с точки зрения пользователя достаточно проблематично, но то, что кажется простым, вскоре становится проблемой и для разработчика, который должен сопровождать этот код.

Допустим, нам нужно расширить поведение OrderProcessor вызовом метода IOrderCollector.Collect. Легко ли нам будет сделать это?

public void Process(Order order)
{
    var validator = Locator.Resolve<IOrderValidator>();
    if (validator.Validate(order))
    {
        var collector = Locator.Resolve<IOrderCollector>();
        collector.Collect(order);
        var shipper = Locator.Resolve<IOrderShipper>();
        shipper.Ship(order);
    }
}

С чисто механистической точки зрения это легко — мы просто добавили новый вызов Locator.Resolve и вызвали IOrderCollector.Collect.

Является ли это изменение критическим?

Ответить на этот вопрос может быть на удивление трудно. Код, конечно, скомпилировался без ошибок, но один из моих модульных тестов демонстрирует ошибку. Что произойдет в рабочем приложении? Интерфейс IOrderCollector может быть уже зарегистрирован в Service Locator, поскольку он уже используется другими компонентами, и в этом случае он будет работать без сбоев. С другой стороны, дела могут обстоять немного по другому.

Суть в том, что становится намного сложнее сказать, вводите ли вы критическое изменение или нет. Вам нужно следить за всем приложением, в котором используется Service Locator, и компилятор вам в этом не поможет.

Вариация паттерна: Concrete Service Locator

Можем ли мы как-то решить эти проблемы?

Одно из самых популярных решений — сделать Service Locator конкретным классом, который используется следующим образом:

public void Process(Order order)
{
    var locator = new Locator();
    var validator = locator.Resolve<IOrderValidator>();
    if (validator.Validate(order))
    {
        var shipper = locator.Resolve<IOrderShipper>();
        shipper.Ship(order);
    }
}

Однако для работы ему все еще требуется статическое хранилище в памяти:

public class Locator
{
    private readonly static Dictionary<Type, Func<object>>
        services = new Dictionary<Type, Func<object>>();
 
    public static void Register<T>(Func<T> resolver)
    {
        Locator.services[typeof(T)] = () => resolver();
    }
 
    public T Resolve<T>()
    {
        return (T)Locator.services[typeof(T)]();
    }
 
    public static void Reset()
    {
        Locator.services.Clear();
    }
}

Другими словами: нет никаких структурных различий между конкретным локатором служб и статическим локатором служб, который мы уже рассматривали. Он имеет те же проблемы и ничего не решает.

Еще одна вариация: Abstract Service Locator #

Есть еще одна вариация, которая немного ближе к настоящему внедрению зависимостей: Service Locator представляет собой конкретный класс, реализующий интерфейс.

public interface IServiceLocator
{
    T Resolve<T>();
}
 
public class Locator : IServiceLocator
{
    private readonly Dictionary<Type, Func<object>> services;
 
    public Locator()
    {
        this.services = new Dictionary<Type, Func<object>>();
    }
 
    public void Register<T>(Func<T> resolver)
    {
        this.services[typeof(T)] = () => resolver();
    }
 
    public T Resolve<T>()
    {
        return (T)this.services[typeof(T)]();
    }
}

В этой вариации Service Locator необходимо внедрять в потребителя. Внедрение зависимостей через конструктор всегда является хорошим выбором для внедрения зависимостей, поэтому наш OrderProcessor трансформируется в следующую реализацию:

public class OrderProcessor : IOrderProcessor
{
    private readonly IServiceLocator locator;
 
    public OrderProcessor(IServiceLocator locator)
    {
        if (locator == null)
        {
            throw new ArgumentNullException("locator");
        }
 
        this.locator = locator;
    }
 
    public void Process(Order order)
    {
        var validator =
            this.locator.Resolve<IOrderValidator>();
        if (validator.Validate(order))
        {
            var shipper =
                this.locator.Resolve<IOrderShipper>();
            shipper.Ship(order);
        }
    }
}

Значит, теперь все хорошо?

Как разработчики, мы наконец получили небольшую помощь от IntelliSense:

Но что это нам говорит? По большому счету, не очень много чего. Итак, OrderProcessor нужен ServiceLocator — это немного больше информации, чем раньше, но мы по-прежнему не знаем, какие службы необходимы. Следующий код компилируется, но вылетает с тем же KeyNotFoundException, что и раньше:

var order = new Order();
var locator = new Locator();
var sut = new OrderProcessor(locator);
sut.Process(order);

С точки зрения разработчика, которому нужно будет сопровождать этот код, ситуация также не сильно улучшилась. Мы по-прежнему не получаем никакой помощи, если нам нужно добавить новую зависимость, окажется ли это изменение критическим или нет? Ответить на этот вопрос так же трудно, как и раньше.

Заключение

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

Компилятор может предложить как пользователям, так и поставщикам немного помощи при внедрении зависимостей через конструктор, но эта помощь недоступна для API, которые полагаются на Service Locator.

Подробнее о паттернах и антипаттернах внедрения зависимостей можно прочитать в моей книге.

Обновление от 20 мая 2014 г.: Еще один способ объяснить отрицательные аспекты Service Locator заключается в том, что он нарушает SOLID.

Обновление от 26 октября 2015 г.: Фундаментальная проблема с Service Locator заключается в том, что он нарушает инкапсуляцию.


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

Недавно в OTUS в рамках онлайн-курса "Unity Game Developer. Professional" прошел открытый урок «Компоненты игровых объектов». На уроке мы рассмотрели, как при помощи компонентного подхода (не ECS) можно гибко изменять функциональность юнитов таким образом, чтобы система была слабо связной. Если интересно, запись вебинара можно посмотреть по ссылке.

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


  1. nin-jin
    20.10.2022 12:19
    +1

    Достаточно предоставить реализацию по умолчанию и ничего неожиданно падать не будет


    1. VYudachev
      20.10.2022 14:56

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


    1. agalakhov
      20.10.2022 17:43
      +5

      Зато может неожиданно вызываться не то. Лучше уж пусть падает.


      1. nin-jin
        20.10.2022 17:48

        Поведение по умолчанию не может быть неожиданным - это часть контракта.


  1. cadovvl
    20.10.2022 12:22
    +5

    Я не понял, причем тут разработка игр: это достаточно общий шаблон, гляньте, например, на IServiceCollection в DependencyInjection.

    Дальше: принципам SOLID что только не противоречит: прочекайте шаблоны на соответствие Open/Closed P, там от синглтонов/фабрик до фасадов и адаптеров одни несоответствия. Считать принципы ООП и SOLID гарантом качества как минимум наивно.

    А если разбирать по-существу, то есть языки (например, C#), в которых очень хорошо сделана рефлексия, и было бы странно этим не пользоваться.

    Этот паттерн дает удобную возможность переключаться между реализациями - это плюс. С другой стороны есть возможность нарваться на ошибку времени выполнения - это минус. Минус может быть компенсирован особенностями проекта, например мы инициализируем все сервисы при запуске согласно конфигу и не меняем их в процессе выполнения. Тогда все потенциальные ошибки обязательно всплывут даже при поверхностном покрытии тестами.

    Дальше мы взвешиваем все плюсы и минусы: если это устраивает нас в нашем проекте - используем. Не устраивает - нет. Всего делов, паттерн как паттерн. Антипаттерна он не заслужил точно.


    1. HexGrimm
      20.10.2022 13:29
      +1

      Кажется, научное сообщество сводится к тому что это очень близко к анти-паттерну, согласно книгам Р. Мартина и М. Симана. В первую очередь из-за ошибок времени вополнения. И конечно всё дерево связей создать один раз в compositionRoot не является возможным в более менее большом проекте, или DI будет заканчиваться именно там где начнутся new() по коду. Рано или поздно в любом проекте придется пересоздавать части графа и переиспользовать код. Не говоря уже о более мелких примитивах где напрашиваются абстрактные фабрики, знающие о скрытых зависимостях. Там где можно не писать тест на резолв, а увидеть сразу ошибку компиляции это всегда выигрыш. А если речь о большой распределённой команде, то отладка багов кривого графа связей это большая боль. Соответственно - если в смежной команде кто-то заюзал сервис локатор и выдает вам инструкции как зарегать его зависимости перед использованием кода, лучше сказать "не юзай антипаттерны" и отправить переделывать.

      Самый чистый код - код который можно написать используя только иньекцию в конструктор и абстрактные фабрики. А потом уже для оптимизации написания их конструкторов можно заюзать DI контейнер.

      Рефлексия - это набор еще более худших антипаттернов, так как нарушить SOLID принципы можно дуновением строки кода.


      1. HexGrimm
        20.10.2022 13:35

        Ой, забавно, собственно оригинальную статью и написал Марк Симан.


      1. nin-jin
        20.10.2022 17:32
        -2

        Самый чистый код - код который можно написать используя только иньекцию в конструктор

        А самая чистая комната - это комната на входе в которую лежат чьи-то трусы.

        DI - это тонны копипасты ради эфемерной "явности", ломающей абстрагирование и усложняющей рефакторинг, и тормоза из-за инициализации всего мира ради одного метода.


    1. sshikov
      20.10.2022 13:58
      +1

      Я в целом с вашим комментарием согласен, но хочется чуть уточнить:

      >Считать принципы ООП и SOLID гарантом качества как минимум наивно.

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

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

      >очень хорошо сделана рефлексия
      Ну да. Вот мне нужно поддерживать три версии фреймворка, у которых местами несовместимый API. Я долго собирал три версии «прокладки», с разными версиями зависимости, пока не понял простую вещь — две версии вполне могут и существуют в рантайме одновременно, в одном окружении. Я не могу в это окружение установить статически собранную версию. Поэтому для меня лучше сделать прокладку на рефлексии, которая проверит API в рантайме, поймет, с какой версией фреймворка мы имеем дело, и вызовет подходящий для нее метод. Это будет в итоге проще. Я усложню одно место, но сильно упрощу другое.

      А общий вывод ровно как у вас. Те кто считает это антипаттерном — догматики.


      1. cadovvl
        20.10.2022 14:26
        +4

         Вот мне нужно поддерживать три версии фреймворка, у которых местами несовместимый API

        Эх, по живому режете ведь :(

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

        Тоже догма. Исходя из моей практики, в легаси хотябы 4-х летней давности (за исключением стандартных библиотек) я крайне редко переиспользовал написанные классы, а уж тем более расширял их (без полного переписывания, как нам O из SOLID гласит). Зато макросы 20-летней давности переиспользовал - только в путь.

        Ну и раз все тут умные и блистают цитатами, сошлюсь ка я на Дядю Боба:

        Customers are very good at "somehow knowing" what your design is. And they are choosing a new feature, that would completely fork your design. This is a fact of life...

        Когда для ООП-шной архитектуры продумывают пути расширения, то обычно держат в голове что-то конкретное. Это конкретное в 95 процентах случаях радикально меняется уже через год, а половина оставшегося через два. И удобная для расширения штука становится препятствием вместо помощи. Сюда нам надо что-то протащить, вот тут что-то инкапсулированное поменять, а вот здесь сделать одного наследника, сам факт существования которога нарушит L из SOLID. И вот перед нами выбор, либо все наше "гибкое" переписывать к чертям, или вставлять "анитипаттерны" и ловить баги через один.

        И это не потому, что мы дизайн плохой сделали, это потому, что мир меняется, рынок меняется, задачи меняются. И дизайн делался для одного продукта, а в процессе реализации продукт стал немного/совсем другим.


        1. sshikov
          20.10.2022 15:02
          +1

          >Тоже догма.
          Не, ну это как бы определение :) Эти методы предложили для достижения вот такой цели. Достигают ли они ее на самом деле — вопрос интересный, с вашими пояснениями я тоже скорее согласен, угадать что расширять, получается далеко не всегда.

          Но все равно, требовать от SOLID достижения просто «качества вообще» (чего никто не обещал) — перебор.


  1. dph
    21.10.2022 02:46

    Хм, классический ServiceLocator - это просто singleton, который реализует кучу методов типа getOrderValidator() - и в такой реализации большей части описанных проблемы не будет возникать. У такого решения, конечно, тоже дофига проблем (например то, что это синглтон), но в некоторых простых случаях это решение удобнее, чем какой-нибудь DI-контейнер.
    Тут же рассматривается очень специфическая реализация, которая, действительно, не очень удачная. Впрочем, часть описанных проблем будут вообще у любых IoC решений - что не повод отказываться от IoC (да и замены особой не предложено).


  1. ggo
    21.10.2022 09:59
    +1

    Тут наверно нужно выяснить, а что такое антипаттерн?

    Это нечто, что нежелательно использовать, дабы не поиметь сложностей.

    А что такое паттерн?

    Как не странно, это не антипод для антипаттерна. Недостаточно просто "желательно использовать, чтобы наступило счастье". Там очень важное дополнение - у каждого паттерна есть набор плюсов и минусов. Соответственно, паттерн нужно использовать в случаях подходящих для это паттерна. Тогда когда плюсы проявляются во всей красе, а минусы наоборот есть, но не мешают жить.

    Соответственно, в чем плюсы и минусы ServiceLocator?

    Про минусы подробно рассказали выше. Плюс - простота связывания разных компонент между собой.

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


    1. HexGrimm
      22.10.2022 18:14

      Когда плюсы проявляются сильнее минусов? Для относительно простых приложений.

      Я бы не согласился что так проще. Это вопрос первоначального мышления и проектирования приложения. Либо мыслишь и проектируешь в древовидном графе, либо наоборот, не думая пишешь куски кода, а потом пытаешься их соеденить, и называешь это гибкость. Если всё время писать плохой код то кажется что его легче писать. И чаще всего плохой код как раз в маленьких проектах и обитает, от сюда и неправильный вывод, что сервис локаторы или синглтоны выгодны для мелких проектов.