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

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

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

Марк Симан

13.3. Работа с составными компонентами

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

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

  • Для разных потребителей должны использоваться разные специфичные типы
  • Зависимости являются последовательностями
  • Используются Decorator'ы

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

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

Выбор среди составных кандидатов

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

Давайте для начала повторим, как Autofac работает с составными регистрациями одной и той же абстракции.

Конфигурирование составных реализаций одного и того же сервиса

Как вы уже видели в разделе 13.1.2 "Конфигурирование ContainerBuilder", вы можете конфигурировать составные реализации одного и того же интерфейса:

builder.RegisterType<SauceBéarnaise>().As<IIngredient>();
builder.RegisterType<Steak>().As<IIngredient>();

В этом примере классы Steak и SauceBéarnaise регистрируются в виде сервиса IIngredient. Выигрывает последняя регистрация, поэтому, если мы будем разрешать IIngredient с помощью container.Resolve<IIngredient>(), то получим экземпляр Steak.

Подсказка

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

Можно попросить контейнер разрешить все компоненты IIngredient. Autofac обладает специально предназначеным для этих целей методом, но вместо того, чтобы его использовать Autofac полагается на типы взаимосвязей. Тип взаимосвязи – это тип, обозначающий взаимосвязь, которую может интерпретировать контейнер. К примеру, для обозначения того, что нам нужны все сервисы, мы можем использовать IEnumerable<T>:

var ingredients = container.Resolve<IEnumerable<IIngredient>>();

Обратите внимание на то, что мы используем обычный метод Resolve, но запрашиваем IEnumerable<IIngredient>. Autofac интерпретирует эту конструкцию как соглашение и отдает нам все компоненты IIngredient, которыми он обладает.

Подсказка

В противоположность IEnumerable<T> мы можем также запросить массив. Результаты в обоих случаях будут одинаковы: мы получим все компоненты запрашиваемого типа.

В случае наличия регистраций, которые невозможно разрешить при запрашивании всех сервисов определенного типа, Autofac выдает исключение, поясняющее, что существуют зависимости, неудовлетворяющие условиям. Такое поведение соответствует разрешению единичного компонента, но отличается от поведения Castle Windsor или MEF.

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

builder.RegisterType<Steak>()
	.Named<IIngredient>("meat");
builder.RegisterType<SauceBéarnaise>()
	.Named<IIngredient>("sauce");

Как обычно мы начинаем с метода RegisterType, но вместо того, чтобы после него использовать метод As, мы используем метод Named, чтобы задать тип сервиса, а также его название. Это позволяет нам разрешать именованные сервисы путем передачи этого же имени в метод ResolveNamed:

var meat = container.ResolveNamed<IIngredient>("meat");
var sauce = container.ResolveNamed<IIngredient>("sauce");

Примечание

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

Присваивание имен компонентам с помощью строк – довольно универсальная возможность DI-контейнеров, но Autofac также позволяет идентифицировать компоненты с помощью произвольных ключей:

var meatKey = new object();
builder.RegisterType<Steak>().Keyed<IIngredient>(meatKey);

В роли ключа может выступать любой объект, который впоследствии можно использовать для разрешения компонента:

var meat = container.ResolveKeyed<IIngredient>(meatKey);

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

Подсказка

Если вы обнаружите, что вызываете метод Resolve с конкретным именем или ключом, подумайте над тем, сможете ли вы сменить свой подход на менее неопределенный.

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

Регистрация именованных зависимостей

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

public ThreeCourseMeal(ICourse entrée,
	ICourse mainCourse, ICourse dessert)

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

Листинг 13-2: Регистрация именованных course
builder.RegisterType<Rillettes>()
	.Named<ICourse>("entrée");
builder.RegisterType<CordonBleu>()
	.Named<ICourse>("mainCourse");
builder.RegisterType<MousseAuChocolat>()
	.Named<ICourse>("dessert");

В этом примере вы регистрируете три именованных компонента, преобразуя Rilettes в экземпляр под названием "entrée", CordonBleu – в экземпляр с именем "mainCourse", а MousseAuChocolat – в экземпляр под названием "dessert".

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

Листинг 13-3: Переопределение автоматической интеграции
builder.RegisterType<ThreeCourseMeal>()
	.As<IMeal>()
	.WithParameter(
		(p, c) => p.Name == "entrée",
		(p, c) =>
			c.ResolveNamed<ICourse>("entrée"))
	.WithParameter(
		(p, c) => p.Name == "mainCourse",
		(p, c) =>
			c.ResolveNamed<ICourse>("mainCourse"))
	.WithParameter(
		(p, c) => p.Name == "dessert",
		(p, c) =>
			c.ResolveNamed<ICourse>("dessert"));

Строка 3: Определение параметра

Строка 4: Фильтры

Строка 5-6: Определение значений

Метод WithParameter позволяет предоставлять значения параметров для конструктора ThreeCourseMeal. Одна из этих перегрузок принимает в качестве входных параметров два аргумента. Первый аргумент – предикат, который определяет, является ли этот параметр результатом этого конкретного вызова метода. Для первого параметра вы задаете условие, что он касается только параметра под названием entree. Если это выражение имеет значение true, то выполняется второй блок кода, определяющий значение для параметра entree. Параметр c – это экземпляр IComponentContext, который можно использовать для разрешения именованного компонента entree.

