Tag Archives: .net core

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 commence 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 !

Propriétés et éléments MSBuild partagés avec Directory.Build.props

Pour être honnête, je n’ai jamais vraiment aimé MSBuild jusqu’à récemment. Les fichiers de projet générés par Visual Studio étaient immondes, l’essentiel de leur contenu était redondant, il fallait décharger les projets pour les éditer, c’était mal documenté… Mais avec l’avènement de .NET Core et du nouveau format de projet, plus léger, MSBuild est devenu un bien meilleur outil.

MSBuild 15 a introduit une nouvelle fonctionnalité assez sympa : les imports implicites (je ne sais pas si c’est le nom officiel, mais c’est celui que j’utiliserai). En gros, vous pouvez créer un fichier nommmé Directory.Build.props n’importe où dans votre solution, et il sera automatiquement importé par tous les projets sous le répertoire qui contient ce fichier. Cela permet de partager très facilement des propriétés et éléments communs entre les projets. Cette fonctionnalité est décrite en détail sur cette page.

Par exemple, si vous voulez partager certaines métadonnées entre plusieurs projets, créer simplement un fichier Directory.Build.props dans le dossier parent de vos projets :

<Project>

  <PropertyGroup>
    <Version>1.2.3</Version>
    <Authors>John Doe</Authors>
  </PropertyGroup>

</Project>

On peut aussi faire des choses plus intéressantes, comme activer et configurer StyleCop pour tous les projets :

<Project>

  <PropertyGroup>
    <!-- Common ruleset shared by all projects -->
    <CodeAnalysisRuleset>$(MSBuildThisFileDirectory)MyRules.ruleset</CodeAnalysisRuleset>
  </PropertyGroup>

  <ItemGroup>
    <!-- Add reference to StyleCop analyzers to all projects  -->
    <PackageReference Include="StyleCop.Analyzers" Version="1.0.2" />
    
    <!-- Common StyleCop configuration -->
    <AdditionalFiles Include="$(MSBuildThisFileDirectory)stylecop.json" />
  </ItemGroup>

</Project>

Notez que la variable $(MSBuildThisFileDirectory) fait référence au répertoire contenant le fichier MSBuild courant. Une autre variable utile est $(MSBuildProjectDirectory), qui fait référence au répertoire du projet en cours de génération.

MSBuild cherche le fichier Directory.Build.props en partant du répertoire du projet et en remontant les dossiers jusqu’à ce qu’il trouve un fichier correspondant, puis s’arrête de chercher. Dans certains cas, il peut être utile de définir des propriétés communes à tous les projets, et d’en ajouter d’autres qui ne s’appliquent qu’à un sous-répertoire. Pour faire cela, il faut que le fichier Directory.Build.props le plus "profond" importe explicitement celui du répertoire parent :

  • (rootDir)/Directory.build.props:
<Project>

  <!-- Properties common to all projects -->
  <!-- ... -->
  
</Project>
  • (rootDir)/tests/Directory.build.props:
<Project>

  <!-- Import parent Directory.build.props -->
  <Import Project="../Directory.Build.props" />

  <!-- Properties common to all test projects -->
  <!-- ... -->
  
</Project>

La documentation mentionne une autre approche, utilisant la fonction GetPathOfFileAbove, mais cela ne semblait pas fonctionner quand j’ai essayé… De toute façon, je pense qu’il est plus simple d’utiliser un chemin relatif, on risque moins de se tromper.

Utiliser les imports implicites apporte quelques avantages :

  • des fichiers de projet plus petits, puisque les propriétés et éléments identiques peuvent être factorisés dans des fichiers communs
  • un seul point de référence : si tous les projets référencent le même package NuGet, la version à référencer est définie à un seul endroit; il n’est plus possible d’avoir des incohérences.

Cette approche a cependant un inconvénient : Visual Studio n’a pas la notion de l’origine d’une variable ou d’un élément, donc si vous changez une propriété ou une référence de package dans l’IDE (via les pages de propriétés du projet ou le gestionnaire de packages NuGet), elle sera modifiée dans le fichier de projet lui-même, et non dans le fichier Directory.Build.props. De mon point de vue, ce n’est pas un gros problème, parce que j’ai pris l’habitude d’éditer les projets manuellement plutôt que d’utiliser l’IDE, mais ça peut être gênant pour certaines personnes.

Si vous voulez un exemple réel de l’utilisation de cette technique, jetez un oeil au repository de FakeItEasy, où nous utilisons plusieurs fichiers Directory.Build.props pour garder les projets propres et concis.

Notez que vous pouvez également créer un fichier Directory.Build.targets, suivant les mêmes principes, pour définir des cibles communes.

Amélioration des performances de Linq dans .NET Core

Depuis le temps qu’on en parle, vous êtes sans doute au courant que Microsoft a publié une version open-source et multiplateforme de .NET : .NET Core. Cela signifie que vous pouvez maintenant créer et exécuter des applications .NET sous Linux ou macOS. C’est déjà assez cool en soi, mais ça ne s’arrête pas là : .NET Core apporte aussi beaucoup d’améliorations à la Base Class Library.

