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

в 9:00, , рубрики: backend, class, csharp, di, dotnet, implementation, inheritance, interface, polymorphism, ruvds_статьи
Dependency Injection контейнеры .NET, допускающие полиморфное поведение - 1

Иногда случается так, что при разработке приложения на платформе .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://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js