Асинхронный код в Startup ASP.NET Core: 4 способа обхода GetAwaiter().GetResult()

в 13:43, , рубрики: .net, andrewlock, ASP, asp.net core, C#, GetAwaiter, GetResult, health check, jwt, kubernetes, Startup ASP.NET, Блог компании Dodo Pizza Engineering, Программирование, Эндрю Лок

С тех пор, как в C# 5.0 завезли механизм async/await, нас постоянно во всех статьях и доках учат, что использовать асинхронный код в синхронном очень плохо. И призывают бояться как огня конструкции GetAwaiter().GetResult(). Однако есть один случай, когда сами программисты Microsoft не гнушаются этой конструкцией.

Асинхронный код в Startup ASP.NET Core: 4 способа обхода GetAwaiter().GetResult() - 1

Предыстория про рабочую задачу

Сейчас мы находимся в процессе перехода со старой легаси-аутентификации на OAuth 2.0, который фактически уже является стандартом в нашей отрасли. Сервис, над которым я сейчас работаю, стал пилотом для интеграции с новой системой и для перехода на JWT-аутентификацию.

В процессе интеграции мы экспериментировали, рассматривая разные варианты, как уменьшить нагрузку на провайдер токенов (IdentityServer в нашем случае) и обеспечить большую надёжность всей системы. Подключение валидации на основе JWT в ASP.NET Core осуществляется очень просто и не привязано к конкретной реализации провайдера токенов:

services
      .AddAuthentication()
      .AddJwtBearer(); 

Но что скрывается за этими двумя строчками? Под их капотом создаётся JWTBearerHandler, который уже и имеет дело с JWT от клиента API.

Асинхронный код в Startup ASP.NET Core: 4 способа обхода GetAwaiter().GetResult() - 2

Взаимодействие клиента, API и провайдера токенов при запросе

Когда JWTBearerHandler получает токен от клиента, он не отправляет токен на валидацию провайдеру, а, наоборот, запрашивает у провайдера Signing Key — открытую часть ключа, которым подписан токен. На основании этого ключа убеждается, что токен подписан нужным провайдером.

Внутри JWTBearerHandler сидит HttpClient, который и взаимодействует с провайдером по сети. Но, если предположить, что Signing Key нашего провайдера не планирует меняться часто, то его можно забрать один раз при запуске приложения, закэшировать себе и избавиться от постоянных сетевых запросов.

Такой код получения Signing Key получился у меня:

public static AuthenticationBuilder AddJwtAuthentication(this AuthenticationBuilder builder, AuthJwtOptions options)
{
    var signingKeys = new List<SecurityKey>();

    var jwtBearerOptions = new JwtBearerOptions {Authority = options?.Authority};
    
    new JwtBearerPostConfigureOptions().PostConfigure(string.Empty, jwtBearerOptions);
    try
    {
        var config = jwtBearerOptions.ConfigurationManager
            .GetConfigurationAsync(new CancellationTokenSource(options?.AuthorityTimeoutInMs ?? 5000).Token)
            .GetAwaiter().GetResult();
        var providerSigningKeys = config.SigningKeys;
        signingKeys.AddRange(providerSigningKeys);
    }
    catch (Exception)
    {
        // ignored
    }

    builder
        .AddJwtBearer(options =>
        {
            options.TokenValidationParameters = new TokenValidationParameters
            {
                // ...
                IssuerSigningKeys = signingKeys,
                // ...
            };
        });
    return builder;
}

В 12 строке мы встречаем .GetAwaiter().GetResult(). Всё потому что AuthenticationBuilder конфигурируется внутри public void ConfigureServices(IServiceCollection services) {...} класса Startup, и метод этот не имеет асинхронного варианта. Беда.

Начиная с C# 7.1 у нас появился асинхронный Main(). А вот асинхронных методов конфигурирования Startup в Asp.NET Core до сих пор не завезли. Меня эстетически напрягало писать GetAwaiter().GetResult() (меня же учили так не делать!), поэтому я полез в интернет, чтобы поискать, как другие справляются с этой проблемой.

Меня напрягает GetAwaiter().GetResult(), а Microsoft – нет

Одним из первых я нашёл вариант, который применили программисты Microsoft в похожей задаче получения секретов из Azure KeyVault. Если спуститься вниз через несколько слоёв абстракции, то мы увидим:

public override void Load() => LoadAsync().ConfigureAwait(false).GetAwaiter().GetResult();

Снова здравствуй, GetAwaiter().GetResult()! А нет ли каких-то иных решений?

