Основы Dependency Injection
Дмитрий БахтенковВнедрение зависимостей
В этой статье мы разберём основные принципы паттерна Dependency Injection, рассмотрим его преимущества и способы применения как в рамках чистого внедрения зависимостей, так и с использованием DI-контейнера. Полноценна она будет понятна тем, кто уже знаком с синтаксисом языка C# и имел опыт создания простых asp.net core приложений.

Основы внедрения зависимостей
Для того, чтобы понять что такое внедрение зависимостей, нужно дать определение слову "зависимость".
В рамках парадигмы ООП мы строим свою программу из различных классов. Одни классы могут использовать другие классы. Например, класс FirstClass использует в своих методах класс SecondClass. Это значит, что FirstClass зависит от SecondClass - ведь если мы что-то поменяем в реализации SecondClass, изменится и поведение FirstClass. Например:
class FirstClass
{
private SecondClass _secondClass;
public FirstClass()
{
_secondClass = new SecondClass();
}
public void FirstMethod()
{
_secondClass.Method("first arg");
}
public void SecondMethod()
{
_secondClass.Method("second arg");
}
}
class SecondClass
{
public void Method(string arg)
{
Console.WriteLine($"Argument: {arg}");
}
}
Первый класс в своих методах использует экземпляры второго класса. Он зависит от второго класса.
Таким образом, SecondClass является зависимостью для FirstClass.
Внедрение зависимостей (Dependency Injection. DI) - это подход к управлению зависимостями, который заключается в предоставлении возможностей управления зависимостями отдельному программному компоненту (например, DI-контейнеру, о котором поговорим позже).
Как мы помним из статьи, между классами может быть отношение либо композиции, когда зависимость создаётся внутри зависимого класса (как на примере), либо агрегации, когда зависимость инициализируется извне, например, в конструкторе класса. Агрегация имеет следующие преимущества перед композицией:
- Не нарушается принцип единой ответственности - при использовании композиции класс отвечает не только за то, для чего он был создан, но ещё и за инициализацию зависимостей
- У зависимого класса появляется возможность зависеть от абстракции, а не от конкретной реализации, что усиливает гибкость и тестируемость программы.
Перепишем FirstClass так, чтобы в нём использовалась агрегация:
class FirstClass
{
private SecondClass _secondClass;
public FirstClass(SecondClass secondClass)
{
_secondClass = secondClass;
}
public void FirstMethod()
{
_secondClass.Method("first arg");
}
public void SecondMethod()
{
_secondClass.Method("secind arg");
}
}
Преимущества использования DI
В книге “Внедрение зависимостей на платформе .NET” Марка Симана выделяются следующие преимущества внедрения зависимостей:
- Позднее связывание - сервисы могут заменяться другими сервисами без перекомпиляции
- Расширяемость - код может быть расширен и повторно использован тем способом, который рвнее не был явно запланирован
- Параллельная разработка - код может разрабытваться несколькими людьми в параллельном режиме
- Сопровождаемость - классы с чётко выраженной ответственностью проще сопровождать
- Пригодность к тестированию - DI и слабая связанность открывают путь к использованию подстановок и заглушек в модульных тестах
Сильная и слабая связанность
Слабая связанность (Low Coupling) - это принцип, который позволяет распределить обязанности между объектами таким образом, чтобы степень связанности между системами оставалась низкой.
Степень связанности (Coupling) - это мера, определяющая, насколько жёстко один элемент связан с другими элементами, либо каким количеством данных о других элементах он обладает
Для создания слабой связанности между классами можно использовать интерфейсы.
Например, мы разрабатываем ПО с функционалом двухфакторной авторизации - сначала по логину и паролю, а затем по коду из почты. Таким образом, у нас есть классы AuthService и EmailService :
class AuthService
{
private EmailService _emailService;
public AuthService(EmailService emailService)
{
_emailService = emailService;
}
public void Auth(string login, string password)
{
// основная логика по авторизации
// отправка кода подтверждения
_emailService.SendConfirmationEmail();
}
}
Это - пример сильной связанности
Для новой версии продукта поступило новое требование - заменить подтверждение по email подтверждением по sms. Разработчик создаёт новый сервис SmsService и добавляет его в сервис авторизации - изменяет конструктор класса, изменяет логику авторизации, возможно ему необходимо написать дополнительный класс-заглушку для этого сервиса, чтобы использовать в юнит-тестах. Работы получается достаточно много.
Что мог сделать разработчик, чтобы уменьшить работу при изменении требований?
Сначала написать интерфейс, а затем первую реализацию EmailService, реализующую его:
interface IMessagingService
{
void SendConfirmation();
// другие методы
}
class EmailService : IMessagingService
{
public void SendConfirmation()
{
// логика отправки сообщения
}
}
И в качестве зависимости использовать уже интерфейс, а реализацию подставлять из корня композиции:
class AuthService
{
private IMessagingService _messagingService;
public AuthService(IMessagingService messagingService)
{
_messagingService = messagingService;
}
public void Auth(string login, string password)
{
// основная логика по авторизации
// отправка кода подтверждения
_messagingService.SendConfirmation();
}
}
После изменения требований, разработчику оставалось бы только реализовать новый сервис для работы с смс и подставить его в корне композиции.
Корень композиции
Любое приложение содержит точку входа, с которой начинается его исполнение. В идеальном случае эта точка содержит всего несколько строк кода, которые сводятся к созданию одного (или нескольких) экземпляров самых высокоуровневых классов, представляющих основную логику приложения. Именно здесь нам придется решить, какие абстракции требуются нашим модулям верхнего уровня, и именно точка входа приложения является идеальным местом для разрешения всех зависимостей.
Конень композиции (Composition Root) - единственное место в приложении, в котором создаются все зависимости и пробрасываются далее по модулям.

