Главная страница   /   8.1. Управление жизненным циклом зависимостей (Внедрение зависимостей в .NET

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

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

Марк Симан

8.1. Управление жизненным циклом зависимостей

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

Примечание

В .NET жизненный цикл объекта достаточно прост: объект создается, используется и уничтожается сборщиком мусора (garbage collector). Присутствие IDisposable слегка все усложняет, но жизненный цикл от этого не становится более сложным. При обсуждении жизненного цикла объектов мы говорим о том, как мы управляем жизненными циклами объектов.

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

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

Для начала мы исследуем, почему композиция объектов влияет на жизненный цикл.

Знакомство с механизмом управления жизненным циклом

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

Простой жизненный цикл зависимостей

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

Проще всего понять это, когда дело доходит до создания объекта. Ниже приведен фрагмент кода Composition Root шаблонного приложения Commerce (окончательный пример вы можете увидеть в листинге 7-3).

var discountRepository =
	new SqlDiscountRepository(connectionString);
var discountPolicy =
	new RepositoryBasketDiscountPolicy(discountRepository);

Надеюсь, это очевидно, что класс RepositoryBasketDiscountPolicy не контролирует то, когда создается discountRepository. В данном случае это, скорее всего, происходит в пределах той же миллисекунды; но для чистоты эксперимента мы могли бы вставить вызов Thread.Sleep между этими двумя строками кода для того, чтобы продемонстрировать, что мы можем условно разделить их на промежутки времени. Это было бы достаточно непонятно для исполнения, но вы все поняли.

Потребители не контролируют создание своих зависимостей, но что насчет уничтожения? Как правило, мы не контролируем то, когда в .NET уничтожаются объекты. Сборщик мусора (garbage collector) собирает неиспользуемые объекты, но до тех пор, пока мы не будем работать с устраняемыми объектами, мы не сможем явно уничтожить объект.

Примечание

Я использую термин устраняемый объект (disposable object) в качестве условного обозначения экземпляров объектов типов, которые реализуют интерфейс IDisposable.

Объекты уничтожаются сборщиком мусора, когда они выходят за рамки области применения. Наоборот, они действуют, пока кто-нибудь еще ссылается на них. Несмотря на то, что потребитель не может явно уничтожить объект, он может сохранять объект, продолжая ссылаться на него. Именно это мы и делаем при использовании Constructor Injection, поскольку мы сохраняем зависимость в приватном поле. Но как демонстрирует рисунок 8-3, когда потребитель выходит за рамки области применения, то же самое может сделать и зависимость.

Рисунок 8-3: Кто бы ни внедрял зависимость в потребителя, он решает, когда создается эта зависимость, но потребитель может сохранять зависимость, продолжая ссылаться на нее. Когда потребитель выходит за границы области применения, зависимость может удовлетворять условиям, необходимым для работы сборщика мусора.

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

Усложнение жизненного цикла зависимости

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

Листинг 8-1: Компоновка с помощью различных экземпляров одной и той же зависимости
var repositoryForPolicy =
	new SqlDiscountRepository(connectionString);
var repositoryForCampaign =
	new SqlDiscountRepository(connectionString);
var discountPolicy =
	new RepositoryBasketDiscountPolicy(
		repositoryForPolicy);
var campaign =
	new DiscountCampaign(repositoryForCampaign);

Строка 6-7, 9: Внедряет соответствующий репозиторий

В данном примере для двоих потребителей необходим экземпляр DiscountRepository, поэтому вы присоединяете два отдельных экземпляра с одинаковой строкой соединения. Теперь вы способны передать repositoryForPolicy в новый экземпляр RepositoryBasketDiscountPolicy, а repositoryForCampaign в новый экземпляр DiscountCampaign.

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

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

var repository =
	new SqlDiscountRepository(connectionString);
var discountPolicy =
	new RepositoryBasketDiscountPolicy(repository);
var campaign = new DiscountCampaign(repository);

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

Примечание

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

Принцип подстановки Барбары Лисков

Как утверждалось ранее, принцип подстановки Барбары Лисков является теоретической и абстрактной сущностью. Но в объектно-ориентированном программировании мы можем перефразировать этот принцип следующим образом: Методы, использующие абстракции, должны уметь использовать любой унаследованный класс, ничего при этом не зная о нем. Другими словами, мы должны уметь заменять абстракцию произвольной реализацией, не изменяя при этом точность системы.

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

При достаточном количестве потребителей, скорее всего, поблизости всегда будет находиться один из них, который сохраняет зависимость "живой". Это может казаться проблемой, но такое редко случается: вместо множества схожих экземпляров, мы имеем только один, который сохраняет память. Это настолько завидное качество, что мы формализуем его в паттерне стиля существования Singleton. Несмотря на их схожесть, не путайте его с паттерном проектирования Singleton. Более подробно этот вопрос мы рассмотрим в разделе "Singleton".

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

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

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

Управление жизненным циклом с помощью контейнера

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

Примечание

В данном разделе обсуждаются принципы, лежащие в основе управления жизненными циклами с помощью DI-контейнера, поэтому я не буду подробно рассматривать конкретные контейнеры. Как и на протяжении всей части 3, я использую Poor Man's DI для иллюстрации этих сущностей.

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

Управление стилями существования с помощью специализированного контейнера

В главе 7 мы создали специализированные контейнеры для построения приложений. Одним из таких контейнеров был CommerceServiceContainer. Листинг 7-9 демонстрирует реализацию его метода ResolveProductManagementService; и, как показывает рисунок 8-4, этот метод является единственным кодом в данном классе.

Рисунок 8-4: Вся реализация класса CommerceServiceContainer в настоящий момент располагается в методе ResolveProductManagementService. Метод Release абсолютно ничего не делает, кроме того, в классе нет ни полей, ни свойств. Если вам интересно, почему здесь присутствует метод Release, то мы вернемся к этому вопросу в разделе "Управление устраняемыми зависимостями".

Как вы можете помнить из листинга 7-9, метод Resolve создает полноценную диаграмму зависимостей, каждый раз, когда он вызывается. Другими словами, каждая зависимость является приватной по отношению к рассматриваемому IProductManagementService, и какие-либо связи отсутствуют. Когда экземпляр IProductManagementService выходит за рамки области применения (что происходит всякий раз, когда сервис отвечает на запрос), все зависимости также выходят за рамки области применения. Это часто называют стилем существования Transient (кратковременным), но подробнее о нем мы поговорим в разделе "Transient".

Давайте проанализируем диаграмму объектов, созданную CommerceServiceContainer и продемонстрированную рисунком 8-5, на факт существования возможности для совершенствования.

Рисунок 8-5: Диаграмма объектов, созданная CommerceServiceContainer. Каждый созданный экземпляр ProductManagementService содержит свой собственный ContractMapper и свой собственный SqlProductRepository, который, в свою очередь, содержит собственную строку соединения. Зависимости, показанные справа, являются неизменными.

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

Класс SqlProductRepository, с другой стороны, полагается на Entity Framework Object Context, и считается хорошим тоном использовать для каждого запроса новый экземпляр.

При данной конкретной конфигурации наилучшая реализация CommerceServiceContainer снова использовала бы те же самые экземпляры как ContractMapper, так и строки соединения, при создании новых экземпляров SqlProductRepository. Короче говоря, вам следует сконфигурировать ContractMapper и строку соединения таким образом, чтобы они использовали стиль существования Singleton и SqlProductRepository в виде Transient. Следующий листинг демонстрирует, как реализовать данное изменение.

Листинг 8-2: Управление жизненным циклом с помощью контейнера
public partial class LifetimeManagingCommerceServiceContainer :
	ICommerceServiceContainer
{
	private readonly string connectionString;
	private readonly IContractMapper mapper;
	public LifetimeManagingCommerceServiceContainer()
	{
		this.connectionString =
			ConfigurationManager.ConnectionStrings
			["CommerceObjectContext"].ConnectionString;
		this.mapper = new ContractMapper();
	}
	public IProductManagementService
		ResolveProductManagementService()
	{
		ProductRepository repository =
			new SqlProductRepository(
				this.connectionString);
		return new ProductManagementService(
			repository, this.mapper);
	}
}

Строка 8-11: Создает Singleton зависимости

Строка 16-18: Создает Transient зависимость

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

Каждый раз, когда контейнер просят создать новый экземпляр, он создает Transient экземпляр SqlProductRepository с помощью Singleton строки соединения. В конечном счете, контейнер использует этот Transient repository вместе с Singleton mapper для того, чтобы скомпоновать и вернуть экземпляр ProductManagementService.

Примечание

Код в листинге 8-2 функционально эквивалентен коду из листинга 7-9, но только слегка более эффективен.

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

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

Управление стилем существования с помощью Autofac

Время от времени на протяжении этой книги я делаю передышку от Poor Man's DI, чтобы предоставить пример того, как мы можем достичь результата с помощью шаблонного DI-контейнера. Каждый DI-контейнер имеет свой собственный конкретный API для выражения множества различных признаков; но, несмотря на то, что детали различаются, принципы остаются теми же. Это справедливо и для механизма управления жизненным циклом.

Примечание

Даже термин "управление жизненным циклом" не является вездесущим. Например, Autofac называет этот процесс Областью применения экземпляра (Instance Scope).

В данном разделе мы вкратце будем рассматривать конфигурирование жизненных циклов с помощью Autofac.

Примечание

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

Следующий листинг демонстрирует, как сконфигурировать Autofac с помощью простых Transient зависимостей аналогично примеру из листинга 7-9.

Листинг 8-3: Конфигурирование Autofac с помощью Transient зависимостей
var builder = new ContainerBuilder();
builder.RegisterType<ContractMapper>()
	.As<IContractMapper>();
builder.Register((c, p) =>
	new SqlProductRepository(
		ConfigurationManager
		.ConnectionStrings["CommerceObjectContext"]
		.ConnectionString))
	.As<ProductRepository>();
builder.RegisterType<ProductManagementService>()
	.As<IProductManagementService>();
var container = builder.Build();

Одной из особенностей Autofac является то, что вы не конфигурируете сам контейнер, а конфигурируете ContainerBuilder и используете его для создания контейнера при завершении конфигурации.

Самая простая форма регистрации – это когда вам нужно определить только преобразование между абстракцией и конкретным типом, например, преобразование IContractMapper в ContractMapper. Обратите внимание на то, что конкретный тип указан перед абстракцией, что является порядком, противоположным тому, который используется большинством DI-контейнеров.

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

Использование лямбда-выражений – одна из заявок Autofac на успех. Несмотря на то, что большинство DI-контейнеров на данный момент обладают похожим свойством, Autofac был одним из первых контейнеров, познакомивших нас с лямбда-выражениями. Вы можете использовать лямбда, чтобы указать, как создается класс SqlProductRepository, и что еще более специфично, вы вытягиваете параметр конструктора connectionString из конфигурации приложения.

Преимущество использования лямбда-выражений заключается в том, что они безопасны относительно типов, поэтому вы получаете статическую верификацию создания SqlProductRepository. Недостаток – вы не получаете автоматическую интеграцию, поэтому до тех пор, пока вам не нужно явно указывать параметр конструктора, предпочтительнее всего является более простое преобразование с помощью RegisterType. Это и есть то, как вы преобразуете IProductManagementService в ProductManagementService, поэтому он может воспользоваться преимуществом автоматической интеграции.

Теперь вы можете использовать экземпляр container для создания новых экземпляров IProductManagementService, подобных следующему:

var service = container.Resolve<IProductManagementService>();

Но постойте, а что насчет управления жизненным циклом? Большинство DI-контейнеров обладают стилем существования по умолчанию. В случае Autofac используемый по умолчанию стиль называется Per Dependency, что то же самое, что и стиль существования Transient. Поскольку он является используемым по умолчанию, вам не нужно было указывать его, но если вы захотите, то можете сделать это следующим образом:

builder.RegisterType<ContractMapper>()
	.As<IContractMapper>()
	.InstancePerDependency();

Обратите внимание на то, что вы используете свободный интерфейс регистрации для того, чтобы определить область применения экземпляра (термин Autofac используемый вместо термина "стиль существования") при помощи метода InstancePerDependency.

Также существует Single Instance Scope (единичная область применения), которая соответствует стилю существования Singleton. Вооружившись этими знаниями, вы можете создать Autofac – аналог листинга 8-2:

builder.RegisterType<ContractMapper>()
	.As<IContractMapper>()
	.SingleInstance();
builder.Register((c, p) =>
	new SqlProductRepository(connectionString))
	.As<ProductRepository>();
builder.RegisterType<ProductManagementService>()
	.As<IProductManagementService>();

Вы хотите, чтобы ContractMapper имел стиль существования Singleton, поэтому вы определяете это путем вызова метода SingleInstance. Когда дело касается SqlProductRepository, все становится немного сложнее, поскольку экземпляр SqlProductRepository должен быть Transient, но внедренная строка соединения должна быть Singleton. Вы можете достичь этого, извлекая connectionString из конфигурации приложения (не продемонстрировано, но похоже на то, как это делалось ранее) и используя эту внешнюю переменную в рамках замыкания (closure), которое вы применяете для определения конструктора. Поскольку connectionString является внешней переменной, она остается неизменной в рамках множества вызовов конструктора. Обратите внимание на то, как безоговорочно вы сделали и SqlProductRepository, и ProductManagementService, не указывая при этом стиль существования.

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

Возможность точной настройки стиля существования каждой зависимости важна с точки зрения представления, но также важна и с точки зрения функциональности. Например, паттерн проектирования Mediator (Посредник) полагается на совместно используемый директор, посредством которого взаимодействуют несколько компонентов. Это работает только, когда Mediator совместно используется рассматриваемыми сотрудниками.

До настоящего момента мы обсуждали то, как инверсия управления намекает на то, что потребители не могут управлять жизненными циклами своих зависимостей, поскольку они, очевидно, не контролируют процесс создания объектов, и поскольку .NET использует механизм сборки мусора (garbage collection) пользователи не могут явным образом уничтожать объекты.

Таким образом, вопрос "а как насчет устраняемых зависимостей?" остается без ответа. Сейчас мы обратим свой взор к этому деликатному вопросу.