Aller au contenu

L'utilisation des middlewares dans une application ASP.NET Core

Introduction

Lorsqu’une application ASP.NET Core reçoit une requête HTTP, une action d'un contrôleur (ou un endpoint d'une Minimal API) n'est pas immédiatement exécutée. La requête est traitée par une suite de composants logiciels dont les responsabilités sont diverses, telles que la gestion des erreurs, la redirection HTTPS, l'identification de l'utilisateur, le contrôle des droits, la journalisation, etc.

Ces composants sont appelés des middlewares et sont organisés au sein d’une pipeline, dont le rôle est de traiter les requêtes et réponses HTTP. Leur ordre d’enregistrement détermine leur ordre d’exécution et peut avoir des conséquences sur leur comportement.

Dans cet article, découvrons ce que sont les middlewares, leurs rôles, leur enregistrement au sein du pipeline et l’implémentation d’un middleware personnalisé.

Qu’est-ce qu’un middleware ?

Dans une application ASP.NET Core, un middleware est un composant logiciel intégré à la pipeline HTTP. Il reçoit un objet de type HttpContext, représentant, entre autres, la requête et la réponse HTTP en cours de traitement.

Lorsqu’il reçoit une requête, un middleware peut réaliser les tâches suivantes :

  • Examiner ou modifier la requête HTTP.
  • Modifier la réponse HTTP.
  • Interrompre la pipeline et produire lui-même une réponse.
  • Décider de ne rien faire et transmettre la requête au middleware suivant.
  • Etc.

Pipeline de middlewares

Les middlewares natifs

Un middleware peut prendre en charge un traitement technique commun à plusieurs actions de contrôleurs ou endpoints d’une Web API, sans qu’il soit nécessaire de dupliquer ce traitement dans chacun d’eux. C’est notamment le cas de la gestion centralisée des erreurs.

ASP.NET Core fournit de nombreux middlewares. Voici quelques exemples :

  • UseExceptionHandler() pour la gestion globale des erreurs.
  • UseHttpsRedirection() pour la redirection vers HTTPS.
  • UseStaticFiles() pour renvoyer les fichiers statiques.
  • UseCors() pour appliquer une politique CORS.
  • UseAuthentication() pour identifier l’utilisateur.
  • UseAuthorization() pour contrôler les droits de l'utilisateur.
  • UseRateLimiter() pour limiter le nombre de requêtes.
  • UseResponseCompression() pour compresser les réponses.
  • Etc.

Quand implémenter un middleware personnalisé ?

Un middleware convient principalement aux traitements globaux et susceptibles de concerner de nombreuses actions d'un contrôleur (endpoints d'une Web API). En plus des middlewares natifs, nous pouvons implémenter des middlewares pour des tâches telles que :

  • Personnalisation de la gestion globale des exceptions.
  • Journalisation des requêtes et des réponses.
  • Ajout ou contrôle d’en-têtes HTTP.
  • Mesure des temps de traitement.
  • Génération d’un identifiant de corrélation.
  • Redirection ou réécriture d’URL.
  • Etc.

La pipeline de traitement des requêtes HTTP

Les middlewares sont enregistrés dans le fichier Program.cs en utilisant l’objet de type WebApplication. Voici un exemple :

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

Les middlewares ne peuvent être enregistrés qu'à partir du moment où l'application est construite. Chaque middleware est ajouté à la pipeline dans l’ordre où il apparaît dans le code.

Lors de la réception d’une requête, les middlewares sont exécutés dans leur ordre d’enregistrement pour éventuellement exécuter un traitement ou des vérifications. Lorsque le dernier middleware ou l’endpoint a terminé son traitement, l’exécution reprend dans l’ordre inverse au sein des middlewares ayant appelé le composant suivant.

Voici une implémentation in-line d'un middleware :

app.Use(async (context, next) =>
{
    Console.WriteLine("Before the next middleware");

    await next(context);

    Console.WriteLine("After the next middleware");
});

L'instruction next(context) transmet le contexte HTTP au middleware suivant dans la pipeline. Le code placé avant cette instruction est exécuté pendant le traitement de la requête. Le code placé après est exécuté pendant le traitement de la réponse.

L’importance de l’ordre des middlewares

L’ordre d’enregistrement des middlewares n’est pas un simple choix d’organisation. Il détermine leur capacité à agir sur les composants enregistrés après eux.

Voici quelques exemples : - Le middleware de gestion des exceptions doit, par exemple, être placé au début de la pipeline, afin de pouvoir intercepter les exceptions produites par les middlewares qui suivent. - Le middleware d’authentification, chargé d’identifier l’utilisateur, doit être exécuté avant celui chargé de vérifier les autorisations, car l’application doit connaître l’identité de l’utilisateur avant de vérifier ses droits.

Une configuration courante peut prendre la forme suivante :

var app = builder.Build();

