Иногда случается так, что при разработке приложения на платформе .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 глазами эксперта.
Помоги спутнику бороться с космическим мусором в нашей новой игре! ????
Комментарии (143)
slonopotamus
02.01.2024 09:36-6DI-контейнеры - зло. Без них, проблемы бы вообще не было. Просто передаём к конструктор нужный объект, конец.
Kanut
02.01.2024 09:36+3А потом у нас 100500 аргументов в конструкторах? Не, спасибо.
slonopotamus
02.01.2024 09:36+10У вас ровно столько же аргументов в конструкторах. Количество зависимостей никак не изменилось.
Kanut
02.01.2024 09:36+3Нет. Если у вас "цепочка" создаваемых классов, то вам надо все аргументы передавать по этой цепочке в конструкторах.
То есть "промежуточные" классы могут иметь в своих конструкторах аргументы, которые им самим не нужны. И существуют только чтобы передать их дальше.
Чем больше иерархия, тем больше таких классов и таких "лишних" аргументов в конструкторах.
Адекватные DI и IOC фреймворки вас от этого избавляют. И каждый класс получает только то, что нужно ему самому.
slonopotamus
02.01.2024 09:36+2Если у вас "цепочка" создаваемых классов
Вы же не делаете такие цепочки с DI-контейнером? Вот и без него не делайте. В "промежуточный" объект его зависимости инжектятся в уже созданном виде, он сам не занимается их конструированием.
Kanut
02.01.2024 09:36-2У вас есть класс А, который использует класс В, который использует класс С, который использует класс D.
Аргументы для создания классов B, C, D мы получаем в классе А. Как вы передадите аргументы для создания класса D в класс C минуя класс B?
slonopotamus
02.01.2024 09:36+3Не надо передавать в класс C аргументы для создания класса D. Передайте ему уже созданный объект D. Код классов ну вот вообще никак не меняется от того, есть снаружи DI-контейнер или там руками всё насоздавали.
Kanut
02.01.2024 09:36-1Ну во первых если вы так делаете, то вы уже имеете Dependency Injection.
А во вторых как вы собираетесь передавать объект D и/или параметры для его создания минуя класс B?
qw1
02.01.2024 09:36+3Просто вы спорите, не прочитав аргумент.
Комментатор не против Dependency Injection, а против DI-контейнеров.
Mingun
02.01.2024 09:36+4class A { public A(B b, C c) {} } class B { public B(C c) {} } class C { public C(D d) {} } class D { public D(int param) {} } class Program { public static void Main() { // Вручную создаём все необходимые // зависимости в нужном порядке D d = new D(42); C c = new C(d); B b = new B(c); A a = new A(b, c); // Делаем работу с a } }
Ничего никуда передавать транзитивно не нужно, ни один из классов не должен создавать свои зависимости, это задача обслуживающего кода, который «запускает мир». Ну, или контейнера зависимостей.
Kanut
02.01.2024 09:36-3А 42 у вас откуда взялось? Это параметр, который вашему обслуживающему коду не известен. Он известен только классу A.
П. С. Ну и кроме того если уже у нас есть обслуживающий код, то почему бы не в виде "DI-контейнеров" ? :)
Mingun
02.01.2024 09:36+3Он известен только классу A.
Если он известен классу
A
, значит, у вас херовая архитектура. Он не должен быть ему известен, в этом вся соль. Если это не так, это значит, что у вас часть логики из обслуживающего кода перекочевала в код классаA
. Ну, или классA
сам является тем самым обслуживающим кодом, но тогда и претензии к нему непонятны.то почему бы не в виде "DI-контейнеров" ?
Ветка началась с вашего утверждения о необходимости 100500 параметров в конструкторах. Я продемонстрировал, что этого не нужно. Чтобы не переусложнять, привёл ручной способ решения проблемы. И добавил, что она также решается «или [посредством] контейнера зависимостей». Потому что суть остаётся прежней.
Kanut
02.01.2024 09:36+1Если он известен классу A, значит, у вас херовая архитектура. Он не должен быть ему известен, в этом вся соль.
Какое-то странное заявление. То есть вы считаете что параметры для создания одного класса не могут быть частью другого класса?
Ветка началась с вашего утверждения о необходимости 100500 параметров в конструкторах. Я продемонстрировал, что этого не нужно. Чтобы не переусложнять, привёл ручной способ решения проблемы
Но этот ваш способ решения создаёт новую проблему. В вашем варианте нужно в одном конкретном месте знать все используемые имплементации. Причём в вашем варианте ещё и в момент компиляции.
И, да я согласен что это другая проблема. Но это тоже проблема, которая решается при помощи DI-фреймворков.
qw1
02.01.2024 09:36+1параметры для создания одного класса не могут быть частью другого класса?
Есть классы-сервисы с определённым поведением, и это поведение конфигурируется на старте приложения. Именно они регистрируются в контейнере. Они обрабатывают данные.
Есть классы, представляющие объекты модели предметной области. Они являются данными, их не надо регистрировать в контейнере. Создаются они напрямую, а не из контейнера. Передаются в качестве параметров методам классов из первой категории.
Ну, это в классических архитектурах. Если вы изобретаете что-то нестандартное, вам классический DI-контейнер не подойдёт.
Mingun
02.01.2024 09:36То есть вы считаете что параметры для создания одного класса не могут быть частью другого класса?
Могут. Но тогда это не будет инверсией зависимостей. Ибо сама суть такой инверсии, что свои зависимости сам класс не создаёт, а получает их извне. Если вам их понадобилось создавать, значит, у вас нет инверсии зависимостей. И контейнер DI здесь вообще не нужен (хотя он может и упростить создание зависимости, если, например, она сама от чего-то другого зависит и мы хотим переложить создание транзитивных зависимостей на контейнер).
Kanut
02.01.2024 09:36+1Но тогда это не будет инверсией зависимостей
До инверсии зависимостей мы ещё вроде бы не дошли :)
qw1
02.01.2024 09:36+1var d = new D(); var c = new C(d); var b = new B(c); var a = new A(b);
Kanut
02.01.2024 09:36-1По условию у класса D есть параметры. И мы их получаем в классе А.
slonopotamus
02.01.2024 09:36+2А как вам помогает в этой задаче DI-контейнер?
Kanut
02.01.2024 09:36Вы создаёте в классе А объект класса D и регистрируете его в фреймворке.
Потом запрашиваете от фреймворка объект класса В(или даже интерфейса, то есть вам уже не нужно знать конкретную реализацию)
Класс B в параметре имеет класс С(или опять же интерфейс). Фреймворк создаёт объект С при помощи уже зарегистрированного D. И передаёт его в конструктор класса B.
В итоге класс А знает только D(конкретную имплементацию) и В(достаточно интерфейса). Класс B знает только C(достаточно интерфейса). Класс С только D(опять же достаточно интерфейса)
Если пытаться сделать это "по старинке", то либо класс А должен знать все другие классы(причём конкретные имплементации), либо будет больше параметров в конструкторах.
qw1
02.01.2024 09:36+2Вы создаёте в классе А объект класса D и регистрируете его в фреймворке.
Вы серьёзно? Настраиваете DI-контейнер не в точке composition root, а прямо в бизнес-логике? Скажите, что я вас не так понял )))
Kanut
02.01.2024 09:36Ну если у нас параметры для класса D существуют только в классе А, то как вы его создадите где-то ещё? Это придётся делать в классе А.
Но если этот пункт убрать, то вы просто где-то в вашем composition root регистрируете всё что вам нужно. И тогда каждый класс должен знать только интерфейс конкретно того класса, который он использует.
qw1
02.01.2024 09:36+2Ну если у нас параметры для класса D существуют только в классе А, то как вы его создадите где-то ещё? Это придётся делать в классе А
И тогда класс D не будет внедрённой зависимостью. DI-контейнер не нужен.
А, я понял. Вы хотите создать правильно настрокнный экземпляр D, добавить его в контейнер, а затем воспользоваться контейнером как сервис-локатором (при этом сам контейнер придётся передать в зависимости класса A. а мы говорим, как бы избежать лишних параметров).
Не говоря о том, что такой код попахивает, это ещё и не потокобезопасно.
Контейнер один на всё приложение. Если это ASP.NET сервис, конкурентные запросы могут перепутать между собой экземпляр D.
Kanut
02.01.2024 09:36-1Ну это всего лишь пример. Контейнер это всего одна зависимость. То есть как бы у нас не росла сложность он уже добавлен. О потокобезопасности можно побеспокоиться в самом контейнере. Да и вообще экземпляр D у нас тут всего один и перепутать его сложно.
Кроме того естественно DI фреймворки не особо имеет смысл использовать если мы просто создаём пару "статичных" объектов. Даже если они зависят друг от друга.
Но чем больше и сложнее у нас иерархия, тем сложнее всем этим жонглировать "вручную". Если у нас добавляется сторонний код/библиотеки(которые в свою очередь должны использовать наш код), то это становится ещё сложнее. Если добавляются плагины/модули, которые загружаются уже во время исполнения, то становится совсем весело. И в какой-то момент просто "по старинке" уже не особо работает.
qw1
02.01.2024 09:36+3Контейнер это всего одна зависимость. То есть как бы у нас не росла сложность он уже добавлен
Добавлять сам контейнер в зависимости класса - это жёсткий антипаттерн, т.к. мы начинаем зависеть от конкретного DI-фреймворка.
О потокобезопасности можно побеспокоиться в самом контейнере
Вы меня всё больше удивляете ))) Да, давайте лочить контейнер на всех операциях ресолва, и смотреть, куда катится производительность. Может, лучше вручную попередавать зависимости?
Да и вообще экземпляр D у нас тут всего один и перепутать его сложно
Нет, экземпляров D столько, сколько раз вызывается метод класса A (по условию, мы должны создавать экземпляр D на каждом вызове, возможно, с разными аргументами конструктора).
Kanut
02.01.2024 09:36-1т.к. мы начинаем зависеть от конкретного DI-фреймворка.
Совсем не обязательно. Интерфейсы никто не отменял.
Да, давайте лочить контейнер на всех операциях ресолва
Ну если ваш контейнер почему-то допускает существование наскольких объектов одного "типа", то возможно придётся делать и так.
Нет, экземпляров D столько, сколько раз вызывается метод класса A
Это уже зависит от вашего контейнера. Большинство известных мне (если не вообще все) допускают существование только одного зарегистрированного объекта каждого "типа" . Как минимум в "дефолтном" варианте.
slonopotamus
02.01.2024 09:36Что такое "тип"? Если ваш DI-контейнер идеологически является Dictionary<Interface, Implementation>, это довольно сильное ограничение, и непонятно кто сказал что приложение в такое вообще говоря впишется.
Kanut
02.01.2024 09:36Ну так в том то и дело что сложно подобрать для этого точное слово. Потому что у вас может быть <Interface, Implementation>, а может <Interface, Object>. А может смесь из того и другого. Или ещё что-то.
Но даже если мы просто договоримся о том что у нас Dictionary, то в ней не может быть два одинаковых ключа. И большинство известных мне DI-фреймворков просто не дают зарегистрировать что-то на тот же ключ второй раз. И выдают ошибку при попытке это сделать. Как минимум в дефолтном варианте.
slonopotamus
02.01.2024 09:36А теперь посмотрите в пост, который мы комментируем :) Пост про то что это ограничение мешает, и его начинают героически преодолевать.
Kanut
02.01.2024 09:36Да вообще-то нет. Мешает не то что Dictionary, а то что ключом может быть только Interface.
То есть делаем "композитный" ключ в том или ином виде и всё всех опять устраивает.
qw1
02.01.2024 09:36+2не обязательно. Интерфейсы никто не отменял.
А есть интерфейс, который реализуют все DI-контейнеры?
У каждого контейнера свои уникальные фички, их под один интерфейс не причешешь.Ну если ваш контейнер почему-то допускает существование наскольких объектов одного "типа", то возможно придётся делать и так
Как раз проблема в том, что не допускает.
Как только мы положим в контейнер объект d1 класса D, он заменит собой предыдущий объект d0 класса D, который положил другой поток, и который возможно ещё нужен ему для ресолва.Нет, экземпляров D столько, сколько раз вызывается метод класса A
Это уже зависит от вашего контейнера.
Ещё раз. По условию задачи, объект D создаётся при работе приложения, потому что мы не знаем заранее параметров конструктора. Значит, объектов D нужно много разных, с разными параметрами конструктора, и нужно не перепутать, кому какой. Гонять их через контейнер - получить возможность перепутать.
Kanut
02.01.2024 09:36А есть интерфейс, который реализуют все DI-контейнеры?
А что-то мешает вам например создать свой и wrapper'ы?
Как раз проблема в том, что не допускает. Как только мы положим в контейнер объект d1 класса D, он заменит собой предыдущий объект d0 класса D
Совсем не факт. Он может просто оставить d0. Или вообще кинуть исключение. То есть это уже зависит от конкретного контейнера.
По условию задачи, объект D создаётся при работе приложения, потому что мы не знаем заранее параметров конструктора. Значит, объектов D нужно много разных,
Даже если вы их создаёте много разных, само по себе не означает что в контейнере не может быть всё время один и тот же.
И если ваш контейнер допускает такую "замену", то вам всё равно надо заботится о потоках и прочих подводных камнях такого подхода.
Значит, объектов D нужно много разных, с разными параметрами конструктора
И вот это совсем не обязательно. Может они нужны разные при каждом новом запуске приложения. . Но при этом это не значит что будет много разных объектов в один момент времени.
qw1
02.01.2024 09:36+1А что-то мешает вам например создать свой и wrapper'ы?
Была бы веская причина. А то вы подбиваете на какую-то мутную авантюру, для которой ещё и врапперы надо писать.
Совсем не факт. Он может просто оставить d0. Или вообще кинуть исключение. То есть это уже зависит от конкретного контейнера.
И если ваш контейнер допускает такую "замену", то вам всё равно надо заботится о потоках и прочих подводных камнях такого подхода.
Столько возможных проблем. И всё это лишь для того, чтобы пропихнуть сомнительное решение - создавать из контейнера объект, параметры которого неизвестны в точке входа в приложение.
Kanut
02.01.2024 09:36А что-то мешает вам например создать свой и wrapper'ы? Была бы веская причина.
Ну так чтобы не зависеть от конкретной имплементации. Если вам это так важно.
Столько возможных проблем.
Нет никаких особых проблем.
И всё это лишь для того, чтобы пропихнуть сомнительное решение - создавать из контейнера объект, параметры которого неизвестны в точке входа в приложение.
У вас эти "проблемы" будут или не будут вне зависимости от того как вы объект создаёте. Это скорее зависит от того какой контейнер вы выбрали.
qw1
02.01.2024 09:36Проблему вы создали на ровном месте, когда поставили условие, что локальная информация, живущая в рамках вызова метода объекта A (параметры объекта D), не должна передаваться в параметрах методов B и C, а должна лежать в контейнере. Но ведь контейнер не место для хранения оперативной информации. Давайте ещё переменные циклов туда писать, а что такого...
Kanut
02.01.2024 09:36Проблему вы создали на ровном месте,
Это был всего лишь по быстрому придуманный пример.
что локальная информация, живущая в рамках вызова метода объекта A (параметры объекта D), не должна передаваться в параметрах методов B и C,
Во первых ы можете её передавать. Но чем больше у вас иерархия тем чаще у вас будут встречаться классы с "проходными" параметрами. То есть параметрами, которые им самим не нужны.
И соответственно тем больше кода вам придётся менять каждый раз когда эти параметры будут меняться.
А во вторых речь не о хранении информации в контейнере, а о том как у вас будут связаны между собой отдельные классы.
Но ведь контейнер не место для хранения оперативной информации.
Никто и не предлагает там хранить информацию.
qw1
02.01.2024 09:36Если у метода много данных, но лень их передавать параметрами на несколько уровней вызовов, можно воспользоваться глобальными переменными. Естественно, с ThreadStatic, или более современным AsyncLocal. Всё лучше, чем конфигурировать контейнер в рантайме.
Kanut
02.01.2024 09:36Действительно. Глобальные переменные это гораздо лучше. И чем больше иерархия тем лучше.
slonopotamus
02.01.2024 09:36+1Я тоже не понял. В конструкторе одного объекта производится регистрация в DI-контейнере других объектов? Кажется, это поломано и не работает, потому что если от D хотели зависеть другие объекты, то на этапе построения графа инициализации всё развалилось по причине отсутствия в DI этого самого D.
qw1
02.01.2024 09:36+2Как я понял, в конструктор A мы не передаём В и C - зависимости, зависящие от D. Мы в классе A доконфигурируем контейнер, кладя туда D, а потом достаём из контейнера зависимости B и C. Хороший способ проехаться по граблям )))
slonopotamus
02.01.2024 09:36+1А потом достаём из контейнера зависимости B и C
Ну такоооооэ. То есть этап инициализации (а следовательно и проверка удовлетворённости зависимостей) распался на куски во времени.
qw1
02.01.2024 09:36+2окей. Вы говорите, что DI-контейнер решает эту проблему?
Тогда покажите код. Я считаю, что если класс D получает аргументы из класса A, придётся лепить какие-то костыли в обход контейнера.
Gromilo
02.01.2024 09:36-1А зачем собирать руками, если можно нагрузить этой работой DI-контейнер?
Есть примеры реп на C#, где не используют DI-контейнер? Какое-нибудь веб апи.
StanEgo
02.01.2024 09:36А какой объект нужный? Для прода, для тестов, для разных провайдеров (скажем разных СУБД), если мне нужно добавить проксирующие реализации с мемоизацией, балансировкой и т.п. Я не говорю про ситуации, когда нужные объекты ещё даже не существуют и разрабатываются отдельными командами.
slonopotamus
02.01.2024 09:36+2Ну если вы справились объяснить своему DI-контейнеру какой объект нужный, вы точно так же (а скорее всего даже проще) сможете сделать это без DI-контейнера. Проще потому что у DI-контейнеров ограниченная выразительность средств описания "кого куда вставлять", в отличие от явной передачи.
StanEgo
02.01.2024 09:36+1У меня не получилось, может вы мне поможете? Представим себе какой-нибудь средней паршивости проект, у которого есть REST API на сотню контроллеров. Каждому нужен какой-нибудь условный IDbConnectionFactory, ILogger и т.п. Я бы написал всего две строки для этих интерфейсов и забыл. Но я так понимаю, что мне нужно руками инстанцировать каждый контроллер? И это только для веб-API.
Дальше - больше. Когда я собираю более-менее крупное решение, у меня там не меньше десятка различных библиотек как других команд, так и вообще других производителей, в каждой из которых могут быть десятки и сотни классов, которым нужно журналирование и тот же самый ILogger. Причем, львиная доля этих классов даже не инстанцируюется мной напрямую, а носит внутренний характер. Подключил я, условно, OAuth-сервер. Обычно - это несколько сточек конфигурации. ILogger, который уже был указан, он сам подцепит. Но мне надо влезть в его исходники и каким-то образом переделать инстанцирование каждой middleware и прочих сервисных классов, о которых я до этого и знать не знал, чтобы передать туда правильный тип?
А если представить какие-нибудь библиотеки с хорошим observability, где каждый самый завалящий класс рвётся писать свои метрики... Да мне там весь код придётся переписать.
А потом выяснится, что нужно как минимум четыре варианта журналов - мок для тестов, консоль для дев-среды, журналирование в файл для прогонов в CI/CD, какой-нибудь Sentry для продакшена. Поделитесь вашими решениями, будет очень познавательно.
Пока вы решаете задачу "Hello world", а в вашем случае вы дальше рудиментарного инстанцирования не пошли, DI не нужен. Если же у вас масштабный проект, со сложной композицией и вы хотите в единообразном стиле дирижировать тысячами компонент, то пока ничего лучше DI не придумали. А говорим DI - подразумеваем DIP. Поскольку этот принцип - более масштабный, а IoC- контейнеры не только за инъекцию отвечают. Я бы на вашем месте просто сделал поиск по гитхабу того же ILogger. Я думаю код ответит на все ваши вопросы.
P.S. А вот примеры в статье, мягко говоря, неудачные.
Aquahawk
02.01.2024 09:36-1Меня поражает насколько .net мир болен идеей DI и не может представить жизнь без него.
slonopotamus
02.01.2024 09:36В Java была подобная история, со всякими спрингами и портянками XML (а потом кучей магических аннотаций), но потом мода прошла. А в мире C# оно застряло.
dimaaannn
02.01.2024 09:36Спорная логика.
Если нужны навороченные DI библиотеки - их полно. Тот же ninject давным давно позволяет регистрировать кучу разных интерфейсов по ключу, или даже явно указывать какой класс будет использован в качестве интерфейса для инъекции внутри конкретного класса.
Mingun
02.01.2024 09:36+5То есть, мне, чтобы в одну либу прокинуть одну реализацию интерфейса, а в другую либу — другую, надо править код этих либ, добавляя в конструкторы какие-то атрибуты? Это же полное дно. А не проще ли тогда сразу по интерфейсам их разделить, раз уж мы всё равно код либ правим:
public interface IDependency {} public interface IDependencyForBar : IDependency {} public interface IDependencyForBaz : IDependency {} public class DependencyImplOne : IDependencyForBar {} public class DependencyImplTwo : IDependencyForBaz {} public class BarService : IBarService { // DependencyImplOne public BarService(IDependencyForBar dependency) { } } public class BazService : IBazService { // DependencyImplTwo public BazService(IDependencyForBaz dependency) { } }
Либо вообще выкинуть нафиг эти интерфейсы и внедрить зависимости напрямую:
public class BarService : IBarService { public BarService(DependencyImplOne dependency) { } } public class BazService : IBazService { public BazService(DependencyImplTwodependency) { } }
ValeryIvanov
02.01.2024 09:36+1Тоже такой вариант сразу на ум пришёл. На мой вкус, это явно лучше атрибута со строковым ключом, который разработчику придётся брать непонятно откуда.
А вот явное внедрение реализации вместо интерфейса, чревато тем, что тестировать код станет сложнее. Если в некоторых языках ещё можно mock'ать классы, то, насколько знаю, в NET это сделать невозможно.
qw1
02.01.2024 09:36Возможно. Например, с помощью Castle.
Но все методы должны быть объявлены как virtual. Ну, а что не сделаешь ради тестирования.
TerekhinSergey
02.01.2024 09:36+13Кажется, вы пытаетесь выстрелить себе в ногу довольно странным и болезненным способом. Если вы хотите, чтобы сервис зависело от конкретной реализации, то положите эту реализацию явно в контейнер и задайте явно зависимость в сервисе. Если сервис зависит от некоторого общего интерфейса, то там может оказаться любая его реализация, и с этим надо жить. Даже если интерфейс имеет одинаковую сигнатуру, но его семантика отличается, то это два разных интерфейса, которые применяются в разных сценариях. Например, у вас есть интерфейс ICommand с методом Execute и есть потребность некоторые команды вызывать определённым единообразным образом через DI. В этом случае целесообразно было бы ввести интерфейс ISpecialCommand с тем же методом Execute и получать все реализации именно этого интерфейса, а не пытаться разобраться, что там за команда прилетела (может и не самый удачный пример с командами, но я думаю, суть ясна)
MonkAlex
02.01.2024 09:36+1Я плохо понимаю, как правильно жить с DI, когда интерфейсы резолвятся контейнером, который не то чтобы умный.
Когда мы создаем дерево зависимостей руками - мы сами явно решаем, в какой конструктор какую реализацию передать. Когда у нас DI контейнер с разными реализациями, нам приходится изобретать ещё более сложные способы работы =(
BashkaMen
02.01.2024 09:36+5предложу свое решение с примером
сделал интерфейс дженерик + создал классы как "тег\маркер"
в итоге инджекчю то что нужно
или IRedis<AwsTag>
или IRedis<DockerTag>
эти маркеры это почти те же ключи, только я их строкой не передаю, и провтыкать их нереальноMonkAlex
02.01.2024 09:36+1Я видел пример с наследованием. Т.е. создается сначала нормально IRedis, а потом нужный набор его наследников IAwsRedis, IDockerRedis (пустых, без дополнительной логики, если её нет) и уже они проставляются в зависимости класса. Но логика та же самая, да - типизация и очевидные читаемая разница.
UPD: а, выше даже накидали такой вариант, невнимательный =)
Kanut
02.01.2024 09:36Проблема таких вариантов начинается когда работаешь с каким-то сторонним кодом/библиотеками.
Ну то есть допустим есть какой-то стандартный для всех интерфейс ILogger. Вы используете две не ваши библиотеки, которые в свою его используют.
И вот вам в одну библиотекаму нужно запихнуть реализацию консольного логгера, а в другую файлового.
TerekhinSergey
02.01.2024 09:36Сложно представить такой сценарий в реальности. Обычно передашь везде один и тот же интерфейс ILogger и всё. Если говорить конкретно про логирование, то там можно конфигурацией определить, куда и что писать.
MonkAlex
02.01.2024 09:36+1Довольно комплексный момент, не уверен что сейчас в голове удержу мысль.
Сторонние зависимости\классы\интерфейсы (если это не архитектурное решение на уровне компании) необходимо ограничивать одним модулем\сборкой, т.е. не отдавать "чужие" типы дальше транзитивно. И тогда мы получаем очень простую реализацию - создаем свои классы, декорирующие внешнего потребителя ILogger, а ему передаем явно созданные экземпляры. Условно:
// внешние классы
class Library1(ILogger log)
class Library2(ILogger log)
// наши реализации для их сокрытия
class Library1Decorator
{
this.impl = new Library1(new ConsoleLogger(...));
}
class Library2Decorator
{
this.impl = new Library2(new FileLogger(...));
}
Итого, никакой DI контейнер даже не знает, что нужно что-то там резолвить, за внешние зависимости отвечает конкретный класс и никто более. При необходимости конфигурация что-то будет знать, чтобы например настроить куда писать файловые логи. Но это будет конфигурация для класса Library2Decorator и только для него.
Kanut
02.01.2024 09:36Я может чего-то не понимаю. Вот у вас есть класс в чужой библиотеке, который ожидает ILogger в конструкторе. И тоже самое во второй библиотеке.
Как вы им дадите два разных варианта именно ILogger? Неважно две разные имплементации или просто два разных объекта с разной конфигурацией?
MonkAlex
02.01.2024 09:36+1Создам вручную, без DI. И напишу класс обёртку, который будет за это отвечать.
Это при условии, что стандартный ILogger у меня либо не используется в приложении, либо его реализация не подходит для зависимостей.
UPD: я не уверен, что это лучший вариант. Но это рабочий вариант. И я не вижу у него явных минусов.
Mingun
02.01.2024 09:36+2Ну дак это ж как раз вариант номер 4 из статьи, только без класса-обёртки, только вот автору он чем-то не нравится (хотя как по мне, он самый понятный, адекватный и легко реализуемый).
Kanut
02.01.2024 09:36Ну "вручную" можно конечно много что наворотить. Но это тоже далеко не всегда будет удобно.
И вопрос то именно в том как это реализовать при помощи DI.
Ив этом плане целый ряд DI/IoC фреймворков предлагают то или иное решение.
MonkAlex
02.01.2024 09:36И все они выглядят костылями, о чём и речь =)
Какой костыль выбрать - дело в целом вкуса.
Kanut
02.01.2024 09:36Да в общем-то нет. Ну вон в восьмёрке это уже работает из коробки.
Или вон где-то я натыкался на вариант когда при регистрации DI можно было указать какой вариант должен использоваться для какого неймспэйса(вроде бы даже классы можно было указать). Вполне себе удобно было.
MonkAlex
02.01.2024 09:36Добавили возможность - хорошо. Делать так - не обязательно =)
Kanut
02.01.2024 09:36Ну так ясное дело что вас никто не заставляет это использовать. Было бы странно если бы оставили только DI, а всё остальное запретили :)
MonkAlex
02.01.2024 09:36+2Просто эта ветка комментов была про альтернативные решения - без именнованных регистраций. И я в целом за то, чтобы без них же и обходиться. На мой взгляд - это вариант кода "с запахом" =)
qw1
02.01.2024 09:36+2Ну вон в восьмёрке это уже работает из коробки.
Если речь об атрибуте [FromKeyedServices], то для задачи
Вот у вас есть класс в чужой библиотеке, который ожидает ILogger в конструкторе
не вариант. В чужой библиотеке нельзя прописать атрибут на конструкторе.
qw1
02.01.2024 09:36Не так давно эта тема обсуждалась https://habr.com/en/articles/761268/
Варианты в комментариях.
Но эта статья написана на порядок лучше.
hVostt
02.01.2024 09:36+7Не вижу проблемы с созданием фабрики, если получение зависимости должно быть обеспечено различными требованиями. Что хорошо в фабрике:
Явное лучше, чем неявное. Если честно, совершенно не хочется чёрной магии, подаваемой порциями из коробки Autofac или другого контейнера. Ибо теперь мне нужно хорошо знать Autofac, и догадаться, что там под капотом происходит, помнить всегда об этом и не удивляться.
Правильная зависимость может управляться конфигурацией приложения, а не компилируемым ключом, или иметь целую гору условий, проверок и т.д. и т.п. Моя фабрика может решить задачу любой сложности, и это будет очевидное место, без чёрной магии.
Нет зависимости от конкретного DI-контейнера, а мне бы такую зависимость иметь не хотелось бы ни на одном проекте. Наелся.
Так ли много таких сложно конструируемых зависимостей в проекте? Если да, пахнет сам проект.
Да, в .NET 8 добавили фичу с подстановкой зависимости по ключу. На мой взгляд, наличие возможности это конечно хорошо, но может принести и вред. Там, где стоило бы подумать и провести небольшой рефакторинг, могут начать бездумно втыкать ключи. А это значит, что по сигнатуре конструктора совершенно непонятно, что это за ключ такой, на что он влияет, зачем нужен, и какой тогда мне использовать ключ? Придётся копаться в кишках регистрации компонентов. Выглядит не хорошо. А если ключ нужно вынести в конфиг? Не-не.
buldo
02.01.2024 09:36А можно сценарий, когда необходимо так выкручиваться, чтобы разным классам в одном приложении выдавались разные реализации одного и того же интерфейса?
andreishe
02.01.2024 09:36+1Допустим, храните вы файлы в Azure blob storage и есть у вас абстракции для контейнеров этого самого стореджа. Разные части кода обращаются к файлам в разных контейнерах, соответственно, им надо подсовывать различным образом сконфигурированные абстракции. Не совсем разные реализации, но как минимум разные инстансы.
vbenedichuk
02.01.2024 09:36+2А можете дать пример из жизни, где этот подход полезен и удобнее, чем создание отдельных интерфейсов?
andreishe
02.01.2024 09:36С Autofac можно использовать `ResolvedParameter` при регистрации типа чтобы не добавлять аттрибуты в исходный класс:
containerBuilder .RegisterType<BazService>() .WithParameter(new ResolvedParameter( (pi, ctx) => pi.ParameterType == typeof(IDependency), (pi, ctx) => ctx.Resolve<DependencyImplTwo>())) .As<IBazService>();
nronnie
02.01.2024 09:36С
Microsoft.Extensions.DependencyInjection
можно почти точно так же:services.AddKeyedTransient<IFoo, Foo1>("foo1"); services.AddKeyedTransient<IFoo, Foo2>("foo2"); services.AddTransient<IBar>(sp => ActivatorUtility.CreateInstance<Bar>( sp, sp.GetRequiredKeyedService<IFoo>("foo1")); services.AddTransient<IBaz>(sp => ActivatorUtility.CreateInstance<Baz>( sp, sp.GetRequiredKeyedService<IFoo>("foo2"));
Или даже без новых возможностей (как, собственно, раньше часто и делали):
services.AddTransient<Foo1>(); services.AddTransient<Foo2>(); services.AddTransient<IBar>(sp => ActivatorUtility.CreateInstance<Bar>( sp, sp.GetRequiredKeyedService<Foo1>()); services.AddTransient<IBaz>(sp => ActivatorUtility.CreateInstance<Baz>( sp, sp.GetRequiredService<Foo2>());
Razbezhkin
Странное решение на основе ключей, похоже на какой-то костыль. Смысл DI как раз в том, чтобы абстрагироваться от реализации.
Очевидный недостаток: если у меня есть 100500 контроллеров, то мне придется отредактировать их все, чтобы реализовать логику конкретизации зависимостей. А потом еще разок, если я захочу поменять сопоставление реализаций.
Kanut
"Абстрагироваться от реализации" это интерфейсы.
DI скорее для уменьшения связности(или как оно там правильно по русски называется).
dopusteam
А как вы абстрагируетесь от реализации без DI (dependency injection)?
Kanut
Вы можете условно говоря просто создавать объекты в классе main и потом передавать их "по цепочке" уже в виде интерфейсов. Тогда кроме main никто не будет ничего знать про конкретные реализации.
dopusteam
Это все ещё инъекция зависимостей :)
Kanut
Ок, согласен. Стормозил.
Но с другой стороны вам никто не запрещает использовать DI без интерфейсов и только с конкретными классами.
И всё ещё будет DI до тех пор пока объекты создаются вне класса, который их использует.
qw1
Как сделать IoC без DI? Например, через паттерн Service Locator.
isadora-6th
Жалуются что антипаттерн
mvv-rus
Где?
Первый же ответ, который я там вижу:
причем - со ссылкой на статью небезызвестного теоретика Мартина Фаулера.
А в самой жалобе, если посмотреть ниже, есть одна реальная претензия (то что раньше - не претензия, а способ корректировки): что компилятор не сможет обнаружить отсутсвие реализации нужного интерфейса, и ошибка вылезет только во время выполнения. Но ведь ровно эта же претензия есть и к внедрению зависимостей в C# - сам язык внедрения зависимостей не поддерживает, а поддержку добавляют фреймворки, причем - ровно через тот же Service Locator, который при этом обзывают антипаттерном.
hVostt
Глобальный Service Locator -- антипаттерн. Претензий огромное количество. "Теоретик" Мартин Фаулер не придумал, он лишь обобщил совокупный опыт. Антипаттерном SL назван не потому, что у кого-то почесалось, а по вполне известным причинам. Кому интересно, тот легко нагуглит, повторяться не имеет смысла. Но "одна реальная претензия" это, конечно же, не правда.
mvv-rus
Одна претензия была по той ссылке, которая была приведена в качестве подтверждения. И, как я объяснил выше, она в качестве претензии именно к SL для C# несостоятельна, точнее, реализация DI в C# плюс .NET обладает ровно тем же недостатком: нет проверки на этапе компиляции.
Имеет. Не всему нагугленному в интернете следует верить. Если хотите обсудждать дальше - приводите ваши доводы, т.к. обязанность доказывать лежит на утверждающем. Тем более, что упомянутый Фаулер, обобщивший опыт, как раз был не согласен именно с вашей точкой зрения.
hVostt
Я бы легко привёл свои доводы, раз у вас рука отваливается погуглить. Мне не сложно совершенно. Стоило только вежливо об этом попросить, а не вменять мне какие-то обязанности, так делать некрасиво, я вам ничем не обязан. Это с какой кстати?
Не хотите разбираться, пользуйтесь первым попавшимся ответом, который вы привели. Ведь в интернете не следует всему верить, если только это не первый ответ по ссылке :)
mvv-rus
Мне гуглить-то как раз не надо: я этот список доводов знаю. Но я хотел увидеть доводы не вообще, а ваши. Причем - конкретно в контексте языка C# и .NET, с их реальными ограничениями, потому что речь в статье именно о них. Чтобы оценить разумность ваших доводов именно в этом контексте.
У предыдущего комментатора, судя по нерелевантности его аргумента, на которую я указал, утверждение "SL - антипатерн", похоже, является предметом веры. А вы, как я понял, решили его защищать.Поэтому я хотел узнать именно ваши аргументы, чтобы обсудить вопрос именно с вами. Чтобы понять, является ли это утверждение лично для вас частью рационального знания или же - предметом слепой веры. И, если оно является рациональным - то каковы следствия их этого конкретно для C# и .NET, т.е., например, когда SL в .NET использовать не то что допустимо, а приходится. Но если вы не хотите, то я ничего такого обсуждать не буду.
По законам науки логики. Т.н. "бритва Хитченса": бремя доказательства лежит на утверждающем
Это было возражение предыдущему комментатору, которое было прямо по той ссылке, которую он привел в доказательство: указание на то, что его аргументация - некачественная.
MonkAlex
Я могу сказать, что SL - антипаттерн.
Потому что, как выглядит типовая реализация - сам SL синглтон. Для тестов обычно ещё добавляют возможность его мокать.
Классы пользователи SL неявно зависят от любого количества абстракций и это не видно по сигнатуре класса\метода, т.к. вызов выполняется по месту требования. Т.е. то, что на ревью могли бы заметить, в случае с SL - пропустят.
Т.к. разрешение зависимостей в рантайме, а не в компайлтайме, бывают решения вида "вместо фабрики сделаю это прямо в SL". А потом в рантайме же огребание проблем.
Часто видел SL выполняющий сразу обе роли - и компоновку и резолв. Это приводит к тому, что некоторые меняют компоновку прямо на ходу работы приложения, что приводит к сложноуловимым багам.
Kanut
Вы же понимаете что это вполне себе фича? И всякие плагины/модули без подобных вещей в общем-то и не сделать?
MonkAlex
Плагины делаются разными способами. Достаточно объявить набор интерфейсов\атрибутов и обеспечить их вызов согласно жизненному циклу, этого хватит на какой-то набор плагинов.
Впрочем, я не погружался в эту тему глубоко, я уверен что местами SL был бы намного удобнее, да. Можно ли сделать хорошо без него - надо смотреть реальный код, сложно из головы сейчас что-то придумать.
Kanut
Расскажите мне как по вашему должна работать система плагиноа в которой компоновка выполняется во время компиляции.
Ну или просто как-то не во время работы приложения.
qw1
Это необходимое зло можно вынести в один класс, который будет заниматься компоновкой и т.п., а все остальные классы - "рабочие лошадки" - написать в чистом стиле, с явными зависимостями, без SL.
Kanut
Во первых у вас всё равно какие-то элементы компоновки будут в каждом отдельном модуле/плагине. То есть они как минимум должны себя как-то где-то регистрировать.
А во вторых выше написано что компоновка во время исполнения это уже само по себе проблема. А на мой взгляд это вполне себе полезная фича.
MonkAlex
В сборке плагина создаете класс\метод, помечаете его атрибутом\интерфейсом, в нём выполняете компоновку.
Компилируете, подкладываете в приложение.
Приложение когда надо загружает сборку и выполняет написанный код, выполнив поиск атрибута\интерфейса.
Kanut
Ну то есть поиск доступных плагиноа и компоновка они когда происходят? Во время компиляции? Или когда приложение уже работает?
MonkAlex
Поиск - в рантайме, компоновка - на компиляции, если вы её заранее напишете.
Kanut
То есть вы во время компиляции закомпонуете содержание плагинов, которые вы ещё не нашли? И которые может быть даже ещё и не написаны? Это как такая магия должна работать?
MonkAlex
Компоновка зависимостей, только о ней речь. И вот плагин явно может свои зависимости нормально объявить и зарегистрировать, если дать ему для этого апи (интерфейс, как я выше писал).
Kanut
Но это же всё равно происходит уже в момент выполнения программы.
То есть если совсем упрощать, то скажем программа(или точнее отдельные её классы) ожидают что где-то существует имплементация определённого интерфейса.
При этом это имплементация совсем не обязательно существует в момент компиляции и может быть добавлена при помощи плагина. Или не добавлена.
А в теории(и часто и на практике) может быть вообще так что один плагин ожидает существование имплементации, которая является частью другого плагина.
MonkAlex
Как только вы уходите в плагины, речь всегда про "возможность" и никогда про "существование\обязанность". Да, чего-то может не быть.
Поэтому хорошая система плагинов - когда софт имеет точки расширения и в эти точки подключаются плагины. Могут подключаться, могут нет, приложение работает и так и так.
Когда плагины зависят друг от друга - нужно заморачиваться и создавать для этого апи. Чтобы потом и в интерфейсе вывести факт зависимости и проверить на уровне приложения (а не в самом плагине) и\или для информирования\логов о нарушении зависимостей.
Нет простого решения, да и от темы вопроса мы далеко ушли. По факту, можно сделать резолв зависимостей на этапе компиляции, в том числе отказавшись от DI-контейнера (регистрируя всё руками). Сейчас можно ещё сорс-генераторы сделать, которые валидацию на себя возьмут.
Kanut
Но при этом у вас всё равно компоновка происходит уже в момент выполнения. Хотя бы частично.
То есть без этого не обойтись. И получается что это не баг/проблема, а именно что полезная фича.
Я не знаю что вы конкретно понимаете под "руками". Но если вы в момент написания кода/компиляции не знаете какие конкретно имплементации будут использоваться, то как вы это сделаете?
В случае с плагинами нельзя. Банально потому что в момент компиляции мы не знаем какие плагины будут использоваться и что они будут содержать.
mvv-rus
Все разговоры о теоретических преимуществах DI над SL в контексте C#/.NET упираются в непреложный факт: DI здесь - это SL где-то выше по стеку, возможно - "заметенный под ковер" в коде фреймворка. А потому главынй теоретический недостаток SL - невозможность проверок на то, что зависимость реализаована (и как именно), на стадии компиляции (и редактирования исходного кода в IDE, где IDE реализацию вам не подскажет) - никуда здесь не девается. И точно так же как и в SL, в DI для C#/.NET ничто не препятствует разработчику забыть реализовать сервис или зацепить сервис из логически другой части программы, создав совершенно необязательную зависимость.
Что до заметности зависимости, то это - вопрос привычки. Если есть исходный текст программы, то наметанный глаз отловит использование RequestServices из контекста запроса (обычное место ссылки на контейнер сервисов) в обработчике конечной точки (или в действии контроллера MVC, или в обработчике Razor Pages) не с меньшей легкостью, чем параметр метода или конструктора. Особенно - при использовании поиска (а методы и конструкторы поиском искать сложнее).
А если исходного текста нет, то должна быть документация.
Ну, а компоновка по ходу работы (иначе говоря, подключаемые модули - plug-ins) - это, вообще-то, архитектурное решение. И, как известно, есть программы, для которых такое решение вполне обосновано. Но архитектурное решение должно приниматься осознаннно. И, желательно - уполномоченными на это людьми. А неуполномоченным нужно давать по рукам. Но это все, конечно - в том же самом недостижимом идеале в котором DI - не SL.
MonkAlex
Стандартный DI контейнер от MS явно показывает, что все зависимости надо настраивать ДО их использования.
SL же таким не ограничен и поэтому я видел очень много разных костылей, которые на уровне запуска ещё умудрялись одновременно и конфигурировать SL и его же дергать для резолва зависимостей. Это плохо, непредсказуемо и сильно связывает то, что в теории и связано то быть не должно.
При чём тут какой-то RequestServices, понятия не имею. SL может быть написан в любом приложении и к аспнет никак не относится.
Kanut
И что будет если вы забыли это сделать? Будет ошибка при компиляции или во время выполнения?
hVostt
Ошибка времени выполнения. Но можно провалидировать все зависимости на старте и получить, конечно, хоть и ошибку времени выполнения, но прям на стадии запуска приложения. Также есть статические реализации DI на основе кодогенерации, которые позволяют выполнять все проверки на стадии компиляции. Даже такие сложные, как попытки внедрения Scoped зависимостей в Singleton.
Kanut
Так это можно сделать в обоих случаях.
Для C# такие тоже есть? Просто я лично пока не сталкивался. Хотя если честно, то и не особо искал :)
hVostt
Вот недавно была статья: https://habr.com/ru/articles/765112/
Правда, лично я бы пока не стал использовать такое решение, хоть оно и выглядит заманчивым.
mvv-rus
Программиста вообще сложно ограничить в возможности писать плохой код. И DI этому не помеха, и даже кабы не подспорье. Вон колега @hVostt, упомянул чуть ниже в качестве преимуществ DI возможность легко и просто декорировать использующие его компоненты. Как по мне, это дает ничуть не меньше (как бы не больше) возможностей накостылить: декорировать компонент IMHO куда проще, чем изменять SL.
Я о своем, о наболевшем. Впрочем, как я понимаю разработка на ASP.NET Core - это очень большая часть разработки на C#/.NET вообще, так что эту мою печаль разделят, полагаю, многие.
hVostt
Так это же ваша личная картина мира :) Соотносится ли это с практикой? Я пока этого не наблюдаю.
DI:
SL:
mvv-rus
Да. Но чтобы не наблюдать, вы специально взяли компонент, рассчитанный на такое вот использование, с заточенным на это сервисом фабрики. А как насчет других компонентов - для которых такие замечательные фабрики MS не написала? А я ведь писал (не вам, кстати) про декорирование именно таких компонентов, которые можно использовать путем вызова из него декоратора декорируемого компонента с подменой нужных параметров. IMHO это дает достаточный простор для написания самых разных костылей, не так ли?
А ещё вы сравнили ваш навороченный DI с самым убогим способом использования объекта SL - подключением по статичной ссылке. А если, к примеру, вы в обработчике запроса HTTP возьмете для использования в SL ссылку на контейнер сервисов из объекта запроса - пресловутый RequestServices, то по крайней мере с временем жизни у вас будет все в порядке. Ибо напоминаю ещё раз, что DI в C#/.NET - это замаскированный SL. И по той же причине есть подозрение (но это мне проверять лень - кода там больно много наворочено), что и клиент придет нужный и с нужной конфигурацией.
hVostt
Ни в коем случае. SL не управляет временем жизни объектов. Он этого делать не в состоянии, DI напротив, всегда знает весь граф объектов, которые создал, и когда они подлежат уничтожению. DI может обеспечить такие жизненные циклы, как Scoped и Owned. SL этого сделать не может, так как понятия не имеет в каком контексте была запрошена зависимость. Максимум Transient и Singleton.
mvv-rus
Вы это с точки зрения чистой высокой теории написали? Или все же - применительно к нашей низкой практической теме C#/.NET?
За чистую теорию ничего говорить не собираюсь, но вот в реальной действительности C#/.NET временем жизни объектов управляет сборщик мусора. А DI может влиять на время жизни объекта сервиса только так, как позволяет контейнер сервисов. И SL может в точности то же самое.
То есть, если вы берете Scoped-сервис из контейнера сервисов ограниченной области, то жить он будет столько, сколько будет жить эта область. И не важно, как именно вы берете этот сервис: получаете через IServiceProvider.GetService или же указываете фреймворку как зависимость класса/метода - которую получит через IServiceProvider.GetService из того же контейнера и подставит на место уже фреймворк.
qw1
Я так понял, претензия в том, что в парадигме SL не предусмотрен ресолв зависимостей в скопе. Либо каждый раз будет создаваться новый экземпляр, либо доставаться singleton. DI позволяет создать, например, 1 экземпляр DbContext на запрос и вбросить его во все сервисы через их конструкторы. На другой запрос - другой DbContext.
mvv-rus
Не буду ничего говорить за парадигму, ибо это - теория, чистая и высокая. А на практике, если взять правильный объект SL (типа IServiceProvider), то от него вы получите сервис с правильным временем жизни ("в скопе", как вам надо). Например, если вы вызовете в контроллере Context.RequestServices.GetService<YourDBContext>() (DbContext обычно используют специфичеcкого типа, в данном случае путь это будет YourDBContext), то получите DbContext годный как раз на время обработки запроса. И через параметр конструктора контроллера вы получите его же - просто за вас это чуть раньше сделает фреймворк.
MonkAlex
МС таки рекомендует использовать зависимости в конструкторе, а не пользоваться озвученным свойством.
https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-3.0#request-services-1
mvv-rus
А причем тут рекомендации МС на тему "что лучше"? Вопрос-то был в том, можно ли, используя шаблон SL вместо DI, получить сервис с нужным временем жизни. Ответ - можно, если использовать правильный объект контейнера сервисов для SL. В том числе, получить правильный объект можно и там, где никакого DI нет. Например - в обработчике конечной точки маршрутизации базового ASP.NET Core, который принимает в качестве параметра строго HttpContext и ничего больше.
То есть, претензия к SL, названная @qw1, по факту оказывается несостоятельной. Ответ был ровно про это. И он никак не касался выбора, какой шаблон использовать, при условии что такой выбор есть (а есть он не всегда).
hVostt
Не является SL, так как RequestServices это внедрённый через DI IServiceProvider, созданный с помощью IServiceScopeFactory. Это чистый DI. В контекст запроса (HttpContext) добавлен RequestServices только затем, что существуют ситуации, которые не позволяют использовать внедрение через конструктор, например, фильтры действий ASP.NET. На самом деле и там можно создавать объекты с внедрением через конструктор, но не всегда удобно. В любом случае, использование RequestServices не рекомендуется.
qw1
А почему нет? В моём понимании, DI - это когда в класс вталкиваются зависимости, хочет он этого или не хочет (через конструктор, через поля или свойства - варианты могут быть разные).
SL - это когда класс сам запрашивает зависимость. Хочет - запрашивает, не хочет - не запрашивает. Может хоть 100 раз одно и то же запросить.
Вы как-то по-другому проводите границу между DI и SL? Тогда как?
hVostt
SL -- это единая точка для получения всех зависимостей. В .NET, обычно, это статический экземпляр, синглтон в тех или иных вариациях:
В старом классическом ASP.NET MVC был такой. В ASP.NET Core такого нет. Да и суть в том, что в SL ничего никуда не внедряется, зависимости извлекаются только из SL. Но хотите натягивать сову на глобус, если вам что-то кажется -- ради бога, ваше законное право :)
qw1
А если context не передан параметром, а является ThreadStatic/AsyncLocal, то это уже ServiceLocator?
Мне кажется, большее натягивание совы на глобус - это считать код
примером DI.
mvv-rus
https://habr.com/ru/companies/ruvds/articles/776768/comments/#comment_26335860
Таки является: В моем понимании (т.е. как я его использую) шаблон SL не ограничивает источник, из которого он может брать сервисы. Но раз у вас другое понимание этого шаблона, то нам с вами спорить, наверное, не о чем.
Чаво?! В той части кода ASP.NET Core, где создается контекст запроса, нет никакого DI. В частности - в получении IServiceScopeFactory. Конкретно в стандартной конфигурации (в которой в качестве HttpContext используется его наследник DefaultHttpContext), в конструктор DefaultHttpContextFactory - класса, реализующего в стандартной конфигурации фабрику контекстов запроса IHttpContextFactory, который и создает этот DefaultHttpContext - передается только контейнер сервисов приложения (типа IServiceProvider, естественно), а конструктор сам вытягивает из него чисто по шаблону SL этот самый IServiceScopeFactory, запоминает в своем внутреннем поле, а потом, при вызове метода Initialize() (а не просто конструктора, потому что DefaultHttpContext может быть взят и из пула) содержимое этого поля копируется в содержимое свойства ServiceScopeFactory в DefaultHttpContext. А DefaultHttpContext, в свою очередь, передает (не напрямую, там ещё есть путь с выборкой из кэша) это свойство в конструктор RequestServiceFeature (это - реализация IServiceProviderFeature, тип которой, кстати, закодирован явно, без всякого IoC), который уже, наконец, создает контейнер сервисов для ограниченной области запроса и делает его доступным через свойство упомянутого интерфейса. А свойство DefaultHttpContext.RequestService в реальности получает доступ к ссылке на этот контейнер через этот интерфейс.
Короче, "Use the Source, Luke": правда - она в исходниках.
А DI обеспечивается уже фреймворком, реализующим обработчик соответствующей конечной точки маршрутизации, через этот самый контейнер сервисов запроса. Нет фреймворка (например MapGet и пр. до появления Minimal API)- нет DI.
hVostt
При чём тут теория?
Нет не может. И зря вы так пренебрежительно относитесь к теории. Или считаете, теория это только для ботанов-задротов? :)
В реальной действительности, DI уничтожает все объекты по окончанию Scope. Уничтожает, значит вызывает метод Dispose() у объектов. Тем самым, закрываются открытые соединения, освобождаются дескрипторы и т.д. и т.п.
Например, DbContext это scoped компонент. И не важно на каких уровнях вложенности был получен объект в рамках Scope:
В рамках scope, он будет в единственном экземпляре
Открытое соединение будет использовать в рамках Scope
При завершении Scope, соединение будет гарантировано закрыто и все транзакции будут завершены.
Я не первый раз слышу от .NET разработчиков, что они путают GC и Disposable, искренне не понимая в чём разница. Это печально.
Это крайне важно, это ключевое отличие. Я надеюсь, у вас получится осознать данный факт, тем самым углубить свои знания и понимание :)
mvv-rus
Я к ней отношусь с высоты своего опыта, не пренебрегая ей, но и не поклоняясь ей слепо. Потому как про многие положения теоретиков я узнал, попробовав их перед тем на практике, на своей, так сказать, шкуре. Эта теория - она ведь не более, чем обобщение опыта, на так ли. Потому подхожу к ее утверждениям со скептицизмом. Иногда он даже оправдывается. Например, в годы моей молодости теоретики требовали писать комментарии фактически на каждой строчке, а мне это не нравилось - ибо я не видел пользы к комментариям типа "удобная единица" после команды загрузки 1 в регистр (запомнился мне такой в коде IBM VM/SP). И писал комментарии в разумном количестве, благо мог себе позволить. А сейчас писать комментарии, наоборот, считается плохой практикой. А я иногда пишу, потому что не колеблюсь вместе с "линией партии" - когда чувствую, что они нужны.
Это делает не некий волшебный DI, а контейнер сервисов, по вызову IServiceScope.Dispose() к которой он принадлежит. А так как контейнер сервисов с равным успехом может быть использован обоими шаблонами - и SL, и DI - то и объекты с временем жизни области, полученные из контейнера ограниченной области, точно так же будут освобождены, как и полученные через DI.
Согласен. Но я-то тут причем? Я не путаю и даже никого не учу путать.
По-моему, это отличие - всего лишь в понимании, что такое шаблон SL: вашем и моем (и как я вижу из комментариев - не только моем). Давайте либо договоримся, что понимать под SL, либо закончим дискуссию как беспредметную. Кстати, если брать SL в вашем понимании, это тоже - конец дискуссии, положительно оценивать такой убогий шаблон я не буду.
Если вы не хотите, чтобы я начал пересчитывать используемые вами древние полемические приемы из известной статьи Чапека - не используйте их. Все равно к рациональному содержимому дискуссии они не добавляют ничего.
hVostt
Ну какой же вы сложный! Давайте я вас научу:
— Коллега, почему вы считаете SL антипаттерном? Интересно ваше мнение.
Спасибо за вопрос! Я так считаю потому что:
Принцип IoC разрушается, так как часть управления созданием зависимости переносится в реализацию.
SL может создать объект, но не может управлять его временем жизни. По сути в SL можно использовать только зависимости типа Singleton и Transient, но никак не Scoped. Конечно же, используя хаки .NET, контекст синхронизации, что-то подобное можно сотворить на костылях, но это всё равно далеко от того, что можно делать совершенно естественным образом, используя DI.
Нарушается основополагающий принцип явное лучше не явного, без детального построчного изучения реализации невозможно сказать, какие зависимости потребляет компонент.
Появляется дополнительная зависимость от SL. Зависимость, объявленная в конструкторе, на самом деле не требует никакого DI, и сконструировать класс можно обычным new, или мок-контейнером -- что крайне полезно для изолированного юнит-тестирования.
Компонент, использующий SL невозможно задекорировать, со специфичными реализациями зависимостей.
Компонент, получающий свои зависимости через конструктор, легко переносится и переиспользуется между разными проектами, так как не требует какого-то определённого SL и в принципе контейнера.
Это самые основные причины избегать использования SL. Но это не значит, что SL совсем нельзя использовать. Можно! Например, мы рефакторили старый легаси проект, где код прям ужас-ужас, и перевести наскоком на DI его не представлялось возможным. Поэтому, начала мы ввели SL, постепенно перевели получение зависимостей на SL вместо new в недрах компонентов. Затем стало сильно проще перевести проект на DI, без прерывания и замораживания разработки.
Также SL может использоваться в одноразовых небольших проектах, которым не сулит развитие, для скорости разработки.
Я надеюсь, смог ответить на ваш вопрос. Ещё раз, спасибо, что проявили интерес!
qw1
Принцип IoC гласит, что компоненты должны зависеть от абстракций, а не от реализаций. Неважно, получает ли компонент свою зависимость (
IService
) в конструкторе или через SL, пока в нем явно не упоминается реализация (Service
), принцип IoC не нарушен.hVostt
Вы путаете с другим принципом: DIP.
mvv-rus
Если рассуждать так, то при добавлении параметра в конструктор этот принцип нарушается точно так же: вы инициируете создание зависимости. Т.е., в контексте обсуждения (.NET и C#, напоминаю) делаете ровно то же, что и вызывая IServiceProvider.GetService: инициируете создание объекта зависимости, не указывая его реализацию и не передавая ему никаких дополнительных параметров.
Звучит страшно, но как я уже написал выше, это страшное "детальное построчное изучение" в обсуждаемом контексте в реальности сводится к поиску в исходном тексте обращения к SL, конкретно в наиболее интересном случае обработчиков в веб-приложении - свойства RequestServices. И это делается даже более механически, нежели поиск внедренных через параметры зависимостей.
Но если у вас вдруг нет исходного текста программы, тогда сложнее - придется таки прочитать документацию.
А вообще, сторонникам IoC по поводу "явное лучше неявного" стоило бы промолчать: сам этот принцип направлен на то, чтобы заменить явное неявным.
Конкретно в обсуждаемом контексте .NET и C#, SL - это не какая-то высшая сущность, а ещё один тип параметра-интерйеса. И он точно так же поддается имитации, как и любой другой параметр. А ещё он в .NET и C# присутствует везде, где есть DI - потому что DI в .NET и C# - это SL, спрятанный где-то выше по стеку вызовов.
Этот компонент откуда ссылку на SL получает? Если через параметр - задекорируйте этот самый SL - и будет вам счастье.
Что интересно, сам .NET именно этим и занимается, когда использует сервисы с временем жизни Scoped: фактически он создает декоратор для основного контейнера, который держит ссылки на созданные реализации сервисов на время работы в ограниченной области (в ASP.NET Core - в рамках обработки одного запроса HTTP). И если вам вдруг понадобится передать такую зависимость (не важно, полученную через SL или DI) в код, выполняющийся вне этой области (между запросами, например), то весьма вероятно у вас будут проблемы.
Но таки да, если компонент получает ссылку на SL не через параметры, откуда-то ещё (к примеру из HttpContext.RequestServices), то с декорированием сложно.
Теоретически - да. На практике в .NET если использовать только возможности контейнера по умолчанию, то компонент переносится в любой проект, где есть DI и контейнер сервисов - потому что в .NET DI реализуется через SL "где-то там". А в том же ASP.NET Core контейнер сервисов есть всегда, так что проблем не будет.
То есть, получается, что недостатки SL конкретно в .NET нивелируются. Это с одной стороны. А с другой стороны, DI в .NET (который, например, в ASP.NET Core практически обязателен к использованию) обеспечивается фреймворками, а потому без SL далеко не везде можно обойтись без серьезных жертв, а недостатки SL - это, в большинстве случаев, и недостатки DI
mvv-rus
PS Хотел дополнить в самом комментарии, но время истекло, поэтому - здесь.
Вообще IMHO все разговоры на тему "smth - антипаттерн" отдают теоретизированием на уровне споров остроконечников с тупоконечниками. А на практике все "антипаттерны" - тоже паттерны, шаблоны проектирования. И как у всех других шаблонов у них есть свои недостатки и свои достоинства, а следовательно - область применимости, выходить за которую небезопасно. Впрочем, тут IMHO - как с инструкциями по ТБ: нарушать можно, но при этом следует четко отдавать себе отчет, зачем ты это делаешь и чем это может тебе грозить. А вообще "И
терпентинgoto на что-нибудь полезен!" (почти по Козьме Пруткову) ;-)hVostt
Да ради бога. Это лично ваша картина мира, как к этому относиться. Это ваша религия, в которой "теория это зло для ботанов" :)
Анти-паттерн, это означает паттерн, который применять не рекомендуется, так как его использование может нанести вред. Основные причины я описал. Они все сугубо практические.
Демагогию можно разводить сколько угодно, за кружкой пива я бы потрепался с вами с удовольствием. Вообще демагогия это великолепный инструмент, с помощью которого можно доказать, что черепаха быстрее бегуна, белая стена на самом деле чёрная, а крыса и мышь -- одно и тоже :)
mvv-rus
Дык, ровно это я написал, упомянув инструкцию по ТБ: применять на практике можно, но с пониманием зачем это делать и каковы могут быть последствия. Но, собственно, это так практически со всеми шаблонами: область применимости любого из них ограничена.
Это очень удобная позиция: назвать то, что вам не нравится, демагогией. Неконструктивная она, однако. И это - еще один довод за прекращение дискуссию с вами.
hVostt
IoC это не догма, а принцип. Даже не шаблон. У принципа нет и не может быть сторонников. Ничего тут неявного нет, речь идёт о контроле. Реализаций у IoC великое множество. SL к слову является одной из реализации принципов IoC.
Получается, вы не знаете теории, и это выходит вам боком, так как вы IoC противопоставляете SL, что выглядит по меньшей мере странно :)
Учите теорию. Иначе ваши рассуждения это сплошная беда.
mvv-rus
Ну вот, вы с чего то решили, что я противопоставляю SL и IoC. А я там противопоставил этот новомодный (ну как новомодный - ноги у него растут из 90-х и нулевых) IoC и традиционный подход с явно прописанными чисто конкретными зависимостями - не от интерфейсов, а от реализаций. Да, это затрудняет модификацию кода, но ведь и достоинство у него есть: всегда видно, с чем именно имеешь дело, IDE подскажет.
Думал, из контекста вы это поймете, но вы предпочли сделать соломенное чучело и поучать его.
hVostt
Ну так кто вам мешает писать "традиционно"? Так ведь и ООП тоже "новомодный". Процедурный подход, тоже нарушение традиций :)
Как раньше было хорошо, вся программа в одном файле, никаких сложностей с процедурами, делаешь GOTO, все переменные -- глобальные и доступные откуда угодно. Сказка :)
Я правда не понимаю в чём ваш посыл. Вы критикуете IoC? Или какие-то теоретики-ботаники навязывают вам DI, а вам и с SL хорошо жилось, пока не пришли "они"? В чём состоит ваш мессадж?
qw1
Согласен, антипаттерн.
Вопрос был теоретический. Ответ - "да, возможно, вот через такое решение".