Зручне створення 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="будівельник">Зовнішній контейнер</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: Доповнення та критика традиційно вітаються.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.