После непродолжительного гугления, я нашёл целую серию замечательных статей Эндрю Лока (Andrew Lock), который год назад задумался над тем же самым вопросом, что и я. Даже по тем же причинам — ему эстетически не нравится синхронно вызывать асинхронный код.

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

Роль асинхронных задач в запуске веб-сервиса

Сделаем шаг назад, чтобы увидеть всю картину целиком. В чём концептуально состоит проблема, которую я пытался решить, вне зависимости от фреймворка?

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

Примеры таких операций:

  • Валидация строготипизированных конфигов.
  • Заполнение кэша.
  • Предварительное подключение к БД или другим сервисам.
  • JIT и подгрузка Assembly (прогрев сервиса).
  • Миграция БД. Это один из примеров Эндрю Лока, однако он сам признаёт, что всё-таки эту операцию нежелательно выполнять при запуске сервиса.

Хочется найти такое решение, которое позволит выполнять произвольные асинхронные задачи при старте приложения, причём естественным для них способом, без GetAwaiter().GetResult().

Эти задачи должны завершиться перед тем, как приложение начнёт принимать запросы, но для своей работы им могут быть необходимы конфигурация и зарегистрированные сервисы приложения. Поэтому выполнение этих задач должно происходить после конфигурации DI.

Эту идею можно представить в виде схемы:

Асинхронный код в Startup ASP.NET Core: 4 способа обхода GetAwaiter().GetResult() - 3

Решение №1: рабочее решение, которое может запутать наследников

Первое рабочее решение, предлагаемое Локом:

public class Program
{
   public static async Task Main(string[] args)
   {
       IWebHost webHost = CreateWebHostBuilder(args).Build();

       using (var scope = webHost.Services.CreateScope())
       {
           // Получаем нужный сервис
           var myService = scope.ServiceProvider.GetRequiredService<MyService>();

           await myService.DoAsyncJob();
       }

       await webHost.RunAsync();
   }

   public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
       WebHost.CreateDefaultBuilder(args)
           .UseStartup<Startup>();
}

Такой подход стал возможен благодаря появлению асинхронного Main() из C# 7.1. Его единственный минус заключается в том, что часть конфигурирования мы перенесли из Startup.cs в Program.cs. Такое нестандартное для ASP.NET-фреймворка решение может запутать человека, которому наш код достанется по наследству.

Решение №2: встраиваем асинхронные операции в DI

Поэтому Эндрю предложил улучшенную версию решения. Объявляется интерфейс для асинхронных задач:

public interface IStartupTask
{
    Task ExecuteAsync(CancellationToken cancellationToken = default);
}

А также метод расширения, регистрирующий эти задачи в DI:

public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddStartupTask<T>(this IServiceCollection services)
        where T : class, IStartupTask
        => services.AddTransient<IStartupTask, T>();
}

Далее объявляется ещё один метод расширения, уже для IWebHost:

public static class StartupTaskWebHostExtensions
{
    public static async Task RunWithTasksAsync(this IWebHost webHost, CancellationToken cancellationToken = default)
    {
        // Получить все асинхронные задачи из DI
        var startupTasks = webHost.Services.GetServices<IStartupTask>();

        // Выполнить эти задачи
        foreach (var startupTask in startupTasks)
        {
            await startupTask.ExecuteAsync(cancellationToken);
        }

        // Запустить сервис как обычно
        await webHost.RunAsync(cancellationToken);
    }
}

И в Program.cs мы меняем только одну строчку. Вместо:

await CreateWebHostBuilder(args).Build().Run();

Вызываем:

await CreateWebHostBuilder(args).Build().RunWithTasksAsync();

По-моему, отличный подход, который делает максимально прозрачным работу с длительными операциями при старте приложения.

Решение №3: для тех, кто перешел на ASP.NET Core 3.x

Но если вы используете ASP.NET Core 3.x, то есть ещё один вариант. Вновь сошлюсь на статью Эндрю Лока.

Вот код запуска WebHost из ASP.NET Core 2.x:

public class WebHost
{
    public virtual async Task StartAsync(CancellationToken cancellationToken = default)
    {
        // ... initial setup
        await Server.StartAsync(hostingApp, cancellationToken).ConfigureAwait(false);

        // Fire IApplicationLifetime.Started
        _applicationLifetime?.NotifyStarted();

        // Fire IHostedService.Start
        await _hostedServiceExecutor.StartAsync(cancellationToken).ConfigureAwait(false);

        // ...remaining setup
    }
}

А вот этот же метод в ASP.NET Core 3.0:

public class WebHost
{
    public virtual async Task StartAsync(CancellationToken cancellationToken = default)
    {
        // ... initial setup

        // Fire IHostedService.Start
        await _hostedServiceExecutor.StartAsync(cancellationToken).ConfigureAwait(false);

        // ... more setup
        await Server.StartAsync(hostingApp, cancellationToken).ConfigureAwait(false);

        // Fire IApplicationLifetime.Started
        _applicationLifetime?.NotifyStarted();

        // ...remaining setup
    }
}

В ASP.NET Core 3.x сначала запускаются HostedServices и только затем основной WebHost, а раньше было ровно наоборот. Что нам это даёт? Теперь все асинхронные операции можно вызывать внутри метода StartAsync(CancellationToken) интерфейса IHostedService и добиться того же эффекта, не создавая отдельных интерфейсов и методов расширения.

Решение №4: история с health check и Kubernetes

На этом можно было бы успокоиться, но есть ещё один подход, и он внезапно оказывается важным в текущих реалиях. Это использование health check.

Основная идея: как можно раньше запустить сервер Kestrel, чтобы сообщить балансировщику нагрузки о том, что сервис готов принимать запросы. Но в то же время все запросы, не связанные с health check, будут возвращать 503 (Service Unavailable). На сайте Microsoft есть достаточно обширная статья про то, как использовать health check в ASP.NET Core. Я же хотел рассмотреть этот подход без особых подробностей в применении к нашей задаче.

У Эндрю Лока выделена отдельная статья под этот подход. Главное его преимущество в том, что он позволяет избежать сетевых таймаутов.

Будет лучше, если сервис быстро вернёт ответ на запрос с кодом ошибки, чем не вернёт ничего, приводя к таймауту у клиента. Запуская Kestrel как можно раньше, приложение также может раньше отвечать на запросы, даже если ответы будут по типу «я ещё не готов».

Не буду приводить здесь полностью решение Эндрю Лока для подхода с health check. Оно достаточно объёмное, но в нём нет ничего сложного.

Расскажу в двух словах: нужно запустить веб-сервис, не дожидаясь завершения асинхронных операций. При этом health check endpoint должен знать о статусе этих операций, выдавать 503, пока они выполняются, и 200, когда они уже завершились.

Честно говоря, когда я изучал этот вариант, у меня был определённый скепсис. Всё решение выглядело громоздким по сравнению с предыдущими подходами. А если проводить аналогию, то это как снова использовать EAP-подход с подпиской на события, вместо уже ставшего привычным async/await.

Но тут в игру вступил Kubernetes. У него есть своя концепция readiness probe. Приведу цитату из книги «Kubernetes in Action» в моём вольном изложении:

Всегда определяйте readiness probe.

Если у вас нет readiness probe, ваши поды становятся endpoint’ами сервисов практически мгновенно. Если у вашего приложения слишком много времени занимает подготовка к тому, чтобы принимать входящие запросы, – запросы клиентов к сервису будут в том числе попадать на стартующие поды, которые ещё не готовы принимать входящие соединения. В итоге клиенты получат ошибку «Connection refused».

Я провёл простейший эксперимент: создал сервис ASP.NET Core 3 c долгой асинхронной задачей в HostedService:

public class LongTaskHostedService : IHostedService
{
    public async Task StartAsync(CancellationToken cancellationToken)
    {
            Console.WriteLine("Long task started...");
            await Task.Delay(5000, cancellationToken);
            Console.WriteLine("Long task finished.");
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {...}
}

Когда я запустил этот сервис с помощью minikube, а потом увеличил количество под до двух, то в течение 5 секунд задержки каждый мой второй запрос выдавал не полезную информацию, а «Connection refused».

UPD от Kubernetes 1.16

Когда статья уже была написана, оказалось, что в Kubernetes 1.16 появилась startup probe (специально для таких случаев). Пока что я не разобрался, чем она отличается от readiness probe. Интересующихся отсылаю к документации. Можем вместе обсудить это в комментариях.

Выводы

Какой вывод можно сделать из всех этих исследований? Пожалуй, каждый должен сам решить для своего проекта, какое решение подойдёт лучше всего. Если предполагается, что асинхронная операция не будет занимать слишком много времени, а у клиентов есть какая-то политика повторов, то можно использовать все подходы, начиная с GetAwaiter().GetResult() и заканчивая IHostedService в ASP.NET Core 3.x.

С другой стороны, если вы используете Kubernetes и ваши асинхронные операции могут выполняться ощутимо долгое время, то без health check (он же readiness/startup probe) вам не обойтись.

Автор: Евгений

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js