Dependency Injection контейнеры .NET, допускающие полиморфное поведение

от автора

Иногда случается так, что при разработке приложения на платформе .NET с внедрением зависимостей и сервисами от контейнера требуется поддержка полиморфного поведения.

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

Однако стандартный DI контейнер платформы долгое время не давал этой возможности.

В рамках этой статьи я решил напомнить альтернативы для решения этой задачи на тот случай, если вы ещё не успели переехать на .NET 8 или работаете в каком-нибудь Иннотехе, где в наличии только зеркало NuGet-пакетов, выпущенных до начала 2022 года.

▍ Постановка задачи

Допустим, у нас есть некоторый интерфейс, который имеет несколько реализаций:

public interface IDependency {}  public class DependencyImplOne : IDependency {} public class DependencyImplTwo : IDependency {} 

И мы хотим, используя стандартный DI контейнер .NET Core, внедрить в определённый сервис конкретную реализацию этого контракта.

То есть существует ряд сервисов, которые будут потреблять различные реализации IDependency.

Например, в некоторый BarService нужно засунуть DependencyImplOne, а в некоторый BazService нужно засунуть DependencyImplTwo:

public class BarService : IBarService {     // dependency is DependencyImplOne     public BarService(IDependency dependency)     {     } }  public class BazService : IBazService {     // dependency is DependencyImplTwo     public BazService(IDependency dependency)     {     } } 

К сожалению, стандартный контейнер не предоставляет встроенных возможностей для решения этой задачи.

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

Однако такая политика Microsoft приводит к тому, что даже для реализации такой элементарной вещи, как паттерн «Декоратор», нужна библиотека.

Scrutor, бесспорно, классный инструмент, но осадок всё же остаётся.

▍ Решение в лоб

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

  1. Создание фабрики

    В этом случае появляется некий дополнительный сервис, скажем, IDependencyProvider, который внедряется туда, где требуется наша зависимость, и на основе какого-либо условия создаётся нужная реализация:

    public class DependencyProvider : IDependencyProvider {     public IDependency Create(string key) =>         key switch         {             "one" => new DependencyImplOne(),             "two" => new DependencyImplTwo(),             _ => throw new ArgumentOutOfRangeException(nameof(key))         }; } 

  2. Создание Service Delegate

    Имеется в виду, что всё то же самое реализуется не через некоторый класс, а с помощью некоторого делегата. И в контейнер регистрируется не инстанс, а функция:

    public delegate IDependency DependencyCreator(string key);  // ...  services.AddSingleton<DependencyCreator>(key => ...); 

  3. Внедрение коллекции зависимостей IEnumerable<IDependency> с её последующим перебором

    Вариант вполне рабочий, но отдаёт ещё большим code smell.

    Напомню, что зарегистрированную зависимость можно получить двумя способами:

    • экземпляром, тогда в наших руках окажется последняя регистрация;
    • коллекцией, тогда в наших руках окажутся все регистрации.

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

    public class BarService : IBarService {     // dependency is DependencyImplOne     public BarService(IEnumerable<IDependency> dependencies)     {         _dependency = dependencies.FirstOrDefault(x => x.GetType() == typeof(DependencyImplOne));     } } 

  4. Явная регистрация

    То есть в процессе регистрации сервиса потребителя нужно будет руками описать процесс его инстанциации:

    services.AddTransient<IBazService>(_ => new BazService(new DependencyImplTwo())); 

Всё это выглядит достаточно неудачно на мой строгий субъективный взгляд. Решения продемонстрированы не в качестве рекомендации, а для показа реального положения дел.

Всё говорит о том, что необходимо посмотреть в сторону альтернативных инструментов.

▍ Simple Injector. Условная регистрация

Словосочетание «условная регистрация» означает, что зарегистрированная реализация будет внедрена в потребителей сервиса, удовлетворяющих определённому условию.

В этом контейнере такая возможность внедрять конкретную реализацию зависимости, согласно определённому контексту, реализована с помощью метода RegisterConditional:

container.RegisterConditional<ILogger, NullLogger>(     c => c.Consumer.ImplementationType == typeof(HomeController));  container.RegisterConditional<ILogger, FileLogger>(     c => c.Consumer.ImplementationType == typeof(UsersController));  container.RegisterConditional<ILogger, DatabaseLogger>(c => !c.Handled); 

Из приведённого примера видно, что условная регистрация позволяет настроить поставку зависимости на основе определения типа потребителя.

То есть HomeController получит NullLogger, UsersController получит FileLogger, а все остальные потребители ILogger получат DatabaseLogger.

▍ Castle Windsor. Явное указание зависимостей

Возвращаясь к нашему примеру с логгером, допустим, что у сервиса ILogger есть две реализации: некий стандартный Logger и безопасный SecureLogger, который требуется использовать в некотором сервисе TransactionProcessingEngine.

В контейнере Castle Windsor это можно настроить, используя метод Dependency.OnComponent.

В нём указывается конкретная зависимость, которую требуется внедрить.

Перегрузок метода много, соответственно, вариантов это сделать несколько: от именованных зависимостей до явного указания типов.

Самый простой вариант будет выглядеть так:

container.Register(     Component.For<ITransactionProcessingEngine>()         .ImplementedBy<TransactionProcessingEngine>()         .DependsOn(Dependency.OnComponent<ILogger, SecureLogger>()) ); 

▍ Autofac. Именованные сервисы

Контейнер Autofac предоставляет возможность внедрять конкретные зависимости, явно указывая некоторый ключ, который соотносится с желаемой зависимостью.

Например, у нас есть сервис IDisplay, отображающий какие-то произведения искусства IArtwork.

Чтобы указать, что мы хотим внедрить конкретную реализацию MyPainting, можно использовать атрибут KeyFilterAttribute.

По указанному ключу он проведёт фильтрацию и выберет нужную зависимость.

Пример:

public class ArtDisplay : IDisplay {     public ArtDisplay([KeyFilter("MyPainting")] IArtwork art) { ... } }  // ...  var builder = new ContainerBuilder();  builder.RegisterType<MyPainting>()     .Keyed<IArtwork>("MyPainting");  builder.RegisterType<ArtDisplay>()     .As<IDisplay>().WithAttributeFiltering();  // ... var container = builder.Build(); 

▍ StructureMap. Настройка конструктора

Контейнер StructureMap позволяет решить задачу, используя настройку конструктора сервиса-потребителя.

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

Например, у нас есть сервис для отправки сообщений IMessageService, который реализуют, соответственно, SmsService и EmailService. И есть некоторые сценарии, в которых нужно использовать разные имплементации. Тогда конфигурация будет выглядеть примерно следующим образом:

var container = new Container(x => {     x.For<FooScenario>().Use<FooScenario>()         .Ctor<IMessageService>("messageService")         .Is<SmsService>();     x.For<BarScenario>().Use<BarScenario>()         .Ctor<IMessageService>("messageService")         .Is<EmailService>(); });  // ...  public class FooScenario {     // sms     public FooScenario(IMessageService messageService) }  // ...  public class BarScenario {     // email     public BarScenario(IMessageService messageService) } 

▍ А что там в .NET 8?

Ну а если вы планируете переезд, то у меня для вас хорошая новость: ASP.NET 8 наконец-то добавит многообразие зависимостей!

Реализовано это будет через механизм с ключами, похожий на Autofac.

Согласно контракту атрибута [FromKeyedServices], ключ имеет тип object, то есть можно использовать строки, енамки и другие варианты.

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

Возвращаясь к старому примеру, он преобразится следующим образом:

public interface IDependency {}  public class DependencyImplOne : IDependency {} public class DependencyImplTwo : IDependency {}  builder.Services.AddKeyedSingleton<IDependency, DependencyImplOne>("one"); builder.Services.AddKeyedSingleton<IDependency, DependencyImplTwo>("two");  // Далее использовать вот так, с помощью атрибута [FromKeyedServices]:  public class BarService : IBarService {     // DependencyImplOne     public BarService([FromKeyedServices("one")] IDependency dependency)     {     } }  public class BazService : IBazService {     // DependencyImplTwo     public BazService([FromKeyedServices("two")] IDependency dependency)     {     } } 

Для меня как поклонника ООП это знаковая веха в развитии платформы, поэтому считаю, что ради этой киллер фичи можно смело планировать переезд на новый LTS-релиз!

▍ Заключение

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

Особенно инструментами, которые реализуют различные способы регистрации и доставки сервиса, у которого существует множество реализаций. Среди них мне больше всего импонируют SimpleInjector и Castle Windsor.

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

Ещё я веду Telegram-канал StepOne, куда выкладываю много интересного контента про коммерческую разработку, C# и мир IT глазами эксперта.

Помоги спутнику бороться с космическим мусором в нашей новой игре! ?


ссылка на оригинал статьи https://habr.com/ru/articles/776768/