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

в 11:53, , рубрики: .net, C#, constructor injection, dependency injection, injection

Давайте разберемся с внедрением зависимостей в .Net, так как данная тема является одним из обязательных к изучению для написания качественного, гибкого к изменениям и тестируемого кода. Начнем мы с самих необходимых и базовых паттернов внедрения зависимостей — внедрение через конструктор и через свойство. Итак, поехали!

Внедрение через конструктор

Назначение

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

Описание

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

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

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

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

private readonly IFoo _foo; 
public Foo(IFoo foo)
{
   if (foo == null)
     throw new ArgumentNullException(nameof(foo));
   _foo = foo;
}

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

Хорошей практикой является объявлять поле, хранящее значение зависимости, как «только для чтения» (Read-Only). Так мы гарантируем, что выполняется, причем только однажды, логика инициализации в конструкторе: поле не может быть модифицировано. Это не нужно для реализации внедрения зависимостей, но таким образом код защищается от случайных модификаций поля (например, от установки его значения в null) в каком-то другом месте кода класса.

Когда и как должно использоваться внедрение через конструктор

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

Рассмотрим наиболее лучшие советы и практики по использования внедрения через конструктор:

  • При возможности нужно ограничивать класс одним конструктором
  • Перегруженные конструкторы провоцируют неоднозначности: какой конструктор должно использовать внедрение зависимостей?
  • Не добавляйте в конструктор никакую другую логику
  • Зависимость более нигде в классе не нужно проверять на null, поскольку конструктор гарантирует ее наличие

Достоинства Недостатки
Внедрение гарантировано В некоторых фреймворках сложно задействовать внедрение через конструктор
Простота реализации Требование немедленной инициализации всего графа зависимости (*)
Обеспечение четкого контракта между классом и его клиентами (проще думать о текущем классе, не задумываясь о том, откуда берутся зависимости у более высокоуровневого класса) -
Сложность класса становится очевидно -

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

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

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

Примеры использования

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

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

Два примера, демонстрирующих применение внедрения конструктора в BCL, используют классы System.IO.StreamReader и System.IO.StreamWriter.

Оба они получают экземпляр класса System.IO.Stream в конструктор.

public StreamWriter(Stream stream);
public StreamReader(Stream stream);

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


// Декораторы
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);

Вывод

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

Внедрение через свойство (Property Injection)

Назначение

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

Описание

Как можно разрешить внедрение зависимостей как опцию в классе, если имеется подходящее локальное умолчание?

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

Класс, использующий зависимость, должен иметь записываемое свойство с модификатором public: тип этого свойства должен соответствовать типу зависимости.

public class SomeClass 
{   
   public ISomeInterface Dependency { get; set; } 
}

Здесь SomeClass зависит от ISomeInterface. Клиенты могут передавать реализации интерфейса ISomeInterface через свойство Dependency. Обратите внимание, что в противоположность внедрению конструктора, вы не можете отметить поле свойства Dependency как «только для чтения» (Read Only), так как вызывающей стороне позволено изменять значение этого свойства в любой момент жизненного цикла класса SomeClass.

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

public string DoSomething(string message)
{   
   return this.Dependency.DoStuff(message); 
}

Однако такая реализация является ненадежной, поскольку свойство Dependency не гарантирует возврата экземпляра ISomeInterface. Например, код, показанный ниже, будет генерировать NullReferenceException, так как значение свойства Dependency есть null:

var sc = new SomeClass(); 
sc.DoSomething("Hello world!");

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

public class SomeClass
 {
    private ISomeInterface _dependency;
 
    public SomeClass()
    {
       _dependency = new DefaultSomeInterface();
    }
 
    public ISomeInterface Dependency
    {
       get => _dependency;
       set => _dependency = value ?? throw new ArgumentNullException(nameof(value));
    }
 }

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

Что должно произойти, если клиент попытается изменить значение зависимости в течение жизненного цикла класса?

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

public class SomeClass
{
   private ISomeInterface _dependency;
 
   public ISomeInterface Dependency
   {
      get => _dependency ?? (_dependency = new DefaultDependency());
      set
      {
         //Разрешается только 1 раз определять зависимость
         if (_dependency != null)
           throw new InvalidOperationException(nameof(value));
 
         _dependency = value ?? throw new ArgumentNullException(nameof(value));
      }
   }
}

Создание DefaultDependency может быть отложено до момента, пока свойство не будет запрошено в первый раз. В таком случае произойдет отложенная инициализация. Обратите внимание, что локальное умолчание назначается через сеттер с модификатором public, что обеспечивает выполнение всех защитных блоков. Первый блок защиты гарантирует, что устанавливаемая зависимость не null (можем при использовании словить NRE). Следующий защитный блок отвечает за то, чтобы зависимость была установлена только один раз.

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

Когда следует применять внедрение свойства

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

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

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

  • Использование Property Injection для обязательных зависимостей. Это одна из самых распространенных ошибок использования этого паттерна. Если классу обязательно нужна некоторая зависимость, то ее следует передавать через конструктор, чтобы сразу после создания объекта он был в валидном состоянии.
  • Использование Foreign Default вместо Local Default. Одной из опасностей использования реализации зависимостей по умолчанию является использование конкретной зависимости, расположенной в сборке, о которой наш сервис знать не должен (Foreign Default). Если таких сервисов будет много, то мы получим десятки лишних физических связей, которые усложнят понимание и сопровождение. Реализация по умолчанию должна находиться в той же сборке(Local Default).
  • Сложность. Проблема использования Property Injection для обязательных зависимостей заключается в том, что это очень сильно увеличивает сложность класса. Класс с тремя полями, каждое из которых может быть null приводит к 8 разным комбинациям состояния объекта. Попытка проверить состояние в теле каждого открытого метода приводит к ненужному скачку сложности.
  • Привязанность к контейнеру. В большинстве случаев мы должны использовать контейнер в минимальном количестве мест. Использование Constructor Injection в целом, позволяет этого добиться, поскольку его использование не привязывает ваш класс к какому-то конкретному контейнеру. Однако ситуация меняется при использовании Property Injection. Большинство контейнеров содержат набор специализированных атрибутов для управлением зависимостями через свойства (SetterAttribute для StructureMap, Dependency для Unity, DoNotWire для Castle Windsor и т.д.). Такая жесткая связь не позволит вам «передумать» и перейти на другой контейнер или вообще отказаться от их использования.
  • Write-only свойства. Далеко не всегда мы хотим выставлять наружу свойство, возвращающее зависимость. В этом случае нам придется либо делать свойство только для записи (set-only property), что противоречит общепринятым принципам проектирования на платформе .NET или использовать метод вместо свойства (Setter Method Injection).
    public class SomeClass
    {
       private ISomeInterface _dependency;
     
       public void SetDependency(ISomeInterface dependency)
       {
          _dependency = dependency;
       }
    }

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

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

public class SomeClass
{
   private ISomeInterface _dependency;
 
   public SomeClass() : this(new DefaultSomeInterface())
   { }
 
   public SomeClass(ISomeInterface dependency)
   {
      _dependency = dependency;
   }
}

Заключение

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

Автор: Дзеранов Иосиф

Источник

Поделиться

* - обязательные к заполнению поля