Рассмотрим различные способы регистраций зависимостей, требующих параметров конфигураций, а также решение, к которому пришли и которым теперь пользуемся.
Краткая вводная
Регистрации зависимостей мы разносим по модулям и затем регистрируем их через RegisterAssemblyModules. Все удобно, все прекрасно. Но как всегда есть «НО». Это удобно и прекрасно ровно до тех пор, пока наши сервисы не требуют параметров из файлов конфигураций. Ситуацию, в которой не требуется выносить настройки вашего приложения в файлы конфигураций, представить достаточно сложно. Как минимум требуется вынести в конфигурации строки подключений.
Мы собираем IConfigurationRoot в конструкторе Startup-класса и кладем его в свойство Configuration. Соответственно, дальше его можно использовать в методе ConfigureServices. В общем, стандартный сценарий.
public Startup(IHostingEnvironment env)
{
IConfigurationBuilder builder = new ConfigurationBuilder()
...
...
Configuration = builder.Build();
}
public IConfigurationRoot Configuration { get; }
Как можно решить проблему с сервисами, требующими параметры конфигурации
1. Не выносить регистрации таких сервисов в модули, а регистрировать их в ConfigureServices
Плюсы:
- Большую часть регистраций прячем в модули и регистрируем в одну строчку через RegisterAssemblyModules.
Минусы:
- Приходится загромождать ConfigureServices регистрациями остальных сервисов, требующих параметры;
- Регистрации таких сервисов по факту относятся к конкретным модулям, но расположены не в них, что не всегда тривиально.
В итоге работа с контейнером выглядит так:
ContainerBuilder builder = new ContainerBuilder();
builder.RegisterAssemblyModules(typeof(SomeModule).GetTypeInfo().Assembly);
builder.RegisterType<SomeServiceWithParameter>()
.As<ISomeServiceWithParameter>()
.WithParameter("connectionString", Configuration.GetConnectionString("SomeConnectionString"));
// Множество других регистраций параметрозависимых сервисов
builder.Populate(services);
Container = builder.Build();
2. Добавить в модули, в которых есть параметрозависимые сервисы, свойства для каждого параметра и регистрировать каждый модуль по-отдельности
Плюсы:
- Все регистрации логически разбиты по модулям и лежат там, где и должны.
Минусы:
- Регистраций модулей может быть много и все это просто загромождает ConfigureServices (особенно если в модули требуется передать большое количество параметров);
- При появлении нового модуля нужно не забывать добавлять регистрацию в ConfigureServices.
В итоге работа с контейнером выглядит так:
ContainerBuilder builder = new ContainerBuilder();
builder.RegisterModule<SomeModule>();
// Другие регистрации параметронезависимых модулей
builder.RegisterModule(new SomeModuleWithParameters
{
ConnectionString = Configuration.GetConnectionString("SomeConnectionString")
// Другие параметры
});
// Другие регистрации параметрозависимых модулей
builder.Populate(services);
Container = builder.Build();
При таком подходе вполне можно обойтись и одним свойством типа IConfigurationRoot и передавать в параметрозависимые модули целиком Configuration.
3. Регистрировать параметрозависимые сервисы как делегат (через метод Register), в котором резолвить IConfigurationRoot и остальные необходимые для таких сервисов зависимости
Плюсы:
- Все регистрации логически разбиты по модулям и лежат там, где и должны;
- Работа с контейнером в ConfigureServices выглядит чисто и не требует изменений при появлении новых модулей.
Минусы:
- Ужасные регистрации параметрозависимых сервисов, особенно если в них должны инъектится другие сервисы;
- Регистрации параметрозависимых сервисов нужно менять, если меняется состав их зависисмостей.
В итоге работа с контейнером выглядит так:
// Не забываем зарегистрировать IConfigurationRoot
services.AddSingleton(Configuration);
ContainerBuilder builder = new ContainerBuilder();
builder.RegisterAssemblyModules(typeof(SomeModule).GetTypeInfo().Assembly);
builder.Populate(services);
Container = builder.Build();
Но при этом регистрации параметрозависимых сервисов в модулях выглядят вот так:
builder.Register(componentContext =>
{
IConfigurationRoot configuration = componentContext.Resolve<IConfigurationRoot>();
return new SomeServiceWithParameter(
componentContext.Resolve<SomeOtherService>(),
// Резолвим другие зависимости
configuration.GetConnectionString("SomeConnectionString"));
})
.As<ISomeServiceWithParameter>();
4. Конфигурация Autofac через JSON/XML
Данный вариант даже не рассматривали из-за очевидной проблемы — мы хотим дать возможность изменять только определенные параметры, но никак не сами регистрации зависимостей.
В итоге, какой из вариантов хуже — вопрос спорный. Очевидно было только то, что ни один из них нас не устраивал.
Что сделали мы
Добавили интерфейс IConfiguredModule:
public interface IConfiguredModule
{
IConfigurationRoot Configuration { get; set; }
}
Отнаследовали класс ConfiguredModule от Module и реализовали интерфейс IConfiguredModule:
public abstract class ConfiguredModule : Module, IConfiguredModule
{
public IConfigurationRoot Configuration { get; set; }
}
Добавили вот такой extension для ContainerBuilder:
public static class ConfiguredModuleRegistrationExtensions
{
// В generic-параметр TType передается тип, находящийся в сборке, в которой мы будем искать IModule-и
// + Передаем IConfigurationRoot, которым мы будем означивать Configuration в ConfiguredModule-х
public static void RegisterConfiguredModulesFromAssemblyContaining<TType>(
this ContainerBuilder builder,
IConfigurationRoot configuration)
{
if (builder == null)
throw new ArgumentNullException(nameof(builder));
if (configuration == null)
throw new ArgumentNullException(nameof(configuration));
// Извлекаем из сборки, в которой лежит TType, все типы, реализующие IModule
IEnumerable<Type> moduleTypes = typeof(TType)
.GetTypeInfo()
.Assembly.DefinedTypes
.Select(x => x.AsType())
.Where(x => x.IsAssignableTo<IModule>());
foreach (Type moduleType in moduleTypes)
{
// Создаем модуль нужного типа
var module = Activator.CreateInstance(moduleType) as IModule;
// Если модуль реализует IConfiguredModule, то означиваем его свойство Configuration переданным в метод IConfigurationRoot
var configuredModule = module as IConfiguredModule;
if (configuredModule != null)
configuredModule.Configuration = configuration;
// Ну и просто регистрируем созданный модуль
builder.RegisterModule(module);
}
}
}
Эти ~40 строк кода дают нам возможность работать с контейнером вот так:
ContainerBuilder builder = new ContainerBuilder();
builder.RegisterConfiguredModulesFromAssemblyContaining<SomeModule>(Configuration);
builder.Populate(services);
Container = builder.Build();
Если модуль параметронезависимый, то мы как и раньше наследуем его от Module — тут никаких изменений.
public class SomeModule : Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<SomeService>().As<ISomeService>();
}
}
Если же параметрозависимый, то наследуем его от ConfiguredModule и можем извлекать параметры через свойство Configuration.
public class SomeConfiguredModule : ConfiguredModule
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<SomeServiceWithParameter>()
.As<ISomeServiceWithParameter>()
.WithParameter("connectionString", Configuration.GetConnectionString("SomeConnectionString"));
}
}
Сам же код работы с контейнером в ConfigureServices не требует никаких изменений при изменении набора модулей.
Надеемся, что кому-то будет полезным. Будем рады любому фидбэку.
UPD. Добавили более лаконичное решение из комментариев от mayorovp (только использование контейнера обернули в using):
public static ContainerBuilder RegisterConfiguredModulesFromAssemblyContaining<TType>(
this ContainerBuilder builder,
IConfigurationRoot configuration)
{
if (builder == null)
throw new ArgumentNullException(nameof(builder));
if (configuration == null)
throw new ArgumentNullException(nameof(configuration));
var metaBuilder = new ContainerBuilder();
metaBuilder.RegisterInstance(configuration);
metaBuilder.RegisterAssemblyTypes(typeof(TType).GetTypeInfo().Assembly)
.AssignableTo<IModule>()
.As<IModule>()
.PropertiesAutowired();
using (IContainer metaContainer = metaBuilder.Build())
{
foreach (IModule module in metaContainer.Resolve<IEnumerable<IModule>>())
builder.RegisterModule(module);
}
return builder;
}
Комментарии (31)
mayorovp
30.03.2017 07:47Рекомендую глянуть реализацию RegisterAssemblyModules в исходниках Autofac. Ваш код можно ужать в несколько раз если для загрузки модулей использовать дополнительный контейнер.
mayorovp
30.03.2017 08:45+1Добрался до компа. Вот тот самый более простой способ:
public static ContainerBuilder RegisterConfiguredModulesFromAssemblyContaining<TType>( this ContainerBuilder builder, IConfigurationRoot configuration) { var metabuilder = new ContainerBuilder(); metabuilder.RegisterInstance(configuration); metabuilder.RegisterAssemblyTypes(typeof(TType).Assembly) .AssignableTo<IModule>().As<IModule>().PropertiesAutowired(); foreach (var m in metabuilder.Build().Resolve<IEnumerable<IModule>>()) builder.RegisterModule(m); return builder; }
tados
30.03.2017 09:00Спасибо за комментарии.
Решение с использованием доп. контейнера тоже рассматривали. По какой причине отмели — теперь уже не понимаю, если честно.
Действительно проще и не менее удобно.
oxidmod
30.03.2017 08:39Не бейте тапками, но насколько всеже удобно конфигурировать зависимости в Symfony Service Container
зы. Что особенно странно, ведь пхп идет в сторону джавы и шарпа, но DI в нем удобней практиковать)mayorovp
30.03.2017 08:50+1Большинство реализаций DI с низким порогом входа либо предполагают что все сервисы — одиночки, либо предлагают использовать аналог паттерна Service Locator для создания тех сервисов, которые одиночками не являются. И сверху на это все — трудности с определением времени жизни объектов.
Но в PHP так делать можно потому что он создан чтобы умирать: среда выполнения приберет все объекты после окончания обработки запроса. А вот C# такой роскоши не предоставляет (зато дает возможность просто держать в памяти все то, ради чего программисты на PHP ставят отдельные key-value хранилища).
oxidmod
30.03.2017 09:051. PHP не обязан умирать с версии эдак 5.3 (начиная от банальных воркеров и заканчивая ReactPHP)
2. Symfony Service Container позволяет как регистрировать синглтон, так и отдавать каждый раз новый инстанс
3. Нет, Service Locator использовать не нужно
4. Проблема описанная в статье просто не стоит, поскольку это одна из бест-практис разделять и конфигурировать сервисы по модулях (бандлах)mayorovp
30.03.2017 09:08PHP не обязан умирать с версии эдак 5.3 (начиная от банальных воркеров и заканчивая ReactPHP)
Но обычно все же умирает. Применим ли фреймворк Symfony для неумирающей версии PHP?
Symfony Service Container позволяет как регистрировать синглтон, так и отдавать каждый раз новый инстанс
Нет, Service Locator использовать не нужноДопустим, вы настроили Symfony Service Container так, чтобы он отдавал каждый раз новый инстанс. Как теперь его получить не используя ничего Service Locator-подобного?
oxidmod
30.03.2017 11:13Применим ли фреймворк Symfony для неумирающей версии PHP?
да
Допустим, вы настроили Symfony Service Container так, чтобы он отдавал каждый раз новый инстанс. Как теперь его получить не используя ничего Service Locator-подобного?
Где получить? Заинжектить в другой сервис? Если да, то также как и синглтон. Для другого сервиса не важно как и откуда возьмется его зависимость. Это знает лишь контейнерmayorovp
30.03.2017 11:27Где получить? Заинжектить в другой сервис? Если да, то также как и синглтон. Для другого сервиса не важно как и откуда возьмется его зависимость. Это знает лишь контейнер
Как получить инстанс службы A изнутри службы B если время жизни службы A меньше чем время жизни службы B? Допустим, служба A создается на каждый запрос, а служба B висит в памяти в течении всего времени жизни сервера.
oxidmod
30.03.2017 11:43Заинжектить в службу B фабрику для создания службы А
mayorovp
30.03.2017 11:55Вот, уже третья сущность появилась — фабрика… Надеюсь, они там автоматически создаются?
oxidmod
30.03.2017 12:30К сожалению нет.
А вы могли бы привести примеры таких служб, когда бы понадобилось пересоздавать её на каждый реквест?mayorovp
30.03.2017 12:32Контекст/сессию ORM обычно в таком режиме рекомендуют использовать
oxidmod
30.03.2017 14:05Не уверен что понял вас правильно, но предыдущий реквест и не должен оставлять мусора. Вообще хендлинг реквеста должен быть как чистая функция. Ни в каких сервисах не должно оставаться состояний.
зы. Возможно у меня просто мало опыта и я не сталкивался с подобной проблемой ниразу. Но за 4 года в веб деве у меня не возникало необходимости получать новый инстанс какого-либо сервиса на каждый меседж из брокера сообщений скажем. Притом что воркеры могли жить месяцами (еще раз о том, что пхп уже давно не обязан умирать)mayorovp
30.03.2017 14:19А вот это как раз и связано с тем, что PHP создан чтобы умирать. Как только вы перейдете на тот же ReactPHP — у вас появится проблема возможного накопления мусора в глобальном состоянии.
В случае же с ORM накопление мусора даже не считается проблемой, а является довольно важной фичей. Точнее, двумя фичами — кешированием сущностей и отслеживанием изменений.
mayorovp
30.03.2017 18:14PS извиняюсь, первый раз не увидел про воркеры.
В таком случае возможны две причины почему не возникало проблем с накоплением состояния:
- Вы не используете продвинутых ORM-фреймворков в воркерах;
- Вы создаете контекст ORM в обход IoC контейнера.
oxidmod
30.03.2017 21:36Я использую доктрину. Орм по умолчанию у симфони. В отличии от большинства пхп-орм доктрина реализует DataMapper и использует UoW. Как раз в неумирающем пхп она себя отлично проявляет
netpilgrim
30.03.2017 09:02Рассматривали вариант использовать конфигурацию приложения как Ambient Context зависимость?
tados
30.03.2017 09:02Нет, к сожалению, не рассматривали. Навскидку не понятны плюсы от такого подхода.
Можете поделиться своим опытом? Было бы интересно.netpilgrim
31.03.2017 00:20В вашем случае вижу это примерно так:
- пишется статический класс предоставляющий доступ к конфигурации, например так: https://github.com/s-tarasov/SerialsCalendar/blob/master/Calendar.Web/Configuration/ConfigurationProvider.cs
- до создания контейнера приложение инициализирует класс конфигурацией
- далее класс используется в модулях для получения конфигурации
особо сильных плюсов не вижу, но тем не менее:
- нет необходимости передавать в модули конфигурацию
- можно написать модули не зависимые от Microsoft.Framework.Configuration (если есть необходимость)
tados
31.03.2017 09:10Спасибо за пример!
Но мне все же кажется, что это не самое уместное использование окружающего контекста. Все-таки он больше подходит для случаев, когда есть много потребителей, которым требуется получать какое-либо разделяемое состояние.
В случае же с конфигурированием модулей нам в общем случае требуется один раз при инициализации приложения получить данные из контекста. То есть потребители в данном случае только модули. И нужен им этот контекст только один раз. Да и данные в контексте уже определены.
При такой потребности выглядит проще передать (ну или заинъектить) конфигурацию один раз. Но тут на вкус и цвет. Ваш вариант не менее рабочий и не менее удобный!
P.S. Что-то вроде окружающего контекста мы используем допустим для разделения текущей транзакции между всеми командами и запросами, когда работаем с UnitOfWork. Только в данном случае этот контекст у нас ThreadStatic.
chumakov-ilya
А как же самый простой способ, работающий в Core "из коробки" — передача самих параметров конфигурации через DI при помощи IOptions?
tados
Безусловно, IOptions — это удобный способ передачи любых параметров внутри фреймворка. Например, если вы хотите передать какие-то параметры в контроллер.
Но если речь идет о других сервисах, то вам придется добавлять еще один конструктор, принимающий IOptions (ну или вообще сделать только один конструктор). Таким образом вы обяжете все ваши приложения (и это далеко не только ASP.NET Core) создавать сервисы, передавая в них IOptions. При этом в библиотеках с вашими сервисами еще и появится лишняя зависимость — Microsoft.Extensions.Options. В итоге, при работе с такими сервисами вы будете навязывать использование IOptions даже там, где это не нужно.
Ну и напоследок — для каждого сервиса, требующего параметров, вам придется регистрировать соответствующие TOptions в ConfigureServices, то есть опять-таки изменять этот метод и загромождать его регистрациями теперь уже не самих сервисов, а параметров для этих сервисов. В итоге бОльшую часть вашего ConfigureServices займет код:
services.Configure(Configuration);
services.Configure(Configuration);
services.Configure(Configuration);
…
tados
Выпилилась часть кода:
Это про ConfigureServices из предыдущего моего коммента.
Taritsyn
Можно сократить количество кода, если использовать подход из статьи Скотта Аллена «Keeping a Clean Startup.cs in Asp.Net Core».
tados
Спасибо за ссылку! Практикуем.
Есть ряд часто используемых extension-ов. Например для локализации дефолтных сообщений об ошибках при model binding-e (LocalizeModelBindingErrorMessages()) или для биндинга модели к конкретному классу-наследнику в зависимости от какого-либо переданного значения, когда в экшене указан класс-родитель (UserHierarchyTypeModelBinding()).
Правильнее было сказать, что проблема не в том, что ConfigureServices будет завален регистрациями IOptions-ов, а в том, что они в принципе будут — неважно где, в ConfigureServices или в каком-либо extension-е. И эти регистрации все равно придется добавлять по мере необходимости передавать параметры в новые сервисы.
chumakov-ilya
Ваша мысль, безусловно, здравая, но, если хочется избежать зависимости от
IOptions<T>
, что помешает считать конфигурацию в ваш собственный класс и зарегистрировать в контейнере как синглтон? Заметьте, это решение будет работать с любым DI-контейнером, в отличие от вашего Autofac-specific кода.решается рефакторингом.
P.S. Борьба с "лишними" зависимостями иногда напоминает одержимость примитивными типами.