Tag Archives: pipeline

Comprendre le pipeline de middleware d’ASP.NET Core

Middlewhat?

L’architecture d’ASP.NET Core est basée sur un système de middlewares, des morceaux de code qui gèrent les requêtes et réponses. Les middlewares sont chainés les uns aux autres pour constituer un pipeline. Les requêtes entrantes passent dans le pipeline, où chaque middleware a l’occasion de les examiner et/ou de les modifier avant des les passer au middleware suivant. Les réponses sortantes passent aussi dans le pipeline, dans l’ordre inverse. Si tout cela semble très abstrait, le schéma suivant, tiré de la documentation officielle ASP.NET Core, devrait aider à comprendre :

Middleware pipeline

Les middlewares peuvent faire toutes sortent de choses, comme gérer l’authentification, les erreurs, les fichiers statiques, etc. La couche MVC d’ASP.NET Core est également implémentée comme un middleware.

Configurer le pipeline

On configure habituellement le pipeline ASP.NET Core dans la méthode Configure de la classe Startup, en appelant des méthodes Use* sur le IApplicationBuilder. Voici un exemple tiré de la documentation :

public void Configure(IApplicationBuilder app)
{
    app.UseExceptionHandler("/Home/Error");
    app.UseStaticFiles();
    app.UseAuthentication();
    app.UseMvcWithDefaultRoute();
}

Chaque méthode Use* ajoute un middleware au pipeline. L’ordre dans lequel ils sont ajoutés détermine l’ordre dans lequel les requêtes les traverseront. Dans cet exemple, une requête entrante va d’abord passer par le middleware de gestion d’exception, puis par le middleware de fichiers statiques, puis par le middleware d’authentification, et sera finalement gérée par le middleware MVC.

Les méthodes Use* dans cet exemple sont en fait juste des raccourcis pour faciliter la construction du pipeline. Sous le capot, elles finissent toutes par appeler, directement ou indirectement, les primitives de bas niveau suivantes : Use et Run. Ces deux méthodes ajoutent un middleware au pipeline, la différence est que Run ajoute un middleware terminal, c’est à dire qui est le dernier du pipeline.

Un pipeline basique sans branches

Regardons d’abord un exemple simple, qui utilise simplement les primitives Use et Run :

public void Configure(IApplicationBuilder app)
{
    // Middleware A
    app.Use(async (context, next) =>
    {
        Console.WriteLine("A (before)");
        await next();
        Console.WriteLine("A (after)");
    });

    // Middleware B
    app.Use(async (context, next) =>
    {
        Console.WriteLine("B (before)");
        await next();
        Console.WriteLine("B (after)");
    });

    // Middleware C (terminal)
    app.Run(async context =>
    {
        Console.WriteLine("C");
        await context.Response.WriteAsync("Hello world");
    });
}

Ici, les middleware sont définis “inline” avec des méthodes anonymes ; ils pourraient aussi être définis commes des classes complètes, mais pour cet exemple j’ai opté pour la forme la plus concise. Les middleware non-terminaux prennent deux arguments : le HttpContext et un delegate qui appelle le middleware suivant. Le middleware terminal prend seulement le HttpContext. Ici on a deux middlewares non-terminaux A et B qui écrivent simplement dans la console, et un middleware terminal C qui écrit la réponse.

Voici la sortie console quand on envoie une requête à l’application :

A (before)
B (before)
C
B (after)
A (after)

On voit que chaque middleware est traversé dans l’ordre dans lequel il a été ajouté, puis traversé à nouveau en sens inverse. Le pipeline peut être représenté comme suit :

Basic pipeline

Court-circuiter le pipeline

Un middleware n’est pas obligé d’appeler le middleware suivant. Par exemple, si le middleware de fichiers statiques peut gérer une requête, il n’a pas besoin de la passer au reste du pipeline, il peut répondre immédiatement. Ce comportement s’appelle “court-circuiter le pipeline”.

Dans l’exemple précédent, si on commente l’appel à next() dans le middleware B, on obtient la sortie suivante :

A (before)
B (before)
B (after)
A (after)

Comme vous pouvez le voir, le middleware C n’est jamais appelé. Le pipeline ressemble maintenant à ceci :

Short-circuited pipeline

Faire des branches dans le pipeline

Dans les exemples précédents, il y avait une seule “branche” dans le pipeline : le middleware qui suivait A était toujours B, et le middleware qui suivait B était toujours C. Mais rien n’impose que ça se passe comme ça ; on peut aussi faire qu’une requête donnée soit traitée par un autre pipeline, en fonction du chemin ou de tout autre critère.

Il y a deux types de branches : celles qui rejoignent le pipeline principal, et celles qui s’en séparent définitivement.

