Паттерны внедрения зависимостей
    Сообщений 9    Оценка 40 [+1/-1]         Оценить  
Система Orphus

Паттерны внедрения зависимостей

Автор: Тепляков Сергей Владимирович
Опубликовано: 23.10.2015
Исправлено: 10.12.2016
Версия текста: 1.1

Constructor Injection
Описание
Назначение
Применимость
Преимущества
Правило четырех зависимостей
Необязательные зависимости
Property Injection
Описание
Назначение
Применимость
Известные применения
Предостережения
DI-паттерны. Method Injection
Описание
Назначение
Применимость
Известные применения
Ограничения
DI Паттерны. Service Locator
Описание
Назначение
Применимость
Недостатки Service Locator
Заключение

На сегодняшний день существует два типовых отношения к практикам инверсии зависимостей (Dependency Injection, DI). С одной стороны, есть ярые приверженцы, которые даже консольное приложение не смогут сделать без своего любимого контейнера. С другой стороны, есть и ярые противники, которые считают DI-контейнеры величайшим злом современной разработки ПО, и предпочтут сгореть на огне, но не воспользоваться принципами инверсии зависимостями.

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

Constructor Injection

Когда речь заходит за внедрение зависимостей (Dependency Injection), то у большинства разработчиков в голове возникает образ конструктора, через который эта зависимость передается в виде интерфейса или абстрактного класса. Именно об этом виде управления зависимостей писал Боб Мартин в своей статье Dependency Injection Principle, поэтому не удивительно, что он является самым известным.

Описание

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


Назначение

Разорвать жесткую связь между классом и его обязательными зависимостями.

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

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

Так, декоратор использует внедрение зависимости через конструктор; стратегия передается через конструктор или «внедряется» в нужный метод; команда может передаваться в качестве параметра, или же может принимать через конструктор окружающий контекст. Абстрактная фабрика зачастую передается через конструктор и по определению реализуется через интерфейс или абстрактный класс; паттерн Состояние принимает в качестве зависимости необходимый контекст и т.д.

Даже в составе .NET Framework существует множество примеров внедрения зависимостей через конструктор:

        // Декораторы
        var ms = new MemoryStream();
var bs = new BufferedStream(ms);
 
// Стратегия сортировкиvar sortedArray = new SortedList<int, string>(
                         new CustomComparer());
 
// Класс ResourceReader принимает Stream
Stream ms = new MemoryStream();
var resourceReader = new ResourceReader(ms);
 
// BinaryReader/BinaryWriter, StreamReader/StreamWriter// также принимают Stream через конструкторvar textReader = new StreamReader(ms);
 
// Icon опять таки принимает Streamvar icon = new System.Drawing.Icon(ms);

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

Применение инверсии зависимостей особенно актуально на границе модулей. Избитым, но, тем не менее, вполне актуальным примером может быть внедрение стратегии доступа к данным (интерфейса IRepository или IQueryable<T>) в более высокоуровневые слои приложения. Аналогичным образом можно «абстрагироваться» от любого набора операций, конкретная реализация которых вам не интересна.

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

Преимущества

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

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

Правило четырех зависимостей

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

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

Это может говорить о том, что не выделены скрытые абстракции. Не нужно останавливаться на текущем количестве классов и интерфейсов; если класс использует по одному методу из пяти зависимостей, то в текущем дизайне явно чего-то не хватает. Подумайте, может быть стоит выделить эти пять методов в самостоятельный класс или интерфейс, и передавать уже его в виде одной новой зависимости? Раз вашему классу это нужно, вполне возможно, у нас присутствует сущность, о которой раньше не думали.

        interface IDependency1 {}
interface IDependency2 {}
interface IDependency3 {}
interface IDependency4 {}

class CustomService
{
    public CustomService(IDependency1 d1, IDependency2 d2,
                         IDependency3 d3, IDependency4 d4)
    {}
}

Выделяем все 4 зависимости в новый LowLevelService:

        class CustomService
{
    public CustomService(ILowLevelService lowLevelService)
    {}
}

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

ПРИМЕЧАНИЕ

ПРИМЕЧАНИЕ

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

Иногда бывает проще воспользоваться банальным наблюдателем и парой событий, нежели завязываться на 100500 абстракций. Ведь наблюдатель может использоваться в двух случаях: (1) когда вашему классу есть, что сказать, или (2) когда вашему классу что-то нужно от внешнего окружения, и вы хотите получить дополнительную информацию или запросить выполнение некоторых действий, если связность (cohesion) полученных методов очень слабая и выделить новую абстракцию не удается:

        interface ICustomServiceObserver
{
    // Добавляем нужные операции
}

class CustomService
{
    privatereadonly ICustomServiceObserver _observer;

    public CustomService(ICustomServiceObserver observer)
    {
        _observer = observer;
    }
}

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

Необязательные зависимости

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

Обычно для передачи такого типа зависимостей принято использовать Property Injection (который мы рассмотрим в следующих постах), однако никто не запрещает сделать интерфейс нашего класса более явным и воспользоваться для этого передачей зависимости через конструктор:

        interface ILogger
{
    void Write(string message);
}

class DefaultLogger : ILogger
{}

class Service
{
    privatereadonly ILogger _logger = new DefaultLogger();

    public Service()
    {}

    public Service(ILogger logger)
    {
        _logger = logger;
    }
}

Кто-то может сказать, что такое решение противоречит самой идее передаче параметров через конструктор, на что получит контрпример, поскольку такое довольно часто применяется в существующем коде, и в .NET Framework, в частности (например, в классе Icon).

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

        interface IAuthenticator
{
    void Authenticate(string userName);
}

// Находится в той же сборке, что и сервисclass DefaultAuthenticator : IAuthenticator
{ }

class Service
{
    privatereadonly IAuthenticator _authenticator;

    public Service()
        : this(new DefaultAuthenticator())
    { }

    // Используем в тестахinternal Service(IAuthenticator authenticator)
    {
        _authenticator = authenticator;
    }
}

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

Независимо от того, используете ли вы DI-контейнеры или нет, Constructor Injection должен быть первым способом управления зависимости. Он не только позволяет сделать отношения между классами более явными, но также позволяет определить проблемы с дизайном, когда количество параметров конструктора превысит определенную границу.

Property Injection

Еще одним достаточно популярным паттерном внедрения зависимостей является Property Injection, который заключается в передаче нужных зависимостей через “setter” свойства. Все современные DI-контейнеры в той или иной мере поддерживают этот паттерн, что делает его использование достаточно простым. Я рекомендую быть осторожным с этим паттерном, поскольку с точки дизайна передача зависимостей через свойства усложняет использование, понимание и поддержку.

Но давайте обо всем по порядку.

Описание

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

        // Dependency.dll
        public
        interface Idependency {}

// CustomService.dll (!)internalclass DefaultDependency : IDependency
{}

// CustomService.dllpublicclass CustomService
{
    public CustomService()
    {
        Dependency = new DefaultDependency();
    }

    public IDependency Dependency { get; set; }
}

Назначение

Разорвать жесткую связь между классом и его необязательными зависимостями.

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

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

Использование любых принципов и паттернов не должно противоречить основным принципам ООП. Попытка передачи обязательной зависимости через свойство нарушает инвариант класса; такой класс становится сложно использовать правильно и легко использовать неправильно.

ПРИМЕЧАНИЕ

ПРИМЕЧАНИЕ

Хорошим примером необязательной зависимости является интерфейс IComparer of T для класса SortedList of T. И хотя в этом случае существует реализация зависимости по умолчанию (DefaultComparer), передача ее через свойство выглядит весьма подозрительным, поскольку comparer не должен изменяться с течением времени жизни объекта SortedList.

Использование этого паттерна возможно лишь в том случае, когда класс сервиса (CustomService) знает о реализации зависимости по умолчанию (DefaultDependency), поскольку она находится в сборке сервиса или в сборке, где зависимость объявлена (реализации называют Local Default). Подобную технику нельзя использовать, когда реализация по умолчанию располагается в сборке, о которой классу сервиса ничего не известно (так называемые Foreign Default Dependencies). В таком случае использование зависимости приведет к более тесной связи между классом сервиса конкретной реализацией зависимости.

Известные применения

Существует десятки примеров использования Property Injection в .NET Framework, просто далеко не всегда мы обращаем внимание, что сталкиваемся с известным паттерном. Вот несколько примеров:

  1. Свойство DataContext для привязки данных в WPF:
      var view = new ErrorMessageWindow() { DataContext = viewModel };
  1. Суррогаты для кастомной сериализации объектов:
      var formatter = new BinaryFormatter();
var ss = new SurrogateSelector();
// Добавляем в SurrogateSelector нужный суррогат// Передаем SurrogateSelector через свойство
formatter.SurrogateSelector = ss;
  1. Многие точки расширения в WCF:
      public class CustomEndpointBehavior : IEndpointBehavior
{
     public void ApplyDispatchBehavior(ServiceEndpoint endpoint, 
        EndpointDispatcher endpointDispatcher)
     {
        // Передаем CustomOperationSelector через свойство
        endpointDispatcher.DispatchRuntime.OperationSelector = 
            new CustomOperationSelector();
     }

     
    // Остальные методы опущены для простоты
}
  1. Свойства SelectCommand, InsertCommand, DeleteCommand и UpdateCommand интерфейса IDbDataAdapter:
      var command = new OleDbCommand(query, connection);
var adapter = new OleDbDataAdapter();
 
