Loupe

Initialisation asynchrone en ASP.NET Core avec un middleware dédié

Parfois il est nécessaire d'effectuer certaines opérations d'initialisation quand une application web démarre. Cependant, mettre ce code dans Startup.Configure n'est généralement pas une bonne idée, car :

  • Il n'y a pas de scope courant dans la méthode Configure, on ne peut donc pas utiliser de services dont la durée de vie est "scoped" (cela causerait une InvalidOperationException : Cannot resolve scoped service 'MyApp.IMyService' from root provider).
  • Si l'initialisation est asynchrone, on ne peut pas l'awaiter, car la méthode Configure ne peut pas être asynchrone. On pourrait utiliser .Wait pour bloquer jusqu'à ce que l'initialisation se termine, mais c'est moche.

Middleware d'initialisation asynchrone

Une solution simple est d'écrire un middleware dédié qui s'assure que l'initialisation est terminée avant de traiter une requête. Ce middleware démarre le processus d'initialisation quand l'application démarre, et à la réception d'une requête, attend que l'initialisation soit terminée avant de passer la requête au middleware suivant. Une implémentation basique pourrait ressembler à ceci :

public class AsyncInitializationMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger _logger;
    private Task _initializationTask;

    public AsyncInitializationMiddleware(RequestDelegate next, IApplicationLifetime lifetime, ILogger<AsyncInitializationMiddleware> logger)
    {
        _next = next;
        _logger = logger;

        // Démarrer l'initialisation quand l'app démarre
        var startRegistration = default(CancellationTokenRegistration);
        startRegistration = lifetime.ApplicationStarted.Register(() =>
        {
            _initializationTask = InitializeAsync(lifetime.ApplicationStopping);
            startRegistration.Dispose();
        });
    }

    private async Task InitializeAsync(CancellationToken cancellationToken)
    {
        try
        {
            _logger.LogInformation("Initialization starting");

            // Faire l'initialisation asynchrone ici
            await Task.Delay(2000);

            _logger.LogInformation("Initialization complete");
        }
        catch(Exception ex)
        {
            _logger.LogError(ex, "Initialization failed");
            throw;
        }
    }

    public async Task Invoke(HttpContext context)
    {
        // Prendre une copie pour éviter une race condition
        var initializationTask = _initializationTask;
        if (initializationTask != null)
        {
            // Attendre que l'initialisation soit terminée avant de passer la requête au middleware suivant
            await initializationTask;

            // Réinitialiser la task pour ne pas l'awaiter à nouveau pour rien
            _initializationTask = null;
        }

        // Passer la requête au middleware suivant
        await _next(context);
    }
}

On peut ensuite ajouter ce middleware au pipeline dans la méthode Startup.Configure. Il faut l'ajouter vers le début du pipeline, avant tout autre middleware qui nécessite que l'initialisation soit terminée.

public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseMiddleware<AsyncInitializationMiddleware>();

    app.UseMvc();
}

Dépendances

À ce stade, notre middleware d'initialisation ne dépend d'aucun service. S'il a des dépendances transitoires (transient) ou singleton, celles-ci peuvent simplement être injectées comme d'habitude dans le constructeur du middleware, et utilisées dans la méthode InitializeAsync.

Cependant, si les dépendances sont "scopées", on a un problème : le middleware est instancié directement depuis le service provider "racine", pas depuis un scope, et ne peut donc pas recevoir de dépendances scopées dans son constructeur.

Dépendre de services scopés pour l'initialisation n'a de toute façon pas beaucoup de sens, puisque par définition ces services n'existent que dans le contexte d'une requête. Mais si pour une raison ou une autre, vous devez le faire quand même, une solution possible est d'effectuer l'initialisation dans la méthode Invoke du middleware, en injectant les dépendances comme paramètres de la méthode. Cette approche a au moins deux inconvénients :

  • L'initialisation ne commencera que quand une requête sera reçue, donc les premières requêtes verront leur temps de réponse rallongé, ce qui peut être problématique si l'initialisation prend beaucoup de temps.
  • Il faut faire très attention à ce que le code soit bien thread-safe : le code d'initialisation ne doit être exécuté qu'une seule fois, même si plusieurs requêtes arrivent avant que l'initialisation soit terminée.

Écrire du code thread-safe sans faire d'erreurs est difficile, donc évitez de vous retrouver dans cette situation si possible, par exemple en refactorisant vos services pour que le middleware d'initialisation ne dépende pas de services scopés.

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus