среда, 23 января 2013 г.

Инверсия зависимостей на практике

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

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

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

// Стратегия сортировки
var sortedArray = new SortedList<int, string>(
                            
new CustomComparer());

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

var wcfTracer = new WcfTracer(Logger.GetLogger());

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

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

ПРИМЕЧАНИЕ
Может показаться, что этот совет противоречит общепринятым DI-практикам, однако это не так. Помните разговор об стабильных и изменчивых зависимостях? Все дело в том, что «изменчивость» зависимости зависит от контекста: для одного уровня – это изменчивая зависимость от реализации которой нам нужно «абстрагироваться», а для другого уровня – это стабильная зависимость, которую можно использовать напрямую.
Любая зависимость – это шов на теле приложения. Чем он будет короче, тем легче его скрыть и от него избавиться; некоторые зависимости являются деталями реализации модуля и выставление их наружу может нарушить инкапсуляцию модуля.

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

Достаточно очевидно, что такое перекладывание ответственности не может продолжаться вечно, поскольку кто-то в конечном итоге должен принять решение о том, какие конкретные классы соответствуют каким интерфейсам приложения. В разных типах приложений это место выглядит по разному, но не зависимо от этого, оно имеет одно название – Composition Root.

ПРИМЕЧАНИЕ
И опять может показаться, что проблема постоянного перекладывания ответственности имеет простое решение – использование контейнера напрямую. В результате контейнер будет выступать в роли Service Locator-а, что многими экспертами считается анти-паттерном (и подробнее об этом мы поговорим в одной из следующих заметок).

Composition Root

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

CompositionRoot

У разных типов приложений точка входа выглядит по разному: для консольного приложения – это метод Main, для WPF приложения – App.xaml, для веб приложения – Global.asax, для NT сервиса – класс наследник ServiceBase и т.д. Не зависимо от типа приложения, именно точка входа приложения является «точкой невозврата», когда откладывать решение о зависимостях на потом уже невозможно.

Независимо от того, какая стратегия конфигурирования выбрана (код, xml-конфигурация, convention-based), именно в Composition Root должна располагаться логика конфигурирования приложения. И, по сути, это должно быть единственным местом использования самого контейнера:

var container = BuildContainer();
var rootModule = container.Resolve<RootModule>();
// используем rootModule
container.Release(rootModule);

В реальном приложении процесс конфигурирования контейнера будет несколько сложнее. Даже средней сложности приложение может содержать десятки, если не сотни зависимостей, конфигурирование которых в одном месте сделает Composition Root слишком сложным. В этом плане могут помочь DI-контейнеры, большая часть которых поддерживают идею модульности (Modules в autofac, Installers в Windsor и т.д.). Использование модулей позволит разбить крупное приложение на высокоуровневые компоненты, каждый из которых будет уже правильно сконфигурирован.

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

Ambient Context

Существуют некоторые зависимости, которые используются десятками классов. В этом случае, попытка передать их через конструкторы приведет к тому, что каждый класс будет содержать не менее 3-х – 4-х параметров, а классы более высокого уровня будут вынуждены знать о десятке зависимостей, требуемых на несколько уровней ниже. В этом случае можно воспользоваться паттерном под названием Ambient Context (глобальный или внешний контекст).

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

interface ILogger {}
class LogManager
{
    
public static ILogger
GetLogger() {} } interface IJobProvider {} class JobProvider {
    
static
JobProvider()
     {
         Provider =
new DefaultJobProvider
();
     }
    
public static IJobProvider Provider { get; set; } }

Этот паттерн интенсивно используется в .NET Framework (SynchronizationContext.Current, Thread.CurrentThread, Thread.CurrentPrincipal, HttpContext.Current и т.д.) и применяется для установки нужного окружения для выполнения так называемых «сквозных задач» (cross-cutting concerns), связанных с транзакциями, безопасностью и т.п. Но он может применяться и для других инфраструктурных зависимостей (например, для кастомного логгера или кастомной безопасности), а также для некоторого типа бизнес задач.

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

Несмотря на эти особенности Ambient Context обладает и главным недостатком, присущим синглтону и Service Locator-у: неявностью. Чтобы понять, что некоторый класс использует зависимость через Ambient Context нужно проанализировать весь код класса (как мы увидим позднее, есть ряд приемов, чтобы по крайней мере уменьшить эту проблему).

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

Практические рекомендации

Если бы мы жили в идеальном мире, то все зависимости передавались бы через конструкторы, а решение о том, каким абстракциям соответствуют какие реализации, принималось бы только в одном месте – в Composition Root. Но в реальном мире приложения обладают высокой связанностью, используются кучи синглтонов, да и абстракции далеки от идеала. Для решения или смягчения этих проблем я использую ряд приемов, которые могут помочь и вам.

Выделяйте нужное количество интерфейсов

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

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

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

Получайте зависимости в конструкторе

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

class MainViewModel
{
    
private readonly IJobProvider _jobProvider = JobProvider
.Provider;
    
private readonly IJobConsumer _jobConsumer = new TaskBasedJobConsumer
();

    
public void
Start()
     {
        
var editViewModel = new EditViewModel
(_jobProvider);
        
var window = new Window { DataContext = editViewModel };
         window.ShowDialog();
     } }

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

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

Поднимайте уровень абстракции зависимостей

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

Находите скрытые абстракции

