Разделение контейнера зависимостей в ASP.NET Core

Разработчики AspNet Core (здесь и далее речь идёт об AspNet актуальных версий: 6 и 7, но может быть применимо и к более ранним версиям) хорошо знают, что механизм Dependency Injection встроен в этот фреймворк изначально и пронизывает его насквозь. И это здорово упрощает работу с зависимостями и сразу вводит разработку в идеологически правильное русло. Более того, в состав самого AspNet входит вполне приличный дефолтный DI-контейнер разработки Microsoft, что позволяет отказаться от использования сторонних решений. Во всяком случае, при отсутствии совсем уж специфичных требований.

Проблема (или, скорее, особенность) ServiceProvider

Главная особенность встроенного ServiceProvider - он единый и глобальный. Да, в нём есть скоупы, но они используются только для управления временем жизни создаваемы зависимостей, например "в рамках обработки конкретного HTTP-запроса". Но, например, если вам нужно в какую-то часть HTTP-конвейера передать другой вариант настроек базовых служб (например, используя методы AddXXX), то у вас проблема.

У меня есть веб-приложение, работающее с базой данных через такой незамысловатый интерфейс:

public interface IDbProvider
{
	DbConnection CreateConnection();
}

Я регистрирую конкретную реализацию провайдера на этапе настройки конвейера, компоненты приложения получают его из DI-контейнера, всё работает как по нотам. Но тут у меня появилась задача добавить копию функционала приложения, например в URI /test, которое должно работать со своей базой данных, отдельной от основного приложения. Задача осложнялась тем, что та же база данных использовалась в обработчиках метода AddAuthentication(). Иными словами, нужно было обособить часть HTTP-конвейера, передав ему свои зависимости, в том числе системные, наподобие аутентификации.

Вроде бы можно добавить второе приложение Kestrel со своими настройками и не заморачиваться. На практике же дело осложнялось тем, что пользователями приложения были разные, иногда совсем небольшие организации, с очень маленькой ИТ-службой или вообще с единственным приходящим админом. Настраивать новый инстанс на другом порту или реверс-прокси просто некому. Решение должно быть максимально простым: запустил MSI с новой версией приложения, нажал next-next-next и всё, никаких лишних телодвижений и настроек.

Поиск решения

Всемирный поисковый разум не дал особых результатов, нашлась только одна статья, которая более-менее описывала мою задачу и возможный путь её решения. Конечно, отсутствие статей говорит о том, что проблема редкая и, следовательно, мало кому интересная. И что наверняка проще сделать отдельное приложение. Но тут уже победило природное любопытство.

Сначала я решил взять за образец решение из упомянутой статьи. Но оно изначально было с душком: финты ушами с копированием контейнера вроде бы работали, но где-то лезли маленькие глюки и вообще всё это выглядело совсем не comme il faut. Помыкавшись с кодом, я уже хотел бросить затею, но тут всплыли два воспоминания, случайно осевших на чердаке после чтения документации на DI-контейнер Autofac, который когда-то использовался для другого проекта.

Первое заключалось в том, что AspNet позволяет заменить штатный DI-контейнер на любой другой совместимый. Autofac как раз один из таких. Парой строчек кода он легко встраивается в AspNet, полностью и бесшовно заменяя встроенный ServiceProvider.

Второй важной особенностью этого DI-контейнера является гораздо более мощная поддержка дочерних скоупов, включая возможность дорегистрировать в них зависимости, и даже переопределять зависимости, зарегистрированные ранее в родительских скоупах. Детали, как всегда, хорошо документированы.

Собираем паззл воедино

Двух перечисленных особенностей Autofac и ранее упомянутой статьи оказалось достаточно для решения поставленной задачи.

После некоторых упражнений рождается метод-расширение MapPartition(), являющийся аналогом штатного Map() с единственным отличием: часть конвейера, выполняющаяся внутри обработчика MapPartition(), выполняется в отдельном дочернем скоупе DI-контейнера с возможность добавления или переопределения зависимостей.

Полный текст метода-расширения:

public static IApplicationBuilder MapPartition(this IApplicationBuilder app, PathString pathMatch,
	bool preserveMatchedPathSegment, Action<IServiceCollection, IServiceProvider> configureServices, Action<IApplicationBuilder> configuration)
{
	// 1. Extract parent scope from the existing container
	var parentScope = app.ApplicationServices.GetRequiredService<ILifetimeScope>();
		
	// 2. Сreate child Autofac ILifetimeScope
	var childScopeFactory = new AutofacChildLifetimeScopeServiceProviderFactory(parentScope);

	// 3. Register new services
	var services = new ServiceCollection();
	serviceConfiguration(services, app.ApplicationServices);
	var serviceAdapter = childScopeFactory.CreateBuilder(services);

	// 4. Сreate a new pipeline branch
	var branchBuilder = app.New();
	// replace ServiceProvider in the to the scoped one
	branchBuilder.ApplicationServices = childScopeFactory.CreateServiceProvider(serviceAdapter);
	branchBuilder.Use(
		async (c, next) =>
		{
			c.RequestServices = branchBuilder.ApplicationServices;
			await next();
		});
	configuration(branchBuilder);
	var branch = branchBuilder.Build();

	// 5. Link the new branch to the original pipeline
	var options = new MapOptions
	{
		Branch = branch,
		PathMatch = pathMatch,
		PreserveMatchedPathSegment = preserveMatchedPathSegment
	};
	return app.Use(next => new MapMiddleware(next, options).Invoke);
}

По комментариям уже понятно, что делает метод:

  1. Извлекает ссылку на текущий скоуп Autofac из контейнера.

  2. Создаёт новый дочерний скоуп Autofac.

  3. Регистрирует новые зависимости в рамках созданного скоупа.

  4. Создаёт новую ветку конвейера, обёрнутую в middleware, заменяющий родительский скоуп на дочерний в свойствах HttpContext.

  5. Прикрепляет созданную ветку конвейера к основной, используя штатный MapMiddleware.

Использование созданного метода:

internal static class Program
{
	private static void ConfigureWebHost(IWebHostBuilder builder)
	{
		// add global services to the container
		builder.ConfigureServices(
			services =>
			{
				services.AddHttpContextAccessor();
				services.AddControllersWithViews();
				// register main database
				services.AddSingleton<IDbProvider, MainDbProvider>();
			});
			
		builder.Configure(
			(context, app) =>
			{
				app.MapPartition("/test", false,
					// register test database
					(services, parentServices) => services.AddSingleton<IDbProvider, TestDbProvider>(),
					appPart => 
					{
						// test partition
						appPart.UseRouting();
						appPart.UseEndpoints(endpoints =>
						{
							endpoints.MapControllerRoute(
								name: "default",
								pattern: "{controller=Home}/{action=Index}/{id?}");
						});
					});

				// main partition
				app.UseRouting();
				app.UseEndpoints(endpoints =>
				{
					endpoints.MapControllerRoute(
						name: "default",
						pattern: "{controller=Home}/{action=Index}/{id?}");
				});
			});
	}

	public static void Main(string[] args)
	{
		var builder = Host.CreateDefaultBuilder(args);
		// replace default ServiceProvider
		builder.UseServiceProviderFactory(new AutofacServiceProviderFactory());
		builder.ConfigureWebHostDefaults(ConfigureWebHost);
		var host = builder.Build();
		host.Run();
	}
}

В примере выше вызов MapPartition("/test", false, ...) заворачивает все вызовы с префиксом пути /test в отдельную ветку конвейера. Префикс при этом удаляется, что позволяет использовать все те же самые контроллеры приложения, но уже с другим набором зависимостей благодаря выделенному дочернему скоупу Autofac.

Заключение

Найденное решение по разделению DI-контейнера оказалось вполне работоспособным, оно уже работает год в реальных проектах без всяких проблем.

@AntonLarinLive
25.06.2023 23:51 UTC
Первоисточник

Комментарии

@MonkAlex
25.06.2023 19:05 UTC
0

Свои скопы в дотнете и его встроенном DI в целом не выглядят удобно.

Это всегда смотрится как какой то костыль или хак.

Интересное решение и здорово, что сторонние инструменты (Autofac) так умеют.

@nronnie
25.06.2023 19:10 UTC
+8

Главная особенность встроенного ServiceProvider - он единый и глобальный.

Неправда. Каждый скоуп имеет свой собственный IServiceProvider. А, во-вторых, даже если все это создает какие-то проблемы, то значит вы как-то DI неправильно используете. Приложение про тот или иной контейнер вообще ничего не должно знать, за исключением той части кода на старте приложения, которая этот контейнер инициализирует.

@AntonLarinLive
25.06.2023 19:34 UTC
-1

Неправда. Каждый скоуп имеет свой собственный IServiceProvider.

Я искал и не нашёл. Судя по исходникам, всё дерево скоупов имеет общий контейнер, который и возвращается указанным вами вызовом. Также я не нашёл ни одного метода, позоволяющего пополнить регистрациями контейнер или отдельный скоуп после создания. Если вы знаете такой - напишите, мне самому интересно.

Приложение про тот или иной контейнер вообще ничего не должно знать, за исключением той части кода на старте приложения, которая этот контейнер инициализирует.

А у меня так и реализовано. Все DI-детали только в коде инициализации. Ни сервисы, ни контроллеры ни про какие Autofac и скоупы ничего не знают.

25.06.2023 20:12 UTC
0

Легко ведь убедиться:

ServiceCollection services = new();
using var serviceProvider = services.BuildServiceProvider();
using var scope = serviceProvider.CreateScope();
Console.WriteLine(scope.ServiceProvider == serviceProvider); // выводит False

А у меня так и реализовано. Все DI-детали только в коде инициализации. Ни сервисы, ни контроллеры ни про какие Autofac и скоупы ничего не знают.

Ну, так а какие тогда проблемы были без использования Autofac?

Мне, как раз, на самом деле всегда нравился Autofac (несмотря на то, что среди всех известных контейнеров он, похоже, вообще самый медленный), и я его постоянно прикручивал к .NET (Core) проектам (через его штатное расширение), но позже я как-то понял, что просто нужды в этом никакой нет - те его возможности, которых нет в Microsoft.Extensions.DependencyInjection на самом деле нужны очень редко, а когда действительно нужны, то без особых проблем реализуются руками без него.

У меня сейчас родилась версия в чем у вас могло возникнуть недопонимание, если вы до этого, в "докоровские" времена именно с Autofac работали. В майкрософтовском DI скоупы, в отличии от AF, не иерархические.

25.06.2023 20:33 UTC
-3

Легко ведь убедиться

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

По поводу остального, я и написал, что задача редкая. Но если нужна, то решается как описано в статье.

25.06.2023 20:49 UTC
0

Никаких модификаций списка зарегистрированных сервисов на уровне скоупа не предполагается.

А зачем это? Инициализация DI всегда проводится только один раз, еще до того как какие-либо скоупы создаются. Если это не так, то это как раз и есть какое-то кривое использование этого самого DI. Контейнер, который на ходу меняет список регистраций во время работы приложения это какой-то архитектурный нонсенс. Если надо изменять поведение тех или иных сервисов в зависимости, допустим, от времени суток или фазы Луны - используйте стандартные подходы (например паттерны "состояние" или "стратегия").

25.06.2023 20:55 UTC
0

Вы статью читали? Там не про "на ходу", а в процессе настройки. Задача простая: двум веткам HTTP-конвейера предоставить разные реализации одного интерфейса. И, в более сложном случае, сделать два варианта сервиса аутентификации для разных веток конвейера.

25.06.2023 21:51 UTC
+7

двум веткам HTTP-конвейера предоставить разные реализации одного интерфейса.

Сценарий вполне нормальный, но прикручивать ради такого целый отдельный фреймворк, это, по-моему избыточно (о чем я выше и говорил). Достаточно сделать самую обычную фабрику с параметром.

public interface IGreeting
{
    void Say();
}

public class DailyGreeting: IGreeting
{
    public void Say() => "Добрый день!";
}

public class NightlyGreeting: IGreeting
{
    public void Say() => "Доброй ночи!";
}

public interface IGreetingFactory
{
    IGreeting GetGreeting(DateTime now);
}

/// <summary>
/// Да, в общем случае использовать IServiceProvider это плохо,
/// но тут это допустимо, т.к. его использование локализовано в фабрике
/// и не приводит потом к нарушению "явности зависимостей".
/// </summary>
public class GreetingFactory: IGreetingFactory
{
    private readonly IService _serviceProvider;

    public GreetingFactory(IServiceProvider serviceProvider) =>
        _serviceProvider = serviceProvider;

    public IGreeting GetGreeing(DateTime now) =>
       (9 < now.Hour && now.Hour < 22) ?
           (IGreeting)_serviceProvider.GetService<DailyGreeting>() :
           (IGreeting)_serviceProvider.GetService<NightlyGreeting>();
}

// инициализация контейнера
ServiceCollection services = new();
services.AddTransient<DailyGreeting>();
services.AddTransient<NightlyGreeting>();
services.AddTransient<IGreetingFactory, GreetingFactory>();

// некий класс куда фабрика вставляется.
public class Foo
{
    private readonly IGreetingFactory greetingFactory;

    public Foo(IGreetingFactory greetingFactory) =>
        _greetingFactory = greetingFactiory;

   public void Greet() => _greetingFactory.GetGreeting(DateTime.Now).Say();
}

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

