Проекты, разработкой и сопровождением которых я занимаюсь, довольно велики по объему. По этой причине в них активно используется паттерн 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: Дополнения и критика традиционно приветствуются.

Поделиться с друзьями
-->

Комментарии (26)


  1. lair
    04.10.2016 02:28
    -1

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

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


    1. Bonart
      04.10.2016 08:01
      -1

      Правда?
      Модуль B для собственных нужд зарегистрировал в качестве реализации интерфейса IA класс X.
      Потом в ядро была добавлена регистрация класса Y как реализации того же интерфейса IA. Какая из реализаций будет доступна модулям и почему?


      1. lair
        04.10.2016 10:56

        Правда.


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


        1. Bonart
          04.10.2016 11:11

          Эта ваша реплика


          "каждая отдельная сборка содержит свой модуль, описывающий ее нужды и экспонируемые ей сервисы"

          никак не сочетается со следующей:


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

          Модуль Autofac всего лишь контейнер для регистраций. Собственный скоуп к модулям абсолютно ортогонален.


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

          И модули Autofac здесь нисколько не помогают декомпозиции: реальные зависимости плагинов нельзя определить и разрешить только по его модулю — необходимо знать все регистрации ядра, все регистрации модулей плюс порядок регистрации модулей в ядре. Это полный аналог глобальной переменной.
          Кстати, на мой вопрос вы не ответили.


          1. lair
            04.10.2016 11:17

            Модуль Autofac всего лишь контейнер для регистраций. Собственный скоуп к модулям абсолютно ортогонален.

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


            И модули Autofac здесь нисколько не помогают декомпозиции

            Почему же? Я могу зарегистрировать те реализации, которые плагин содержит.


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

            Определить — можно. Просто собираем все, что плагин экспонировал, и смотрим, что нужно для разрешения.


            Разрешить — нельзя, но и не нужно: разрешение зависимостей происходит в тот момент, когда у нас есть весь composition root, от всего приложения, включая все плагины.


            Кстати, на мой вопрос вы не ответили.

            На вот этот?


            Какая из реализаций будет доступна модулям и почему?

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


            1. Bonart
              04.10.2016 11:42

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

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


              Почему же? Я могу зарегистрировать те реализации, которые плагин содержит.

              И где тут декомпозиция?
              Для того, чтобы понять зависимости плагина, реализованного как модуль Autofac, вам придется прошерстить все зарегистрированные в нем реализации, вычеркивая по ходу то, что плагин реализовал сам. Разительный контраст с зависимостями класса.
              А для того чтобы понять, как эти зависимости будут разрешены — надо просмотреть все ядро и все модули, которые зарегистрированы до вашего. Любое изменение регистрации в ядре и других модулях способно сломать ваш код.
              Это не декомпозиция по построению — модулем Autofac нельзя оперировать как единым целым, его "абстракция" не то что дырява, а целиком состоит из одной большой дыры.


              Я счел его риторическим. Но вообще ответ на него есть в документации

              И этот ответ ставит крест на модулях Autofac как средстве декомпозиции. Ибо знание всех деталей реализации модуля необходимо для корректного конфигурирования Composition Root.
              Что характерно, детали реализации бизнес-классов для этого не нужны: достаточно списка интерфейсов и параметров конструктора.


              1. lair
                04.10.2016 11:51

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

                Это вам они сразу потребовались. Я без них обходился и продолжаю обходиться.


                И где тут декомпозиция?

                Там, где каждая сборка сама объявляет, какие сервисы она экспонирует.


                Ибо знание всех деталей реализации модуля необходимо для корректного конфигурирования Composition Root.

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


                1. Bonart
                  04.10.2016 12:06

                  Это вам они сразу потребовались. Я без них обходился и продолжаю обходиться.

                  То есть вы вручную следите за согласованностью всех регистраций во всех модулях и ядре.


                  Там, где каждая сборка сама объявляет, какие сервисы она экспонирует.

                  У модуля Autofac экспонированные сервисы — не интерфейс, а детали реализации.


                  ограничиваясь соглашением о том, какие сервисы кто предоставляет

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


                  1. lair
                    04.10.2016 12:29

                    То есть вы вручную следите за согласованностью всех регистраций во всех модулях и ядре.

                    Нет, я не делаю для этого никаких действий.


                    У модуля Autofac экспонированные сервисы — не интерфейс, а детали реализации.

                    Я не очень понимаю, как вы это разделяете. Могу ли я узнать, какие сервисы зарегистрированы модулем? Могу (согласен, что это не очень просто). Нужно ли мне это… не уверен.


                    И соблюдение этих соглашений накладывает жесткие требования на реализацию модулей.

                    Не такие уж и жесткие.


                    Т.е. сами модули по части декомпозиции вам снова не помогли никак.

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


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


                    1. Bonart
                      04.10.2016 13:11

                      Нет, я не делаю для этого никаких действий.

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


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

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


                      1. lair
                        04.10.2016 13:17

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

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


                        Модули Autofac не делают по этой части ничего от слова совсем.

                        Модули Autofac скрывают от меня сложность сбора зависимостей из сборки.


                        1. Bonart
                          04.10.2016 13:24

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

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


                          1. lair
                            04.10.2016 13:41

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


                            1. Bonart
                              04.10.2016 13:51

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


                              1. lair
                                04.10.2016 13:52

                                Это тот случай, когда базовой функциональности Autofac недостаточно, и надо реализовывать что-то свое. Это не означает, что для остальных случаев базовой функциональности недостаточно.


                                1. Bonart
                                  04.10.2016 14:01

                                  Это тот случай, когда базовой функциональности Autofac недостаточно, и надо реализовывать что-то свое

                                  Так и появился исходный материал для этой статьи. К счастью, своего поверх Autofac потребовалось совсем немного.


      1. RouR
        04.10.2016 11:00
        +1

        Можете привести конкретный пример из практики?
        Пока что попахивает нарушением то ли SOLID, то ли DRY


        1. Bonart
          04.10.2016 11:14
          -3

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


          1. RouR
            04.10.2016 11:48

            Ваш случай описывается http://docs.autofac.org/en/latest/faq/select-by-context.html с 4 способами решения.


            1. Bonart
              04.10.2016 11:55

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


              1. RouR
                04.10.2016 12:18

                Декомпозиция — разделение целого на части.
                В документации написано чётко — A module is a small class that can be used to bundle up a set of related components behind a ‘facade’ to simplify configuration and deployment. Задача модулей — Decrease Configuration Complexity

                Модули пригодны для декомпозиции.

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


                1. Bonart
                  04.10.2016 12:30

                  Задача модулей — Decrease Configuration Complexity

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


                  1. RouR
                    04.10.2016 12:45

                    Вы обязаны контролировать все регистрации во всех модулях

                    Да, должны. No magic here.

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


                    1. Bonart
                      04.10.2016 13:03

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

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


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


          1. RouR
            04.10.2016 13:08
            +2

            Если не прятаться за NDA, а взять пример с ISender из http://docs.autofac.org/en/latest/faq/select-by-context.html , то какой ваш 5й способ? С вашей «правильной» декомпозицией.
            Можете показать код?


            1. Bonart
              04.10.2016 13:34
              -5

              Для такого простого случая хватит второго способа по ссылке. Все гораздо интереснее, если зависимость от ISender не прямая, а транзитивная.
              PS: на следующие вопросы в таком тоне ищите ответ сами.