Simple container

    Да-да, вы все правильно поняли, это статья об еще одном велосипеде — о моем 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-ы очень даже приветствуются.
    Метки:
    СКБ Контур 91,87
    Компания
    Поделиться публикацией

    Вакансии компании СКБ Контур

    Комментарии 20
    • +3
      Всё таки хотелось бы узнать парочку значимых преимуществ велосипеда перед одним из выбранных популярных контейнеров (Autofac, Unity, Ninject), кроме одного очевидного — вносить непосредственно в ядро любые свои хотелки. Спасибо.
      • 0
        Я специально глубоко не исследовал популярные контейнеры — в наших условиях удобнее велосипед заточить было.
        Но, насколько могу судить, чего-то похожего на контракты нет ни в одном из них.
        • +2
          Я специально глубоко не исследовал популярные контейнеры — в наших условиях удобнее велосипед заточить было.

          Что же это за условия такие?

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

          Ну, у вас какое-то очень свое понимание термина «контракт». Но вообще на Unity такое можно сделать через расширения.
          • +2
            Что же это за условия такие?

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

            Но вообще на Unity такое можно сделать через расширения.

            Вы не могли бы пример привести?
            • +1
              желание контролировать инфраструктуру и легко менять ее под потребности

              БД у вас тоже своя? Операционная система?

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

              Вот в этот момент и надо было смотреть на контейнеры, в которых эта логика есть. А лучше — в момент накручивания фич.

              Вы не могли бы пример привести?

              Сходу — нет, надо думать и писать.
              • 0
                БД у вас тоже своя? Операционная система?

                Суть в том, что DI-контейнер — штука, неизмеримо более простая, чем БД или операционная система. В идеале конечно-же мне хотелось бы целиком контролировать БД и ОС чтобы иметь возможность быстро править там баги и добавлять нужные мне фичи. И в другом масштабе (google, microsoft) это так и происходит.

                Про фичи — я далеко не уверен, что популярные контейнеры поддерживают все из описанного и с нужным нам минимумом вербозности. Основная фишка этого контейнера в том, что он не предполагает сложного конфигурирования чтобы учесть все возможные сценарии своего использования. Он реализует только те convention-ы, которые показали себя полезными в нашей практике, и не более.
                • +1
                  Любое крупное приложение состоит из множества «простых вещей». Тем не менее, мне в среднем кажется неэффективным самостоятельно разрабатывать эти «простые вещи», если они уже есть в легко повторно используемой форме.
          • +2
            Конечно выбор понятия «Контракты» под ваши задачи не очень удачный, так как сильно разнится с уже существующими «Контрактами» в C# и может внести путаницу для программиста. И конечно же данная задача легко решается в Autofac (именованные контексты, использование контейнера, как фабрики с именованными параметрами, передающиеся прямо в конструктор, реализация своей фабрики и билдеров в том числе с поддержкой своих атрибутов), поэтому я и спросил, ведь логично перед изобретением велосипеда как следует изучить уже доступные решения.
            • +1
              Как раз на Autofac я смотрел более пристально, чем на остальные, и аналогов не нашел. Здесь описано, как можно выбрать конкретную реализацию интерфейса в точке использования интерфейса — на уровне параметра конструктора типа ISender я могу атрибутом указать, что использоваться на самом деле должен EmailSender. Либо тоже самое можно сделать в соответствующей ему конфигурации. Контракты же в некотором смысле более общий механизм. Атрибутом я могу пометить не сам интерфейс, а целое дерево сервисов, которое где-то в своих листьях имеет замыкания на этот интерфейс. Деревья, помеченные разными атрибутами, будут автоматически созданы контейнером в нескольких экземплярах, т.к. имеют различные конфигурации для своих листьев. Основной пример — сложная бизнес-логика, реализуемая деревом сервисом и замыкающася на некоторый источник данных. В одном месте мне может быть нужно создать все это сложное дерево поверх файлового источника, а в другом — поверх базы данных. Причем у меня в системе будет всего два таких сервиса — контракты позволяют дать каждому из них имя и использовать по мере надобности в различных частях системы.
              • 0
                Похоже на то, что вы изобрели именованные контексты только встроили конкретный механизм в контейнер под свои конкретные задачи на основе специальных атрибутов, это имеет смысл. Спасибо за пояснение. Я бы создавал для такого дерева новый скоуп с определённым контекстом, тогда бы и получилось дерево, в листьях замыкающееся на свою имплементацию, без коллизий. Настораживает общая синглетоновость вашей архитектуры, на самом деле издержки на создание экземпляров минимальны, не считая узких мест, которые можно оптимизировать.
                • +1
                  Согласен, что создание экземпляра — относительно дешевая операция, даже с учетом gc/memory traffic. Основной профит синглтонов — в их простоте для прикладного кода. Чтобы использовать внутри сервиса другой сервис нужно просто принять его в конструкторе. Точка использования ничего не знает о lifestyle-ах/dispose-ах — довольно второстепенных, по сути, вещах. Чтоб получить сервис, обладающий определенными характеристиками, нужно просто повесить на параметр конструктора атрибут с выразительным именем, а все эти характеристики формализовать в конфигураторах.
            • 0
              А зачем нужны такие контракты? Вы создали дополнительный маркер-класс, почему не сделать тогда просто интерфейсный маркер. Для него не надо выдумывать дополнительных костылей для регистрации, усложнять резолвинг.
              • 0
                Спасибо что поделились своим опытом.
                Оставлю это тут, может кому поможет при выборе контейнера

                docs.autofac.org/en/latest/advanced/keyed-services.html

                public class ArtDisplay : IDisplay
                {
                  public ArtDisplay([WithKey("Painting")] IArtwork art) { ... }
                }
                
            • +1
              Вроде все, что вы перечислили можно сделать на Autofac. За примерами далеко ходить не надо. Достаточно посмотреть что вытворяют с ним парни из OrchardCMS.

              Имхо лучше бы помогали Autofac, Structuremap или Windsor.
              • +1
                Когда я ещё работал в Эльбе, там использовался как раз Windsor. И переход на robocontainer, а потом на свою реализацию, был как раз вызван неудобством его использования.
                • +1
                  Я сам не фанат виндзора, но Autofac сейчас имхо покрывает практически любые, даже самые дикие сценарии. А еще пачка вопросов:
                  Какая лицензия на исходники вашего контейнера?
                  Где codestyle, политика на пул реквесты?
                  Кто за это отвечает?
                  Какое комьюнити у вашего проекта?
                  Кто им пользуется?
                  Протестирован ли он на уровне Autofac?
                  Когда будет поддержка Asp.Net 5 на CoreClr?
                  Где nuget пакеты?
                • +1
                  Этот контейнер отличается от Autofac, Structuremap и т.п. тем, что никогда не являлся самоцелью. В нем реализованы только те фишки, которые смогли существенно облегчить нам жизнь при решении конкретных задач. Поэтому, в частности, там нет понятия lifestyle-а — хоть и красивого теоретически, но приводящего к определенным сложностям в использовании и стыковке с другими фичами контейнера.

                  Уверен, что кое-что из описанного можно реализовать поверх Autofac — весть вопрос в том, какими усилиями и насколько простой код/конфигурация в итоге получится.
                  • 0
                    Из того. что вы привели, большая часть уже есть в Autoаac. И как раз реализовать все достаточно просто.
                • 0
                  Мы используем такое решение для автофабрик. Да, вдохновлял Autofac, но на вкус и цвет последний не приглянулся, Unity как-то более поддерживаем сообществом.
                  • +1
                    К слову говоря, контейнер с минимальными правками переезжает на PC (Portable Class Library), после чего его вполне можно использовать для, например, мобильной разработки под Xamarin (iOS, Android) и Windows Phone. Как только протестируем его в боевых условиях, наверняка сможем выложить такую сборку.

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

                    Самое читаемое