Иногда случается так, что при разработке приложения на платформе .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, бесспорно, классный инструмент, но осадок всё же остаётся.
▍ Решение в лоб
Если оставаться в рамках работы со стандартным контейнером, то существует несколько способов решить задачу, каждый из которых будет напоминать велосипедно-костыльную методологию разработки:
- Создание фабрики
В этом случае появляется некий дополнительный сервис, скажем,
IDependencyProvider
, который внедряется туда, где требуется наша зависимость, и на основе какого-либо условия создаётся нужная реализация:public class DependencyProvider : IDependencyProvider { public IDependency Create(string key) => key switch { "one" => new DependencyImplOne(), "two" => new DependencyImplTwo(), _ => throw new ArgumentOutOfRangeException(nameof(key)) }; }
- Создание Service Delegate
Имеется в виду, что всё то же самое реализуется не через некоторый класс, а с помощью некоторого делегата. И в контейнер регистрируется не инстанс, а функция:
public delegate IDependency DependencyCreator(string key); // ... services.AddSingleton<DependencyCreator>(key => ...);
- Внедрение коллекции зависимостей
IEnumerable<IDependency>
с её последующим переборомВариант вполне рабочий, но отдаёт ещё большим code smell.
Напомню, что зарегистрированную зависимость можно получить двумя способами:
- экземпляром, тогда в наших руках окажется последняя регистрация;
- коллекцией, тогда в наших руках окажутся все регистрации.
Во втором случае потребление зависимости будет выглядеть примерно так:
public class BarService : IBarService { // dependency is DependencyImplOne public BarService(IEnumerable<IDependency> dependencies) { _dependency = dependencies.FirstOrDefault(x => x.GetType() == typeof(DependencyImplOne)); } }
- Явная регистрация
То есть в процессе регистрации сервиса потребителя нужно будет руками описать процесс его инстанциации:
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 глазами эксперта.
Автор: Степан Минин