.NET
C#
ООП
Программирование
Проектирование и рефакторинг
4 октября 2016

Удобное создание Composition Root с помощью Autofac

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


Важнейшей частью его реализации является Composition Root — точка сборки, обычно выполняемая по паттерну Register-Resolve-Release. Для хорошо читаемого, компактного и выразительного описания Composition Root обычно используется такой инструмент как DI-контейнер, при наличии выбора я предпочитаю использовать Autofac.


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


Много ошибок в настройке конфигурации выявляется только во время исполнения


Типобезопасная версия метода As


Минимальное средство с максимальным эффектом:


public static IRegistrationBuilder<T, SimpleActivatorData, SingleRegistrationStyle> AsStrict<T>(
    this IRegistrationBuilder<T, SimpleActivatorData, SingleRegistrationStyle> registrationBuilder)
{
    return registrationBuilder.As<T>();
}

public static IRegistrationBuilder<T, ConcreteReflectionActivatorData, SingleRegistrationStyle> AsStrict<T>(
    this IRegistrationBuilder<T, ConcreteReflectionActivatorData, SingleRegistrationStyle> registrationBuilder)
{
    return registrationBuilder.As<T>();
}

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


Образцы замены Resharper для облегчения перехода к AsStrict


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


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


    // Поиск
    $builder$.As<$type$>()
    builder - expression placeholder
    type - type placeholder

    // Замена
    $builder$.AsStrict<$type>()

А вот ограничение на тип у $builder$ у каждого образца свое:


    IRegistrationBuilder<$type$, SimpleActivatorData, SingleRegistrationStyle>
    IRegistrationBuilder<$type$, ConcreteReflection, SingleRegistrationStyle>

Использование Register вместо RegisterType


Это также может быть полезно:


  1. Можно тоньше настроить создание реализаций
  2. По сравнению с регистрацией типа проще и яснее явная передача параметров
  3. Выше производительность при разрешении реализаций

… но есть и минусы:


  1. Любое изменение сигнатуры конструктора класса потребует исправления кода регистрации
  2. Регистрация типа заметно компактнее самой простой регистрации делегата

Сложно понять, какой именно тип регистрируется через делегат


Лучше всегда указывать тип интерфейса с помощью AsStrict, за исключением случаев использования RegisterType<>() с идентичными типами интерфейса и реализации. Бонусом пойдет ошибка при компиляции, если типы интерфейса и значения, возвращаемого делегатом, несовместимы


Регистрация реализаций через делегат занимает слишком много места


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


Проще всего выделить регистрацию через делегат в метод расширения для ContainerBuilder


public static IRegistrationBuilder<Implementation, SimpleActivatorData, SingleRegistrationStyle> RegisterImplementation(
     this ContainerBuilder builder)
{
    return builder.Register(c =>
    {
        // Здесь некоторое количество кода
                // ...
        return implementation;
    });
}

Использовать лучше в сочетании с предыдущим способом


builder.RegisterImplementation().AsStrict<IInterface>();

Сложно найти регистрацию именованного делегата


Autofac умеет разрешать значения таких делегатов через автосвязывание, но тут есть нюансы:


  1. Если параметрам анонимного делегата(Func) параметры конструктора сопоставляются по типам, то параметрам именованных делегатов — по именам
  2. Если тип значения, возвращаемого анонимным делегатом виден сразу, то для именованного надо сначала перейти к его определению

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


Отказ от использования именованных делегатов


Если в конструкторе нет параметров одинаковых типов, то замена на анонимный делегат элементарна.
В противном случае можно заменить множество параметров на структуру, класс или интерфейс (а-ля EventArgs), которые надо зарегистрировать отдельно.


Явная регистрация именованных делегатов


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


Сложно поддерживать необходимый порядок инициализации компонентов


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


Устранение RegisterInstance


Любой вызов RegisterInstance — это де-факто Resolve, чего при регистрации быть не должно. Даже заранее созданную реализацию лучше регистрировать как SingleInstance.


Создание специальных классов инициализации


Для любого инициализирующего действия, которое в рамках вашего Composition Root считается атомарным, создается отдельный класс. Само действие выполняется в конструкторе этого класса.
Если нужна финализация — реализуется обычный IDisposable
Каждый такой класс наследуется от маркерного интерфейса IInitializer
В Composition Root в качестве Resolve используется


context.Resolve<IEnumerable<IInitialization>>();

