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 !

Nettoyer l’historique d’une branche Git pour supprimer les fichiers indésirables

J’ai récemment eu à travailler sur un dépôt Git qui contenait des modifications à reporter sur un autre dépôt. Malheureusement, ce dépôt n’avait pas de fichier .gitignore au départ, si bien que de nombreux fichiers inutiles (répertoires bin/obj/packages…) avaient été archivés. Cela rendait l’historique très difficile à lire, puisque chaque commit contenait des centaines de fichiers modifiés.

Heureusement, Git permet assez facilement de "nettoyer" une branche, en recréant les mêmes commits sans les fichiers qui n’auraient pas dû se trouver là. Voyons donc pas-à-pas comment arriver à ce résultat.

Mise en garde

L’opération à réaliser ici consiste en une réécriture de l’historique, une mise en garde s’impose donc : il ne faut jamais réécrire l’historique d’une branche publiée partagée avec d’autres personnes. En effet, si quelqu’un d’autre crée des commits à partir de la version actuelle de la branche, et que celle-ci est réécrite, il deviendra beaucoup plus compliqué d’intégrer ces commits à la branche réécrite.

Dans mon cas, je n’avais pas besoin de publier la branche réécrite mais seulement de l’examiner en local, donc le problème ne se posait pas. Mais n’appliquez pas cette solution à une branche sur laquelle vos collègues travaillent, si vous tenez à conserver de bonnes relations avec eux 😉.

Créer une branche de travail

On va faire des modifications assez lourdes et potentiellement risquées sur le dépôt, il convient donc de prendre quelques précautions. Le plus simple dans ce genre de situation est tout bêtement de travailler sur une autre branche, pour ne pas risquer de faire des dégâts sur la branche originale. Par exemple, si la branche à nettoyer est master, on va créer une nouvelle branche master2 à partir de master :

git checkout -b master2 master

Identifier les fichiers à supprimer

Avant de lancer le nettoyage, il faut d’abord identifier les fichiers à supprimer. Dans le cas d’un projet .NET, il s’agit bien souvent du contenu des répertoires bin et obj (où qu’ils se trouvent) et packages (généralement à la racine de la solution), on va donc partir sur cette hypothèse pour l’instant. Les patterns des fichiers à supprimer sont donc les suivants :

  • **/bin/**
  • **/obj/**
  • packages/**

Nettoyer la branche : la commande git filter-branch

La commande Git qui va nous permettre de supprimer les fichiers indésirables s’appelle filter-branch. Elle est décrite dans le livre Pro Git comme "l’option nucléaire", car elle est très puissante et potentiellement dévastatrice… elle est donc à manipuler avec précaution.

Le principe de cette commande est de reprendre chaque commit de la branche, lui appliquer un filtre, et le recommiter avec les modifications causées par le filtre. Il existe plusieurs types de filtre, par exemple :

  • --msg-filter : permet de réécrire les messages des commits de la branche.
  • --tree-filter : permet de filtrer les fichiers au niveau de la copie de travail du dépôt (effectue un checkout de chaque commit, ce qui peut être assez long sur un gros dépôt)
  • --index-filter : permet de filtrer les fichiers au niveau de l’index (ne nécessite pas un checkout de chaque commit, donc plus rapide).

Dans notre scénario, --index-filter est parfaitement indiqué, vu qu’on souhaite simplement filtrer les fichiers par rapport à leur chemin. La commande filter-branch avec ce type de filtre s’utilise comme ceci :

git filter-branch --index-filter '<command>'

<command> désigne une commande bash qui sera exécutée pour chaque commit de la branche à réécrire. Dans le cas qui nous intéresse, ce sera simplement un appel à git rm pour supprimer de l’index les fichiers indésirables :

git filter-branch --index-filter 'git rm --cached --ignore-unmatch **/bin/** **/obj/** packages/**'

Le paramètre --cached indique qu’on travaille sur l’index et non sur la copie de travail; --ignore-unmatch permet d’ignorer les cas où aucun fichier ne correspond au pattern spécifié. Par défaut, la commande s’applique uniquement à la branche courante.

Pour peu que la branche ait beaucoup d’historique, la commande peut prendre assez longtemps à s’exécuter, il faudra donc s’armer de patience… Une fois terminé, vous devriez avoir une branche contenant des commits identiques à ceux de la branche d’origine, mais sans les fichiers indésirables.

Cas plus complexes

Dans l’exemple ci-dessus, il n’y avait que 3 patterns de fichiers à supprimer, donc la commande était assez courte pour être écrite "inline". Mais s’il y en a beaucoup plus, ou si la logique à appliquer pour supprimer les fichiers est plus complexe, ça ne tient plus vraiment la route… le plus simple est donc d’écrire un script (bash) qui contient toutes les commandes nécessaires, et de passer ce script en paramètre de git filter-branch --index-filter.

Nettoyer uniquement à partir d’un commit spécifique

Dans l’exemple précédent, on applique filter-branch à la totalité de la branche. Mais il est également possible de ne l’appliquer qu’à partir d’un commit spécifique, en spécifiant une plage de commits :

git filter-branch --index-filter '<command>' <ref>..HEAD

Ici, <ref> désigne une référence de commit (SHA1, branche ou tag). Notez que la fin de la plage de commits doit forcément être HEAD : on ne peut pas réécrire le début ou le milieu d’une branche sans toucher aux commits suivants, puisque le SHA1 de chaque commit dépend du commit précédent.