Главная страница   /   1.2. Привет DI (Внедрение зависимостей в .NET

Внедрение зависимостей в .NET

Внедрение зависимостей в .NET

Марк Симан

1.2. Привет DI

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

Код приложения "Hello DI!"

Вероятно, вы привыкли видеть примеры "Hello World", которые пишутся в одну строку кода. В этой книге мы берем нечто чрезвычайно простое и делаем его сложным. Зачем? Мы скоро доберемся до этого, но сначала давайте посмотрим, как бы выглядел пример "Hello World" с использованием механизма внедрения зависимостей.

Партнеры

Чтобы понять структуру программы, мы начнем с рассмотрения метода Main консольного приложения, а затем я продемонстрирую вам взаимодействующие классы:

private static void Main()
{
	IMessageWriter writer = new ConsoleMessageWriter();
	var salutation = new Salutation(writer);
	salutation.Exclaim();
}

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

Рисунок 1-11 демонстрирует взаимоотношения между партнерами.

Рисунок 1-11: Метод Main создает новые экземпляры как класса ConsoleMessageWriter, так и класса Salutation. ConsoleMessageWriter реализует интерфейс IMessageWriter, который используется Salutation. В сущности Salutation использует ConsoleMessageWriter, несмотря на то, что это непрямое использование не продемонстрировано.

Основная логика приложения инкапсулирована в классе Salutation, что продемонстрировано в следующем листинге.

Листинг 1-1: Класс Salutation
public class Salutation
{
	private readonly IMessageWriter writer;
	public Salutation(IMessageWriter writer)
	{
		if (writer == null)
		{
			throw new ArgumentNullException("writer");
		}
		this.writer = writer;
	}
	public void Exclaim()
	{
		this.writer.Write("Hello DI!");
	}
}

Строка 4: Внедряет зависимость

Строка 14: Использует зависимость

Класс Salutation зависит от пользовательского интерфейса под названием IMessageWriter и запрашивает экземпляр этого интерфейса через его конструктор. Этот процесс называется внедрением через конструктор (Constructor Injection) и описывается подробно в главе 4, которая также содержит более детальный анализ похожего примера кода.

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

IMessageWriter – это простой интерфейс, определенный следующим образом:

public interface IMessageWriter
{
	void Write(string message);
}

Он мог бы иметь другие элементы, но в этом простом примере вам нужен только метод Write. Этот интерфейс реализуется с помощью класса ConsoleMessageWriter, который метод Main передает в класс Salutation:

public class ConsoleMessageWriter : IMessageWriter
{
	public void Write(string message)
	{
		Console.WriteLine(message);
	}
}

Класс ConsoleMessageWriter реализует IMessageWriter путем упаковывания класса Console из библиотеки базовых классов. Это простое приложение паттерна проектирования Adapter, о котором мы говорили в разделе "Осознание цели DI".

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

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

Чем предыдущий пример лучше обычного однострочного кода, который мы обычно используем для реализации "Hello World" в C#? В этом примере механизм DI прибавляет издержок в размере 1,100%, но как только сложность кода возрастает от одной строки до десятков тысяч строк, эти издержки сокращаются и почти исчезают. Глава 2 предоставляет более сложный пример применения механизма внедрения зависимостей, и, несмотря на то, что этот пример все еще слишком прост по сравнению с реальными приложениями, вы должны заметить, что механизм DI менее навязчивый.

Я не виню вас в том, что вы можете найти предыдущий пример слишком надуманным, но обдумайте следующее: по своей сущности классический пример "Hello World" – это простая проблема с хорошо заданными и ограниченными требованиями. В реальном мире разработка программного обеспечения никогда не происходит таким образом. Требования изменяются и часто являются довольно расплывчатыми. Возможности, которые вам необходимо реализовывать, также стремятся к усложнению. Механизм внедрения зависимостей помогает решать такие вопросы путем разрешения слабого связывания. В частности мы получаем преимущества, перечисленные в таблице 1-1.

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

Таблица 1-1: Преимущества, получаемые при использовании слабого связывания. Каждое преимущество доступно всегда, но может быть по-разному оценено в зависимости от обстоятельств.
Преимущество Описание Когда оно полезно?
Позднее связывание Сервисы могут меняться местами с другими сервисами. Ценится в стандартном программном обеспечении, но, возможно, менее ценится в корпоративных приложениях, в которых исполняющая среда стремится к тому, чтобы быть хорошо определенной.
Расширяемость Код можно расширять и использовать заново с помощью явно не запланированных способов. Ценится всегда
Параллельная разработка Код может разрабатываться параллельно. Ценится в больших, сложных приложениях; но не так сильно в небольших, простых приложениях
Удобство сопровождения Классы с явно определенными обязанностями легче поддерживать. Ценится всегда
Тестируемость Классы можно тестировать модульно. Ценится только, если вы выполняете модульное тестирование (а вы действительно должны это делать)

Позднее связывание

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

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

NoSQL, Windows Azure и аргументы в пользу композиции (composability)

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

"Допустим, таким образом, вы можете заменить ваш реляционный компонент доступа к данным чем-то другим. Чем?" Существует ли какая-нибудь альтернатива реляционных баз данных?"

XML файлы никогда не казались подходящей альтернативой для крупномасштабных корпоративных сценариев. За последнюю пару лет все значительно изменилось.

О Windows Azure было объявлено на конференции Microsoft PDC 2008, и эта платформа многое сделала для того, чтобы убедить даже консервативные организации, которые используют только продукцию Microsoft, в том, что необходимо переоценить их позицию касаемо хранилища данных. На данный момент существует реальная альтернатива реляционных баз данных, и мне приходится спрашивать людей только о том, хотели бы они, чтобы их приложение было "cloud-ready" приложением. Аргумент в пользу замещения на данный момент становится все весомее.

Связанное движение можно обнаружить во всей концепции NoSQL, которая моделирует приложения на основе ненормализованных данных – часто документо-ориентированных баз данных, но такие концепции, как Event Sourcing, также становятся все более важными.

В разделе "Код приложения "Hello DI!"" вы не использовали "позднее связывание", поскольку вы явно создавали новый экземпляр IMessageWriter при помощи жестко-закодированного создания нового экземпляра ConsoleMessageWriter. Тем не менее, вы можете ввести "позднее связывание" путем изменения только одного фрагмента кода. Вам нужно всего лишь изменить следующую строку кода:

IMessageWriter writer = new ConsoleMessageWriter();

Для того чтобы разрешить позднее связывание, вы могли бы заменить эту строку кода чем-то вроде этого:

var typeName = ConfigurationManager.AppSettings["messageWriter"];
var type = Type.GetType(typeName, true);
IMessageWriter writer = (IMessageWriter)Activator.CreateInstance(type);

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

Чтобы выполнить это, вы указываете имя типа при настройке приложения messageWriter в конфигурационном файле этого приложения:

<appSettings>
	<add key="messageWriter"
			value="Ploeh.Samples.HelloDI.CommandLine.ConsoleMessageWriter, HelloDI" />
</appSettings>

Предупреждение

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

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

В примере "Hello DI" "позднее связывание" будет предоставлять вам возможность писать сообщения другим адресатам, а не только в консоль – например, в базу данных или файл. Можно добавлять такие возможности, даже если вы явно не планировали их до этого.

Расширяемость

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

Давайте скажем, что вы хотите сделать пример "Hello DI" более безопасным, разрешая только авторизованным пользователям писать сообщения. Следующий листинг демонстрирует, как вы можете добавить эту возможность без изменения какой-либо существующей возможности: вы добавляете новую реализацию интерфейса IMessageWriter.

Листинг 1-2: Расширение приложения "Hello DI" путем добавления возможности обеспечения безопасности
public class SecureMessageWriter : IMessageWriter
{
	private readonly IMessageWriter writer;
	public SecureMessageWriter(IMessageWriter writer)
	{
		if (writer == null)
		{
			throw new ArgumentNullException("writer");
		}
		this.writer = writer;
	}
	public void Write(string message)
	{
		if (Thread.CurrentPrincipal.Identity.IsAuthenticated)
		{
			this.writer.Write(message);
		}
	}
}

Строка 14: Проверяет наличие авторизации

Строка 16: Записывает сообщение

Класс SecureMessageWriter реализует интерфейс IMessageWriter и в то же время использует его: он использует механизм внедрения зависимости через конструктор для того, чтобы запросить экземпляр IMessageWriter. Это стандартное приложение паттерна проектирования Decorator, о котором я упоминал в разделе "Осознание цели DI". Более подробно об этом паттерне мы поговорим в главе 9.

При реализации метода Write сначала проверяется, авторизован ли текущий пользователь. Только в этом случае этому пользователю разрешается записывать сообщения в поле writer с помощью метода Write.

Примечание

Метод Write в листинге 1-2 обращается к текущему пользователю через Ambient Context (окружающий контекст). Более гибкий, но в то же время немного более сложный вариант также мог бы предоставить пользователя посредством внедрения через конструктор.

Единственное место, где вам нужно будет изменить существующий код – это метод Main, поскольку вам нужно скомпоновать доступные классы несколько другим способом, нежели вы это делали до этого:

IMessageWriter writer =
	new SecureMessageWriter(
		new ConsoleMessageWriter());

Заметьте, что вы награждаете предыдущий экземпляр ConsoleMessageWriter классом SecureMessageWriter. В очередной раз класс Salutation не модифицируется, поскольку он использует только интерфейс IMessageWriter.

Слабое связывание позволяет вам писать код, который открыт для расширяемости, но закрыт для модификации. Это называется принципом открытости/закрытости (Open/closed principle). Единственное место, где вам нужно модифицировать код – в точке входа приложения; мы называем ее Composition Root.

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

Параллельная разработка

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

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

В приведенном выше примере благодаря тому, что классы SecureMessageWriter и ConsoleMessageWriter не зависят друг от друга напрямую, они могут разрабатываться параллельными командами. Момент, который им нужно будет согласовывать – это совместно используемый интерфейс IMessageWriter.

Удобство сопровождения

Как только ответственность каждого класса становится явно определенной и ограниченной, процесс сопровождения всего приложения облегчается. Это хорошо известное преимущество принципа единственной ответственности (single responsibility principle), который утверждает, что каждый класс должен обладать только единственной ответственностью.

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

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

Тестируемость

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

Определение

Приложение считается тестируемым, когда его можно тестировать помодульно.

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

Практически случайно слабое связывание разрешает модульное тестирование, потому что пользователи руководствуются принципом замещения Лисков: они не заботятся о том, чтобы у их зависимостей были конкретные типы. Это означает, что мы можем внедрить дублеры теста (Test Doubles) в тестируемую систему (System Under Test (SUT)), как мы это видим в листинге 1-3.

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

Тестируемость

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

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

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

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

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

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

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

Дублеры теста

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

Для дублеров теста существует законченный язык паттернов и множество подтипов таких, как Stubs, Mocks и Fakes.

Пример: Модульное тестирование логики приложения "Hello"

В разделе "Код приложения "Hello DI!"" вы видели пример приложения "Hello DI". Несмотря на то, что я сначала продемонстрировал вам конечный код, я, в действительности, разрабатывал это приложение при помощи тестирования через разработку. Листинг 1-3 демонстрирует самый важный модульный тест.

Примечание

Не волнуйтесь, если у вас нет опыта работы с модульным тестированием или динамическими mock-объектами. Они могут случайно всплывать на протяжении всей книги, но ни коим образом не являются обязательными для чтения.

Листинг 1-3: Модульное тестирование класса Salutation
[Fact]
public void ExclaimWillWriteCorrectMessageToMessageWriter()
{
	var writerMock = new Mock<IMessageWriter>();
	var sut = new Salutation(writerMock.Object);
	sut.Exclaim();
	writerMock.Verify(w => w.Write("Hello DI!"));
}

Для класса Salutation требуется экземпляр интерфейса IMessageWriter, поэтому вам нужно его создать. Вы могли бы использовать любую реализацию, но в модульных тестах могут быть очень полезными динамические mock-объекты – в данном случае вы используете Moq, но могли бы использовать и другие библиотеки или вместо этого свернуть свою собственную. Важная составляющая – обеспечение реализации IMessageWriter для конкретного теста с целью убедиться в том, что вы тестируете только один объект за раз; в настоящий момент вы тестируете метод Exclaim класса Salutation, поэтому вы не хотите, чтобы какая-нибудь производственная реализация IMessageWriter захламляла тест.

Чтобы создать класс Salutation, вы передаете Mock-экземпляр IMessageWriter. Поскольку writerMock – это экземпляр Mock<IMessageWriter>, свойство Object – это динамически создаваемый экземпляр IMessageWriter. Внедрение нужной зависимости посредством конструктора носит название "внедрение через конструктор".

После применения тестируемой системы (System Under Test (SUT)) вы можете использовать Mock, чтобы проверить, что метод Write был вызван с корректным текстом. При использовании Moq вы выполняете это путем вызова метода Verify, в качестве параметра которого задано выражение, которое определяет то, что вы запланировали. Если метод IMessageWriter.Write был вызван со строкой "Hello DI!", то вызов метода Verify завершается, но если метод Write не вызывался или вызывался с другим параметром, то метод Verify выдавал бы исключение и тест бы не выполнялся.

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