Par exemple, Linq est plus rapide dans .NET Core. J’ai fait un petit benchmark pour comparer les performances de certaines méthodes couramment utilisées de Linq, et les résultats sont assez impressionnants :


Le code complet de ce benchmark est disponible ici. Comme pour tous les microbenchmarks, les résultats ne sont pas à prendre pour argent comptant, mais ça donne quand même une idée des améliorations.

Certaines lignes de ce tableau sont assez surprenantes. Comment Select peut-il s’exécuter 5000 fois presque instantanément ? D’abord, il faut garder à l’esprit que la plupart des opérateurs Linq ont une exécution différée : ils ne font rien tant que qu’on n’énumère pas le résultat, donc quelque chose comme array.Select(i => i * i) s’exécute en temps constant (ça renvoie juste une séquence "lazy", sans consommer les éléments de array). C’est pourquoi j’ai ajouté un appel à Count() dans mon benchmark, pour m’assurer que le résultat est bien énuméré.

Pourtant, ce test s’exécute 5000 fois en 413µs… Cela est possible grâce à une optimisation dans l’implémentation .NET Core de Select et Count. Une propriété utile de Select est qu’il produit une séquence avec le même nombre d’éléments que la séquence source. Dans .NET Core, Select tire parti de cette propriété. Si la source est une ICollection<T> ou un tableau, il renvoie un objet énumérable qui garde la trace du nombre d’élément. Count peut ensuite récupérer directement la valeur et la renvoyer sans énumérer la séquence, ce qui donne un résultat en temps constant. L’implémentation de .NET 4.6.2, en revanche, énumère naïvement la séquence produite par Select, ce qui prend beaucoup plus longtemps.

Il est intéressant de noter que dans cette situation, .NET Core ne va pas exécuter la projection spécifiée dans Select, c’est donc un breaking change par rapport à .NET 4.6.2 pour du code qui dépend des effets de bord de la projection. Cela a été identifié comme un problème, qui a déjà été corrigé sur la branche master, donc la prochaine version n’aura plus cette optimisation et exécutera bien la projection sur chaque élément.

OrderBy suivi de Count() s’exécute aussi presque instantanément… Les développeurs de Microsoft auraient-ils inventé un algorithme de tri en O(1) ? Malheureusement, non… L’explication est la même que pour Select : puisque OrderBy préserve le nombre d’éléments, l’information est conservée pour pouvoir être utilisée par Count, et il n’est pas nécessaire de vraiment trier les éléments avant d’obtenir leur nombre.

Bon, ces cas étaient des améliorations assez évidentes (qui ne vont d’ailleurs pas rester, comment mentionné précédemment). Mais que dire du cas de SelectAndToArray ? Dans ce test, j’appelle ToArray() sur le résultat de Select, pour être certain que la projection soit bien exécutée sur chaque élément : cette fois, on ne triche pas. Pourtant, l’implémentation de .NET Core arrive encore à être 68% plus rapide que celle du framework .NET classique dans ce scénario. La raison est liée aux allocations : puisque l’implémentation .NET Core sait combien il y a d’éléments dans le résultat de Select, elle peut directement allouer un tableau de la bonne taille. Dans .NET 4.6.2, cette information n’est pas disponible, donc il faut commencer par allouer un petit tableau, y copier des éléments jusqu’à ce qu’il soit plein, puis allouer un tableau plus grand, y copier les données du premier tableau, y copier les éléments suivants de la séquence jusqu’à ce qu’il soit plein, etc. Cela cause de nombreuses allocations et copies, d’où la performance dégradée. Il y a quelques années, j’avais suggéré des versions optimisées de ToList et ToArray, auxquelles on passait le nombre d’éléments. L’implémentation de .NET Core fait grosso modo la même chose, sauf qu’il n’y a pas besoin de passer la taille manuellement, elle est transmise à travers la chaine de méthodes Linq.

Where et WhereAndToArray sont tous les deux environ 8% plus rapides sur .NET Core 1.1. En examinant le code, (.NET 4.6.2, .NET Core), je ne vois pas de différences évidentes qui pourraient expliquer les meilleures performances, donc je soupçonne qu’elles sont dues à des améliorations du runtime. Dans ce cas, ToArray ne connait pas le nombre d’éléments dans la séquence (puisqu’on ne peut pas prédire le nombre d’éléments que Where va laisser passer), il ne peut donc pas utiliser la même optimisation qu’avec Select, et doit construire le tableau en utilisant l’approche plus lente.

On a déjà parlé du cas de OrderBy + Count(), qui n’était pas une comparaison équitable puisque l’implémentation de .NET Core ne triait pas réellement la séquence. Le cas de OrderByAndToArray est plus intéressant, car le tri ne peut pas être évité. Et dans ce cas, l’implémentation de .NET Core est un peu plus lente que celle de .NET 4.6.2. Je ne sais pas très bien pourquoi; là aussi, l’implémentation est très similaire, à part quelques refactorisations dans la version .NET Core.

Au final, Linq a l’air globalement plus rapide dans .NET Core que dans 4.6.2, ce qui est une très bonne nouvelle. Bien sûr, je n’ai benchmarké qu’un nombre limité de scénarios, mais cela montre quand même que l’équipe .NET Core travaille dur pour optimiser tout ce qu’ils peuvent.