Предыстория про рабочую задачу
Сейчас мы находимся в процессе перехода со старой легаси-аутентификации на OAuth 2.0, который фактически уже является стандартом в нашей отрасли. Сервис, над которым я сейчас работаю, стал пилотом для интеграции с новой системой и для перехода на JWT-аутентификацию.
В процессе интеграции мы экспериментировали, рассматривая разные варианты, как уменьшить нагрузку на провайдер токенов (IdentityServer в нашем случае) и обеспечить большую надёжность всей системы. Подключение валидации на основе JWT в ASP.NET Core осуществляется очень просто и не привязано к конкретной реализации провайдера токенов:
services
.AddAuthentication()
.AddJwtBearer();
Но что скрывается за этими двумя строчками? Под их капотом создаётся JWTBearerHandler, который уже и имеет дело с JWT от клиента API.
Взаимодействие клиента, 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.
Эту идею можно представить в виде схемы:
Решение №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».
Выводы
Какой вывод можно сделать из всех этих исследований? Пожалуй, каждый должен сам решить для своего проекта, какое решение подойдёт лучше всего. Если предполагается, что асинхронная операция не будет занимать слишком много времени, а у клиентов есть какая-то политика повторов, то можно использовать все подходы, начиная с GetAwaiter().GetResult() и заканчивая IHostedService в ASP.NET Core 3.x.
С другой стороны, если вы используете Kubernetes и ваши асинхронные операции могут выполняться ощутимо долгое время, то без health check (он же readiness/startup probe) вам не обойтись.
maxbl4
Недавно решал такую же задачу. Сначала очень хотелось сохранить асинхронность вызовов, делал всякие хаки типа вызова конфигурации после создания хоста, но до его старта. Это даже работало. Но как правильно упомянули — это не очень понятно для тех, кто потом будет читать код.
В итоге сделал элементарную обёртку:
И в нескольких местах проекта, где раньше вызывался GetAwaiter().GetResult() заменил на такой вызов. Таким оразом мы явным образом берём поток из ThreadPool и уже на нём выполняем блокировку GetResult
Делать это пришлось не только из-за эстетических соображений, но ещё потому что у нас зависали интеграционные тесты. Дело в том, что сам по себе asp.net core не устанавливает никакой специальный SynchronizationContext, поэтому в нём можно без последствий вызывать GetAwaiter().GetResult(). Но XUnit применяет свой хитрый контекст для контроля параллельного выполнения теста и тут мы легко приходим к дедлоку.
Кстати, есть баг, который описывает потенциальное решение проблемы github.com/dotnet/aspnetcore/issues/5897 но пока его не хотят исправлять и это печально
Xneg Автор
Да, Ваш вариант тоже можно добавить в копилку. Отдельное спасибо за ссылку на гитхаб. Собственно, основная мысль, которая побудила меня написать статью, что до тех пор, пока у нас не появится официального async Startup, мы все будем вынуждены придумывать свои более или менее очевидные решения.
maxbl4
Проблема не только в Startup, ещё есть места, где требуется вызов async кода в синхронных методах. И везде в таких случаях использование GetResult может вести к дедлокам