Faire une branche entièrement séparée

On peut faire cela à l’aide de la méthode Map ou MapWhen. Map permet de spécifier une branche en fonction du chemin de la requête. MapWhen donne un contrôle plus fin : on peut spécifier un prédicat pour décider de passer ou non sur la branche. Prenons un exemple simple avec Map :

public void Configure(IApplicationBuilder app)
{
    app.Use(async (context, next) =>
    {
        Console.WriteLine("A (before)");
        await next();
        Console.WriteLine("A (after)");
    });

    app.Map(
        new PathString("/foo"),
        a => a.Use(async (context, next) =>
        {
            Console.WriteLine("B (before)");
            await next();
            Console.WriteLine("B (after)");
        }));

    app.Run(async context =>
    {
        Console.WriteLine("C");
        await context.Response.WriteAsync("Hello world");
    });
}

Le premier argument de Map est un PathString qui représente le préfixe du chemin de la requête. Le second argument est un delegate qui configure le pipeline pour la branche (le paramètre a représente le IApplicationBuilder pour la branche). La branche définie par le delegate traitera la requête si son chemin commence par le préfixe spécifié.

Pour une requête qui ne commence pas par /foo, ce code produit la sortie suivante :

A (before)
C
A (after)

Le middleware B n’est pas appelé, puisqu’il est dans la branche, et que la requête ne correspond pas au préfixe pour la branche. Mais pour une requête dont le chemin commence par /foo, la sortie est la suivante :

A (before)
B (before)
B (after)
A (after)

Remarquez que cette requête renvoie une erreur 404 (Not found) : c’est parce que le middleware B appelle next(), mais il n’y a pas de middleware suivant ; dans ce cas le comportement par défaut est de renvoyer une erreur 404. Pour régler ça, on pourrait utiliser Run au lieu de Use, ou alors ne pas appeler next().

Le pipeline défini par ce code peut être représenté comme suit :

Non-rejoining branch

(Pour plus de clarté, j’ai omis les flèches des réponses)

Comme vous pouvez le voir, la branche où se trouve le middleware B ne rejoint pas le pipeline principal, le middleware C n’est donc pas appelé.

Faire une branche qui rejoint le pipeline principal

On peut créer une branche qui rejoint le pipeline principal à l’aide de la méthode UseWhen. Cette méthode accepte un prédicat sur le HttpContext pour décider de passer ou non sur la branche. À la fin de son exécution, cette branche rejoindra le pipeline principal là où elle l’a quitté. Voici un exemple similaire au précédent, mais avec une branche qui rejoint le pipeline principal :

public void Configure(IApplicationBuilder app)
{
    app.Use(async (context, next) =>
    {
        Console.WriteLine("A (before)");
        await next();
        Console.WriteLine("A (after)");
    });

    app.UseWhen(
        context => context.Request.Path.StartsWithSegments(new PathString("/foo")),
        a => a.Use(async (context, next) =>
        {
            Console.WriteLine("B (before)");
            await next();
            Console.WriteLine("B (after)");
        }));

    app.Run(async context =>
    {
        Console.WriteLine("C");
        await context.Response.WriteAsync("Hello world");
    });
}

Pour une requête dont le chemin ne commence pas par /foo, ce code produit la même sortie que l’exemple précédent :

A (before)
C
A (after)

Là encore, le middleware B n’est pas appelé, puisque la requête ne correspond pas au prédicat de la branche. Mais pour une requête dont le chemin commence par /foo, on obtient la sortie suivante :

A (before)
B (before)
C
B (after)
A (after)

Comme on peut le voir, la requête passe par la branche (middleware B), puis revient sur le pipeline principal, en finissant par le middleware C. Le pipeline peut donc être représenté comme suit :

Rejoining branch

Remarquez qu’il n’y a pas de méthode Use qui accepte un PathString pour spécifier le préfixe du chemin. Je ne sais pas pourquoi cette méthode n’est pas incluse, mais il est facile de l’écrire à l’aide de UseWhen :

public static IApplicationBuilder Use(this IApplicationBuilder builder, PathString pathMatch, Action<IApplicationBuilder> configuration)
{
    return builder.UseWhen(
        context => context.Request.Path.StartsWithSegments(new PathString("/foo")),
        configuration);
}

Conclusion

Comme vous pouvez le voir, le principe du pipeline de middlewares est assez simple, mais très puissant. La plupart des fonctionnalités standard d’ASP.NET Core (authentification, fichiers statiques, mise en cache, MVC, etc) sont implémentées comme des middlewares. Et bien sûr, il est très simple de créer son propre middleware !

Tout faire ou presque avec le pipeline de HttpClient