У разных типов приложений точка входа тоже разная: в консольном приложении - метод Main, для WPF-приложения - App.xaml и т.д.
Рассмотрим простой пример - консольное приложение, которое выводит сообщение приветствия, с принимением DI:
// интерфейс, описывающий вывод сообщения
interface IMessageWriter
{
public void Write(string message);
}
// класс для вывода приветствия
class Salutation
{
// зависимость класса приветствия - реализация интерфейса
private readonly IMessageWriter _writer;
public Salutation(IMessageWriter writer)
{
_writer = writer;
}
public void PrintHello()
{
_writer.Write("Hello!");
}
}
Перейдём в метод Main:
public static void Main()
{
// создаём зависимость. Это место в программе и будет являться
// корнем композиции
var writer = new ConsoleWriter();
// создаём класс, который будем использовать
var salutation = new Salutation(writer);
// используем класс
salutation.PrintHello();
}
class ConsoleWriter : IMessageWriter
{
public void Write(string message)
{
Console.WriteLine(message);
}
}
Сам класс Salutation не знает, куда он будет выводить сообщение - может быть в консоль, может быть в файл, может ещё куда-то. Это детали, которые мы выносим на более высокий уровень. В методе Main, который является корнем композиции, мы создаём конкретную реализацию IMessageWriter и “внедряем” её в конструктор класса Salutation. Это - чистое внедрение зависимостей.
Жизненный цикл зависимостей
Жизненный цикл зависимости - это время от создания объекта до его уничтожения
Существует три вида жизненного цикла - Singleton, Scoped и Transient.
Немного расширим предыдущий пример консольного приложения: добавим класс Farewell - он будет отвечать за прощание с пользователем
class Farewell
{
private readonly IMessageWriter _writer;
public Farewell(IMessageWriter writer)
{
_writer = writer;
}
public void PrintGoodbye()
{
_writer.Write("Goodbye!");
}
}
Singleton
При использовании жизненного цикла Singleton зависимость создаётся один раз на все время жизни приложения. Например:
public static void Main()
{
// создаём единственный экземляр класса
// ConsoleWriter для использования во
// всех зависимых классах
var writer = new ConsoleWriter();
var salutation = new Salutation(writer);
var farewell = new Farewell(writer)
salutation.PrintHello();
farewell.PrintGoodbye();
}
В данном примере создаётся единственный экземпляр класса ConsoleWriter, который за тем используют классы Salutation и Farewell. Так как объекты передаются по ссылке, эти классы используют один и тот же экземпляр ConsoleWriter.
Transient
При использовании этого жизненного цикла новый объект создаётся всегда, когда создаётся его зависимость, например:
public static void Main()
{
var writerForSalutation = new ConsoleWriter();
var writerForFarewell = new ConsoleWriter();
var salutation = new Salutation(writerForSalutation);
var farewell = new Farewell(writerForFarewell);
salutation.PrintHello();
farewell.PrintGoodbye();
}
В данном примере для каждого класса создаётся отдельный объект зависимости.
Scoped
При использовании жизненного цикла Scoped зависимости создаются на каждый пользовательский запрос. Усложним наше консольное приложение, чтобы рассмотреть этот жизненный цикл на примере:
public static void Main()
{
while(true)
{
var command = Console.ReadLine();
var writer = new ConsoleWriter();
var salutation = new Salutation(writer);
var farewell = new Farewell(writer);
salutation.PrintHello();
if(command == "quit")
{
farewell.PrintGoodbye();
break;
}
}
}
В данном примере пользовательским запросом является ввод команды. На каждой итерации цикла - то есть на каждый пользовательский запрос - будут создаваться новые объекты Salutation и Farewell, однако они используют один и тот же экземпляр класса ConsoleWriter. Это и есть Scoped-зависимости.
DI-контейнеры
DI-контейнер - это библиотека, которая берёт на себя ответственность за управление зависимостями. Подобные контейнеры активно используются для централизованного управления зависимостями в больших приложениях, независимо от целевой платформы - серверные приложения, десктоп и т.д. Мы рассмотрим библиотеку Microsoft.DependencyInjection на примере Asp.Net Core приложения.
В каждом приложени Asp.Net Core есть класс Program, который отвечает за конфигурирование приложения и DI-контейнера (В случае использования версии меньше .NET6, приложение конфигурируется в отдельном классе Startup). За добавление зависимостей в контейнер отвечает свойство IServiceCollection Services класса WebApplicationBuilder .
Объект ServiceCollection - это коллекция объектов ServiceDescriptor, которые имеют три основных свойства:
Type ImplementationType- тип конкретной реализации какого-либо сервисаType ServiceType- тип интерфейса для сервиса (тип данных поля, которое мы будем инициализировать в конструкторе)ServiceLifetime Lifetime- жизненный цикл зависимости - перечислениеSingleton,TransientиScoped
Добавим в наше приложение интерфейс IDateTimeService. Экземпляры этого интерфейса будут отвечать за предоставление текущей даты нашему приложению:
public interface IDateTimeService
{
DateTime UtcNow();
}
И добавим реализацию:
public class DateTimeService : IDateTimeService
{
public DateTime UtcNow()
{
return DateTime.UtcNow;
}
}
Теперь необходимо зарегистрировать сервис в DI-контейнере:
builder.Services.AddTransient<IDateTimeService, DateTimeService>();
Объект IServiceCollection имеет множество методов для удобной регистрации зависимостей - с generic-параметрами, без них и т.д. Мы использовали метод AddTransient<,>, чтобы добавить зависимость с жизненным циклом Transient - потому что дата и время могут меняться со временем выполнения программы, и, соответственно, нам всегда будут нужны новые экземпляры сервиса для получения актуального времени.
Теперь добавим контроллер DateTimeController:
[Route("api/[controller]")]
[ApiController]
public class DateTimeController : ControllerBase
{
private readonly IDateTimeService _dateTimeService;
public DateTimeController(IDateTimeService dateTimeService)
{
_dateTimeService = dateTimeService;
}
[HttpGet]
public DateTime Now()
=> _dateTimeService.UtcNow();
}
Запустим наше приложение и перейдём по адресу /api/datetime - мы увидим текущую дату и время:

При обновлении текущей страницы дата также будет меняться.
Механизм разрешения зависимостей
Тут, собственно, встаёт резонный вопрос - какой механизм подставляет реализации зависимостей в конструктор? Если на примере консольного приложения мы сами создавали нужные реализации, то что в случае использования DI-контейнера?

Во фреймворке asp.net есть интерфейс IControllerActivator, который отвечает за создание экземпляра определённого контроллера, и его стандартная реализация DefaultControllerActivator. Если исследовать исходный код, можно обнаружить, что для создания экземпляров зависимостей для параметров конструктора используется объект IServiceProvider. Этот интерфейс имеет метод GetSetvice - который достаёт зарегистрированный сервис из настроенного контейнера и передаёт его экземпляр в конструктор контроллера. Создание нового экземпляра сервиса происходит примерно по следующему алгоритму:
- Забираем параметры конструктора зависимости
- Для каждого параметра достаём экземляр нужного типа из контейнера
Создание экземпляра каждого параметра также начинается с пункта 1, то есть алгоритм создания зависимости в контейнере рекурсивный.
Мы также можем доставать зависимости вручную:
[Route("api/[controller]")]
[ApiController]
public class DateTimeController : ControllerBase
{
private readonly IServiceProvider serviceProvider;
public DateTimeController(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider;
}
[HttpGet]
public DateTime Now()
{
var dateTimeService = serviceProvider.GetService<IDateTimeService>();
return dateTimeService.UtcNow();
}
}
Также можно создать собственную реализацию IControllerActivator, чтобы изменить логику создания контроллеров, или, например, избавиться от DI вовсе. Добавим класс DateTimeControllerActivator:
public class DateTimeControllerActivator : IControllerActivator
{
public object Create(ControllerContext context)
{
if(context.ActionDescriptor.ControllerTypeInfo.Name == nameof(DateTimeController))
{
return new DateTimeController(new DateTimeService());
}
throw new ArgumentException();
}
public void Release(ControllerContext context, object controller)
{
}
}
Для того, чтобы использовать новый активатор, необходимо зарегистрировать его в контейнере как singleton-зависимость:
builder.Services.AddSingleton<IControllerActivator, DateTimeControllerActivator>();
Теперь снова попробуйте запустить проект в браузере и использовать контроллер.
Антипаттерны DI
Под антипаттерном понимается часто встречающееся решение проблемы, порождающее явные негативные последствия, хотя наряду с ними существуют и другие доступные более безопасные решения.
Применение композиции вместо агрегации
Не следует создавать зависимости с помощью ключевого слова new где-либо, кроме корня композиции:
class SomeService
{
private readonly IOtherService _otherService;
public SomeService()
{
_otherService = new OtherService();
}
}
Это чревато следующими проблемами:
- Сильная связанность - если вам требуется подставить другую реализацию сервиса, придётся дополнительно искать места, где используется композиция. Нельзя будет использовать подмену сервисов без перекомпиляции
- Ухудшается тестируемость - проблематично подставить объект-заглушку в такой конструктор
Этот антипаттерн не означает, что ключевое слово new полностью запрещено, однако в случае нестабильных зависмостей (сервисы для доступа к БД, внешние интеграции, недетерминированные зависимости, такие как классыRandomиDateTime) следует использовать внедрение зависимостей и избегать их прямого использования
Service Locator
Этот антипаттерн возникает при получении отдельных сервисов через универсальный класс. Подобным локатором является зависимость IServiceProvider, которую мы рассматривали выше. При этом важно понимать, что в корне композиции (месте, где разрешаются конкретные зависимости) этот объект в любом случае будет использоваться непосредственно для создания этих зависимостей. Однако вне корня композиции следует избегать этого паттерна по следующим причинам:
- Неявный контракт класса - нельзя однозначно определить, какие сервисы используются в классе, необходимо просмотреть все методы и найти вызов метода сервис-локатора
- При использовании DI-контейнера и внедрения зависимостей в конструктор, библиотека просканирует все классы при запуске и выкинет исключение, если какой-либо сервис не зарегистрирован. В случае использования локатора, ошибка будет только при попытке вызвать нужный сервис.
Пример сервис-локатора:
public class DateTimeController : ControllerBase
{
private readonly IServiceProvider serviceProvider;
public DateTimeController(IServiceProvider serviceProvider)
{
this.serviceProvider = serviceProvider;
}
[HttpGet]
public DateTime Now()
{
var dateTimeService = serviceProvider.GetService<IDateTimeService>();
return dateTimeService.UtcNow();
}
}
Данный антипаттерн является дискусионным, и в некоторых случаях его применение вполне оправдано. Просто учитывайте его недостатки при использовании и анализируйте, какое решение лучше использовать в вашей конкретной ситуации
Ограниченный конструктор
Этот антипаттерн представляет собой ситуацию, в которой для классов создаётся специальный конструктор, например, с определённым порядком или количеством зависимостей. Обычно это делается для того, чтобы создавать зависимости однотипно через механизмы рефлексии:
string connectionString = ConfigurationManager.ConnectionStrings["Context"]; string productRepositoryTypeName = ConfigurationManager.AppSettings["ProductRepositoryType"]; var productRepositoryType = Type.GetType(productRepositoryTypeName, true); // используем рефлексию для динамического создания типов по его названию var repository = (ProductRepository)Activator.CreateInstance(productRepositoryType, connectionString);
Следует избегать данного антипаттерна, а для однотипного создания келассов использовать различные фабрики.
Используемые источники
- [Dependency Injection: анти-паттерны / Хабр (habr.com)](https://habr.com/ru/post/166287/?)
- Programming stuff: Инверсия зависимостей на практике (sergeyteplyakov.blogspot.com)
- “Внедрение зависимостей на .NET”. Марк Симан
С вами был Flex Code