Да-да, вы все правильно поняли, это статья об еще одном велосипеде — о моем Dependency Injection (DI) контейнере. За окном уже 2015-ый год, и самых разных контейнеров на любой вкус и цвет полным полно. Зачем может понадобиться еще один?
Во-первых, он может просто образоваться сам собой! Мы в Эльбе довольно долго использовали этот контейнер, и некоторые из описываемых в статье идей (Factory Injection, Generics Inferring, Configurators) изначально были реализованы поверх него через публичное API.
Во-вторых, для большого проекта DI-контейнер — существенная часть инфраструктуры, во многом определяющая организацию кода. Простой, гибкий и легко модифицируемый контейнер часто позволяет найти элегантное решение специфической проблемы, избежать связности отдельных компонент, многословного и шаблонного прикладного кода. При решении конкретной задачи можно вывести некоторый паттерн, реализовать его на уровне контейнера и затем повторно использовать в других задачах.
В-третьих, DI-контейнер — относительно простая штука. Он очень хорошо поддается разработке в режиме TDD, за счет чего делать его становится весело и приятно.
Эта статья — не введение в DI. На эту тему есть много других прекрасных публикаций, в том числе и на Хабре. Скорее здесь собран набор рецептов приготовления DI так, чтобы получившееся блюдо было вкусным, но не острым. Если у вас DI-контейнер в продакшене или вы написали свой собственный самый лучший контейнер, то здесь отличное место для холиваров о том, чей контейнер круче!
Мотивация и Api
Основной посыл контейнера — Convention Over Configuration. Какой смысл мучить пользователя, требуя от него явно указывать соответствие интерфейса реализации, если этот интерфейс имеет всего одну доступную реализацию? Почему бы просто не подставить ее, сэкономив время для решения более важных вопросов? Как оказалось, сходный принцип действует и во многих других ситуациях. Что, например, контейнер мог бы подставить в параметр конструктора типа IEnumerable или Func, чтобы принести наибольшую пользу? Об этом поговорим чуть позже.
Код контейнера писался исключительно под конкретные практические задачи. Это позволило сконцентрироваться на небольшом числе наиболее полезных возможностей и игнорировать все остальные. Так, например, контейнер поддерживает только один lifestyle – singletone. Это означает, что экземпляры всех классов создаются по требованию и запоминаются во внутреннем кэше контейнера до его разрушения. Контейнер реализует IDisposable, перевызывая Dispose на поддерживающих его объектах из кэша. Порядок вызова Dispose на разных сервисах определяется зависимостями между ними: если сервис A зависит от сервиса B, то Dispose на A будет вызван раньше Dispose на B. Чтобы создать дерево сервисов на время и затем разрушить его можно воспользоваться методом Clone на контейнере. Он возвращает новый контейнер с той же конфигурацией, что и исходный, но с пустым кэшем экземпляров.
Основными методами контейнера являются Resolve и BuildUp. Первый возвращает экземпляр по типу, применяя constructor injection, второй использует property injection для инициализации уже созданного объекта. Метод BuildUp имеет смысл использовать только если применение Resolve затруднительно.
Учитывая, что контейнер много решений принимает самостоятельно, для целей отладки он поддерживает метод GetConstructionLog. С помощью него можно для любого сервиса в любой момент времени получить описание процесса создания. Это описание представляет собой дерево, листьями которого являются либо сервисы, не имеющие параметров конструктора, либо конкретные примитивные значения, подсказанные контейнеру через конфигурационное API.
Sequence Injection
Это самый простой и в то же время довольно мощный прием: если класс в параметре конструктора принимает массив или IEnumerable, то контейнер подставит в этот параметр все подходящие реализации, какие сможет найти. В своей дальнейшей работе класс может в любой момент выбрать из списка определенную реализацию и делегировать ей часть своих функций. Или, например, оповестить все реализации о наступлении определенного события.
Рассмотрим пример. Пусть нам необходимо поднять http-сервер, обслуживающий некоторый фиксированный набор адресов. За обработку каждого адреса ответственен отдельный блок кода, который удобно представить таким интерфейсом:
public interface IHttpHandler
{
string UrlPrefix { get; }
void Handle(HttpContext context);
}
Тогда логику диспетчеризации запрос-обработчик можно очень просто выразить следующим образом:
public class HttpDispatcher
{
private IEnumerable<IHttpHandler> handlers;
public HttpDispatcher(IEnumerable<IHttpHandler> handlers)
{
this.handlers = handlers;
}
public void Dispatch(HttpContext context)
{
handlers.Single(h => context.Url.StartsWith(h.Prefix)).Handle(context);
}
}
Контейнер находит все доступные реализации IHttpHandler, создает по одному экземпляру каждой из них и подставляет получившийся список в параметр handlers. Заметьте, что для добавления нового обработчика достаточно просто создать новый класс, реализующий IHttpHandler — контейнер сам найдет его и передаст в конструктор HttpDispatcher. Этим довольно просто достигается соблюдение SRP и OCP.
Другой вариант использования Sequence Injection — оповещение о событии:
public class UserService
{
private readonly IDatabase database;
private readonly IEnumerable<IUserDeletedHandler> handlers;
public UserService(IDatabase database, IEnumerable<IUserDeletedHandler> handlers)
{
this.database = database;
this.handlers = handlers;
}
public void DeleteUser(Guid userId)
{
database.DeleteUser(userId);
foreach (var handler in handlers)
handler.OnUserDeleted(userId);
}
}
Удаление пользователя может влиять на ряд компонентов системы. Например, часть из них могут иметь сущности, ссылающиеся на удаленного пользователя. Чтобы правильно обработать эту ситуацию, такому компоненту достаточно просто реализовать интерфейс IUserDeletedHandler. При этом, если появится новый такой компонент или сущность, нет необходимости править код UserService — достаточно, в соответствии с OCP, просто добавить обработчик IUserDeletedHandler.
Factory Injection
Иногда бывает нужно создать новый экземпляр сервиса. Тому могут быть разные причины. Очевидный пример — сервис в конструкторе принимает параметр, значение которого становится известно лишь на этапе выполнения. Или, возможно, сервис должен быть пересоздан по некоторым архитектурным соображениям. Так, например, класс DataContext из стандартного ORM Linq2Sql рекомендуют пересоздавать на каждый http-запрос, т.к. иначе он начинает съедать слишком много памяти. В любом случае действовать можно примерно так:
public class Calculator
{
private readonly SomeService someService;
private readonly int factor;
public A(SomeService someService, int factor)
{
this.someService = someService;
this.factor = factor;
}
public int Calculate()
{
return someService.SomeComplexCalculation() * factor;
}
}
public class Client
{
private readonly Func<object, Calculator> createCalculator;
public Client(Func<object, Calculator> createCalculator)
{
this.createCalculator = createCalculator;
}
public int Calculate(int value)
{
var instance = createCalculator(new { factor = value });
return instance.Calculate();
}
}
Механика создания реализуется через принимаемый в конструкторе делегат. Этот делегат генерируется контейнером таким образом, что при его вызове всегда будет создаваться новый экземпляр Calculator. Через object-аргумент с помощью анонимного типа можно передать параметры создаваемого сервиса. Соответствие параметров происходит по имени — член анонимного типа factor попадает в параметр factor конструктора Calculator. Для параметра конструктора someService не указано значение в анонимном типе, поэтому контейнер при его получении будет руководствоваться стандартными правилами.
Основной минус здесь в том, что проверка имени/типа параметров откладывается с этапа компиляции до этапа выполнения. Аналогично ключевому слову dynamic, это требует отдельного внимания при добавлении/удалении/переименовании параметров и дополнительных интеграционных тестов. Тем не менее, на практике это не приводит к существенным проблемам. В основном из-за того, что использовать Factory Injection приходится не очень часто. В наших проектах во всей базе кода из тысяч классов таких ситуаций всего несколько штук. Во-вторых, даже в этих случаях ошибки с передачей параметров обычно очень простые и легко выявляются — при вызове делегата контейнер делает проверку параметров аналогично тому, как компилятор это делает при компиляции.
Generics Inferring
Довольно часто контейнер может сам выбрать не только реализацию интерфейса, но и generic-аргументы. Для примера рассмотрим интерфейс простой шины сообщений:
public interface IBus
{
void Publish<TMessage>(TMessage message);
void Subscribe<TMessage>(Action<TMessage> action);
}
Через IBus можно публиковать сообщения и подписываться на их обработку. Механика доставки сообщений здесь не важна, но обычно это та или иная queue-система (RabbitMQ, MSMQ и т.п.). Конкретный обработчик сообщений удобно представить таким интерфейсом:
public interface IHandleMessage<in TMessage>
{
void Handle(TMessage message);
}
Для обработки нового типа сообщений достаточно просто реализовать IHandleMessage с соответствующим generic-аргументом:
public class UserRegistered
{
}
public class UserRegisteredHandler : IHandleMessage<UserRegistered>
{
public void Handle(UserRegistered message)
{
//whatever
}
}
Теперь нам нужно для каждой реализации IHandleMessage вызвать Subscribe. Сделать это легко для конкретного IHandleMessage:
public static class MessageHandlerHelper
{
public static void SubscribeHandler<TMessage>(IBus bus, IHandleMessage<TMessage> handler)
{
bus.Subscribe<TMessage>(handler.Handle);
}
}
Но с каким generic-аргументом нам вызывать метод SubscribeHandler? И откуда взять все такие правильные аргументы и соответствующие реализации IHandleMessage? В идеале, хотелось бы свести ситуацию к примеру из Sequence Injection, просто заинжектив IEnumerable от чего-то, поручив тем самым контейнеру задачу поиска всех реализаций IHandleMessage.
Для этого перенесем generic-аргумент с уровня метода на уровень класса, а то, что получилось спрячем за не-generic интерфейсом:
public interface IMessageHandlerWrap
{
void Subscribe();
}
public class MessageHandlerWrap<TMessage> : IMessageHandlerWrap
{
private readonly IHandleMessage<TMessage> handler;
private readonly IBus bus;
public MessageHandlerWrap(IHandleMessage<TMessage> handler, IBus bus)
{
this.handler = handler;
this.bus = bus;
}
public void Subscribe()
{
bus.Subscribe<TMessage>(handler.Handle);
}
}
public class MessagingHost
{
private readonly IEnumerable<IMessageHandlerWrap> handlers;
public MessagingHost(IEnumerable<IMessageHandlerWrap> handlers)
{
this.handlers = handlers;
}
public void Subscribe()
{
foreach (var handler in handlers)
handler.Subscribe();
}
}
Как это работает? Для создания MessagingHost контейнеру необходимо получить все реализации IMessageHandlerWrap. Есть только один класс, реализующий этот интерфейс — MessageHandlerWrap<TMessage>, но чтобы его создать, нужно указать конкретное значение generic-аргумента. Для этого контейнер рассматривает параметр конструктора типа IHandleMessage<TMessage> — существование подходящей реализации IHandleMessage<X> является необходимым условием для создания MessageHandlerWrap<X>. Для IHandleMessage<TMessage> существует реализация — это класс UserRegisteredHandler, закрывающий IHandleMessage через UserRegistered. Таким образом, контейнер подставит в параметр handlers MessagingHost-а экземпляр MessageHandlerWrap<UserRegistered>.
Этот вариант закрытия generic-ов основан на анализе зависимостей. Приведенная выше цепочка рассуждений легко распространяется на случай произвольного числа generic-аргументов и произвольной вложенности одних generic-сервисов в другие. Текущая реализация контейнера корректно обрабатывает эти общие случаи.
Другой вариант закрытия generic-ов основан на ограничениях (generic constraints). Он может быть полезен в тех случаях, когда у generic-сервиса нет generic-зависимостей. Пусть в примере из Sequence Injection зависимые от пользователя сущности реализуют следующий интерфейс:
public interface IUserEntity
{
Guid UserId { get; }
}
Тогда для удаления всех таких сущностей достаточно одного обобщенного обработчика:
public class DeleteDependenciesWhenUserDeleted<TEntity>: IUserDeletedHandler
where TEntity : IUserEntity
{
private readonly IDatabase database;
public DeleteDependenciesWhenUserDeleted(IDatabase database)
{
this.database = database;
}
public void OnDeleted(User entity)
{
foreach (var child in database.Select<TEntity>(x => x.UserId == entity.id))
database.Delete(child);
}
}
Контейнер создаст по одному экземпляру DeleteDependenciesWhenUserDeleted для каждого из классов, реализующих IUserEntity.
Configurators
Контейнер предоставляет конфигурационное API, через которое можно подсказывать ему, как вести себя в определенной ситуации:
public interface INumbersProvider
{
IEnumerable<int> ReadAll();
}
public class FileNumbersProvider : INumbersProvider
{
private readonly string fileName;
public FileNumbersProvider(string fileName)
{
this.fileName = fileName;
}
public IEnumerable<int> ReadAll()
{
return File.ReadAllLines(fileName).Select(int.Parse).ToArray();
}
}
public class FileNumbersProviderConfigurator : IServiceConfigurator<FileNumbersProvider>
{
public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<FileNumbersProvider> builder)
{
builder.Dependencies(new { fileName = "numbers.txt" });
}
}
Здесь через метод Dependencies мы указываем конкретное значение параметра конструктора. Как и в Factory Injection, привязка происходит по имени параметра.При создании контейнера он сканирует переданные ему сборки и вызывает метод Configure на всех найденных реализациях IServiceConfigurator. По соглашению, конфигурация класса X должна находиться в классе XConfigurator, расположенном в папке Configuration той же сборки, хотя это и не обязательно. Помимо параметров конструктора, с помощью методов ServiceConfigurationBuilder можно выбрать определенную реализацию интерфейса или, например, указать делегат, который контейнер должен использовать для создания класса:
public class LogConfigurator : IServiceConfigurator<ILog>
{
public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<ILog> builder)
{
builder.Bind(c => c.target == null ? LogManager.GetLogger("root") : LogManager.GetLogger(c.target));
}
}
Параметр этого делегата содержит свойство target — тип создаваемого класса-клиента ILog. Этот тип будет равен null, если клиента нет, т.е. был вызван метод Resolve() на контейнере.
Хардкодить конкретные значения параметров конструктора в коде может некоторым показаться сомнительным решением. На практике, однако, большая часть настроек (размер кэша, длина очереди, значения таймаутов, номера tcp-портов) меняется крайне редко. Они жестко привязаны к использующему их коду. Их изменение — ответственный шаг, требующий понимания нюансов работы этого кода и потому мало чем отличающийся от изменения самого кода.
Другое нетипичное решение — создание для каждого сервиса отдельного класса-конфигуратора. Основной профит от этого — очень простая структура конфигурационного кода. Это во многом упрощает жизнь. Так, во-первых, чтобы понять, как именно создается класс X достаточно поискать решарпером класс XConfigurator — действие, занимающее секунды. Во-вторых, если описывать конфигурацию разных сервисов в одном классе (модули в Ninject или Autofac, например), то высока вероятность возникновения свалки, т.к. строки кода, конфигурирующие разные классы, зачастую никак не связаны друг с другом. В production-проекте с десятком тысяч классов, сотни из которых нуждаются в конфигурировании, такой модуль может стать нечитаемым. В третьих, сама абстракция модуля зачастую неочевидна — не всегда может быть просто очертить те рамки, где заканчивается один модуль и начинается другой. Специально думать об этом только для организации конфигурационного кода кажется избыточным.
PrimaryAssembly
Рассмотрим довольно типичную ситуацию: FileNumbersProvider и его конфигуратор из примера выше лежат в некоторой общей Class Library Lib.dll и используются в большом числе консольных приложений. В каждом из них FileNumbersProvider работает с файлом «numbers.txt» – и это как раз то, что нужно. Но что делать, если вдруг появляется новая консолька A.exe, в которой имя файла должно быть “a.txt”? Можно, конечно, убрать FileNumbersProviderConfigurator из Lib.dll и раскопипастить его в каждой из консолек, указывая правильное значение имени файла. Или внутри общего конфигуратора читать имя файла из другого файла с настройками (для этого контейнер предоставляет метод Settings на ConfigurationContext-е). Но можно поступить иначе — просто добавить в A.exe конфигуратор для FileNumbersProvider с правильным именем файла. Это сработает за счет того, что контейнер сначала запустит конфигуратор из Lib.dll, а потом конфигуратор из A.exe, и последний перебьет действие первого. Такой порядок запуска обеспечивается простым правилом: все конфигураторы не из PrimaryAssembly запускаются перед всеми конфигураторами из PrimaryAssembly. Конкретная сборка, которую следует считать PrimaryAssembly, указывается при создании контейнера.
Profiles
Довольно часто способ создания того или иного сервиса зависит от окружения. Например, в режиме модульного тестирования для INumbersProvider естественно использовать некоторую inmemory-реализацию — InMemoryNumbersProvider, при запуске на боевых серверах — FileNumbersProvider с одним значением имени файла, а в режиме ручного тестирования — с другим. Решением этой проблемы служит концепция профилей. Профиль — это любой класс, реализующий экспортируемый контейнером маркерный интерфейс IProfile. Тип профиля можно передать при создании контейнера, и его текущее значение будет доступно внутри конфигуратора через ConfigurationContext. Обычно профили используются так:
public class InMemoryProfile : IProfile
{
}
public class IntegrationProfile : IProfile
{
}
public class ProductionProfile : IProfile
{
}
public class NumbersProviderConfigurator : IServiceConfigurator<INumbersProvider>
{
public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<INumbersProvider> builder)
{
if (context.ProfileIs<InMemoryProfile>())
builder.Bind<InMemoryNumbersProvider>();
else
builder.Bind<FileNumbersProvider>();
}
}
public class FileNumbersProviderConfigurator : IServiceConfigurator<FileNumbersProvider>
{
public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<FileNumbersProvider> builder)
{
var fileName = context.ProfileIs<ProductionProfile>() ? "productionNumbers.txt" : "integrationNumbers.txt";
builder.Dependencies(new { fileName });
}
}
Различные приложения могут определять свои наборы профилей, но обычно этих трех вполне достаточно.
Contracts
Выше уже упоминалось, что Factory Injection на практике необходим бывает довольно редко. Большую часть системы обычно удается описать в виде дерева сервисов, элементы которого закэшированы на уровне контейнера. Такая «синглтоновая» модель очень удобна своей простотой. Чтобы воспользоваться некоторым сервисом классу-клиенту достаточно просто принять его в конструкторе. Ему не нужно заботиться о том, как этот сервис будет создан и в какой момент разрушен – во всем этом он может положиться на контейнер.
Однако, довольно часто сервис условно можно назвать синглтоном «локально», но не «глобально». Большому дереву сервисов, реализующему сложную бизнес-логику и имеющему среди своих листьев некоторую абстракцию над источником данных для нее, совершенно не обязательно знать, что контейнер создаст его в двух экземплярах, подставив в качестве этого источника в первом случае один файл, а во втором – другой. Причина и следствие здесь отделены друг от друга несколькими уровнями абстракций, которыми оперирует это дерево. Причина – конкретный параметр конструктора в некотором сервисе, куда по логике приложения нужно подставить экземпляр дерева с определенным именем файла. Следствие – использование этого имени файла соответствующими листьями дерева.
Описанного выше обычно достигают либо протаскиванием параметра через все дерево, либо созданием фабрик, подставляющих этот параметр в правильные элементы дерева. Контейнер предлагает более естественное решение:
public class StatCalculator
{
private readonly FileNumbersProvider numbers;
public StatCalculator(FileNumbersProvider numbers)
{
this.numbers = numbers;
}
public double Average()
{
return numbers.ReadAll().Average();
}
}
public class StatController
{
private readonly StatCalculator historyCalculator;
private readonly StatCalculator mainCalculator;
public StatController([HistoryNumbersContract] StatCalculator historyCalculator, [MainNumbersContract] StatCalculator mainCalculator)
{
this.historyCalculator = historyCalculator;
this.mainCalculator = mainCalculator;
}
public int HistoryAverage()
{
return historyCalculator.Average();
}
public int MainAverage()
{
return mainCalculator.Average();
}
}
public class FileNumbersProviderConfigurator : IServiceConfigurator<FileNumbersProvider>
{
public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<FileNumbersProvider> builder)
{
builder.Contract<HistoryNumbersContract>().Dependencies(new { fileName = “history” });
builder.Contract<MainNumbersContract>().Dependencies(new { fileName = “main” });
}
}
Атрибуты [InMemoryNumbersContract] и [FileNumbersContract] должны быть унаследованы от предоставляемого контейнером [RequireContractAttribute]. По сути такой атрибут — это просто метка, с помощью которой можно объявить некоторый именованный контекст. Это объявление можно сделать сразу в нескольких местах дерева либо на уровне параметра конструктора, либо на уровне класса. Определение контракта по структуре ничем не отличается от обычного конфигурационного кода — метод Contract на билдере возвращает новый билдер, с помощью которого можно снабдить контракт определенным смыслом. Заданная таким образом конфигурация действует на помеченный атрубутом-контрактом параметр и на все расположенное под ним поддерево. Для этого контейнер автоматически создает новый экземпляр сервиса, если тот существенным образом зависит от конфигурации текущего контракта. Процесс пересоздания экземпляров поднимается рекурсивно вверх, пока не достигнет помеченного контрактом параметра.
Дерево сервисов может содержать несколько контрактов на пути от корня к листьям. В этом случае, если несколько из этих контрактов определяют конфигурацию одного и того же сервиса, то действует простое стековое правило — используется конфигурация контракта, ближайшего к точке использования сервиса. Если же некоторый сервис из помеченного контрактом поддерева не использует конфигурацию контракта, то гарантируется, что используемый для него экземпляр будет в точности тем же, как если бы метки контракта не существовало. Иными словами, если где-то в другой ветви дерева зависимостей этот сервис встретится без контракта, то для него будет использован тот же экземпляр соответствующего класса.
Конфигурацию можно навешивать на последовательности контрактов:
public class FileNumbersProviderConfigurator : IServiceConfigurator<FileNumbersProvider>
{
public void Configure(ConfigurationContext context, ServiceConfigurationBuilder<FileNumbersProvider> builder)
{
builder.Contract<HistoryNumbersContractAttribute>()
.Contract<ArchiveContractAttribute>()
.Dependencies(new { "archiveHistoryNumbers.txt" });
}
}
В этом случае «archiveHistoryNumbers.txt» будет использовано только если объявленная на пути от корня последовательность контрактов содержит HistoryNumbersContractAttribute и ArchiveContractAttribute в указанном порядке.
Можно также определить контракт как объединение других контрактов:
public class AllNumbersConfigurator : IContainerConfigurator
{
public void Configure(ConfigurationContext context, ContainerConfigurationBuilder builder)
{
builder.Contract<AllNumbersContractAttribute>()
.Union<HistoryNumbersContract>()
.Union<MainNumbersContract>()
}
}
Смысл такого объединения в том, что иногда необходимо обработать сразу несколько задаваемых контрактами контекстов:
public class StatController
{
private readonly IEnumerable<StatCalculator> statCalculators;
public StatController([AllNumbersContract] IEnumerable<StatCalculator> statCalculators)
{
this.statCalculators = statCalculators;
}
public int Sum()
{
return statCalculators.Sum(c => c.Sum());
}
}
Контейнер переберет все контракты из объединения, и получившийся для каждого из них экземпляр StatCalculator подставит в последовательность statCalculators.
Контракты позволяют описывать состояния сервиса только для статического, конечного набора конфигураций, когда все возможные варианты дерева зависимостей известны на этапе конфигурирования. Если же имя файла для FileNumbersProvider-а вводится пользователем, то гораздо естественнее будет просто передать его параметром через цепочку StatController -> StatCalculator -> FileNumbersProvider.
Optional Injection
Конфигураторы позволяют запретить использование некоторой реализации интерфейса или конкретного экземпляра класса:
public class FileNumbersProviderConfigurator : IServiceConfigurator<FileNumbersProvider>
{
public void Configure(ConfigurationContext c, ServiceConfigurationBuilder<FileNumbersProvider> b)
{
b.WithInstanceFilter(p => p.ReadAll().Any());
}
}
public class InMemoryNumbersProviderConfigurator : IServiceConfigurator<InMemoryNumbersProvider>
{
public void Configure(ConfigurationContext c, ServiceConfigurationBuilder<InMemoryNumbersProvider> b)
{
b.DontUse();
}
}
Метод WithInstanceFilter накладывает фильтр на все создаваемые контейнером экземпляры FileNumbersProvider — клиенты получат только те из них, которые смогут вернуть хотя бы одно число. Метод DontUse полностью запрещает использование InMemoryNumbersProvider. Конструктор класса также может принять решение, что в определенной ситуации создаваемый им экземпляр не должен использоваться клиентами контейнера. Чтобы сообщить об этом контейнеру, конструктор должен кинуть специальное исключение — ServiceCouldNotBeCreatedException. Это будет эквивалентно использованию метода WithInstanceFilter в конфигураторе.
Если создание зависимости некоторого сервиса было запрещено одним из описанных способов, то создание самого сервиса также будет считаться запрещенным. Такой процесс последовательного исключения сервисов будет подниматься рекурсивно вверх по дереву зависимостей пока не достигнет вызова Resolve на контейнере. В этот момент будет сгенерировано исключение о том, что для данного сервиса не удалось получить ни одной реализации. Другой вариант остановки этого процесса – если на его пути встретится параметр конструктора, имеющий тип последовательности (Sequence Injection). В этом случае данный элемент последовательности просто будет пропущен. Есть и третий вариант остановки – когда параметр помечен как опциональный:
public class StatController
{
private readonly StatCalculator statCalculator;
public StatController([Optional] StatCalculator statCalculator)
{
this.statCalculator = statCalculator;
}
public int InMemorySum()
{
return statCalculator == null ? 0 : statCalculator.Sum();
}
}
Предоставляемый контейнером атрибут [Optional] декларирует, что при невозможности создать соответствующий сервис в параметр должен быть передан null. Того же эффекта можно добиться, использовав значение параметра по умолчанию ( = null) или пометив параметр атрибутом [CanBeNull] из библиотеки JetBrains.Annotations.
Допустим теперь, что сервис A имеет две неопциональные зависимости B и C. Допустим также, что контейнер успешно создал B, но создание C было запрещено. Тогда создание A тоже будет запрещено и экземпляр B окажется неиспользуемым. Это не проблема, если создание B было дешевой операцией, но если B требуется сложная инициализация (поход в базу, открытие больших файлов, инициализация кэша), то перед ее запуском хотелось бы иметь уверенность, что она не бесполезна. Для этого контейнер предоставляет следующий интерфейс:
public interface IComponent
{
void Run();
}
Вся тяжелая логика поднятия сервиса должна располагаться в реализации метода Run этого интерфейса. Фишка здесь в том, что контейнер вызовет Run отдельным этапом, после того, как целиком создаст все дерево зависимостей в методе Resolve. Зная состав дерева, контейнер просто пробегает по нему и последовательно вызывает Run в порядке от листьев к корню. Для каждого сервиса вызов делается только один раз — при первом получении. Если сервис используется в нескольких поддеревьях, каждое из которых было создано отдельным вызовом Resolve, то Run на этом сервисе (если он есть) так же будет вызван только в первый раз.
Итого
Если вас заинтересовало что-либо из описанного, исходники доступны на github. До документации руки пока не дошли, поэтому за ответами на вопросы по API удобнее всего обращаться к тестам. Если вы чувствуете, что вам не хватает какой-то фичи или convention-а, то Fork-и и Pull Request-ы очень даже приветствуются.
Автор: gusev_p