Главная страница   /   4.2. Внедрение в свойство (Property Injection) (Внедрение зависимостей в .NET

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

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

Марк Симан

4.2. Внедрение в свойство (Property Injection)

Как мы можем включить DI в качестве опции в классе, когда у нас есть хорошая Local Default?

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

Рисунок 4-5: SomeClass имеет опциональную зависимость для ISomeInterface; вместо того, чтобы требовать от вызывающих элементов предоставить экземпляр, он дает вызывающим элементам возможность определить его через свойство.

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

Примечание

Внедрение в свойство также известно как внедрение в сеттер.

В соответствии с рисунком 4-5, клиенты, желающие воспользоваться SomeClass как есть, могут создать новый экземпляр класса и использовать его, в то время как клиенты, желающие изменить поведение класса могут это сделать путем установки свойства Dependency для другой реализации ISomeInterface.

Как это работает

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

Листинг 4-3: Внедрение в свойство
public partial class SomeClass
{
	public ISomeInterface Dependency { get; set; }
}

SomeClass зависит от ISomeInterface. Клиенты могут поставлять реализации ISomeInterface, устанавливая свойство Dependency. Обратите внимание, что в отличие от внедрения в конструктор, вы не можете отметить поле свойства Dependency как readonly, потому что вы разрешаете вызывающим элементам менять это свойство в любой момент жизненного цикла SomeClass.

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

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

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

var mc = new SomeClass();
mc.DoSomething("Ploeh");

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

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

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

Когда это использовать

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

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

Примечание

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

Local Default

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

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

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

Пример в этом разделе содержит Local Default.

Рисунок 4-6: Даже в пределах одного модуля мы можем ввести абстракции (представлены вертикальным прямоугольником), которые помогают снизить связанность классов в этом модуле. Основным мотивом для этого является повышение поддержки модуля, что достигается тогда, когда классы варьируют независимо друг от друга.

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

Рисунок 4-6 иллюстрирует, что абстракции могут быть определены, реализованы и использованы внутри одного модуля с основной целью открытия классов для расширения.

Примечание

Концепция открытия класса для расширения охватывается принципом открытости/закрытости (Open/Closed Principle), который, если вкратце, утверждает, что класс должен быть открытым для расширения, но закрытым для изменений.

Когда мы реализуем классы, следуя принципу открытости/закрытости, мы можем иметь в виду Local Default, но мы по-прежнему даем клиентам способ расширить класс, заменив зависимость чем-то еще.

Примечание

Внедрение в свойство является лишь одним из многих способов применения принципа открытости/закрытости.

Совет

Иногда вы только хотите дать точку расширения, но оставить Local Default как пустую операцию. В таких случаях вы можете использовать паттерн Null Object для реализации Local Default.

Совет

Иногда вы хотите оставить Local Default на месте, но иметь возможность добавить больше реализаций. Вы можете добиться этого путем моделирования зависимости вокруг паттернов Наблюдатель (Observer) или Компоновщик (Composite).

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

Таблица 4-2: Преимущества и недостатки внедрения в свойство
Преимущества Недостатки
Легко понять Ограниченная применимость
Не совсем просто реализовать надежно

Основным преимуществом внедрения в свойство является то, что его легко понять. Я часто видел, как этот паттерн используется в качестве первой попытки, когда люди решают применять DI.

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

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

Существование хорошей Local Default частично зависит от степени детализации модулей. .NET Base Class Library (BCL) поставляется как довольно большой пакет; до тех пор, пока default остается в пределах BCL, можно утверждать, что она также и local. В следующем разделе я кратко остановлюсь на этой теме.

Использование

В .NET BCL, внедрение в свойство является немного более используемым, чем внедрение в конструктор, вероятно, потому что много хороших Local Default определяются в разных местах.

System.ComponentModel.IComponent имеет доступное для записи свойство Site, которое позволяет определить экземпляр ISite. Это главным образом используется в разработке сценариев (например, Visual Studio), чтобы изменить или усилить компонент, когда он находится в дизайнере.

Другой пример, который сильнее отражает то, как мы привыкли думать о DI, можно найти в Windows Workflow Foundation. Класс WorkflowRuntime дает вам возможность добавлять, получать и удалять сервисы. Это не совсем внедрение в свойство, потому что API позволяет добавлять ноль или несколько нетипизированных сервисов посредством одного API общего назначения:

public void AddService(object service)
public T GetService<T>()
public object GetService(Type serviceType)
public void RemoveService(object service)

Хотя AddService выбросит ArgumentNullException если значение сервиса является null, нет никакой гарантии, что вы можете получить сервис заданного типа, потому что он, возможно, никогда не будет добавлен к текущему экземпляру WorkflowRuntime (на самом деле, это потому что метод GetService является Service Locator).

С другой стороны, WorkflowRuntime поставляется с большим количеством Local Default для каждого из требуемых сервисов, которые ему нужны, и они даже имеют префикс Default, например DefaultWorkflowSchedulerService и DefaultWorkflowLoaderService. Если, например, не добавлен альтернативный WorkflowSchedulerService либо с помощью метода AddService, либо конфигурационного файла приложения, используется класс DefaultWorkflowSchedulerService.

После этих BCL примеров давайте перейдем к более существенным примерам использования и реализации внедрения в свойство.

Пример: Определение сервиса профиля валюты для BasketController

В разделе 4.1.4 я начал добавлять функционал по конверсии валюты в пример коммерческого приложения и вкратце показал вам некоторую реализацию метода Index в BasketController, но умолчал о появлении CurrencyProfileService. Дело вот в чем:

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

public abstract class CurrencyProfileService
{
	public abstract string GetCurrencyCode();
	public abstract void UpdateCurrencyCode(string currencyCode);
}

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

В ASP.NET MVC (и ASP.NET в целом) у вас есть известная часть инфраструктуры, которая занимается таким сценарием: сервис Profile. Отличная реализация Local Default для CurrencyProfileService это то, что оборачивает ASP.NET сервис Profile и обеспечивает необходимую функциональность, определенную методами GetCurrencyCode и UpdateCurrencyCode. BasketController будет использовать этот DefaultCurrencyProfileService по умолчанию, когда раскрывает свойство, которое позволит вызывающему элементу заменить его чем-то другим.

Листинг 4-4: Раскрытие свойства CurrencyProfileService
private CurrencyProfileService currencyProfileService;
public CurrencyProfileService CurrencyProfileService
{
	get
	{
		if (this.currencyProfileService == null)
		{
			this.CurrencyProfileService =
				new DefaultCurrencyProfileService(
					this.HttpContext);
		}
		return this.currencyProfileService;
	}
	set
	{
		if (value == null)
		{
			throw new ArgumentNullException("value");
		}
		if (this.currencyProfileService != null)
		{
			throw new InvalidOperationException();
		}
		this.currencyProfileService = value;
	}
}

Строки 6-12: Отложенная инициализация Local Default

Строки 20-23: Зависимость определяется только один раз

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

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

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

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

По сравнению с внедрением в конструктор, это гораздо более сложно. Внедрение в свойство может выглядеть простым в сыром виде, как показано в листинге 4-3, но при правильном применении это, как правило, гораздо более сложно: и в этом примере я даже проигнорировал проблему безопасности потока.

Когда CurrencyProfileService на месте, метод Index в BasketController теперь может использовать его для получения предпочтительной валютой пользователя:

public ViewResult Index()
{
	var currencyCode =
		this.CurrencyProfileService.GetCurrencyCode();
	var currency =
		this.currencyProvider.GetCurrency(currencyCode);
	// …
}

Это тот же фрагмент кода, что показан в разделе 4.1.4. CurrencyProfileService используется для получения выбранной пользователем валюты, а CurrencyProvider в дальнейшем используется для извлечения этой валюты.

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

Связанные паттерны

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

Когда зависимость представляет CROSS-CUTTING CONCERN, который должен быть доступен для всех модулей в приложении, вы можете реализовать его как Ambient Context.

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