if (!app.Environment.IsDevelopment())
{
    app.UseExceptionHandler();
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseCors();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

Ajouter un middleware avec la méthode Use

La méthode Use() ajoute un middleware capable de transmettre la requête au middleware suivant.

app.Use(async (context, next) =>
{
    var stopwatch = Stopwatch.StartNew();

    try
    {
        await next(context);
    }
    finally
    {
        stopwatch.Stop();

        Console.WriteLine(
            $"{context.Request.Method} {context.Request.Path} : {context.Response.StatusCode} en {stopwatch.Elapsed.TotalMilliseconds} ms");
    }
});

Dans cet exemple, le middleware utilise un objet de type Stopwatch pour calculer le temps d'exécution des middlewares suivants dans la pipeline et l'action du contrôleur (ou l'endpoint d'une Web API).

Un middleware ajouté avec la méthode Use() ne doit pas toujours appeler le middleware suivant. Il peut interrompre volontairement l'exécution des middlewares dans la pipeline (et de l'action du contrôleur), en n'écrivant pas l'instruction await next(context); dans le bloc de code if :

app.Use(async (context, next) =>
{
    if (!context.Request.Headers.ContainsKey("X-Api-Key"))
    {
        context.Response.StatusCode = StatusCodes.Status401Unauthorized;
        await context.Response.WriteAsync("API Key not found.");
        return;
    }

    await next(context);
});

Terminer la pipeline avec la méthode Run

La méthode Run enregistre un middleware qui termine la pipeline :

app.Run(async context =>
{
    context.Response.StatusCode = StatusCodes.Status404NotFound;
    await context.Response.WriteAsync("Ressource introuvable.");
});

Dans le pipeline ou la branche concernée, aucun middleware enregistré après ce middleware ne pourra être exécuté. Attention : il ne faut pas confondre cette méthode avec l'instruction app.Run() sans délégué, qui démarre l’application.

Créer une branche avec la méthode Map

La méthode Map() permet de créer une branche dans la pipeline en fonction du chemin de la requête. Voici un exemple :

app.Map("/administration", administrationApp =>
{
    administrationApp.Use(async (context, next) =>
    {
        Console.WriteLine("Administration Zone Access Asked");
        await next(context);
    });

    administrationApp.Run(async context =>
    {
        await context.Response.WriteAsync("Administration Zone");
    });
});

La branche est sélectionnée lorsque le chemin commence par /administration.

Attention : il ne faut pas confondre cette méthode avec les méthodes MapGet, MapPost, MapPut ou MapDelete. La méthode Map crée une branche de middlewares à partir d’un préfixe de chemin dans la requête HTTP, alors que ces méthodes déclarent des endpoints associés à une route et à une méthode HTTP.

Implémenter un middleware personnalisé

Les middlewares in-line sont pratiques pour les implémentations légères, qu'on ne souhaite pas réutiliser dans différentes applications. Pour rendre un composant réutilisable et testable, il est préférable de créer une classe dédiée qui contiendra son implémentation. Voici un exemple :

using System.Diagnostics;

public class RequestDurationMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestDurationMiddleware> _logger;

    public RequestDurationMiddleware(
        RequestDelegate next,
        ILogger<RequestDurationMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var stopwatch = Stopwatch.StartNew();

        try
        {
            await _next(context);
        }
        finally
        {
            stopwatch.Stop();

            _logger.LogInformation(
                "La requête {Method} {Path} a retourné {StatusCode} en {Duration} ms",
                context.Request.Method,
                context.Request.Path,
                context.Response.StatusCode,
                stopwatch.Elapsed.TotalMilliseconds);
        }
    }
}

Le constructeur reçoit un paramètre de type RequestDelegate, qui représente le middleware suivant dans la pipeline. La classe doit exposer une méthode publique nommée InvokeAsync, dont le premier paramètre est un objet de type HttpContext.

Pour enregistrer le middleware dans la pipeline, l'implémentation d'une méthode d’extension est une bonne pratique, car elle peut améliorer la lisibilité. Voici un exemple :

public static class RequestDurationMiddlewareExtension
{
    public static IApplicationBuilder UseRequestDuration(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<RequestDurationMiddleware>();
    }
}

Pour ajouter le middleware dans la pipeline, nous écrivons :

app.UseRequestDuration();

Injection de dépendances et durée de vie

Nous venons de voir qu'un middleware est instancié lors de la construction de la pipeline. Une dépendance ayant une durée de vie scoped, comme un DbContext, ne doit donc pas être injectée dans son constructeur. Elle peut cependant être injectée dans la méthode InvokeAsync, qui est appelée pour chaque requête :

public async Task InvokeAsync(
    HttpContext context,
    ApplicationDbContext dbContext)
{
    // Utilisation de la dépendance scoped pour la requête courante.

    await _next(context);
}

ASP.NET Core propose également l’interface IMiddleware. Le middleware est alors obtenu via le conteneur d’injection de dépendances. Ainsi, contrairement à l'implémentation précédente, il peut recevoir des dépendances scoped dans son constructeur :

public class AuditMiddleware : IMiddleware
{
    private readonly ApplicationDbContext _dbContext;

    public AuditMiddleware(ApplicationDbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public async Task InvokeAsync(
        HttpContext context,
        RequestDelegate next)
    {
        // Enregistrement des informations d’audit.

        await next(context);
    }
}

public static class AuditMiddlewareExtension
{
    public static IApplicationBuilder UseAudit(
        this IApplicationBuilder app)
    {
        return app.UseMiddleware<AuditMiddleware>();
    }
}

Pour l'utiliser, il doit être enregistré dans le conteneur, puis ajouté à la pipeline :

builder.Services.AddScoped<AuditMiddleware>();

// ...

app.UseAudit();