Не «залипайте» на существующем наборе абстракций. Тот факт, что вашему классу требуется 3-4 интерфейса говорит о том, что в вашем дизайне не хватает еще одной зависимости или конкретного класса. Возможно, стоит скрыть все четыре зависимости за одним фасадом и протаскивать уже именно его. А может быть стоит вообще воспользоваться наблюдателем и пусть высокоуровневый класс решает, как его реализовать.

Заключение

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

Думайте, что полезно в каждом конкретном случае и управляйте зависимостями разумно.

12 комментариев:

  1. Здравствуйте, Сергей.
    Вы пишете:
    "Чтобы понять, что некоторый класс использует зависимость через Ambient Context нужно проанализировать весь код класса (как мы увидим позднее, есть ряд приемов, чтобы по крайней мере уменьшить эту проблему)."

    Далее в статье я не обнаружил приемов для этой проблемы. Я что-то упустил или "позднее" стоит понимать, как в следующей статье?

    ОтветитьУдалить
  2. Сергей, огромное спасибо за статью (для остальных: я тот человек, который задавал вопрос). Всё начинает становиться на свои места! :-)

    P.S. У тебя предложение не согласованно: низкоуровневые классы принимают зависимость через конструктор, которые затем «резолвится» как можно раньше.

    ОтветитьУдалить
  3. Мне кажется, есть несколько спорных моментов:
    1) Даже средней сложности приложение может содержать десятки, если не сотни зависимостей, конфигурирование которых в одном месте сделает Composition Root слишком сложным.

    С моей точки зрения основной недостаток здесь - это не объем и сложность конфига, а информация обо ВСЕХ типах, которые используются при конфигурировании. Получается какой-то God Object - знаю все и обо всех. Что не есть хорошо.

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

    Очень смело я бы сказал :). В самом простом случае даже одна реализация помогает избегать cycling reference. Я не говорю уже о тестах.

    3) Не «залипайте» на существующем наборе абстракций. Тот факт, что вашему классу требуется 3-4 интерфейса говорит о том, что в вашем дизайне не хватает еще одной зависимости или конкретного класса. Возможно, стоит скрыть все четыре зависимости за одним фасадом и протаскивать уже именно его.

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

    Кстати, если уж заговорили про контейнеры здесь, то можно было бы дать ссылочку на http://blog.ploeh.dk/2010/09/29/TheRegisterResolveReleasePattern.aspx. По-моему коротку и исчерпывающе.

    Я не увидел в твоих статьях, ты упоминал, почему code configuration более предпочтительно чем скажем xml configuration. Лично мне это показалось интересным.

    И да, статья понравилась :).

    ОтветитьУдалить
  4. @Анатолий: Чтобы смягчить эту проблему я получаю зависимость не в месте ее использования, а в конструкторе. В этом случае, чтобы понять, какие зависимости есть у класса достаточно просмотреть его поля и конструктор, а не каждый метод.

    Я это показываю в примере в разделе "Получайте зависимости в конструкторе". Я немного обновил текст, чтобы акцент был понятнее.

    @Kostiantyn: не за что!
    Предлоение подправил.

    ОтветитьУдалить
  5. @eugene: я бы сказал, что это не столько спорные моменты, сколько "контекстно-зависимые":

    1) пробема с God Object-ом в его сложности и монолитности, а значит хрупкостью; такой подход также будет противоречить основной цели DI, которая заключается в упрощении сопровождаемости. ИМХО, мы говорим об одном и том же, только только разными словами.

    2) тестируемость - безусловно важная тема. Но мне кажется, что при выделении интерфейсов нужно еще и играть в адвоката дьявола с собой. Я тут недавно выложил простенькую библиотеку для тестирования поведения с Microsoft Fakes, под названием VerificationFakes, она отлично покрыта тестами и содержит всего несколько интерфейсов.

    Это я все к тому, что интерфейсы полезны, но и у них есть своя цена.

    3) В DDD Эванса есть хороший совет о поиске пропущенных абстракций, и тот факт, что классу нужно 4 (!) внешние стратегии говорит либо о нарушении SRP, либо о скрытой абстракции, которая заменит все 4 интерфейса. Я не увеличиваю количество абстракций, я предлагаю бороться со сложностью.

    Да, я не писал о конфигурации в коде vs xml конфигурироции, хз почему, как-то не попала эта тема под руку.

    ОтветитьУдалить
  6. "Да, я не писал о конфигурации в коде vs xml конфигурироции, хз почему, как-то не попала эта тема под руку."

    Да, было бы очень интересно Ваше мнение по этому вопросу.

    ОтветитьУдалить
  7. @Анатолий: там, вроде бы, и тема небольшая, так что постараюсь осветить ее в одном из будущих постов.

    ОтветитьУдалить
  8. Спасибо за цикл статей.
    Из упоминавшегося уже в комментариях блога я когда-то переводил статью Service Locator - паттерн или антипаттерн? (оригинал: Mark Seemann, Service Locator is an Anti-Pattern)

    ОтветитьУдалить
  9. Чем Ambient Context отличается от Service Locator?

    ОтветитьУдалить
    Ответы
    1. Ambient Context - это изменяемый синглтоно-подобный тип, который не говорит, что же он будет возвращать.
      Service Locator - это способ протаскивания вместо конкретных зависимостей самого контейнера, либо явно через конструктор, либо через неизменяемый синглтон.

      Удалить
  10. Хорошая статья, Серёж! Инверсия зависимостей становится всё более популярной и твой пост помогает немного лучше понять, когда и как лучше нужно (или не нужно) её использовать.

    ОтветитьУдалить