Dependency Injection контейнеры .NET, допускающие полиморфное поведение
Простой 6 мин Блог компании RUVDS.com Программирование *.NET *C# *ООП * Туториал
Иногда случается так, что при разработке приложения на платформе .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 глазами эксперта.
Помоги спутнику бороться с космическим мусором в нашей новой игре! 🛸
Теги:
- ruvds_статьи
- backend
- csharp
- dotnet
- oop
- di
- polymorphism
- inheritance
- interface
- class
- implementation
Хабы:
- Блог компании RUVDS.com
- Программирование
- .NET
- C#
- ООП
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку Задонатить