Подсказка

Аргументы метода WithParameter – это разновидность паттерна Tester-Doer.

Рассмотрим подробнее то, что происходит. Метод WithParameter действительно является оберткой класса ResolvedParameter, который обладает следующим конструктором:

public ResolvedParameter(
	Func<ParameterInfo, IComponentContext, bool> predicate,
	Func<ParameterInfo, IComponentContext, object> valueAccessor);

Параметр predicate – это тест, который определяет, будет ли вызываться делегат valueAccessor: если параметр predicate возвращает true, то для определения значения параметра вызывается valueAccessor. Оба делегата принимают в качестве входных данных одну и ту же информацию: информацию о параметре в виде объекта ParameterInfo и IComponentContext, который можно использовать для разрешения других компонентов. Когда Autofac использует экземпляры ResolvedParameter, то при вызове делегатов он возвращает оба эти значения. Иногда нет другого варианта, кроме как старательно использовать метод WithParameter для каждого параметра конструктора. Но в остальных случаях можно воспользоваться преимуществами соглашений.

Разрешение именованных компонентов с помощью соглашения

Если вы внимательно проанализировали листинг 13-3, то возможно обратили внимание на повторяющийся паттерн. Каждый вызов метода WithParameter относится только к одному параметру конструктора, но каждый valueAccessor выполняет то же самое: он использует IComponentContext для разрешения компонента ICourse, имеющего то же название, что и параметр.

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

Листинг 13-4: Переопределение автоматической интеграции с помощью соглашения
builder.RegisterType<ThreeCourseMeal>()
	.As<IMeal>()
	.WithParameter(
		(p, c) => true,
		(p, c) => c.ResolveNamed(p.Name, p.ParameterType));

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

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

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

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

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

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

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

Интеграция последовательностей

В разделе 10.3.2 "Разработка пользовательского стиля существования" мы обсуждали, как выполнить рефакторинг явного класса ThreeCourseMeal к более универсальному классу Meal, который обладает приведенным ниже конструктором:

public Meal(IEnumerable<ICourse> courses)

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

Автоматическая интеграция последовательностей

Autofac довольно хорошо разбирается в последовательностях, поэтому, если нам необходимо использовать все зарегистрированные компоненты этого сервиса, то нам нужен как раз механизм автоматической интеграции. Например, имея сконфигурированные в листинге 13-2 экземпляры ICourse, можно сконфигурировать сервис IMeal следующим образом:

builder.RegisterType<Meal>().As<IMeal>();

Обратите внимание на то, что это совершенно стандартное преобразование конкретного типа в абстракцию. Autofac будет автоматически понимать конструктор Meal и определять, что правильным направлением действия является разрешение всех компонентов ICourse. При разрешении IMeal вы получите экземпляр Meal, компонентами которого являются ICourse из листинга 13-2: Rillettes, CordonBleu и MousseAuChocolat.

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

Отбор нескольких компонентов из большого набора

Используемая Autofac по умолчанию стратегия внедрения всех компонентов зачастую является правильной линией поведения. Но, как показывает рисунок 13-6, возможны случаи, когда нам нужно отобрать только несколько зарегистрированных компонентов из большого набора всех зарегистрированных компонентов.

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

Когда мы ранее позволяли Autofac автоматически интегрировать все сконфигурированные экземпляры, это соответствовало ситуации, изображенной в правой части рисунка 13-6. Если нам нужно зарегистрировать компонент так, как это показано в левой части рисунка, то мы должны явно определить, какие компоненты необходимо использовать.

Для получения такого результата можно еще раз применить метод WithParameter так, как вы уже использовали его в листингах 13-3 и 13-4. Все это время вы работали с конструктором Meal, который в качестве входной информации принимал только один-единственный параметр. В следующем листинге продемонстрировано, как можно реализовать составляющую метода WithParameter, которая предоставляет значение параметра, так, чтобы явным образом отбирать именованные компоненты из IComponentContext.

Листинг 13-5: Внедрение именованных компонентов в последовательность
builder.RegisterType<Meal>()
	.As<IMeal>()
	.WithParameter(
		(p, c) => true,
		(p, c) => new[]
		{
			c.ResolveNamed<ICourse>("entrée"),
			c.ResolveNamed<ICourse>("mainCourse"),
			c.ResolveNamed<ICourse>("dessert")
		});

Как вы уже видели в разделе 13.3.1 "Выбор среди составных кандидатов", метод WithParameter в качестве входных параметров принимает два делегата. Первый – это предикат, который используется для того, чтобы определить, должен ли вызываться второй делегат. В этом примере мне захотелось полениться, и я вернул значение true. Вы знаете, что у конструктора класса Meal есть только один параметр, поэтому метод WithParameter будет работать. Тем не менее, если вы впоследствии измените класс Meal таким образом, что у его конструктора будет два параметра, метод WithParameter уже не будет работать корректно. Поэтому безопаснее всего будет установить явную проверку имени параметра.