// Передаем SelectCommand через свойство
adapter.SelectCommand = command;

Предостережения

1. Использование Property Injection для обязательных зависимостей.

Это одна из самых распространенных ошибок использования этого паттерна. Если нашему классу обязательно нужна некоторая зависимость, то ее следует передавать через конструктор, чтобы сразу после создания объекта он был в валидном состоянии. Бывают случаи, когда это невозможно (например, инфраструктура может требовать конструктор по умолчанию), в остальных же случаях следует применять более подходящие техники передачи зависимостей (например, Constructor Injection).

2. Использование Foreign Default вместо Local Default.

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

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

3. Сложность.

Проблема использования Property Injection для обязательных зависимостей заключается в том, что это очень сильно увеличивает сложность класса. Класс с тремя полями, каждое из которых может быть null приводит к 8 разным комбинациям состояния объекта. Попытка проверить состояние в теле каждого открытого метода приводит к ненужному скачку сложности.

Но даже при использовании с необязательными зависимостями, сложность реализации класса с Property Injection выше, чем с Constructor Injection. Большинство примеров внедрения зависимости через свойства используют автосвойства, но в реальном коде это приведет к добавлению проверки на null при доступе к зависимости или к использованию обычного свойства с проверкой нового значения на null.

Еще одним аспектом, который нужно учитывать является то, что далеко не каждая зависимость, передаваемая через свойства, может изменяться во время жизни объекта. Если это так (а очень часто мы не можем поменять стратегию сортировку после создания объекта), то логика setter-а еще усложняется, поскольку необходимо реализовать write-once паттерн.

      public IDependency Dependency
{
    get { return _dependency; }
    set
    {
        if (value == null)
            thrownew ArgumentNullException("value");
        if (_dependencyWasChanged)
            thrownew InvalidOperationException("You can set dependency only once.");

        _dependency = value;
        _dependencyWasChanged = true;
    }
}

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

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

4. Auto-wiring

Некоторые контейнеры (такие как Castle Windsor) автоматически устанавливают все зависимости через свойства, доступные для записи. Такая неявность может привести к нежелательным последствиям, поскольку вносит дополнительную связанность между вашим классом и местом инициализации контейнера.

5. Привязанность к контейнеру

В большинстве случаев мы можем (и должны) использовать контейнер в минимальном количестве мест. Использование Constructor Injection в целом позволяет этого добиться, поскольку его использование не привязывает ваш класс к какому-то конкретному контейнеру.

Однако ситуация меняется при использовании Property Injection. Большинство контейнеров содержат набор специализированных атрибутов для управления зависимостями через свойства (SetterAttribute для StructureMap, Dependency для Unity, DoNotWire для Castle Windsor и т.д.). Такая жесткая связь не позволит вам «передумать» и перейти на другой контейнер или вообще отказаться от их использования.

6. Write-only свойства

Далеко не всегда мы хотим выставлять наружу свойство, возвращающее зависимость. В этом случае нам придется либо делать свойство только для записи (set-only property), что противоречит общепринятым принципам проектирования на платформе .NET, либо использовать метод вместо свойства (использовать так называемый Setter Method Injection).

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

Альтернативы

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

      public
      class CustomService
{
    privatereadonly IDependency _dependency;

    public CustomService()
        : this(new DefaultDependency())
    {}

    public CustomService(IDependency dependency)
    {
        _dependency = dependency;
    }
}

Этот подход не всеми признается, например, Марк Сииман, автор книги Dependency Injection in .NET называет его Bastard Injection и считает одним из анти-паттернов, не смертельным, но все же. Я не вижу особой проблемы в этом подходе, тем более, что он интенсивно используется в .NET Framework (по сути, он подходит везде, когда класс принимает в качестве зависимости стратегию с реализацией по умолчанию).

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

Property Injection. Заключение

Использование инструментов (например, контейнеров) или принципов разработки (например, DI) не должны сильно влиять на дизайн ваших классов. Полноценная реализация внедрения зависимостей через свойства приводит к слишком большому количеству вопросов, на которые вам придется ответить при реализации.

Property Injection идеально подходит для необязательных зависимостей или для классов с циклическими зависимостями. Они вполне подойдут для стратегий с реализацией по умолчанию (мы видели довольно много примеров использования этого паттерна в .NET Framework), но все равно, я бы рекомендовал использовать Constructor Injection и рассматривал другие варианты только в случае необходимости.

DI-паттерны. Method Injection

Существует две разновидности паттерна под названием Method Injection. В некоторых случаях под этим паттерном понимается установка зависимостей объекта с помощью вызова метода:

      public interface IDependency
{
}
 
public class CustomService
{
    private IDependency _dependency;
 
    public void SetDependency(IDependency dependency)
    {
        _dependency = dependency;
    }
}

