У нас готовится к выходу второе издание легендарной книги Марка Симана, Внедрение зависимостей на платформе .NET
Поэтому сегодня мы решили кратко освежить тему внедрения зависимостей для специалистов по .NET и C# и предлагаем перевод статьи Грэма Даунса, где эта парадигма рассматривается в контексте инверсии управления (IoC) и использования контейнеров
Большинству программистов, пожалуй, известен феномен Внедрение зависимостей (Dependency Injection), но не всем понятно, какой смысл в него вкладывается. Вероятно, вам приходилось иметь дело с интерфейсами и контейнерами, и иногда работа с ними приводила вас в тупик. С другой стороны, вы, возможно, только что-то слышали о внедрении зависимостей, и открыли эту статью, так как хотите лучше разобраться в их сути. В этой статье я покажу, насколько проста концепция внедрения зависимостей, и какой мощной она может быть на самом деле.
Внедрение зависимостей это самодостаточный подход, который можно использовать сам по себе. С другой стороны, этот подход можно применять и вместе с интерфейсами, и с контейнерами для внедрения зависимостей/инверсии управления (DI/IoC). Применяя внедрение зависимостей в таком контексте, можно столкнуться с некоторой путаницей, которую поначалу испытывал и я.
На протяжении всей карьеры (я специализируюсь на разработке в Net/C#), я привык использовать внедрение зависимостей в его чистейшей форме. При этом я реализовывал DI, вообще не прибегая ни к контейнерам, ни к инверсии управления. Все изменилось совсем недавно, когда мне поставили задачу, в которой без использования контейнеров было не обойтись. Тогда я крепко усомнился во всем, что знал ранее.
Поработав в таком стиле несколько недель, я осознал, что контейнеры и интерфейсы не осложняют внедрение зависимостей, а, наоборот, расширяют возможности этой парадигмы.
(Здесь важно отметить: интерфейсы и контейнеры используются только в контексте внедрения зависимостей. Внедрение зависимостей можно реализовать и без интерфейсов/контейнеров, но, в сущности, единственное назначение интерфейсов или контейнеров облегчить внедрение зависимостей).
В этой статье будет показано, как делается внедрение зависимостей, как с интерфейсами и контейнерами, так и без них. Надеюсь, что, дочитав ее, вы будете четко представлять принцип работы внедрения зависимостей и сможете принимать информированные решения о том, когда и где прибегать к использованию интерфейсов и контейнеров при внедрении зависимостей.
Подготовка
Чтобы лучше понять внедрение зависимостей в их чистейшей форме, давайте разберем пример приложения, написанного на C#.
Для начала отметим, что такое приложение можно было бы написать без какого-либо внедрения зависимостей. Затем мы введем в него внедрение зависимостей, добавив простую возможность логирования.
По ходу работы вы увидите, как требования, предъявляемые к логированию, постепенно усложняются, и мы удовлетворяем эти требования, используя внедрение зависимостей; при этом зона ответственности класса
Calculator
сводится к минимуму.
Внедрение зависимостей также избавит нас от необходимости
видоизменять класс Calculator
всякий раз, когда мы
захотим поменять устройство логирования.Приложение
Рассмотрим следующий код. Он написан для простого приложения-калькулятора, принимающего два числа, оператор и выводящего результат. (Это простое рабочее приложение для командной строки, поэтому вам не составит труда воспроизвести его как C# Console Application в Visual Studio и вставить туда код, если вы хотите следить за развитием примера. Все должно работать без проблем.)
У нас есть класс
Calculator
и основной класс
Program
, использующий его.Program.cs:
using System;using System.Linq;namespace OfferZenDiTutorial{ class Program { static void Main(string[] args) { var number1 = GetNumber("Enter the first number: > "); var number2 = GetNumber("Enter the second number: > "); var operation = GetOperator(); var calc = new Calculator(); var result = GetResult(calc, number1, number2, operation); Console.WriteLine($"{number1} {operation} {number2} = {result}"); Console.Write("Press any key to continue..."); Console.ReadKey(); } private static float GetNumber(string message) { var isValid = false; while (!isValid) { Console.Write(message); var input = Console.ReadLine(); isValid = float.TryParse(input, out var number); if (isValid) return number; Console.WriteLine("Please enter a valid number. Press ^C to quit."); } return -1; } private static char GetOperator() { var isValid = false; while (!isValid) { Console.Write("Please type the operator (/*+-) > "); var input = Console.ReadKey(); Console.WriteLine(); var operation = input.KeyChar; if ("/*+-".Contains(operation)) { isValid = true; return operation; } Console.WriteLine("Please enter a valid operator (/, *, +, or -). " + "Press ^C to quit."); } return ' '; } private static float GetResult(Calculator calc, float number1, float number2, char operation) { switch (operation) { case '/': return calc.Divide(number1, number2); case '*': return calc.Multiply(number1, number2); case '+': return calc.Add(number1, number2); case '-': return calc.Subtract(number1, number2); default: // Такого произойти не должно, если с предыдущими валидациями все было нормально throw new InvalidOperationException("Invalid operation passed: " + operation); } } }}
Главная программа запускается, запрашивает у пользователя два числа и оператор, а затем вызывает класс
Calculator
для
выполнения простой арифметической операции над этими числами. Затем
выводит результат операции. Вот класс Calculator
.Calculator.cs:
namespace OfferZenDiTutorial{ public class Calculator { public float Divide(float number1, float number2) { return number1 / number2; } public float Multiply(float number1, float number2) { return number1 * number2; } public float Add(float number1, float number2) { return number1 + number2; } public float Subtract(float number1, float number2) { return number1 - number2; } }}
Логирование
Приложение работает отлично, но только представьте: вашему начальнику вздумалось, что теперь все операции должны логироваться в файл на диске, чтобы было видно, чем люди занимаются.
Кажется, что не так это и сложно, верно? Берете и добавляете инструкции, в соответствии с которыми все операции, производимые в
Calculator
, должны заноситься в текстовый файл. Вот
как теперь выглядит ваш Calculator
:Calculator.cs:
using System.IO;namespace OfferZenDiTutorial{ public class Calculator { private const string FileName = "Calculator.log"; public float Divide(float number1, float number2) { File.WriteAllText(FileName, $"Running {number1} / {number2}"); return number1 / number2; } public float Multiply(float number1, float number2) { File.WriteAllText(FileName, $"Running {number1} * {number2}"); return number1 * number2; } public float Add(float number1, float number2) { File.WriteAllText(FileName, $"Running {number1} + {number2}"); return number1 + number2; } public float Subtract(float number1, float number2) { File.WriteAllText(FileName, $"Running {number1} - {number2}"); return number1 - number2; } }}
Прекрасно работает. Всякий раз, когда в
Calculator
что-либо происходит, он записывает это в файл
Calculator.log
, расположенный в той же директории,
откуда он запускается.Но, возможен вопрос: а в самом ли деле уместно, чтобы класс Calculator отвечал за запись в текстовый файл?
Класс FileLogger
Нет. Разумеется, это не его зона ответственности. Поэтому, чтобы не нарушался принцип единственной ответственности, все, что касается логирования информации, должно происходить в файле логов, и для этого требуется написать отдельный самодостаточный класс. Давайте этим и займемся.
Первым делом создаем совершенно новый класс, назовем его
FileLogger
. Вот как он будет выглядеть.FileLogger.csh:
using System;using System.IO;namespace OfferZenDiTutorial{ public class FileLogger { private const string FileName = "Calculator.log"; private readonly string _newLine = Environment.NewLine; public void WriteLine(string message) { File.AppendAllText(FileName, $"{message}{_newLine}"); } }}
Теперь все, что касается создания файла логов и записи информации в него обрабатывается в этом классе. Дополнительно получаем и одну приятную мелочь: что бы ни потреблял этот класс, не требуется ставить между отдельными записями пустые строки. Записи должны просто вызывать наш метод
WriteLine
, а все остальное
мы берем на себя. Разве не круто?Чтобы использовать класс, нам нужен объект, который его инстанцирует. Давайте решим эту проблему внутри класса
Calculator
. Заменим содержимое класса
Calculator.cs
следующим:Calculator.cs:
namespace OfferZenDiTutorial{ public class Calculator { private readonly FileLogger _logger; public Calculator() { _logger = new FileLogger(); } public float Divide(float number1, float number2) { _logger.WriteLine($"Running {number1} / {number2}"); return number1 / number2; } public float Multiply(float number1, float number2) { _logger.WriteLine($"Running {number1} * {number2}"); return number1 * number2; } public float Add(float number1, float number2) { _logger.WriteLine($"Running {number1} + {number2}"); return number1 + number2; } public float Subtract(float number1, float number2) { _logger.WriteLine($"Running {number1} - {number2}"); return number1 - number2; } }}
Итак, теперь нашему калькулятору не важно, как именно новый логгер записывает информацию в файл, или где находится этот файл, и происходит ли вообще запись в файл. Однако, все равно существует одна проблема: можем ли мы вообще рассчитывать на то, что класс
Calculator
будет знать, как создается логгер?Внедрение зависимости
Очевидно, ответ на последний вопрос отрицательный!
Именно здесь, уважаемый читатель, в дело вступает внедрение зависимости. Давайте изменим конструктор нашего класса
Calculator
:Calculator.cs:
public Calculator(FileLogger logger) { _logger = logger; }
Вот и все. Больше в классе ничего не меняется.
Внедрение зависимостей это элемент более крупной темы под названием Мнверсия управления, но ее подробное рассмотрение выходит за рамки этой статьи.
В данном случае вам всего лишь требуется знать, что мы инвертируем управление классом логгера, либо, выражаясь метафорически, делегируем кому-то проблему создания файла
FileLogger
, внедряя экземпляр FileLogger
в наш калькулятор, а не рассчитывая, что класс
Calculator
сам будет знать, как его создать.Итак, чья же это ответственность?
Как раз того, кто инстанцирует класс
Calculator
. В
нашем случае это основная программа.Чтобы это продемонстрировать, изменим метод Main в нашем классе Program.cs следующим образом:
Program.cs
static void Main(string[] args) { var number1 = GetNumber("Enter the first number: > "); var number2 = GetNumber("Enter the second number: > "); var operation = GetOperator(); // Следующие две строки изменены var logger = new FileLogger(); var calc = new Calculator(logger); var result = GetResult(calc, number1, number2, operation); Console.WriteLine($"{number1} {operation} {number2} = {result}"); Console.Write("Press any key to continue..."); Console.ReadKey(); }
Таким образом, требуется изменить всего две строки. Мы не рассчитываем, что класс
Calculator
инстанцирует
FileLogger
, это за него сделает Main
, а
затем передаст ему результат.В сущности, это и есть внедрение зависимостей. Не нужны ни интерфейсы, ни контейнеры для инверсии управления, ни что-либо подобное. В принципе, если вам доводилось выполнять что-либо подобное, то вы имели дело с внедрением зависимостей. Круто, правда?
Расширение возможностей: сделаем другой логгер
Несмотря на вышесказанное, у интерфейсов есть свое место, и по-настоящему они раскрываются именно в связке с Внедрением Зависимостей.
Допустим, у вас есть клиент, с точки зрения которого логирование каждого вызова к
Calculator
пустая трата времени и
дискового пространства, и лучше вообще ничего не логировать.Как вы считаете, придется ли ради этого делать изменения внутри
Calculator
, что потенциально потребует
перекомпилировать и перераспределить ту сборку, в которой он
находится?Вот здесь нам и пригодятся интерфейсы.
Давайте напишем интерфейс. Назовем его
ILogger
,
поскольку его реализацией будет заниматься наш класс
FileLogger
.ILogger.cs
namespace OfferZenDiTutorial{ public interface ILogger { void WriteLine(string message); }}
Как видите, он определяет единственный метод:
WriteLine
, реализованный FileLogger
.
Сделаем еще шаг и формализуем эти отношения, сделав так, чтобы этот
класс официально реализовывал наш новый интерфейс:FileLogger.cs
public class FileLogger : ILogger
Это единственное изменение, которое мы внесем в этот файл. Все остальное будет как прежде.
Итак, отношение мы определили что нам теперь с ним делать?
Для начала изменим класс Calculator таким образом, чтобы он использовал интерфейс
ILogger
, а не конкретную
реализацию FileLogger
:Calculator.cs
private readonly ILogger _logger; public Calculator(ILogger logger) { _logger = logger; }
На данном этапе код по-прежнему компилируется и выполняется без всяких проблем. Мы передаем в него
FileLogger
из
главного метода программы, того, который реализует
ILogger
. Единственное отличие заключается в том, что
Calculator
не просто не требуется знать, как создавать
FileLogger
, но и даже логгер какого рода ему
выдается.Поскольку все, что бы вы ни получили, реализует интерфейс
ILogger
(и, следовательно, имеет метод
WriteLine
), с практическим использованием проблем не
возникает.Теперь давайте добавим еще одну реализацию интерфейса
ILogger
. Это будет класс, который ничего не делает при
вызове метода WriteLine
. Мы назовем его
NullLogger
, и вот как он выглядит:NullLogger.cs
namespace OfferZenDiTutorial{ public class NullLogger : ILogger { public void WriteLine(string message) { // Ничего не делаем в этой реализации } }}
На этот раз нам вообще ничего не потребуется менять в классе
Calculator
, если мы соберемся использовать новый
NullLogger
, поскольку тот уже принимает что угодно,
реализующее интерфейс ILogger
.Нам потребуется изменить только лишь метод Main в нашем файле Program.cs, чтобы передать в него иную реализацию. Давайте этим и займемся, чтобы метод
Main
принял следующий вид:Program.cs
static void Main(string[] args) { var number1 = GetNumber("Enter the first number: > "); var number2 = GetNumber("Enter the second number: > "); var operation = GetOperator(); var logger = new NullLogger(); // Эту строку нужно изменить var calc = new Calculator(logger); var result = GetResult(calc, number1, number2, operation); Console.WriteLine($"{number1} {operation} {number2} = {result}"); Console.Write("Press any key to continue..."); Console.ReadKey(); }
Опять же, изменить нужно только ту строку, которую я откомментировал. Если мы хотим использовать иной механизм логирования (например, такой, при котором информация записывается в журнал событий Windows, либо с применением SMS-уведомлений или уведомлений по электронной почте), то нам потребуется всего лишь передать иную реализацию
интерфейса
ILogger
.Небольшая оговорка об интерфейсах
Как видите, интерфейсы очень сильный инструмент, но они привносят в приложение дополнительный уровень абстракции, а, значит, и лишнюю сложность. В борьбе со сложностью могут помочь контейнеры, но, как будет показано в следующем разделе, приходится регистрировать все ваши интерфейсы, а при работе с конкретными типами это делать не требуется.
Существует так называемая обфускация при помощи абстракции, которая, в сущности, сводится к переусложнению проекта ради реализации всех этих различных уровней. Если хотите, то можете обойтись и без всех этих интерфейсов, если только нет конкретных причин, по которым они вам нужны. Вообще не следует создавать такой интерфейс, у которого будет всего одна реализация.
Контейнеры для внедрения зависимостей
Пример, прорабатываемый в этой статье, довольно прост: мы имеем дело с единственным классом, у которого всего одна зависимость. Теперь предположим, что у нас множество зависимостей, и каждая из них связана с другими зависимостями. Даже при работе над умеренно сложными проектами вполне вероятно, что вам придется иметь дело с такими ситуациями, и будет не просто держать в уме, что нужно создать все эти классы, а также помнить, какой из них от какого зависит особенно, если вы решили пользоваться интерфейсами.
Знакомьтесь с контейнером для внедрения зависимостей. Он упрощает вам жизнь, но принцип работы такого контейнера может показаться весьма запутанным, особенно, когда вы только начинаете его осваивать. На первый взгляд эта возможность может отдавать некоторой магией.
В данном примере мы воспользуемся контейнером от Unity, но на выбор есть и много других, назову лишь наиболее популярные: Castle Windsor, Ninject. С функциональной точки зрения эти контейнеры практически не отличаются. Разница может быть заметна на уровне синтаксиса и стиля, но, в конечном итоге, все сводится к вашим персональным предпочтениям и опыту разработки (а также к тому, что предписывается в вашей компании!).
Давайте подробно разберем пример с использованием Unity: я постараюсь объяснить, что здесь происходит.
Первым делом вам потребуется добавить ссылку на Unity. К счастью, для этого существует пакет Nuget, поэтому щелкните правой кнопкой мыши по вашему проекту в Visual Studio и выберите Manage Nuget Packages:
Найдите и установите пакет Unity, ориентируйтесь на проект Unity Container:
Итак, мы готовы. Измените метод
Main
файла
Program.cs
вот так:Program.cs
static void Main(string[] args) { var number1 = GetNumber("Enter the first number: > "); var number2 = GetNumber("Enter the second number: > "); var operation = GetOperator(); // Следующие три строки необходимо изменить var container = new UnityContainer(); container.RegisterType<ILogger, NullLogger>(); var calc = container.Resolve<Calculator>(); var result = GetResult(calc, number1, number2, operation); Console.WriteLine($"{number1} {operation} {number2} = {result}"); Console.Write("Press any key to continue..."); Console.ReadKey(); }
Опять же, менять требуется только те строки, которые отмечены. При этом, нам не придется ничего менять ни в классе Calculator, ни в одном из логгеров, ни в их интерфейсе: теперь они все внедряются во время исполнения, поэтому, если нам понадобится иной логгер, то мы должны будем всего лишь изменить конкретный класс, регистрируемый для
ILogger
.При первом запуске этого кода вы можете столкнуться с такой ошибкой:
Вероятно, это одна из причуд с той версией пакета Unity, которая была актуальна на момент написания этой статьи. Надеюсь, что у вас все пройдет гладко.
Все дело в том, что при установке Unity также устанавливается неверная версия другого пакета,
System.Runtime.CompilerServices.Unsafe
. Если вы
получаете такую ошибку, то должны вернуться к менеджеру пакетов
Nuget, найти этот пакет под вкладкой Installed и обновить его до
новейшей стабильной версии:Теперь, когда этот проект работает, что именно он делает? Когда вы впервые пишете код таким образом, тянет предположить, что здесь замешана какая-то магия. Сейчас я расскажу, что мы здесь видим на самом деле и, надеюсь, это будет сеанс магии с разоблачением, поэтому далее вы не будете остерегаться работы с контейнерами.
Все начинается со строки
var calc =
container.Resolve<Calculator>();
, поэтому именно
отсюда я изложу смысл этого кода в форме диалога контейнера с самим
собой: о чем он думает, когда видит эту инструкцию.- Мне задано разрешить что-то под названием
Calculator
. Я знаю, что это такое? - Вижу, в актуальном дереве процессов есть класс под названием
Calculator
. Это конкретный тип, значит, у него всего лишь одна реализация. Просто создам экземпляр этого класса. Как выглядят конструкторы? - Хм, а конструктор всего один, и принимает он что-то под
названием
ILogger
. Я знаю, что это такое? - Нашел, но это же интерфейс. Мне вообще сообщалось, как его разрешать?
- Да, сообщалось! В предыдущей строке сказано, что, всякий раз,
когда мне требуется разрешить
ILogger
, я должен передать экземпляр классаNullLogger
. - Окей, значит тут есть
NullLogger
. У него непараметризованный конструктор. Просто создам экземпляр. - Передам этот экземпляр конструктору класса
Calculator
, а затем верну этот экземпляр к var calc.
Обратите внимание: если бы у
NullLogger
был
конструктор, который запрашивал бы дополнительные типы, то
конструктор просто повторил бы для них все шаги, начиная с 3. В
принципе, он просматривает все типы и пытается автоматически
разрешить все найденные типы в конкретные экземпляры.Также обратите внимание, что в пункте 2 разработчикам не приходится явно регистрировать конкретные типы. При желании это можно сделать, например, чтобы изменить жизненный цикл либо передать производные классы и т.д. Но это еще одна причина избегать создания интерфейсов, пока в них нет очевидной нужды.
Вот и все. Ничего таинственного и особо мистического.
Другие возможности
Стоит отметить, что контейнеры позволяют делать еще некоторые вещи, которые было бы довольно сложно (но не невозможно) реализовать самостоятельно. Среди таких вещей управление жизненным циклом и внедрение методов и свойств. Обсуждение этих тем выходит за рамки данной статьи, поскольку, маловероятно, что такие техники понадобятся начинающим. Но я рекомендую вам, освоившись с темой, внимательно почитать документацию, чтобы понимать, какие еще возможности существуют.
Если вы хотите сами поэкспериментировать с примерами, приведенными в этой статье: смело клонируйте с Гитхаба репозиторий, в котором они выложены github.com/GrahamDo/OfferZenDiTutorial.git. Там семь веток, по одной на каждую рассмотренную нами итерацию.