понедельник, 17 декабря 2012 г.

DI Паттерны. Constructor Injection

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

Описание

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

image

Назначение

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

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

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 опять таки принимает Stream var icon = new System.Drawing.Icon(ms);

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

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

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

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

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

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

Правило 4-х зависимостей

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

Использование конструктора для передачи зависимостей является отличным индикатором хорошего дизайна. Как только количество параметров переваливает определенную границу (скажем, 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 {
    
private readonly ICustomServiceObserver
_observer;
    
public CustomService(ICustomServiceObserver observer)
     {
         _observer = observer;
     } }

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

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

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

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

interface ILogger
{
    
void Write(string
message); } class DefaultLogger : ILogger { } class Service {
    
private readonly 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 {
    
private readonly IAuthenticator
_authenticator;
    
public
Service()
         :
this(new DefaultAuthenticator
())
     { }

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

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

Заключение

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

Цикл статей по управлению зависимостями
  1. DI Паттерны. Constructor Injection
  2. Property Injection и необязательные зависимости
  3. Ambient Context
  4. Service Locator

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

  1. Не использую несколько открытых конструкторов для DI.
    Суть в том, что ваш сервис должен декларировать единственный набор зависимостей.
    Ситауция, когда у вас есть один безпараметризованный конструктор, который "пробрасывает" набор стандартных зависимостей в другой - называется Poor Man's dependency injection и является хорошо известным анти-паттерном.

    ОтветитьУдалить
  2. @Вячеслав: на самом деле, это не Poor Man's DI, а Bastard Injection, кроме того, "антипаттерность" этого подхода весьма сомнительна.

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

    Например здесь дается толковый совет по этому поводу (кстати, я привел более или менее тот же аргумент в статье, Bastard Injection - абсолютно ОК, когда мы можем "зарезолвить" зависимость, поскольку она находится в нашей же сборке):

    I really don't think the author is being sensational as you call him, he does make a compelling argument in that if the default instance that is created in the empty constructor is defined in a separate assembly you have just coupled those two dependencies quite tightly. With that argument and unless the default belongs to the same assembly I can totally see why "Bastard Injection" would be an anti-pattern.

    ОтветитьУдалить
  3. @Sergey Teplyakov: что косается терминилогии: то я уже начинаю путаться :)

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

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

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

    public OrdersService(): this(new SqlRepository());

    public OrdersService(IRespository repository);

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

    ОтветитьУдалить
  4. @Вячеслав: да, с паттернами есть путаница.

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

    Если же наша зависимость является стратегией и у нас есть реализация этой стратегии по умолчанию, то в этом нет ничего плохого (пример, использование компарера в классе SortedList).

    ОтветитьУдалить
  5. Сергей, интересно твоё мнение на счёт случая, когда Dependency - это указатель на функцию он же делегат. Иногда мне не очень нравится "инджектить" интерфейсы с одним методом, особенно если этот самый метод не слишком сложен. Ну и разумеется, у меня есть какая-никакая уверенность, что мне нужен именно метод, а не целый интерфейс.

    Тогда мой конструктор принимает делегат. Примерный псевдокод с таким Dependency:

    public abstract class SomeClass
    {
    private Func construct;

    public SomeClass(Func factoryT)
    {
    construct = factoryT;
    }

    //...потом используем construct() для производства объектов типа Т

    }

    В классе мне нужно что-то, что производит объекты Т. Чаще всего юзают абстрактную фабрику, но лично мне кажется как-то уж слишком расточительной фабрика по производству одного-единственного типа объектов.

    Наверное, эту штуку DI контейнерам сложно поддерживать - это наверняка минус.

    ОтветитьУдалить
  6. @kosmoh: по сути, передача делегата - это частный случай наблюдателя (который, в свою очередь, является зависимостью) и я не вижу никакого криминала в его использовании.

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

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

    ОтветитьУдалить
  7. Сереж. Есть несколько уязвимых моментов(относительно Bastard Injection).
    1) Проблема с DI контейнером (создание AUTO-WIRING уже не работает ).
    2) Если ты используешь конструктор с зависимостью только для тестов - все ок, но если ты захочешь (хорошо, не ты, кто-то, кто использует твой класс) использовать через DI - ты фактически нарушишь COMPOSITION_ROOT. Как вариант - конструктор с параметром сделать internal и для тестов сделать InternalsVisibleTo. Так что Poor Man's DI не так уж безопасен даже с учетом LocalDefault.

    Тот же Симен предлагает избавляться от Bastard injection через Property Injection. Я сейчас говорю только про LocalDefault, который в этом случае получится еще и ленивым.

    Я не говорю, что Poor Man DI плох всегда. В случае тестов все ок(при условии LocalDefault). Даже Ayende писал, что делать Poor Man's Di - нормально. Главное не делать бастарда.
    Однако, как ты много раз говорил, лучше сделать так, чтобы пользоваться правильно можно было легко, а неправильно сложно(Я помню, что это Мейерс :)). Отталкиваясь от этого утверждения, я бы не стал так делать. А реализовал бы через PropertyInjection.

    ОтветитьУдалить
  8. Жень, смотри, в целом, я повторюсь, что да, техника достаточно неоднозначная. Первый пример, где я бы ее применял (и применяю) - работа с легаси системами, а которых нет ни композишн рута, ни контейнеров, ни нормальной декомпозиции. Это позволит сделать первые шаги в этом направлении и легко затем избавиться от второстепенных конструкторов.

    Что же касается еще одного случая - то это реализация стратегии по-умолчанию. Тот же SortedList использует именно такой подход: четко разделяя две ответственности.

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

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

    ОтветитьУдалить
  9. Тут коллеги подсказали, что я неправильно понимаю Poor Man's DI. Я как-то всегда считал, что это бедный конструктор, который вставляет зависимости через другой конструктор. Здесь этого нет и поэтому говорить о Poor Man's DI нет смысла вообще.
    К нашим баранам возвращаясь :), поскольку статья у тебя называется DI patterns - то я собственно и писал о недостатках такого подхода с точки DI. Если DI не использовать - то все ок. То, как ты используешь имеет место быть, но подразумевает, что будет следующий шаг рефакторинга(ну как у тебя и написано).
    К тому же, вот что меня смущает в PropertyInjection (ну или в Bastard injection) - это этот пресловутый LocalDefault. Вот сто пудово найдется какой-нибудь Петя, который туда вставит зависимость из другой сборки и получит hard link. Легко использовать неправильно. :(

    ОтветитьУдалить
  10. Блин, перечитал коммент. Понял, что неоднозначно написал. Poor Man's DI - это самописный DI. Велосипед короче.

    ОтветитьУдалить
  11. Кстати, еще один момент, который нужно учитывать при выборе реализации класса, со стратегией по умолчанию (т.е. с Local Default): возможность смены "поведения" после создания объекта.

    Я бы не использовал Property Injection, если зависимость не должна изменяться на проятжении времени жизни объекта (понятно, что можно бросать исключение при повторной попытке установки зависимости). Опять таки, в случае с SortedList-ом было бы нелогичным передавать сортировку через свойство.

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

    ОтветитьУдалить
  12. В целом хорошо раскрыта тема в топике и комментариях.

    Хочется прокомментировать насчет одного конкретного примера - зависимостей на ILoger. Часто наблюдается весьма плачевная ситуация когда все зависимости на ILogger передаются через Property Injection не в последнюю очередь благодаря примерам самих разработчиков DI - http://docs.castleproject.org/Windsor.History.aspx?Page=Windsor.Part-Eight-Satisfying-Dependencies&Revision=9 (там много букв с описанием когда стоит использовать, которые никто не читает). Иногда, случается еще хуже, когда просто идет зависимость на DI container и вызов .Resolve() где-то внутри.

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

    Таким образом мы должны вынести логирование за пределы класса. В зависимости от конкретных потребностей логирования нам потребуется сделать какие-то из следующих действий:
    1. Дополнить результат работы метода(ов) класса данными которые мы хотим залогировать
    2. Изменить класс и разделить то что он делает на более мелкие составные части - что б логировать больше чем работу метода (тут логирование позволяет построить более правильную архитектуру, диктуя дополнительные требования к нашему классу, которые раньше были скрыты)
    3. Бывают случаи когда нужно не просто логировать все, но и нужно логировать прямо сейчас, а не после завершения длительного метода.
    Например - у нас есть такой класс:
    public class NodeAggregator
    {
    public NodeResult[] Aggregate(AggregationExpression ae)
    { //делаем долго,
    //агрегируем по очереди,
    //какую ноду обрабатывать дальше - зависит от ответа предыдущих нод
    }
    }
    мы хотим видеть ответы от разных нод(в логе) сразу как они приходят а не после того как они все пришли.
    В таком случае достаточно поменять сигнатуру метода Aggregete например на:
    public IObservable Aggregate(AggregationExpression ae)

    ОтветитьУдалить
  13. @llytvynenko: я согласен, что *иногда* логирование может "драйвать" дизайн, но я не уверен, что это достаточно распространенная практика.

    Я, например, полностью ОК выносить кэширование в отдельную сущность; в этом плане идеально подходит декоратор, который только и делает, что добавляет кэширование определенных (или всех) операций.

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

    В большинстве же остальных случаев, разделять классы на несколько, только для добавления логирования, ИМХО, многовато.

    Во многих системах (бэкенд) у 70% классов может быть логирование. Увеличить количество сущностей почти в двое может лишь усложнить и без того непростой дизайн. Да и разбивать сущности каждый раз, когда нам нужно залогировать закрытую операцию или лишь один из кейсов, ИМХО, перебор.

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

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