Разделение контейнера зависимостей в 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-контейнера оказалось вполне работоспособным, оно уже работает год в реальных проектах без всяких проблем.


ссылка на оригинал статьи https://habr.com/ru/articles/743688/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *