Продолжаем делиться полезными материалами в backend-разработке. Осваивая новые инструменты, специалисты SimbirSoft часто читают материалы зарубежных авторов, чтобы быть в курсе актуальных тенденций. В этот раз наш выбор пал на серию материалов британского разработчика Эндрю Лока про новые возможности .NET 6. С разрешения автора мы перевели статью, в которой он разбирает функции внедрения зависимостей в .NET 6. Материал будет полезен тем, кто хочет познакомиться с нововведениями в .Net 6 при переходе на эту технологию.

Исследуем .Net 6. Часть 10

В этой серии статей я собираюсь взглянуть на некоторые из новых функций, которые появились в .NET 6. Про .NET 6 уже написано много контента, в том числе множество постов непосредственно от команд .NET и ASP.NET. Я же собираюсь рассмотреть код некоторых из этих новых функций.

Обработка служб IAsyncDisposable с помощью IServiceScope

Первая функция, которую мы рассмотрим, устраняет давно существующую проблему. В .NET Core 3.0 и C# 8 добавлена поддержка IAsyncDisposable — асинхронного эквивалента интерфейса IDisposable. Это позволяет запускать асинхронный (async) код при освобождении ресурсов, что в некоторых случаях необходимо для предотвращения взаимоблокировок.

«Проблема» с IAsyncDisposable заключается в том, что везде, где «очищаются» объекты с помощью IDisposable и вызывается  Dispose(). Там также требуется поддерживать объекты IAsyncDisposable для  очистки объектов. Из-за вирусной природы async/await это означает, что эти пути кода теперь также должны быть асинхронными и так далее.

Одно из очевидных мест, где необходимо поддерживать IAsyncDisposable, — контейнер DI в Microsoft.Extensions.DependencyInjection, используемый ASP.NET Core. Поддержка этого интерфейса была добавлена в .NET Core 3.0, но только в IServiceProvider, а для циклов жизни (scopes) поддержки не было.

В качестве примера, когда это вызывает проблему, представьте, что у вас есть тип, который поддерживает IAsyncDisposable, но не поддерживает IDisposable. Если вы зарегистрируете этот тип с временем существования Scoped и получите его экземпляр из контейнера DI, то при вызове метода Dispose() для него вы получите исключение. Это показано в следующем примере приложения на .NET 5:

using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
 
public class Program
{
	public static async Task Main()
	{
    	await using var provider = new ServiceCollection()
        	.AddScoped<Foo>()
        	.BuildServiceProvider();
 
    	using (var scope = provider.CreateScope())
    	{
        	var foo = scope.ServiceProvider.GetRequiredService<Foo>();
    	} // Throws System.InvalidOperationException
	}
}
 
class Foo : IAsyncDisposable
{
	public ValueTask DisposeAsync() => default;
}

Этот пример выдаст исключение при высвобождении неуправляемых ресурсов переменной Scope.

Unhandled exception. System.InvalidOperationException: 'Foo' type only implements IAsyncDisposable. Use DisposeAsync to dispose the container.
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.Dispose()
   at <Program>$.<<Main>$>d__0.MoveNext() in C:\src\tmp\asyncdisposablescope\Program.cs:line 12
--- End of stack trace from previous location ---
   at <Program>$.<<Main>$>d__0.MoveNext() in C:\src\tmp\asyncdisposablescope\Program.cs:line 12
--- End of stack trace from previous location ---
   at <Program>$.<Main>(String[] args)

Проблема в том, что тип IServiceScope, возвращаемый функцией CreateScope(), реализует IDisposable, а не IAsyncDisposable, поэтому вы не можете вызвать его с помощью await using. Итак, нам просто требуется, чтобы ServiceScope реализовал IAsyncDisposable?

К сожалению, нет, это было бы кардинальным изменением. Кастомные DI-контейнеры (Autofac, Lamar, SimpleInjector и т.д.) должны реализовывать интерфейс IServiceScope, поэтому добавление дополнительных требований к интерфейсу будет серьезным изменением для этих библиотек.

