Middleware e pipeline delle richieste in ASP.NET Core

Middleware e pipeline delle richieste in ASP.NET Core

Quando abbiamo costruito una Web API nella terza serie, abbiamo configurato il punto di avvio con alcune chiamate come UseHttpsRedirection e MapControllers, senza soffermarci sul loro significato. Quelle righe definiscono la pipeline delle richieste, la sequenza di componenti che ogni richiesta HTTP attraversa. Questi componenti si chiamano middleware e costituiscono il cuore del funzionamento di ASP.NET Core. In questo articolo capiamo come funziona la pipeline, come i middleware si compongono e come scriverne di propri.

Il concetto di pipeline

Ogni richiesta HTTP che arriva all'applicazione attraversa una catena di middleware, uno dopo l'altro. Ciascun middleware può esaminare o modificare la richiesta, decidere di passarla al middleware successivo, e poi esaminare o modificare la risposta al ritorno. Si può immaginare la pipeline come una serie di strati concentrici: la richiesta entra attraversandoli in un verso e la risposta esce attraversandoli nel verso opposto.

L'ordine dei middleware

L'ordine in cui i middleware vengono registrati è determinante, perché stabilisce la sequenza di elaborazione. Un middleware di autenticazione, ad esempio, deve precedere quello di autorizzazione, e la gestione degli errori va posta all'inizio per intercettare i problemi di tutto ciò che segue. Ogni chiamata Use... nel punto di avvio aggiunge un middleware in coda alla pipeline.

var app = builder.Build();

// L'ordine di registrazione e l'ordine di esecuzione
app.UseExceptionHandler("/error"); // per primo: cattura gli errori sottostanti
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();           // prima l'identita
app.UseAuthorization();            // poi i permessi
app.MapControllers();              // infine gli endpoint

app.Run();

Anatomia di un middleware

Il metodo Use permette di inserire un middleware inline. Riceve il contesto della richiesta e un delegato next che rappresenta il middleware successivo nella pipeline. Chiamare next passa il controllo al prossimo componente; il codice prima della chiamata viene eseguito all'andata, quello dopo al ritorno.

app.Use(async (context, next) =>
{
    // Codice eseguito all'andata, prima dei middleware successivi
    Console.WriteLine($"Richiesta in arrivo: {context.Request.Path}");

    // Passa il controllo al middleware successivo
    await next();

    // Codice eseguito al ritorno, dopo i middleware successivi
    Console.WriteLine($"Risposta in uscita: {context.Response.StatusCode}");
});

Interrompere la pipeline

Un middleware non è obbligato a chiamare next. Se non lo fa, la pipeline si interrompe e la risposta viene restituita senza raggiungere i middleware successivi: si dice che il middleware ha cortocircuitato la pipeline. Il metodo Run definisce un middleware terminale, che non ha un successore e chiude la catena.

app.Use(async (context, next) =>
{
    // Blocca le richieste senza una chiave di accesso
    if (!context.Request.Headers.ContainsKey("X-Api-Key"))
    {
        context.Response.StatusCode = 401;
        await context.Response.WriteAsync("Accesso non autorizzato");
        return; // non chiama next: la pipeline si interrompe qui
    }

    await next();
});

Middleware in una classe dedicata

Per logiche più complesse o riutilizzabili, conviene definire il middleware in una classe apposita, secondo una convenzione precisa: un costruttore che riceve il delegato successivo e un metodo InvokeAsync che elabora la richiesta. La dependency injection può fornire i servizi necessari come parametri del metodo di invocazione.

class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;

    public RequestTimingMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    // I servizi possono essere iniettati nel metodo InvokeAsync
    public async Task InvokeAsync(HttpContext context, ILogger<RequestTimingMiddleware> logger)
    {
        var start = DateTime.UtcNow;

        await _next(context);

        var elapsed = DateTime.UtcNow - start;
        logger.LogInformation("Richiesta {Path} elaborata in {Ms} ms",
            context.Request.Path, elapsed.TotalMilliseconds);
    }
}

// Registrazione del middleware nella pipeline
app.UseMiddleware<RequestTimingMiddleware>();

Diramare la pipeline con Map

Il metodo Map permette di creare una diramazione della pipeline in base al percorso della richiesta. Le richieste che corrispondono a un certo prefisso vengono gestite da una pipeline separata, utile per applicare middleware specifici solo a una parte dell'applicazione.

// Una pipeline dedicata al percorso /admin
app.Map("/admin", adminApp =>
{
    adminApp.Use(async (context, next) =>
    {
        // Middleware applicato solo alle richieste sotto /admin
        await next();
    });
});

I middleware integrati

ASP.NET Core fornisce numerosi middleware pronti all'uso, che coprono le esigenze più comuni. Conoscerli evita di reinventare funzionalità già disponibili e collaudate:

  • UseExceptionHandler: intercetta le eccezioni non gestite e produce una risposta di errore controllata.
  • UseStaticFiles: serve file statici come immagini, fogli di stile e script.
  • UseRouting e UseAuthorization: gestiscono l'instradamento verso gli endpoint e il controllo dei permessi.
  • UseCors: applica le politiche di condivisione delle risorse tra origini diverse.

Conclusione

Abbiamo compreso la pipeline delle richieste di ASP.NET Core come una sequenza ordinata di middleware che ogni richiesta attraversa all'andata e al ritorno. Abbiamo visto come registrarli con Use, come l'ordine ne determini il comportamento, come cortocircuitare la pipeline, come scrivere middleware in classi dedicate con dependency injection e come diramare il flusso con Map. Abbiamo infine passato in rassegna i middleware integrati più utili. Nel prossimo e ultimo articolo della serie porteremo la nostra applicazione in produzione con la containerizzazione tramite Docker.