26.06.2023 06:28 UTC
+4

Каким образом вы сможете в вашем примере сделать два вызова стандартного AddAuthentication() для разных веток конвейера, со своими параметрами аутентификации для каждой ?

26.06.2023 08:11 UTC
+1

Вы хотите для разных частей приложения сделать разную аутентификацию? Ну так это делается совсем не так. Потому что всегда есть аутентификация "вообще" (определяем кто это у нас такой), а потом вы уже даете или не даете доступ (авторизация) на основе, допустим, не только identity пользователя, но и на основе того, через какой метод аутентификации этот identity был получен. Впрочем, это уже даже не имеет отношения к DI, а больше к общему подходу - не должно быть отдельных "аутентификации для /api/foo/"" и "аутентификации для /api/bar/". Я допускаю, что кто-то может такой изврат придумать, но в таком случае вы просто создаете костыль для решения изначально кривой задачи.

Да, еще, к тому же вы поймите, что во время выполнения пайплайна DI уже создал "composition root" и его дерево зависимостей, и менять уже после этого что-либо в контейнере это какой-то костыльный подход.

26.06.2023 08:36 UTC
0

не должно быть отдельных "аутентификации для /api/foo/"" и "аутентификации для /api/bar/".

Жизнь несколько шире ваших категоричных "не должно". Я же не зря написал, что задача редкая. Но это не значит, что её нельзя решить. Дискуссию о прямоте/кривизне задач предлагаю вывести за скобки технического обсуждения, иногда это просто данность.

Да, еще, к тому же вы поймите, что во время выполнения пайплайна DI уже создал "composition root" и его дерево зависимостей, и менять уже после этого что-либо в контейнере это какой-то костыльный подход.

Я уже акцентировал, что в процессе исполнения ничего не меняется. Вы или невнимательно читали, или спорите с кем-то другим.

26.06.2023 08:57 UTC
+1

Жизнь несколько шире ваших категоричных "не должно". Я же не зря написал, что задача редкая. Но это не значит, что её нельзя решить.

Я с этим полностью согласен. Но решение костыльной задачи костылями не делает эти костыли правильным паттерном :)

30.06.2023 06:22 UTC
0

Это же идентификация?

Потому что всегда есть аутентификация "вообще" (определяем кто это у нас такой)

Аутентификация это проверка подлинности, а авторизация - проверка прав.

30.06.2023 11:42 UTC
0

Формально, да, согласен. Но, в случае ASP.NET "аутентификацией" чаще всего (в том числе) называют и механизм получения уже готового identity клиента из куки (по-старинке), или из заголовка Authorization (по-современному). Получение клиентом самого этого identity может быть вообще за скоупом приложения (выделенный провайдер аутентификации). Так чтобы специально и отдельно в ASP.NET использовался термин "идентификация" лично я не встречал.

26.06.2023 22:35 UTC
+1

Вы не делаете несколько вызовов AddAuthentication; вы добавляете несколько authn schemas и настраиваете ForwardDefaultSelector, например:

services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddScheme<AuthenticationSchemeOptions, ApiAuthHandler>("Api", o => { })
    .AddCookie(options =>
    {
        // Foward any requests that start with /api to that scheme
        options.ForwardDefaultSelector = ctx =>
        {
            return ctx.Request.Path.StartsWithSegments("/api") ? "Api" : null;
        };
        options.AccessDeniedPath = "/account/denied";
        options.LoginPath = "/account/login";
    });

(см. пример, доки).

30.06.2023 06:16 UTC
0

среди всех известных контейнеров он, похоже, вообще самый медленный

Не самый, но да стоит обратиться к бенчмаркам )

danielpalme/IocPerformance: Performance comparison of .NET IoC containers (github.com)

@MonkAlex
25.06.2023 19:34 UTC
+3

Так а как надо было делать правильно? Текущее решение выглядит удобно и корректно, имхо.

@posledam
02.07.2023 23:27 UTC
0

Посмотрите на named options, это одно из решений, которое успешно применяется для разделения ресурсов на множество веток. Например, можно зарегистрировать один и тот же HttpClient с разными именами, и совершенно разными настройками. Тоже самое касается EF / DbContext.

Да, при разработке своих компонентов, желательно это всегда учитывать. Что у вас появится просто отдельные "ветки" экземпляров.

А создавать полностью независимые DI контейнеры, это может быть как простой удар по производительности, так и невозможность использовать адекватно вполне легальные синглтоны. В общем, решаете проблему, как мне кажется, не там.