Вместо этого решение, принятое в этом PR, позволяет реализациям контейнеров медленно подключаться:

  1. Добавьте новый метод расширения в IServiceProvider с именем CreateAsyncScope().

  2. Создайте оболочку над AsyncServiceScope, реализующую IAsyncDisposable.

Глядя на код ниже, мы видим, как это решает проблему. Во-первых, метод расширения вызывает CreateScope() для получения реализации IServiceScope из контейнера. Это будет зависеть от реализации в зависимости от используемого вами контейнера. Затем из IServiceScope создается и возвращается новый AsyncServiceScope:

public static AsyncServiceScope CreateAsyncScope(this IServiceProvider provider)
{
    return new AsyncServiceScope(provider.CreateScope());
}

AsyncServiceScope реализует как IServiceScope, так и IAsyncDisposable, делегируя первый экземпляру IServiceScope, переданному в конструкторе. В методе DisposeAsync() он проверяет, поддерживает ли реализация IServiceScope DisposeAsync(). Если это так, вызывается DisposeAsync(), и потенциальная причина исключения устраняется. Если реализация не поддерживает IAsyncDisposable, она возвращается к синхронному вызову Dispose().

public readonly struct AsyncServiceScope : IServiceScope, IAsyncDisposable
{
    private readonly IServiceScope _serviceScope;
    public AsyncServiceScope(IServiceScope serviceScope)
    {
    	_serviceScope = serviceScope ?? throw new ArgumentNullException(nameof(serviceScope));
    }
 
    public IServiceProvider ServiceProvider => _serviceScope.ServiceProvider;
    public void Dispose() => _serviceScope.Dispose();
 
    public ValueTask DisposeAsync()
    {
   	 if (_serviceScope is IAsyncDisposable ad)
   	 {
       	 return ad.DisposeAsync();
   	 }
    	_serviceScope.Dispose();
 
   	 // ValueTask.CompletedTask доступен только в net5.0 и более поздних версиях.
   	 return default;
    }
}

Для обернутого таким образом ServiceScope, который теперь поддерживает IAsyncDisposable, проблема решена. В .NET 6 вы можете использовать await using с CreateAsyncScope(), и проблем не возникнет.

using Microsoft.Extensions.DependencyInjection;
 
await using var provider = new ServiceCollection()
    .AddScoped<Foo>()
    .BuildServiceProvider();
 
await using (var scope = provider.CreateAsyncScope())
{
    var foo = scope.ServiceProvider.GetRequiredService<Foo>();
} // не выбрасывается, если контейнер поддерживает DisposeAsync()
 
class Foo : IAsyncDisposable
{
    public ValueTask DisposeAsync() => default;
}

Если вы применяете пользовательский контейнер, который не поддерживает IAsyncDisposable, это все равно вызовет исключение. Вам нужно будет либо заставить Foo реализовать IDisposable, либо обновить/изменить собственную реализацию контейнера. Хорошая новость заключается в том, что если сейчас вы не сталкиваетесь с этой проблемой, то все еще можете использовать шаблон CreateAsyncScope(), и когда контейнер будет обновлен, ваш код не нужно будет изменять. 

В общем, если вы вручную создаете циклы жизни (scopes) в своем приложении (как в приведенном выше примере), мне кажется, что желательно использовать CreateAsyncScope() везде, где это возможно.

Использование сторонних DI-контейнеров с WebApplicationBuilder

К слову о кастомных контейнерах. Если вы используете новые минимальные API хостинга .NET 6 с WebApplicationBuilder и WebApplication, то вам может быть интересно, как вы вообще регистрируете свой пользовательский контейнер.

В .NET 5 вам нужно сделать две вещи:

  1. Вызвать UseServiceProviderFactory() (или аналогичный метод расширения, например UseLamar()) в IHostBuilder.

  2. Реализовать соответствующий метод ConfigureContainer() в своем классе Startup.