По сути, этот паттерн аналогичен рассмотренному ранее паттерну Property Injection с закрытым геттером, со всеми преимуществами и недостатками. Он применяется в языках без встроенной поддержки свойств, а также может успешно применяться в языке C#, если вам это больше нравится.

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

Описание

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

Назначение

Предоставить классу сервиса дополнительную информацию для выполнения определенной задачи.

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

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

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

1. Метод является статическим и другие варианты не подходят.

          public interface ICurrencyRate
{
    int GetCurrencyRate(string currency);
}
 
// PaymentServicepublic static Money CalculatePayment(ICurrencyRate currencyRate)
{
    return new Money();
}

В этом же контексте используется IFormatProvider в методе double.Parse и других аналогичных методах. Иногда этот подход применим и для бизнес-объектов, и может использоваться, например, для статической фабрики или других подобных целей.

2. Зависимость может изменяться от операции к операции.

Существует вариант паттерна Стратегия, при котором эта стратегия не может быть передана в аргументах конструктора, поскольку она требуется лишь одному методу и может изменяться от вызова к вызову. Классическим примером такой стратегии может служить стратегия сортировки, передаваемая методу List<T>.Sort().

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

          // Задает «стратегию» форматирования отчета
          public interface IReportFormatter
{
    string GetFormatString();
}
 
// ReportServicepublic string CreateReport(IReportFormatter reportFormatter)
{
    // Используем reportFormatter для формирования отчетаreturn default(string);
}

3. Передача локального контекста для выполнения операции.

Ряд паттернов проектирования, такие как Команда, Состояние и некоторые другие, может использовать дополнительный внешний контекст для выполнения операции. Этот же подход интенсивно используется в многопоточном программировании, когда в поток (или таймер) передается дополнительный контекст, известный вызывающему коду.

          public interface ICommandContext
{
    int ProcessorCount { get; }
}
 
// CustomCommandpublic void Execute(ICommandContext context)
{}

Известные применения

В составе .NET Framework этот подход используется достаточно интенсивно, как в контексте локальных стратегий, так и в контексте передачи контекста исполнения.

Локальные стратегии

IFormatProvider provider = new NumberFormatInfo {NumberDecimalSeparator = ";"};
// Задаем "стратегию" разбора doublevar value = double.Parse("1;1", provider);
 
IComparer<int> comparer = Comparer<int>.Default;
var list = new List<int> {3, 4, 1};
// Передаем "стратегию" сортировки
list.Sort(comparer);
 
var task = Task.Run(() => { });
TaskScheduler taskScheduler = TaskScheduler.Current;
// Задаем "стратегию" запуска "продолжения" задачи
task.ContinueWith(t => { },
                    taskScheduler);

Команды в WPF

ICommand command = new RelayCommand();
// В реальности сюда могут приходить данные, необходимые// для выполенния команды
command.Execute("value");

Дополнительный контекст в многопоточности

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

          // Использование контекста для передачи данных в другой поток.
          var context = new CustomViewModel();
var thread = new Thread(o =>
{
    var localContext = (CustomViewModel) o;
});
thread.Start(context);
 

Ограничения

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

Кроме того, к этому паттерну нужно отнестись с осторожностью, если он применяется потому, что некоторая зависимость нужна лишь одному методу в классе, и нам лень из-за этого протаскивать ее через конструктор. Иногда этот подход оправдан, но он также может означать низкую внутреннюю связность (low cohesion) класса и нарушение Принципа Единой Обязанности: точно ли нормально, что некоторая зависимость нужна лишь одной операции и не нужна другим методам? Может, здесь скрыто два класса?

В отличие от Constructor Injection и Property Injection данный паттерн имеет более «локальный» характер и не является типовым паттерном управления зависимостями в приложении. Тем не менее, если под зависимостями понимать «локальный» контекст или локальную стратегию, то этот паттерн вполне применим на практике.

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

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

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

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

Описание

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


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

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

Назначение

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

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

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

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

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

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

        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);
    }
 
}

Service Locator довольно популярен, но у него есть ряд фатальных существенных недостатков.

Недостатки Service Locator

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

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

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

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

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

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

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

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


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

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

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

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

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

ПРИМЕЧАНИЕ

ПРИМЕЧАНИЕ

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

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

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

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

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

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

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

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

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

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

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

          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 то, что он дает видимость хорошего дизайна. У нас никто не знает о конкретных классах, все завязаны на интерфейсы, все «нормально» тестируется и «расширяется». Но когда вы попробуете использовать ваш код в другом контексте, или когда кто-то попробует использовать его повторно, вы с ужасом поймете, что у вас есть дикая «логическая» связанность, о которой вы и не подозревали.

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

Заключение

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

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


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 9    Оценка 40 [+1/-1]         Оценить