Il y a quelques années, Microsoft a introduit la classe HttpClient comme alternative moderne à HttpWebRequest pour faire des requêtes web depuis des applications .NET. Non seulement cette nouvelle API est beaucoup plus facile à utiliser, plus propre, et asynchrone, mais elle est aussi facilement extensible.

Vous avez peut-être remarqué que HttpClient a un constructeur qui accepte un HttpMessageHandler. De quoi s’agit-il ? Un HttpMessageHandler est un objet qui accepte une requête (HttpRequestMessage) et renvoie une réponse (HttpResponseMessage) ; la façon dont il le fait dépend complètement de l’implémentation. Par défaut, HttpClient utilise HttpClientHandler, un handler qui envoie une requête vers un serveur sur le réseau et renvoie la réponse du serveur. L’autre implémentation fournie de HttpMessageHandler est une classe abstraite nommée DelegatingHandler, et c’est de celle là que je voudrais parler.

Le pipeline

DelegatingHandler est un handler qui est conçu pour être chaîné à un autre handler, ce qui donne un pipeline à travers lequel les requêtes et réponses vont passer, comme illustré par ce schéma :

Schéma du pipeline de HttpClient

(Image tirée du site officiel ASP.NET)

Chaque handler a la possibilité d’examiner et/ou de modifier la requête avant de la passer au handler suivant, et d’examiner et/ou de modifier la réponse reçue du handler suivant. Habituellement, le dernier handler dans le pipeline est le HttpClientHandler, qui communique directement avec le réseau.

La chaîne de handlers peut être configurée comme ceci :

var pipeline = new MyHandler1()
{
    InnerHandler = new MyHandler2()
    {
        InnerHandler = new HttpClientHandler()
    }
};
var client = new HttpClient(pipeline);

Mais si vous préférez les interfaces “fluent”, il est facile de créer une méthode d’extension qui permet de le faire comme ceci :

var pipeline = new HttpClientHandler()
    .DecorateWith(new MyHandler2())
    .DecorateWith(new MyHandler1());
var client = new HttpClient(pipeline);

Tout ça semble peut-être un peu abstrait pour l’instant, mais cette architecture à base de pipeline rend possible plein de scénarios intéressants. En effet, ces handlers de messages HTTP peuvent être utilisés pour ajouter des comportements personnalisés au traitement des requêtes et réponses. Je vais en donner quelques exemples.

Remarque : Je présente cette fonctionnalité d’un point de vue client (vu que je développe essentiellement des applis clientes), mais le même système de handlers est également utilisé côté serveur dans ASP.NET Web API.

Tests unitaires

Le premier cas d’utilisation qui vient à l’esprit, et le premier que j’ai mis en oeuvre, c’est les tests unitaires. Si vous testez une classe qui fait des paiements en ligne via HTTP, vous ne voulez pas que vos tests envoient réellement des requêtes au vrai serveur… Vous voulez juste vous assurer que les requêtes envoyées sont correctes, et que le code réagit correctement à des réponses spécifiques. Une solution simple à ce problème est de créer un handler “stub”, et de l’injecter dans votre classe à la place de HttpClientHandler. Voici une implémentation possible :

class StubHandler : HttpMessageHandler
{
    // Responses to return
    private readonly Queue<HttpResponseMessage> _responses =
        new Queue<System.Net.Http.HttpResponseMessage>();

    // Requests that were sent via the handler
    private readonly List<HttpRequestMessage> _requests =
        new List<System.Net.Http.HttpRequestMessage>();

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        if (_responses.Count == 0)
            throw new InvalidOperationException("No response configured");

        _requests.Add(request);
        var response = _responses.Dequeue();
        return Task.FromResult(response);
    }

    public void QueueResponse(HttpResponseMessage response) =>
        _responses.Enqueue(response);

    public IEnumerable<HttpRequestMessage> GetRequests() =>
        _requests;
}

Cette classe permet d’enregister les requêtes qui sont envoyées via le handler et de spécifier les réponses qui doivent être renvoyées. Par exemple, on pourrait écrire un test comme celui-ci :

// Arrange
var handler = new StubHandler();
handler.EnqueueResponse(new HttpResponseMessage(HttpStatusCode.Unauthorized));
var processor = new PaymentProcessor(handler);

// Act
var paymentResult = await processor.ProcessPayment(new Payment());

// Assert
Assert.AreEqual(PaymentStatus.Failed, paymentResult.Status);

Bien sûr, plutôt que de créer un stub manuellement, il est possible d’utiliser un framework de mock pour générer un faux handler. Le fait que la méthode SendAsync soit protégée rend cette approche un peu moins facile qu’elle devrait l’être, mais on peut facilement contourner le problème en créant une classe dérivée qui expose une méthode publique virtuelle, et en faisant un mock de cette classe :