Например, для Autofac ваш Program.cs может выглядеть примерно так, как показано ниже, где AutofacServiceProviderFactory создается и передается в UseServiceProviderFactory():

public static class Program
{
    public static void Main(string[] args)
   	 => CreateHostBuilder(args).Build().Run();
 
    public static IHostBuilder CreateHostBuilder(string[] args) =>
    	Host.CreateDefaultBuilder(args)
 .UseServiceProviderFactory(new AutofacServiceProviderFactory()) // <-- добавляем эту строку
       	 .ConfigureWebHostDefaults(webBuilder =>
       	 {
            	webBuilder.UseStartup<Startup>();
       	 });
}

В Startup вы должны добавить метод ConfigureContainer() (показан ниже) и выполнить регистрацию DI для Autofac:

public class Startup
{
   public void ConfigureContainer(ContainerBuilder containerBuilder)
    {
   	// Зарегистрируйте напрямую в Autofac здесь
    	builder.RegisterModule(new MyApplicationModule());
    }
}

В новом минимальном хостинге .NET 6 приведенные выше шаблоны заменены на WebApplicationBuilder и WebApplication, поэтому класс Startup отсутствует. Итак, как вы должны выполнить приведенную выше конфигурацию?

В .NET 6 вам по-прежнему нужно выполнить те же 2 шага, что и раньше, но в свойстве Host у WebApplicationBuilder. В приведенном ниже примере показано, как вы могли бы перевести пример Autofac на минимальный хостинг:

var builder = WebApplication.CreateBuilder(args);
 
// Вызов UseServiceProviderFactory для подсвойства Host
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
 
// Вызов ConfigureContainer для подсвойства Host
builder.Host.ConfigureContainer<ContainerBuilder>(builder =>
{
	builder.RegisterModule(new MyApplicationModule());
});
 
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run();

Как видите, свойство Host имеет тот же метод UseServiceProviderFactory(), что и раньше, поэтому вы можете добавить туда экземпляр AutofacServiceProviderFactory(). Существует также метод ConfigureContainer<T>, который принимает лямбда-метод той же формы, что и метод, который мы использовали ранее в Startup. Обратите внимание, что <T> здесь зависит от контейнера, поэтому это ContainerBuilder для Autofac, ServiceRegistry для Lamar и т. д.

Этот пример взят из великолепного руководства Дэвида Фаулера «Миграция на ASP.NET Core в .NET 6». Если вы застряли, пытаясь понять, как перейти на новые минимальные API-интерфейсы хостинга, обязательно ознакомьтесь с ними, чтобы найти подсказки и ответы на часто задаваемые вопросы.

Как определить, зарегистрирована ли служба в контейнере DI

Следующая новая функция, которую мы рассмотрим, была введена в .NET 6 для поддержки функций в новых minimal APIs. В частности, minimal APIs позволяют вам запрашивать службы из контейнера DI в обработчиках маршрутов, не помечая их явно с помощью [FromService]. Это отличается от контроллеров MVC API, где вам нужно будет использовать атрибут, чтобы получить эту функцию.

Например, в .NET 6 можно сделать так:

var builder = WebApplication.CreateBuilder(args);
 
// Добавляем сервис в DI
builder.Services.AddSingleton<IGreeterService, GreeterService>();
var app = builder.Build();
 
// Добавляем обработчик маршрута, использующий сервис из DI
app.MapGet("/", (IGreeterService service) => service.SayHello());
 
app.Run();
 
public class GreeterService: IGreeterService
{
    public string SayHello() => "Hellow World!";
}
public interface IGreeterService
{
    string SayHello();
}

Эквивалентный контроллер API может выглядеть примерно так (обратите внимание на атрибут):

public class GreeterApiController : ControllerBase
{
    [HttpGet("/")]
    public string SayHello([FromService] IGreeterService service)
   	 => service.SayHello();
}

