Передача параметров конфигураций в модули Autofac-а в ASP.NET Core

    Мы начали работать с ASP.NET Core практически сразу после релиза. В качестве IoC-контейнера выбрали Autofac, так как реализации привычного нам Windsor под Core нет (не было).

    Рассмотрим различные способы регистраций зависимостей, требующих параметров конфигураций, а также решение, к которому пришли и которым теперь пользуемся.

    image

    Краткая вводная


    Регистрации зависимостей мы разносим по модулям и затем регистрируем их через 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
    • +3

      А как же самый простой способ, работающий в Core "из коробки" — передача самих параметров конфигурации через DI при помощи IOptions?

      • 0
        Безусловно, IOptions — это удобный способ передачи любых параметров внутри фреймворка. Например, если вы хотите передать какие-то параметры в контроллер.

        Но если речь идет о других сервисах, то вам придется добавлять еще один конструктор, принимающий IOptions (ну или вообще сделать только один конструктор). Таким образом вы обяжете все ваши приложения (и это далеко не только ASP.NET Core) создавать сервисы, передавая в них IOptions. При этом в библиотеках с вашими сервисами еще и появится лишняя зависимость — Microsoft.Extensions.Options. В итоге, при работе с такими сервисами вы будете навязывать использование IOptions даже там, где это не нужно.

        Ну и напоследок — для каждого сервиса, требующего параметров, вам придется регистрировать соответствующие TOptions в ConfigureServices, то есть опять-таки изменять этот метод и загромождать его регистрациями теперь уже не самих сервисов, а параметров для этих сервисов. В итоге бОльшую часть вашего ConfigureServices займет код:

        services.Configure(Configuration);
        services.Configure(Configuration);
        services.Configure(Configuration);

        • 0
          Выпилилась часть кода:

          services.Configure<SomeOptions1>(Configuration);
          services.Configure<SomeOptions2>(Configuration);
          services.Configure<SomeOptions3>(Configuration);
          

          Это про ConfigureServices из предыдущего моего коммента.
          • 0
            Ну и напоследок — для каждого сервиса, требующего параметров, вам придется регистрировать соответствующие TOptions в ConfigureServices, то есть опять-таки изменять этот метод и загромождать его регистрациями теперь уже не самих сервисов, а параметров для этих сервисов.

            Можно сократить количество кода, если использовать подход из статьи Скотта Аллена «Keeping a Clean Startup.cs in Asp.Net Core».

            • +1
              Спасибо за ссылку! Практикуем.
              Есть ряд часто используемых extension-ов. Например для локализации дефолтных сообщений об ошибках при model binding-e (LocalizeModelBindingErrorMessages()) или для биндинга модели к конкретному классу-наследнику в зависимости от какого-либо переданного значения, когда в экшене указан класс-родитель (UserHierarchyTypeModelBinding()).

              Правильнее было сказать, что проблема не в том, что ConfigureServices будет завален регистрациями IOptions-ов, а в том, что они в принципе будут — неважно где, в ConfigureServices или в каком-либо extension-е. И эти регистрации все равно придется добавлять по мере необходимости передавать параметры в новые сервисы.
            • –2
              При этом в библиотеках с вашими сервисами еще и появится лишняя зависимость — Microsoft.Extensions.Options.

              Ваша мысль, безусловно, здравая, но, если хочется избежать зависимости от IOptions<T>, что помешает считать конфигурацию в ваш собственный класс и зарегистрировать в контейнере как синглтон? Заметьте, это решение будет работать с любым DI-контейнером, в отличие от вашего Autofac-specific кода.


              бОльшую часть вашего ConfigureServices займет код

              решается рефакторингом.


              P.S. Борьба с "лишними" зависимостями иногда напоминает одержимость примитивными типами.

          • 0

            Рекомендую глянуть реализацию RegisterAssemblyModules в исходниках Autofac. Ваш код можно ужать в несколько раз если для загрузки модулей использовать дополнительный контейнер.

            • +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;
              }
              • 0
                Спасибо за комментарии.

                Решение с использованием доп. контейнера тоже рассматривали. По какой причине отмели — теперь уже не понимаю, если честно.
                Действительно проще и не менее удобно.
            • 0
              Не бейте тапками, но насколько всеже удобно конфигурировать зависимости в Symfony Service Container

              зы. Что особенно странно, ведь пхп идет в сторону джавы и шарпа, но DI в нем удобней практиковать)
              • +1

                Большинство реализаций DI с низким порогом входа либо предполагают что все сервисы — одиночки, либо предлагают использовать аналог паттерна Service Locator для создания тех сервисов, которые одиночками не являются. И сверху на это все — трудности с определением времени жизни объектов.


                Но в PHP так делать можно потому что он создан чтобы умирать: среда выполнения приберет все объекты после окончания обработки запроса. А вот C# такой роскоши не предоставляет (зато дает возможность просто держать в памяти все то, ради чего программисты на PHP ставят отдельные key-value хранилища).

                • 0
                  1. PHP не обязан умирать с версии эдак 5.3 (начиная от банальных воркеров и заканчивая ReactPHP)

                  2. Symfony Service Container позволяет как регистрировать синглтон, так и отдавать каждый раз новый инстанс

                  3. Нет, Service Locator использовать не нужно

                  4. Проблема описанная в статье просто не стоит, поскольку это одна из бест-практис разделять и конфигурировать сервисы по модулях (бандлах)
                  • 0
                    PHP не обязан умирать с версии эдак 5.3 (начиная от банальных воркеров и заканчивая ReactPHP)

                    Но обычно все же умирает. Применим ли фреймворк Symfony для неумирающей версии PHP?


                    Symfony Service Container позволяет как регистрировать синглтон, так и отдавать каждый раз новый инстанс
                    Нет, Service Locator использовать не нужно

                    Допустим, вы настроили Symfony Service Container так, чтобы он отдавал каждый раз новый инстанс. Как теперь его получить не используя ничего Service Locator-подобного?

                    • 0
                      Применим ли фреймворк Symfony для неумирающей версии PHP?

                      да

                      Допустим, вы настроили Symfony Service Container так, чтобы он отдавал каждый раз новый инстанс. Как теперь его получить не используя ничего Service Locator-подобного?

                      Где получить? Заинжектить в другой сервис? Если да, то также как и синглтон. Для другого сервиса не важно как и откуда возьмется его зависимость. Это знает лишь контейнер
                      • 0
                        Где получить? Заинжектить в другой сервис? Если да, то также как и синглтон. Для другого сервиса не важно как и откуда возьмется его зависимость. Это знает лишь контейнер

                        Как получить инстанс службы A изнутри службы B если время жизни службы A меньше чем время жизни службы B? Допустим, служба A создается на каждый запрос, а служба B висит в памяти в течении всего времени жизни сервера.

                        • 0
                          Заинжектить в службу B фабрику для создания службы А
                          • 0

                            Вот, уже третья сущность появилась — фабрика… Надеюсь, они там автоматически создаются?

                            • 0
                              К сожалению нет.
                              А вы могли бы привести примеры таких служб, когда бы понадобилось пересоздавать её на каждый реквест?
                              • 0

                                Контекст/сессию ORM обычно в таком режиме рекомендуют использовать

                                • 0
                                  Не уверен что понял вас правильно, но предыдущий реквест и не должен оставлять мусора. Вообще хендлинг реквеста должен быть как чистая функция. Ни в каких сервисах не должно оставаться состояний.

                                  зы. Возможно у меня просто мало опыта и я не сталкивался с подобной проблемой ниразу. Но за 4 года в веб деве у меня не возникало необходимости получать новый инстанс какого-либо сервиса на каждый меседж из брокера сообщений скажем. Притом что воркеры могли жить месяцами (еще раз о том, что пхп уже давно не обязан умирать)
                                  • 0

                                    А вот это как раз и связано с тем, что PHP создан чтобы умирать. Как только вы перейдете на тот же ReactPHP — у вас появится проблема возможного накопления мусора в глобальном состоянии.


                                    В случае же с ORM накопление мусора даже не считается проблемой, а является довольно важной фичей. Точнее, двумя фичами — кешированием сущностей и отслеживанием изменений.

                                    • 0

                                      PS извиняюсь, первый раз не увидел про воркеры.


                                      В таком случае возможны две причины почему не возникало проблем с накоплением состояния:


                                      1. Вы не используете продвинутых ORM-фреймворков в воркерах;
                                      2. Вы создаете контекст ORM в обход IoC контейнера.
                                      • 0
                                        Я использую доктрину. Орм по умолчанию у симфони. В отличии от большинства пхп-орм доктрина реализует DataMapper и использует UoW. Как раз в неумирающем пхп она себя отлично проявляет
                                        • 0

                                          Вы используете глобальный UoW — или все же создаете его на запрос? :-)

                                          • 0
                                            Глобальный естественно. Руками я ничего не создаю
                                            • 0

                                              Общий UoW на все запросы?.. Мда, ничего не понимаю.

                                              • 0
                                                ну в целом да. В концке обработки в рамках одной транзакции все изменения флашатся в бд. Почему ві решили что дожен копиться мусор?
                • 0
                  Рассматривали вариант использовать конфигурацию приложения как Ambient Context зависимость?
                  • 0
                    Нет, к сожалению, не рассматривали. Навскидку не понятны плюсы от такого подхода.

                    Можете поделиться своим опытом? Было бы интересно.
                    • 0

                      В вашем случае вижу это примерно так:



                      особо сильных плюсов не вижу, но тем не менее:


                      • нет необходимости передавать в модули конфигурацию
                      • можно написать модули не зависимые от Microsoft.Framework.Configuration (если есть необходимость)
                  • 0
                    Спасибо за пример!

                    Но мне все же кажется, что это не самое уместное использование окружающего контекста. Все-таки он больше подходит для случаев, когда есть много потребителей, которым требуется получать какое-либо разделяемое состояние.
                    В случае же с конфигурированием модулей нам в общем случае требуется один раз при инициализации приложения получить данные из контекста. То есть потребители в данном случае только модули. И нужен им этот контекст только один раз. Да и данные в контексте уже определены.
                    При такой потребности выглядит проще передать (ну или заинъектить) конфигурацию один раз. Но тут на вкус и цвет. Ваш вариант не менее рабочий и не менее удобный!

                    P.S. Что-то вроде окружающего контекста мы используем допустим для разделения текущей транзакции между всеми командами и запросами, когда работаем с UnitOfWork. Только в данном случае этот контекст у нас ThreadStatic.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.