Мы начали работать с 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)


  1. chumakov-ilya
    29.03.2017 19:57
    +3

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


    1. tados
      30.03.2017 07:24

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

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

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

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


      1. tados
        30.03.2017 09:16

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

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

        Это про ConfigureServices из предыдущего моего коммента.


      1. Taritsyn
        30.03.2017 10:45

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

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


        1. tados
          30.03.2017 13:16
          +1

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

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


      1. chumakov-ilya
        30.03.2017 11:36
        -2

        При этом в библиотеках с вашими сервисами еще и появится лишняя зависимость — Microsoft.Extensions.Options.

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


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

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


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


  1. mayorovp
    30.03.2017 07:47

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


    1. 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;
      }


      1. tados
        30.03.2017 09:00

        Спасибо за комментарии.

        Решение с использованием доп. контейнера тоже рассматривали. По какой причине отмели — теперь уже не понимаю, если честно.
        Действительно проще и не менее удобно.


  1. oxidmod
    30.03.2017 08:39

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

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


    1. mayorovp
      30.03.2017 08:50
      +1

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


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


      1. oxidmod
        30.03.2017 09:05

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

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

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

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


        1. mayorovp
          30.03.2017 09:08

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

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


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

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


          1. oxidmod
            30.03.2017 11:13

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

            да

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

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


            1. mayorovp
              30.03.2017 11:27

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

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


              1. oxidmod
                30.03.2017 11:43

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


                1. mayorovp
                  30.03.2017 11:55

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


                  1. oxidmod
                    30.03.2017 12:30

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


                    1. mayorovp
                      30.03.2017 12:32

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


                      1. oxidmod
                        30.03.2017 14:05

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

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


                        1. mayorovp
                          30.03.2017 14:19

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


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


                        1. mayorovp
                          30.03.2017 18:14

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


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


                          1. Вы не используете продвинутых ORM-фреймворков в воркерах;
                          2. Вы создаете контекст ORM в обход IoC контейнера.


                          1. oxidmod
                            30.03.2017 21:36

                            Я использую доктрину. Орм по умолчанию у симфони. В отличии от большинства пхп-орм доктрина реализует DataMapper и использует UoW. Как раз в неумирающем пхп она себя отлично проявляет


                            1. mayorovp
                              30.03.2017 21:46

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


                              1. oxidmod
                                30.03.2017 21:48

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


                                1. mayorovp
                                  31.03.2017 09:07

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


                                  1. oxidmod
                                    31.03.2017 09:40

                                    ну в целом да. В концке обработки в рамках одной транзакции все изменения флашатся в бд. Почему ві решили что дожен копиться мусор?


  1. netpilgrim
    30.03.2017 09:02

    Рассматривали вариант использовать конфигурацию приложения как Ambient Context зависимость?


    1. tados
      30.03.2017 09:02

      Нет, к сожалению, не рассматривали. Навскидку не понятны плюсы от такого подхода.

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


      1. netpilgrim
        31.03.2017 00:20

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



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


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


  1. tados
    31.03.2017 09:10

    Спасибо за пример!

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

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