public abstract class MockableMessageHandler : HttpMessageHandler
{
    protected override sealed Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        return DoSendAsync(request);
    }

    public abstract Task<HttpResponseMessage> DoSendAsync(HttpRequestMessage request);
}

Exemple d’utilisation avec FakeItEasy :

// Arrange
var handler = A.Fake<MockableMessageHandler>();
A.CallTo(() => handler.DoSendAsync(A<HttpRequestMessage>._))
    .Returns(new HttpResponseMessage(HttpStatusCode.Unauthorized));
var processor = new PaymentProcessor(handler);
...

Logging

Écrire dans le log les requêtes envoyées et les réponses reçues peut aider à diagnostiquer certains problèmes. C’est très facile à mettre en oeuvre avec un un DelegatingHandler personnalisé :

public class LoggingHandler : DelegatingHandler
{
    private readonly ILogger _logger;

    public LoggingHandler(ILogger logger)
    {
        _logger = logger;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        _logger.Trace($"Request: {request}");
        try
        {
            // base.SendAsync calls the inner handler
            var response = await base.SendAsync(request, cancellationToken);
            _logger.Trace($"Response: {response}");
            return response;
        }
        catch (Exception ex)
        {
            _logger.Error($"Failed to get response: {ex}");
            throw;
        }
    }
}

Réessayer les requêtes échouées

Un autre cas d’utilisation intéressant des handlers de messages HTTP est de réessayer automatiquement les requêtes qui ont échouées. Par exemple, le serveur auquel on s’adresse peut être temporairement indisponible (503), il peut limiter nos requêtes (429), ou on peut tout simplement avoir perdu l’accès à internet. Dans toutes ces situations, réessayer la même requête plus tard a de bonnes chances de fonctionner (le serveur peut avoir redémarré, on peut avoir retrouvé du wifi…). Gérer la retentative au niveau du code applicatif est laborieux, car ça peut se produire pratiquement n’importe où. Avoir cette logique au plus bas niveau possible et implémentée d’une façon complètement transparente pour l’appelant rend les choses beaucoup plus simples.

Voici une implémentation possible d’un handler qui réessaie les requêtes échouées :

public class RetryHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        while (true)
        {
            try
            {
                // base.SendAsync calls the inner handler
                var response = await base.SendAsync(request, cancellationToken);

                if (response.StatusCode == HttpStatusCode.ServiceUnavailable)
                {
                    // 503 Service Unavailable
                    // Wait a bit and try again later
                    await Task.Delay(5000, cancellationToken);
                    continue;
                }

                if (response.StatusCode == (HttpStatusCode)429)
                {
                    // 429 Too many requests
                    // Wait a bit and try again later
                    await Task.Delay(1000, cancellationToken);
                    continue;
                }

                // Not something we can retry, return the response as is
                return response;
            }
            catch (Exception ex) when(IsNetworkError(ex))
            {
                // Network error
                // Wait a bit and try again later
                await Task.Delay(2000, cancellationToken);
                continue;
            }
        }
    }

    private static bool IsNetworkError(Exception ex)
    {
        // Check if it's a network error
        if (ex is SocketException)
            return true;
        if (ex.InnerException != null)
            return IsNetworkError(ex.InnerException);
        return false;
    }
}

Remarquez que cette implémentation est un peu simpliste ; pour l’utiliser dans du code de production, il faudra sans doute ajouter un “exponential backoff” (attendre de plus en plus longtemps entre chaque tentative), prendre en compte l’en-tête Retry-After pour déterminer combien de temps il faut attendre, ou encore déterminer de façon un peu plus subtile si une exception correspond à une erreur réseau. De plus, en l’état actuel, ce handler réessaiera indéfiniment jusqu’à ce que la requête réussisse ; assurez-vous donc de passer un jeton d’annulation (CancellationToken) pour pouvoir l’arrêter si besoin.

Autres cas d’utilisations

Je ne peux pas donner d’exemples pour chaque scénario possible, mais voilà quelques autres cas d’utilisation possible des handlers de messages HTTP :

  • Gestion personnalisée des cookies (quelque chose que j’ai réellement fait, pour contourner un bug dans CookieContainer)
  • Authentification personnalisée (également quelque chose que j’ai fait, pour implémenter l’authentification OAuth2)
  • Utiliser l’en-tête X-HTTP-Method-Override pour passer les proxy qui refusent certaines méthode HTTP (voir l’article de Scott Hanselman pour plus de détails)
  • Chiffrement ou encodage personnalisé
  • Mise en cache

Comme vous le voyez, il y a tout un monde de possibilités. Si vous avez d’autres idées, indiquez les dans les commentaires !