Упорядочение инициализации


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


  1. Сложная процедура инициализации разбивается на маленькие, простые, легко реализуемые, повторно используемые части
  2. Autofac сам выстраивает правильный порядок инициализации при добавлении, удалении или изменении инициализаторов
  3. Autofac автоматически определяет наличие циклов и разрывов в требованиях такого порядка
  4. Собственно реализация паттерна RRR легко выносится в отдельный, не зависящий от конкретного модуля или проекта класс

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


Composition Root слишком велик


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


Декомпозиция Composition Root


Autofac позволяет разделить один монолитный Composition Root на совокупность корневого и дочерних с помощью весьма скупо описанной в документации возможности регистрации компонентов при создании LifetimeScope.


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


Устранение InstanceForLifetimeScope


Начав использовать регистрацию компонентов при создании LifetimeScope можно сразу получить еще одну вкусную плюшку: полный отказ от InstanceForLifetimeScope и InstancePerMatchedLifetimeScope. Достаточно просто регистрировать эти компоненты как SingleInstance в родном для них LifetimeScope. Попутно исчезает зависимость от тегов LifetimeScope и их становится возможным использовать по своему усмотрению, в моем случае каждый LifetimeScope получает в качестве тега уникальное человекочитаемое имя.


Удобная регистрация дочерних Composition Root


К сожалению, прямое использование метода BeginLifetimeScope нетривиально. Но этому горю можно помочь, используя следующий метод:


/// <summary>
/// Регистрация типа с использованием внутреннего скоупа
/// </summary>
/// <typeparam name="T">Регистрируемый интерфейс</typeparam>
/// <typeparam name="TParameter">Параметр для создания реализации (если необходимо)</typeparam>
/// <param name="builder">Внешний контейнер</param>
/// <param name="innerScopeTagResolver">Источник тегов для внутренних контейнеров</param>
/// <param name="innerScopeBuilder">Метод для регистрации зависимостей во внутреннем скоупе - их видно только фабрике</param>
/// <param name="factory">Фабрика для создания реализаций с использованием обоих скоупов и параметра</param>
/// <returns></returns>
public static IRegistrationBuilder<Func<TParameter, T>, SimpleActivatorData, SingleRegistrationStyle> 
    RegisterWithInheritedScope<T, TParameter>(
        this ContainerBuilder builder, 
        Func<IComponentContext, TParameter, object> innerScopeTagResolver,
        Action<ContainerBuilder, IComponentContext, TParameter> innerScopeBuilder,
        Func<IComponentContext, IComponentContext, TParameter, T> factory)
{
    return builder.Register<Func<TParameter, T>>(c => p =>
    {
        var innerScope = c.Resolve<ILifetimeScope>().BeginLifetimeScope(innerScopeTagResolver(c, p),
            b => innerScopeBuilder(b, c, p));
        return factory(c, innerScope, p);
    });
}

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


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


Плюсы:


  1. Внешний скоуп никак не зависит от внутреннего.
  2. Все, что зарегистрировано во внешнем скоупе, доступно и во внутреннем.
  3. Регистрации во внутреннем скоупе могут спокойно перекрывать внешние.

Минусы:


  1. Внутренний скоуп получает в нагрузку все прелести наследования реализаций

Следующий метод позволяет полностью контролировать зависимость внутреннего скоупа от внешнего (включая вариант с полной изоляцией).


public static IRegistrationBuilder<Func<TParameter, T>, SimpleActivatorData, SingleRegistrationStyle>
    RegisterWithIsolatedScope<T, TParameter>(
        this ContainerBuilder builder,
        Func<IComponentContext, TParameter, object> innerScopeTagResolver,
        Action<ContainerBuilder, IComponentContext, TParameter> innerScopeBuilder,
        Func<IComponentContext, IComponentContext, TParameter, T> factory)
{
    return builder.Register<Func<TParameter, T>>(c => p =>
    {
        var innerScope = new ContainerBuilder().Build().BeginLifetimeScope(
            innerScopeTagResolver(c, p),
            b => innerScopeBuilder(b, c, p));

        return factory(c, innerScope, p);
    });
}

Итоги


  1. Для полноценного применения внедрения зависимостей в сложных случаях требуется как подходящий инструмент (контейнер), так и отработанные навыки разработчика в его использовании
  2. Даже такой гибкий, мощный и прекрасно документированный контейнер как Autofac требует определенной доработки напильником под нужды конкретного проекта и конкретной команды.
  3. Декомпозиция точек сборки с помощью Autofac вполне возможна, реализация такой идеи относительно проста, хотя и не описана в официальной документации.
  4. Модули Autofac для декомпозиции непригодны, так как не обеспечивают инкапсуляцию.

PS: Дополнения и критика традиционно приветствуются.

+4
5,5k 34
Комментарии 26
Похожие публикации
Популярное за сутки