Очевидно, что обычно для этого используется внедрение конструктора, я просто хочу подчеркнуть!

Проблема для minimal APIs заключается в том, что теперь нет простого способа узнать назначение данного параметра в обработчике лямбда-маршрута.

— Это модель, которая должна быть привязана к телу запроса?

— Это служба, которую следует извлекать из DI-контейнера?

Одним (плохим) решением было бы то, чтобы API всегда предполагал, что это сервис, и пытался получить его из контейнера DI. Однако при этом будет предпринята попытка создать экземпляр службы, что может иметь непредвиденные последствия. Например, представьте, что контейнер записывает журнал каждый раз, когда вы пытаетесь получить несуществующую службу, чтобы вам было легче обнаруживать неверные настройки. Это может повлиять на производительность, если журнал будет записываться для каждого отдельного запроса!

Вместо этого ASP.NET Core действительно нужен способ проверки регистрации типа без создания его экземпляра, как описано в этой статье. В .NET 6 добавлена поддержка этого сценария с помощью нового интерфейса IServiceProviderIsService в библиотеку абстракций внедрения зависимостей Microsoft.Extensions.DependencyInjection.Abstractions.

public partial interface IServiceProviderIsService
{
    bool IsService(Type serviceType);
}

Эта служба предлагает единственный метод, который можно вызвать, чтобы проверить, зарегистрирован ли данный тип службы в DI контейнере. Саму службу IServiceProviderIsService также можно получить из контейнера. Если вы используете пользовательский контейнер, в котором не добавлена поддержка этой функции, то вызов GetService<IServiceProviderIsService>() вернет значение null.

Обратите внимание, что этот интерфейс предназначен для указания того, может ли вызов IServiceProvider.GetService(serviceType) вернуть допустимую службу. Но нет гарантий, что это действительно сработает, поскольку существует бесконечное количество вещей, которые могут пойти не так при попытке создать службу!

Вы можете увидеть пример того, как мы могли бы использовать новый интерфейс ниже. Это в значительной степени отражает то, как ASP.NET Core использует службу за кулисами при создании минимальных обработчиков маршрутов API:

var collection = new ServiceCollection();
collection.AddTransient<IGreeterService, GreeterService>();
IServiceProvider provider = collection.Build();
 
// попробуем получить новый интерфейс.
// Это вернет null, если функция еще не поддерживается контейнером 
var serviceProviderIsService = provider.GetService<IServiceProviderIsService>();
 
// IGreeterService регистрируется в контейнере DI
Assert.True(serviceProviderIsService.IsService(typeof(IGreeterService)));
// GreeterService НЕ зарегистрирован непосредственно в контейнере DI.
Assert.False(serviceProviderIsService.IsService(typeof(GreeterService)));

Я не планирую использовать эту службу напрямую в своих собственных приложениях, но она может быть полезна для библиотек, ориентированных на .NET 6, а также дает возможность исправить другие проблемы, связанные с DI.

Если вас смущает название IServiceProviderIsService, то не волнуйтесь, как и Дэвид Фаулер, который первоначально предложил эту функцию и реализовал ее:

Дополнительная диагностика для DI-контейнера

dotnet-trace global tool — это кроссплатформенный инструмент, который позволяет собирать профили запущенного процесса без использования собственного профилировщика. Он основан на компоненте .NET Core EventPipe, который по сути представляет собой кроссплатформенный эквивалент Event Tracing для Windows (ETW) или LTTng.

В .NET 6 были добавлены два новых диагностических события:

  • ServiceProviderBuilt: показывает, когда создается IServiceProvider и сколько в нем временных, ограниченных и одноэлементных служб. Он также включает количество зарегистрированных открытых и закрытых дженериков.

  • ServiceProviderDescriptors: показывает описание всех служб в контейнере, их время жизни и тип реализации в виде большого двоичного объекта JSON.

Кроме того, хэш-код IServiceProvider включен во все события, поэтому при необходимости вы можете легко сопоставлять различные события. Например, следующий пример устанавливает global tool dotnet-trace и запускает простое приложение ASP.NET Core .NET 6, собирая события DI:

dotnet tool install -g dotnet-trace
dotnet trace collect --providers Microsoft-Extensions-DependencyInjection::Verbose -- name ./aspnettest.exe

Полученные логи можно просмотреть в PerfView (в Windows). На приведенном ниже снимке экрана показано, что события были записаны. Вы можете просто увидеть начало дескрипторов для события ServiceProviderDescriptors справа:

Эта функция была частью более масштабной попытки добавить больше диагностических средств в .NET 6, но большая часть предложенных диагностических средств не успела появиться в .NET 6 вовремя. Диагностика DI была ошибкой!

Попытка улучшить производительность методов TryAdd*

Последняя функция в этом посте касается улучшения, которое не совсем вошло в .NET 6. Тем не менее, я думаю, что это интересно!

Проблема, о которой идет речь, обнаружилась, когда фанат производительности Бен Адамс занимался своим делом, сокращая выделение ресурсов в ASP.NET Core. С относительно небольшим изменением он сократил количество выделений закрытия для Func<ServiceDescriptor, Boolean> с 754 объектов до 2 во время запуска приложения ASP.NET MVC.

Проблема связана с тем, что когда большая часть «базовой» платформы ASP.NET Core добавляет службы, она проверяет, не добавляется ли дублирующаяся служба, используя версии метода TryAdd*. Например, метод расширения ведения журнала AddLogging() содержит такой код:

services.TryAdd(ServiceDescriptor.Singleton<ILoggerFactory, LoggerFactory>());
services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(Logger<>)));
 
services.TryAddEnumerable(ServiceDescriptor.Singleton<IConfigureOptions<LoggerFilterOptions>>(
    new DefaultLoggerLevelConfigureOptions(LogLevel.Information)));

Из-за стиля «Плати за игру» ASP.NET Core метод AddLogging() может вызываться многими различными компонентами. Вы не хотите, чтобы дескриптор службы ILoggerFactory регистрировался несколько раз, поэтому TryAdd* сначала гарантирует, что служба еще не зарегистрирована, и добавляет регистрацию только в том случае, если это первый вызов.

К сожалению, в настоящее время проверка того, что служба еще не зарегистрирована, требует перечисления всех уже зарегистрированных служб. Если мы будем делать это каждый раз, когда добавляем новый сервис, вы получите поведение O(N²), описанное в приведенной выше проблеме. Дальнейшее расследование Бена показало, что это так.

Итак, как решить эту проблему? Выбранный подход состоял в том, чтобы переместить существующий класс реализации ServiceCollection в пакет Microsoft.Extensions.DependencyInjection.Abstractions (из пакета реализации) и добавить к типу конкретные реализации TryAdd* (вместо методов расширения IServiceCollection, которые они в настоящее время используют).

Эти дополнительные методы будут использовать Dictionary<> для отслеживания того, какие службы были зарегистрированы, уменьшая стоимость поиска одной службы до O (1) и делая начальную регистрацию N типов O (N) намного лучше, чем существующая О (N²).

ServiceCollection был перемещен, как того требует этот PR, с перенаправлением типов, чтобы избежать критических изменений в API, но PR для добавления словаря в ServiceCollection так и не был объединен ???? Но почему?

Причина резюмируется в этом комментарии Эрика Эрхардта:

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

public class ServiceCollection : IServiceCollection
{
    private readonly List<ServiceDescriptor> _descriptors = new();
    private readonly Dictionary<Type, List<ServiceDescriptor>> _serviceTypes = new();
    // ...
}

Это множество дополнительных объектов, которые хранятся, создаются и управляются, поэтому не ясно, что это определенно улучшит производительность при запуске. К сожалению, тесты производительности не были завершены вовремя для .NET 6, поэтому улучшение так и не было реализовано. Надеюсь, он вернется в будущей версии .NET!

Итого

В этой статье я описал некоторые новые функции, добавленные в библиотеки DI в .NET 6. В IServiceScope была добавлена улучшенная поддержка IAsyncDisposable, появилась возможность запрашивать, зарегистрирована ли служба в DI, а также добавлены новые средства диагностики. Кроме того, я показал, как использовать настраиваемый DI-контейнер с новыми минимальными API-интерфейсами хостинга, и рассказал о функции повышения производительности, которая не вошла в .NET 6.

Спасибо за внимание! Надеемся, что этот перевод был полезен для вас. 

Авторские материалы для разработчиков мы также публикуем в наших соцсетях – ВК и Telegram.

Комментарии (1)


  1. mvv-rus
    03.10.2022 15:11
    +1

    Похоже, что автор оригинала не совсем знает предмет.

    В .NET 5 вам нужно сделать две вещи:

    1. Вызвать UseServiceProviderFactory() (или аналогичный метод расширения, например UseLamar()) в IHostBuilder.
    2. Реализовать соответствующий метод ConfigureContainer() в своем классе Startup.

    На самом деле нет. Уже в .NET Core 3.1 у интерфейса IHostBuilder (к которому как раз дает доступ свойство WebApplicationBuilder.Host в новом минимальном API ASP.NET 6) есть тот самый метод ConfigureContainer, который в статье описывается как некое нововведение. Так что и в .NET Core 3.1 (и, тем более, в .NET 5) можно было не добавлять метод ConfigureContainer в Startup-класс, а написать конфигурирование контейнера аналогично тому, как это сделано для нового минимального API .NET 6, примерно так (бойлерплейт опущен):
    public static IHostBuilder CreateHostBuilder(string[] args) =>
        	Host.CreateDefaultBuilder(args)
     .UseServiceProviderFactory(new AutofacServiceProviderFactory()) // <-- добавляем эту строку
     .ConfigureContainer<ContainerBuilder>(builder =>builder.RegisterModule(new MyApplicationModule()))  //<-- и эту строку, вместо того, чтобы использовать Startup.ConfigureContainer
           	 .ConfigureWebHostDefaults(webBuilder =>
           	 {
                	webBuilder.UseStartup<Startup>();
           	 });

    Более того, если посмотреть в документации руководство по Startup-классу в ASP.NET Core 5, то там метод ConfigureContainer вообще не упомянут: я предполагаю, что он был оставлен для облегчения переноса кода из приложения, написанного по устаревшему шаблону Web Host.
    А если посмотреть на то, как реализован вызов метода ConfigureContainer (занудные, но поучительные подробности, если кому интересно, есть в этой моей статье), то видно, что он все равно работает через IHost.ConfigureContainer.
    Придираемся дальше: в общем-то и так понятно, зачем нужно проверять, зарегистрирован ли сервис в контейнере. А что не понятно (по крайней мере — в этом переводе) — объяснение, откуда возникает проблематика на конкретном примере.
    Ну и дальше непонятно, зачем было выбирать для перевода именно эту статью, вышедшую слишком рано (почти год назад!) до того, как разработчики .NET 6 успели до конца реализовать фичи, отсутствующие в первом релизе, да ещё и без комментариев переводчика, реализованы ли эти фичи на настоящий момент.
    Ну и, проблема O(N2) в однократно выполняющемся коде добавления сервисов в список мне кажется надуманной — код-то один раз выполняется, а сервисов — не миллион (и даже- вряд ли тысяча IMHO).
    Короче, сомневаюсь, что эту статью стоило переводить. Но уж раз переведено, работа сделана — не грех воспользоваться, хуже не будет.
    Так что я, несмотря на недостатки, все же считаю нужным поблагодарить комапнию SimbirSoft за перевод