Второй делегат предоставляет значение для параметра. Для разрешения трех именованных компонентов в массив вы используете IComponentContext. В результате получаем массив ICourse, который сравним с IEnumerable<ICourse>.

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

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

Интеграция Decorator'ов

В разделе 9.1.2 "Паттерны и принципы механизма перехвата" мы обсуждали то, насколько паттерн проектирования Decorator полезен при реализации сквозных сущностей. По определению Decorator'ы представляют собой составные типы одной и той же абстракции. У нас есть, по крайней мере, две реализации абстракции: сам Decorator и вложенныйй тип. Если бы мы помещали Decorator'ы в стек, то у нас было бы еще больше реализаций.

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

Создание обертки с помощью метода WithParameter

Метод WithParameter предлагает универсальный способ определения того, как создаются и внедряются компоненты. В разделах 13.3.1 "Выбор среди составных кандидатов" и 13.3.2 "Интеграция последовательностей" вы уже видели, как можно использовать метод WithParameter для отбора конкретных компонентов для параметров конструктора. Кроме того, метод WithParameter – это отличный способ предоставления параметров для Decorator'ов.

Рассмотрим способы использования метода WithParameter для конфигурирования класса Breading, который является оберткой IIngredient. Для получения экземпляра, оберткой которого должен стать WithParameter, он использует паттерн Constructor Injection:

public Breading(IIngredient ingredient)

Чтобы получить Cotoletta, вам следует обернуть VealCutlet (еще один IIngredient) в класс Breading. То есть вы собираетесь внедрить VealCutlet в Breading. В следующем листинге продемонстрировано, как для этих целей можно использовать метод WithParameter.

Листинг 13-6: Создание обертки с помощью метода WithParameter
builder.RegisterType<VealCutlet>().Named<IIngredient>("cutlet");
builder.RegisterType<Breading>()
	.As<IIngredient>()
	.WithParameter(
		(p, c) => p.ParameterType == typeof(IIngredient),
		(p, c) => c.ResolveNamed<IIngredient>("cutlet"));

Breading – это Decorator, но вам же нужно что-то обертывать, поэтому вы регистрируете VealCutlet в виде именованного компонента. В этом примере вы регистрируете VealCutlet перед Breading, но можно сделать это и по-другому. Порядок регистраций не имеет значения.

При регистрации Breading для определения параметра ingredient конструктора класса Breading вы используете метод WithParameter. Вы реализуете предикат, проверяя, что тип параметра – IIngredient, и предоставляете значение для параметра путем разрешения именованного компонента cutlet из заданного IComponentContext.

В этом примере вы использовали именованную регистрацию IIngredient для регистрации компонента VealCutlet. Это делает компонент Breading компонентом IIngredient по умолчанию. Еще один вариант – регистрировать VealCutlet и как IIngredient, и как VealCutlet. В следующем примере продемонстрирован этот подход в сочетании со строго типизированным делегатом.

Создание обертки с помощью делегатов

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

builder.RegisterType<VealCutlet>()
	.As<IIngredient, VealCutlet>();
builder.Register(c => new Breading(c.Resolve<VealCutlet>()))
	.As<IIngredient>();

В качестве альтернативы регистрации VealCutlet в виде именованного компонента можно также регистрировать его и как IIngredient, и как VealCutlet. При таком подходе важно делать это до регистрации Breading, псокольку в противном случае VealCutlet станет компонентом IIngredient по умолчанию.

Вместо метода RegisterType, который вы в основном использовали до настоящего момента, можно также зарегистрировать сервис и с помощью метода под названием Register. Существует две перегрузки этого метода, и каждая из них принимает в качестве входного параметра делегат, который создает рассматриваемый сервис. Чтобы зарегистрировать сервис IIngredient, вы реализуете блок кода, который создает новый экземпляр Breading путем прямого вызова конструктора. Чтобы передать значение в параметр конструктора ingredient, вы разрешаете тип VealCutlet из переданного IComponentContext. Это возможно, поскольку вы зарегистрировали VealCutlet как конкретный тип, а также как IIngredient.

Примечание

Вы также могли бы разрешить VealCutlet по имени, если бы зарегистрировали его в виде именованного компонента, как делали это в предыдущем примере.

Если вы попросите контейнер разрешить IIngredient, он передаст IComponentContext в качестве входного параметра в блок кода, который вы определили в методе Register. При выполнении блока кода из контекста разрешается экземпляр VealCutlet и передается в конструктор Breading, который возвращает экземпляр Breading.

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

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

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

Примечание

В этом разделе мы не обсуждали механизм перехвата во время выполнения. Несмотря на то, что Autofac имеет Seam'ы, которые позволяют использовать механизм перехвата, он также обладает встроенной поддержкой динамически создаваемых прокси. Эти Seam'ы можно применять, чтобы использовать для создания таких классов другие библиотеки (например, Castle Dynamic Proxy). Но поскольку они не являются частью Autofac, эта тема выходит за рамки этой главы.

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

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