Fin de mon blog en français

Chers lecteurs,

J’ai créé ce blog en français il y a 10 ans environ. Quelque temps plus tard, j’en ai créé une version anglaise pour toucher une plus large audience. Pendant des années, j’ai publié tous mes articles dans les deux langues. Mais l’audience de la version française n’a jamais vraiment décollé, et le fait de devoir traduire tous mes articles nuit à ma motivation pour écrire. J’ai donc décidé d’arrêter pour consacrer mon énergie à la version anglaise, qui est lue par beaucoup plus de monde (environ 20 fois plus).

A court ou moyen terme, les articles déjà publiés resteront accessibles, mais je n’en publierai plus de nouveau. A plus long terme, je ne peux pas garantir qu’ils resteront accessibles à cette adresse, mais je tâcherai de les héberger ailleurs.

Je continuerai cependant à publier sur la version anglaise du blog. Et si vraiment vous ne pouvez pas lire en anglais, sachez que je publie certains de mes articles en français sur le blog de mon employeur Infinite Square.

Merci à ceux qui m’ont lu fidèlement pendant toutes ces années. J’espère vous retrouver sur mon blog en anglais, ou ailleurs !

Bonne continuation à tous !

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.

Meilleure gestion du timeout avec HttpClient

Le problème

Si vous avez l’habitude d’utiliser HttpClient pour appeler des APIs REST ou transférer des fichiers, vous avez peut-être déjà pesté contre la façon dont cette classe gère le timeout. Il y a en effet deux problèmes majeurs dans la gestion du timeout par HttpClient :

  • Le timeout est défini de façon globale, et s’applique à toutes les requêtes, alors qu’il serait plus pratique de pouvoir le définir individuellement pour chaque requête.
  • L’exception levée quand le temps imparti est écoulé ne permet pas de déterminer la cause de l’erreur. En effet, en cas de timeout, on s’attendrait à recevoir une TimeoutException, non ? Eh bien, surprise, c’est une TaskCanceledException qui est levée! Du coup, impossible de savoir si la requête a réellement été annulée, ou si le timeout est écoulé.

Heureusement, tout n’est pas perdu, et la flexibilité de HttpClient va permettre de compenser cette petite erreur de conception…

On va donc implémenter un mécanisme permettant de pallier les deux problèmes mentionnés plus haut. On souhaite donc :

  • pouvoir spécifier un timeout différent pour chaque requête
  • recevoir une TimeoutException plutôt que TaskCanceledException en cas de timeout

Spécifier le timeout pour une requête

Voyons d’abord comment associer une valeur de timeout à une requête. La classe HttpRequestMessage a une propriété Properties, qui est un dictionnaire dans lequel on peut mettre ce qu’on veut. On va donc l’utiliser pour stocker le timeout pour une requête, et pour faciliter les choses, on va créer des méthodes d’extension pour accéder à la valeur de façon fortement typée :

public static class HttpRequestExtensions
{
    private static string TimeoutPropertyKey = "RequestTimeout";

    public static void SetTimeout(
        this HttpRequestMessage request,
        TimeSpan? timeout)
    {
        if (request == null)
            throw new ArgumentNullException(nameof(request));

        request.Properties[TimeoutPropertyKey] = timeout;
    }

    public static TimeSpan? GetTimeout(this HttpRequestMessage request)
    {
        if (request == null)
            throw new ArgumentNullException(nameof(request));

        if (request.Properties.TryGetValue(
                TimeoutPropertyKey,
                out var value)
            && value is TimeSpan timeout)
            return timeout;
        return null;
    }
}

Rien de très compliqué ici, le timeout est une valeur optionnelle de type TimeSpan. Évidemment il n’y a pour l’instant aucun code pour tenir compte du timeout associé à une requête…

Handler HTTP

L’architecture de HttpClient est basée sur un système de pipeline : chaque requête est envoyée à travers une chaîne de handlers (de type HttpMessageHandler), et la réponse repasse en sens inverse à travers cette chaîne. Cet article rentre un peu plus dans le détail si vous voulez en savoir plus. Nous allons donc insérer dans le pipeline notre propre handler, qui sera chargé de la gestion du timeout.

Notre handler va hériter de DelegatingHandler, un type de handler conçu pour être chaîné à un autre handler. Pour implémenter un handler, il faut redéfinir la méthode SendAsync. Une implémentation minimale ressemblerait à ceci :

class TimeoutHandler : DelegatingHandler
{
    protected async override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        return await base.SendAsync(request, finalCancellationToken);
    }
}

L’appel à base.SendAsync va simplement passer la requête au handler suivant. Du coup, pour l’instant notre implémentation ne sert à rien, mais on va l’enrichir petit à petit.

Prendre en compte le timeout pour une requête

Ajoutons d’abord à notre classe une propriété DefaultTimeout, qui sera utilisée pour les requêtes dont le timeout n’est pas explicitement défini :

public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(100);

La valeur par défaut de 100 secondes est la même que celle de HttpClient.Timeout.

Pour implémenter le timeout, on va récupérer la valeur associée à la requête (ou à défaut DefaultTimeout), créer un CancellationToken qui sera annulé après la durée du timeout, et passer ce CancellationToken au handler suivant : la requête sera donc annulée après l’expiration de ce délai (ce qui correspond au comportement par défaut de HttpClient).

Pour créer un CancellationToken dont on peut contrôler l’annulation, on utilise un objet CancellationTokenSource, qu’on va créer comme ceci en fonction du timeout de la requête :

private CancellationTokenSource GetCancellationTokenSource(
    HttpRequestMessage request,
    CancellationToken cancellationToken)
{
    var timeout = request.GetTimeout() ?? DefaultTimeout;
    if (timeout == Timeout.InfiniteTimeSpan)
    {
        // No need to create a CTS if there's no timeout
        return null;
    }
    else
    {
        var cts = CancellationTokenSource
            .CreateLinkedTokenSource(cancellationToken);
        cts.CancelAfter(timeout);
        return cts;
    }
}

Deux choses à noter ici :

  • si le timeout de la requête est infini, on ne crée pas de CancellationTokenSource; il ne servirait à rien puisqu’il ne serait jamais annulé, on économise donc une allocation inutile.
  • Dans le cas contraire, on crée un CancellationTokenSource qui sera annulé après expiration du timeout (CancelAfter). Notez que ce CTS est lié au CancellationToken reçu en paramètre de SendAsync: il sera donc annulé soit par après expiration du timeout, soit quand ce CancellationToken sera lui-même annulé. Je vous renvoie à cet article pour plus d’infos à ce sujet.

Enfin, modifions la méthode SendAsync pour prendre en compte le CancellationTokenSource qu’on a créé :

protected async override Task<HttpResponseMessage> SendAsync(
    HttpRequestMessage request,
    CancellationToken cancellationToken)
{
    using (var cts = GetCancellationTokenSource(request, cancellationToken))
    {
        return await base.SendAsync(
            request,
            cts?.Token ?? cancellationToken);
    }
}

On récupère le CTS, et on passe son token à base.SendAsync. Notez qu’on utilise cts?.Token puisque GetCancellationTokenSource peut renvoyer null; si c’est le cas, on utilise le CancellationToken reçu en paramètre.

À ce stade, on a un handler qui permet de spécifier un timeout différent pour chaque requête. Mais il reste le problème de l’exception renvoyée en cas de timeout, qui est encore une TaskCanceledException… Mais on va régler ça très facilement!

Lever la bonne exception

En effet, il suffit d’intercepter l’exception TaskCanceledException (ou plutôt sa classe de base, OperationCanceledException), et de vérifier si le CancellationToken reçu en paramètre est annulé: si oui, l’annulation vient de l’appelant, et on laisse l’exception se propager normalement; si non, c’est qu’elle est causée par le timeout, et dans ce cas on lance une TimeoutException. Voilà donc notre méthode SendAsync finale:

protected async override Task<HttpResponseMessage> SendAsync(
    HttpRequestMessage request,
    CancellationToken cancellationToken)
{
    using (var cts = GetCancellationTokenSource(request, cancellationToken))
    {
        try
        {
            return await base.SendAsync(
                request,
                cts?.Token ?? cancellationToken);
        }
        catch(OperationCanceledException)
            when (!cancellationToken.IsCancellationRequested)
        {
            throw new TimeoutException();
        }
    }
}

On utilise ici un filtre d’exception : cela évite d’intercepter OperationCanceledException si on doit la laisser se propager; on évite ainsi de dérouler la pile inutilement.

Notre handler est terminé, voyons maintenant comment l’utiliser.

Utilisation du handler

Quand on crée un HttpClient, il est possible de passer en paramètre du constructeur le premier handler du pipeline. Si on ne spécifie rien, par défaut c’est un HttpClientHandler qui est créé; ce handler envoie directement les requêtes vers le réseau. Pour utiliser notre nouveau TimeoutHandler, on va le créer, lui attacher un HttpClientHandler comme handler suivant, et le passer au HttpClient:

var handler = new TimeoutHandler
{
    InnerHandler = new HttpClientHandler()
};

using (var client = new HttpClient(handler))
{
    client.Timeout = Timeout.InfiniteTimeSpan;
    ...
}

Notez qu’il faut désactiver le timeout du HttpClient en lui donnant une valeur infinie, sinon le comportement par défaut viendra interférer avec notre handler.

Essayons maintenant d’envoyer une requête avec un timeout de 5 secondes vers un serveur qui met trop longtemps à répondre:

var request = new HttpRequestMessage(HttpMethod.Get, "http://foo/");
request.SetTimeout(TimeSpan.FromSeconds(5));
var response = await client.SendAsync(request);

Si le serveur n’a pas répondu au bout de 5 secondes, on obtiendra bien une TimeoutException, et non une TaskCanceledException.

Vérifions maintenant que le cas de l’annulation marche toujours correctement. Pour cela, on va passer un CancellationToken qui sera annulé au bout de 2 secondes (avant expiration du timeout, donc) :

var request = new HttpRequestMessage(HttpMethod.Get, "http://foo/");
request.SetTimeout(TimeSpan.FromSeconds(5));
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
var response = await client.SendAsync(request, cts.Token);

Et on obtient bien une TaskCanceledException!

En implémentant notre propre handler HTTP, on a donc pu régler notre problème de départ et avoir une gestion intelligente du timeout.

Le code complet de cet article est disponible ici.

Transformer les templates T4 pendant la build, et passer des variables du projet

T4 (Text Template Transformation Toolkit) est un excellent outil pour générer du code ; on peut, par exemple, créer des classes POCO à partir des tables d’une base de données, générer du code répétitif, etc. Dans Visual Studio, les fichiers T4 (extension .tt) sont associés au custom tool TextTemplatingFileGenerator, qui transforme un template pour générer un fichier de sortie à chaque fois qu’on enregistre le template. Mais il arrive que ce ne soit pas suffisant, et qu’on souhaite regénérer les sorties des templates à chaque build. C’est assez facile à mettre en œuvre, mais il y a quelques écueils à éviter.

Transformer les templates lors du build

Si votre projet est un csproj ou vbproj "classique" (c’est-à-dire pas un projet .NET Core "SDK-style"), les choses sont assez simples et bien documentées sur cette page.

Déchargez votre projet, et ouvrez le dans l’éditeur. Ajoutez le PropertyGroup suivant vers le début du fichier :

<PropertyGroup>
    <!-- 15.0 is for VS2017, adjust if necessary -->
    <VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">15.0</VisualStudioVersion>
    <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
    <!-- This is what will cause the templates to be transformed when the project is built (default is false) -->
    <TransformOnBuild>true</TransformOnBuild>
    <!-- Set to true to force overwriting of read-only output files, e.g. if they're not checked out (default is false) -->
    <OverwriteReadOnlyOutputFiles>true</OverwriteReadOnlyOutputFiles>
    <!-- Set to false to transform files even if the output appears to be up-to-date (default is true)  -->
    <TransformOutOfDateOnly>false</TransformOutOfDateOnly>
</PropertyGroup>

Et ajoutez l’Import suivant à la fin, après l’import de Microsoft.CSharp.targets ou Microsoft.VisualBasic.targets :

<Import Project="$(VSToolsPath)\TextTemplating\Microsoft.TextTemplating.targets" />

Rechargez votre projet, et le tour est joué. Générer le projet devrait maintenant transformer les templates et regénérer leur sortie.

Projets "SDK-style"

Si vous utilisez le nouveau format de projet du SDK .NET Core (souvent appelé de façon informelle projet "SDK-style"), l’approche décrite ci-dessus nécessite un petit changement pour fonctionner. C’est parce que le fichier de cibles par défaut (Sdk.targets dans le SDK .NET Core) est maintenant importé implicitement à la toute fin du projet, il n’est donc pas possible d’importer les cibles T4 après les cibles par défaut. Du coup la variable BuildDependsOn, qui est modifiée par les cibles T4, est écrasée par les cibles par défaut, et la cible TransformAll ne s’exécute plus avant la cible Build.

Heureusement, il y a un workaround : vous pouvez importer les cibles par défaut explicitement, et importer les cibles T4 après celles-ci :

<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk" />
<Import Project="$(VSToolsPath)\TextTemplating\Microsoft.TextTemplating.targets" />

Notez que cela causera un avertissement MSBuild dans la sortie de la build (MSB4011), parce que Sdk.targets est importé deux fois ; cet avertissement peut être ignoré sans risque.

Passer des variables MSBuild aux templates

À un moment donné, il est possible que la logique de génération de code devienne trop complexe pour rester entièrement dans le template T4. Vous pourriez vouloir en extraire une partie dans un assembly utilitaire, qui sera référencé depuis le template comme ceci :

<#@ assembly name="../CodeGenHelper/bin/Release/net462/CodeGenHelper.dll" #>

Mais spécifier le chemin de l’assembly de cette façon n’est pas très pratique… par exemple, si vous êtes actuellement sur la configuration Debug, la version Release de CodeGenHelper.dll ne sera pas forcément à jour. Heureusement, le custom tool TextTemplatingFileGenerator de Visual Studio supporte l’utilisation de variables MSBuild du projet, il est donc possible de faire ceci :

<#@ assembly name="$(SolutionDir)/CodeGenHelper/bin/$(Configuration)/net462/CodeGenHelper.dll" #>

Les variables $(SolutionDir) et $(Configuration) seront remplacées par leurs valeurs respectives. Si vous enregistrez le template, il sera bien transformé en utilisant l’assembly CodeGenHelper.dll. Pratique !

Mais il y a un hic… Si vous avez configuré votre projet pour transformer les templates lors de la build, comme décrit plus haut, la build va maintenant échouer, avec une erreur comme celle-ci :

System.IO.FileNotFoundException: Could not find a part of the path ‘C:\Path\To\The\Project\$(SolutionDir)\CodeGenHelper\bin\$(Configuration)\net462\CodeGenHelper.dll’.

Vous avez remarqué les variables $(SolutionDir) et $(Configuration) dans le chemin ? Elles n’ont pas été remplacées ! C’est parce que la cible MSBuild qui transforme les templates et le custom tool TextTemplatingFileGenerator n’utilisent pas le même moteur de transformation de texte. Et malheureusement, celui utilisé par MSBuild ne supporte pas nativement les variables MSBuild… un comble !

Cependant tout n’est pas perdu. Il suffit de spécifier explicitement les variables que vous voulez passer en temps que paramètres T4. Éditez à nouveau votre fichier projet, et créez un nouveau ItemGroup avec les éléments suivants :

<ItemGroup>
    <T4ParameterValues Include="SolutionDir">
        <Value>$(SolutionDir)</Value>
        <Visible>False</Visible>
    </T4ParameterValues>
    <T4ParameterValues Include="Configuration">
        <Value>$(Configuration)</Value>
        <Visible>False</Visible>
    </T4ParameterValues>
</ItemGroup>

L’attribut Include est le nom du paramètre tel qu’il sera passé au moteur de transformation de texte. L’élément Value, comme son nom l’indique, est la valeur du paramètre. Et l’élément Visible permet d’éviter que l’élément T4ParameterValues n’apparaisse comme élément du projet dans l’explorateur de solution.

Avec ce changement, la build devrait à nouveau transformer les templates correctement.

Pour résumer, gardez à l’esprit que le custom tool TextTemplatingFileGenerator et la cible MSBuild de transformation de texte ont des mécanismes différents pour passer des variables aux templates :

  • TextTemplatingFileGenerator supporte uniquement les variables MSBuild du projet
  • MSBuild supporte uniquement T4ParameterValues

Donc si vous utilisez des variables dans votre template, et que vous voulez pouvoir transformer le template quand vous l’enregistrez dans Visual Studio et quand vous générez le projet, les variables doivent être définies à la fois comme variables MSBuild et comme T4ParameterValues.

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.

Tester et déboguer une bibliothèque depuis LINQPad

Cela faisait bien longtemps que je voulais bloguer à propos de LINQPad. Au cas où vous ne connaissez pas, LINQPad est un outil qui permet d’écrire et de tester du code très rapidement sans avoir besoin de créer un projet complet dans Visual Studio. Il supporte C#, VB.NET, F# et SQL. Il était initialement conçu comme un outil éducatif pour expérimenter avec LINQ (son auteur, Joe Albahari, l’avait développé pour accompagner son livre C# in a Nutshell), mais il est aussi extrêmement utile comme outil générique pour tester du code .NET.

J’utilise fréquemment LINQPad pour tester rapidement une bibliothèque sur laquelle je travaille. C’est très facile, il suffit de référencer l’assembly à tester et de l’utiliser normalement. Mais quand la bibliothèque ne se comporte pas comme prévu, il est souvent utile de pouvoir la déboguer pas à pas… Il s’avère que c’est assez simple à faire à partir de LINQPad !

La version premium de LINQPad a un débogueur intégré, qui n’est pas aussi puissant que celui de Visual Studio, mais quand même utile pour déboguer les scripts LINQPad. Cependant, il ne permet pas de rentrer dans le code de la bibliothèque… Heureusement, il y a une astuce qui permet d’utiliser le débogueur de Visual Studio pour déboguer le script qui s’exécuter dans LINQPad.

Tout d’abord, ouvrez dans Visual Studio la bibliothèque à déboguer, si ce n’est pas déjà fait. Compilez le projet, et ajoutez une référence à l’assembly dans votre script LINQPad:

Add reference to the library

Ecrivez du code qui utilise votre bibliothèque:

Use your library from LINQPad

Et ajoutez cette ligne au début du script LINQPad:

Debugger.Launch();

Quand vous exécutez le script, un dialogue va s’affichez pour vous demander de choisir un débogueur:

Choose a debugger

Sélectionnez l’instance de Visual Studio dans laquelle votre solution est ouverte et cliquez OK. Cela va attacher le débogueur de Visual Studio au processus qui exécute le script LINQPad, et suspendre l’exécution au niveau de l’appel à Debugger.Launch():

Debugging in Visual Studio

Vous pouvez maintenant déboguer votre script LINQPad et votre bibliothèque. Vous pouvez mettre des points d’arrêt, rentrer dans les méthodes, ajouter des espions, etc, exactement comme quand vous déboguez une application normale !

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.

Parser du texte facilement en C# avec Sprache

Il y a quelques jours, j’ai découvert un petit bijou : Sprache. Le nom signifie "langage" en allemand. C’est une librairie très élégante et facile à utiliser pour créer des analyseurs de texte, à l’aide de parser combinators, qui sont une technique très courante en programmation fonctionnelle. Le concept théorique peut sembler un peu effrayant, mais comme vous allez le voir dans un instant, Sprache rend ça très accessible.

Analyse syntaxique

L’analyse syntaxique (parsing) est une tâche très courante, mais qui peut être laborieuse et où il est facile de faire des erreurs. Il existe de nombreuses approches :

  • analyse manuelle basée sur Split, IndexOf, Substring etc.
  • expressions régulières
  • parser codé à la main qui scanne les tokens dans une chaine
  • parser généré avec ANTLR ou un outil similaire
  • et certainement beaucoup d’autres qui m’échappent…

Aucune de ces options n’est très séduisante. Pour les cas simples, découper la chaine ou utiliser une regex peut suffire, mais ça devient vite ingérable pour des grammaires plus complexes. Construire un vrai parser à la main pour une grammaire non-triviale est loin d’être facile. ANTLR nécessite Java, un peu de connaissance, et se base sur de la génération de code, ce qui complique le process de build.

Heureusement, Sprache offre une alternative très intéressante. Il fournit de nombreux parsers et combinateurs prédéfinis qu’on peut utiliser pour définir une grammaire. Examinons pas à pas un cas concret : analyser le challenge dans le header WWW-Authenticate d’une réponse HTTP (j’ai dû faire un parser à la main pour ça récemment, et j’aurais aimé connaître Sprache à ce moment-là).

La grammaire

Le header WWW-Authenticate est envoyé par un serveur HTTP dans une réponse 401 (Non autorisé) pour indiquer au client comment s’authentifier :

# Challenge Basic
WWW-Authenticate: Basic realm="FooCorp"

# Challenge OAuth 2.0 après l'envoi d'un token expiré
WWW-Authenticate: Bearer realm="FooCorp", error=invalid_token, error_description="The access token has expired"

Ce qu’on veut parser est le challenge, c’est-à-dire la valeur du header. Nous avons donc un mécanisme d’authentification ou "scheme" (Basic, Bearer), suivi d’un ou plusieurs paramètres (paires nom-valeur). Ça semble assez simple, on pourrait sans doute juste découper selon les ',', puis selon les '=' pour obtenir les valeurs… mais les guillemets compliquent les choses, car une chaine entre guillemets peut contenir les caractères ',' ou '='. De plus, les guillemets sont optionnels si la valeur du paramètre est un simple token, donc on ne peut pas compter sur le fait que les guillemets seront présents (ou pas). Clairement, si on veut parser ça de façon fiable, il va falloir regarder les specs de plus près…

Le header WWW-Authenticate est décrit en détails dans la RFC-2617. La grammaire ressemble à ceci, sous une forme que la spec appelle "forme Backus-Naur augmentée" (voir RFC 2616 §2.1) :

# from RFC-2617 (HTTP Basic and Digest authentication)

challenge      = auth-scheme 1*SP 1#auth-param
auth-scheme    = token
auth-param     = token "=" ( token | quoted-string )

# from RFC2616 (HTTP/1.1)

token          = 1*<any CHAR except CTLs or separators>
separators     = "(" | ")" | "<" | ">" | "@"
               | "," | ";" | ":" | "\" | <">
               | "/" | "[" | "]" | "?" | "="
               | "{" | "}" | SP | HT
quoted-string  = ( <"> *(qdtext | quoted-pair ) <"> )
qdtext         = <any TEXT except <">>
quoted-pair    = "\" CHAR

Nous avons donc quelques règles de grammaire, voyons comment on peut les encoder en C# à l’aide de Sprache, et les utiliser pour analyser le challenge.

Parser les tokens

Commençons par les parties les plus simples de la grammaire : les tokens ("jetons"). Un token est défini comme un ou plusieurs caractères qui ne sont ni des caractères de contrôle, ni des séparateurs.

Nous allons définir nos règles dans une classe Grammar. Commençons pour définir certaines classes de caractères :

static class Grammar
{
    private static readonly Parser<char> SeparatorChar =
        Parse.Chars("()<>@,;:\\\"/[]?={} \t");

    private static readonly Parser<char> ControlChar =
        Parse.Char(Char.IsControl, "Control character");

}
  • Chaque règle est déclarée comme un Parser<T> ; puisque ces règles valident des caractères seuls, elles sont de type Parser<char>.
  • La classe Parse de Sprache expose des primitives d’analyse et des combinateurs.
  • Parse.Chars valide n’importe quel caractère de la chaine spécifiée, on l’utilise pour spécifier la liste des caractères de séparation.
  • La surcharge de Parse.Char qu’on utilise ici prend un prédicat qui sera appelé pour valider un caractère, et une description de cette classe de caractères. Ici on utilise la méthode System.Char.IsControl comme prédicat pour identifier les caractères de contrôle.

Définissons maintenant une classe de caractères TokenChar, qui correspond aux caractères qui peuvent être inclus dans un token. Selon la RFC, il s’agit de n’importe quel caractère qui n’appartient pas aux deux classes précédemment définies :

    private static readonly Parser<char> TokenChar =
        Parse.AnyChar
            .Except(SeparatorChar)
            .Except(ControlChar);
  • Parse.AnyChar, comme son nom l’indique, valide n’importe quel caractère.
  • Except permet de spécifier des exceptions, c’est à dire des règles qui ne doivent pas valider le caractère.

Enfin, un token est une séquence d’un ou plusieurs de ces caractères :

    private static readonly Parser<string> Token =
        TokenChar.AtLeastOnce().Text();
  • Un token est une chaine, donc la règle pour un token est de type Parser<string>.
  • AtLeastOnce() signifie une ou plusieurs répétitions, et puisque TokenChar est un Parser<char>, AtLeastOnce() renvoie un Parser<IEnumerable<char>>.
  • Text() combine la séquence de caractères en une chaine, et renvoie donc un Parser<string>

Nous voilà donc capables de parser un token. Mais ce n’est qu’un premier pas, il nous reste encore pas mal de travail…

Parser les chaines entre guillemets

La RFC définit une chaine entre guillemets (quoted string) comme une séquence de :

  • un guillemet qui ouvre la chaine
  • n’importe quel nombre de l’un de ces éléments :
    • un "qdtext", c’est-à-dire n’importe quel caractère sauf un guillemet
    • un "quoted pair", c’est-à-dire n’importe quel caractère précédé d’un backslash (c’est utilisé pour échapper les guillemets à l’intérieur d’une chaine)
  • un guillemet qui ferme la chaine

Écrivons donc les règles pour "qdtext" et "quoted pair" :

    private static readonly Parser<char> DoubleQuote = Parse.Char('"');
    private static readonly Parser<char> Backslash = Parse.Char('\\');

    private static readonly Parser<char> QdText =
        Parse.AnyChar.Except(DoubleQuote);

    private static readonly Parser<char> QuotedPair =
        from _ in Backslash
        from c in Parse.AnyChar
        select c;

La règle QdText se passe d’explication, mais QuotedPair est plus intéressante… Comme vous pouvez le voir, ça ressemble à une requête Linq : c’est comme ça qu’on spécifie une séquence avec Sprache. Cette requête-là signifie : prendre un backslash (qu’on nomme _ parce qu’on va l’ignorer) suivi de n’importe quel caractère nommé c, et renvoyer juste c (les "quoted pairs" ne sont pas vraiment des séquences d’échappement comme en C, Java ou C#, donc "\n" n’est pas interprété comme "nouvelle ligne", mais simplement comme "n").

On peut donc maintenant écrire la règle pour une chaine entre guillemets :

    private static readonly Parser<string> QuotedString =
        from open in DoubleQuote
        from text in QuotedPair.Or(QdText).Many().Text()
        from close in DoubleQuote
        select text;
  • La méthode Or indique un choix entre deux parsers. QuotedPair.Or(QdText) essaie de valider une "quoted pair", et si cela échoue, il essaie de valider un "qdtext" à la place.
  • Many() indique un nombre quelconque de répétitions.
  • Text() combine les caractères en une chaine.
  • on sélectionne juste text, car on n’a plus besoin des guillemets (ils ne servaient qu’à délimiter la chaine).

Nous avons maintenant toutes les briques de bases, on va donc pouvoir passer à des règles de plus haut niveau.

Parser les paramètres du challenge

Un challenge est constitué d’un scheme d’authentification suivi d’un ou plusieurs paramètres. Le scheme est trivial (c’est juste un token), donc commençons par parser les paramètres.

Bien que la RFC n’ait pas de règle nommée pour ça, définissons-nous une règle pour les valeurs des paramètres. La valeur peut être soit un token, soit une chaine entre guillemets :

    private static readonly Parser<string> ParameterValue =
        Token.Or(QuotedString);

Puisqu’un paramètre est une élément composite (nom et valeur), déclarons une classe pour le représenter :

class Parameter
{
    public Parameter(string name, string value)
    {
        Name = name;
        Value = value;
    }
    
    public string Name { get; }
    public string Value { get; }
}

Le T de Parser<T> n’est pas limité aux caractères ou aux chaines, ça peut être n’importe quel type. La règle pour parser les paramètres sera donc de type Parser<Parameter> :

    private static readonly Parser<char> EqualSign = Parse.Char('=');

    private static readonly Parser<Parameter> Parameter =
        from name in Token
        from _ in EqualSign
        from value in ParameterValue
        select new Parameter(name, value);

Ici on prend un token (le nom du paramètre), suivi du signe '=', suivi d’une valeur de paramètre, et on combine le nom et la valeur en une instance de Parameter.

Analysons maintenant une séquence d’un ou plusieurs caractères. Les paramètres sont séparés par des virgules, avec des caractères d’espacement optionnels avant et après la virgule (chercher "#rule" dans la RFC 2616 §2.1). La grammaire pour les listes autorise plusieurs virgules successives sans éléments entre elles, par exemple item1 ,, item2,item3, ,item4, donc la règle pour le séparateur de liste peut être écrite comme ceci :

    private static readonly Parser<char> Comma = Parse.Char(',');

    private static readonly Parser<char> ListDelimiter =
        from leading in Parse.WhiteSpace.Many()
        from c in Comma
        from trailing in Parse.WhiteSpace.Or(Comma).Many()
        select c;

On valide juste la première virgule, le reste peut être n’importe quel nombre de virgules et de caractères d’espacement. On renvoie la virgule parce qu’il faut bien renvoyer quelque chose, mais on ne l’utilisera pas (dans un langage fonctionnel on aurait pu renvoyer le type unit à la place).

On peut maintenant analyser une séquence de paramètres comme ceci :

    private static readonly Parser<Parameter[]> Parameters =
        from first in Parameter.Once()
        from others in (
            from _ in ListDelimiter
            from p in Parameter
            select p).Many()
        select first.Concat(others).ToArray();

Mais c’est un peu alambiqué… heureusement Sprache fournit une approche plus facile avec la méthode DelimitedBy :

    private static readonly Parser<Parameter[]> Parameters =
        from p in Parameter.DelimitedBy(ListDelimiter)
        select p.ToArray();

Parser le challenge

On y est presque. On a maintenant tout ce qu’il nous faut pour parser le challenge complet. Déclarons d’abord une classe pour le représenter :

class Challenge
{
    public Challenge(string scheme, Parameter[] parameters)
    {
        Scheme = scheme;
        Parameters = parameters;
    }
    public string Scheme { get; }
    public Parameter[] Parameters { get; }
}

Et on peut enfin écrire la règle globale :

    public static readonly Parser<Challenge> Challenge =
        from scheme in Token
        from _ in Parse.WhiteSpace.AtLeastOnce()
        from parameters in Parameters
        select new Challenge(scheme, parameters);

Remarquez que j’ai déclaré cette règle comme publique, contrairement aux autres : c’est la seule qu’on a besoin d’exposer.

Utiliser le parseur

Notre parseur est terminé, il n’y a plus qu’à l’utiliser, ce qui est assez simple :

void ParseAndPrintChallenge(string input)
{
    var challenge = Grammar.Challenge.Parse(input);
    Console.WriteLine($"Scheme: {challenge.Scheme}");
    Console.WriteLine($"Parameters:");
    foreach (var p in challenge.Parameters)
    {
        Console.WriteLine($"- {p.Name} = {p.Value}");
    }
}

Avec le challenge OAuth 2.0 de l’exemple précédent, ce code produit la sortie suivante :

Scheme: Bearer
Parameters:
- realm = FooCorp
- error = invalid_token
- error_description = The access token has expired

S’il y a une erreur de syntaxe dans le texte en entrée, la méthode Parse lancera une ParseException avec un message décrivant où et pourquoi l’analyse a échoué. Par exemple, si j’enlève l’espace entre "Bearer" et "realm", j’obtiens l’erreur suivante :

Parsing failure: unexpected ‘=’; expected whitespace (Line 1, Column 12); recently consumed: earerrealm

Vous trouverez le code complet de cet article ici.

Conclusion

Comme vous le voyez, il est très facile avec Sprache de parser un texte complexe. Le code n’est pas particulièrement concis, mais il est complètement déclaratif ; il n’y pas de boucles, pas de conditions, pas de variables temporaires, pas d’état… Cela rend le code très facile à comprendre, et il peut facilement être comparé à la définition originale de la grammaire pour s’assurer de sa conformité. Sprache fournit aussi de bons retours en cas d’erreur, ce qui est assez difficile à implémenter dans un parseur écrit à la main.

Quoi de neuf dans FakeItEasy 3.0.0 ?

FakeItEasy est un framework de mocking populaire pour .NET, avec une API intuitive et facile à utiliser. Depuis environ un an, je suis un des principaux développeurs de FakeItEasy, avec Adam Ralph and Blair Conrad. Ça a été un vrai plaisir de travailler avec eux, et je me suis éclaté !

Aujourd’hui j’ai le plaisir d’annoncer la sortie de FakeItEasy 3.0.0, avec le support de .NET Core et quelques fonctionnalités utiles.

Voyons ce que cette nouvelle version apporte !

Support de .NET Core

En plus de .NET 4+, FakeItEasy supporte maintenant .NET Standard 1.6, vous pouvez donc l’utiliser dans vos projets .NET Core.

Notez qu’en raison de certaines limitations de .NET Standard 1.x, il y a quelques différences mineures avec la version .NET 4 de FakeItEasy :

  • Les fakes ne sont pas sérialisables avec la sérialisation binaire
  • Les fakes "auto-initialisés" (self-initializing fakes) ne sont pas supportés (c’est-à-dire fakeService = A.Fake<IService>(options => options.Wrapping(realService).RecordedBy(recorder))).

Un immense merci aux personnes qui ont rendu possible le support de .NET Core :

  • Jonathon Rossi, qui maintient le projet Castle.Core. FakeItEasy s’appuie beaucoup sur Castle.Core, il n’aurait donc pas été possible de supporter .NET Core sans que Castle.Core le supporte aussi.
  • Jeremy Meng de Microsoft, qui a fait l’essentiel du gros-œuvre pour porter FakeItEasy et Castle.Core vers .NET Core.

Analyseur

Support de VB.NET

L’analyseur FakeItEasy, qui avertit en cas d’usage incorrect de la librairie, supporte maintenant VB.NET en plus de C#.

Nouvelles fonctionnalités et améliorations

Syntaxe améliorée pour configurer des appels successifs au même membre

Quand on configure les appels vers un fake, cela crée des règles qui sont "empilées" les unes sur les autres, ce qui fait qu’on peut écraser une règle précédemment configurée. Combiné avec la possibilité de spécifier le nombre de fois qu’une règle doit s’appliquer, cela permet de configurer des choses comme "renvoie 42 deux fois, puis lance une exception". Jusqu’ici, pour faire cela il fallait configurer les appels dans l’ordre inverse, ce qui n’était pas très intuitif et obligeait à répéter la spécification de l’appel :

A.CallTo(() => foo.Bar()).Throws(new Exception("oops"));
A.CallTo(() => foo.Bar()).Returns(42).Twice();

FakeItEasy 3.0.0 introduit une nouvelle syntaxe pour rendre cela plus simple :

A.CallTo(() => foo.Bar()).Returns(42).Twice()
    .Then.Throws(new Exception("oops"));

Notez que si vous ne spécifiez pas le nombre de fois que la règle doit s’appliquer, elle s’appliquera indéfiniment jusqu’à ce qu’elle soit écrasée. Par conséquent, on ne peut utiliser Then qu’après Once(), Twice() ou NumberOfTimes(...).

C’est un breaking change au niveau de l’API, dans la mesure où la forme des interfaces de configuration a changé, mais à moins que vous ne manipuliez explicitement ces interfaces, vous ne devriez pas être affecté.

Support automatique pour l’annulation

Quand une méthode accepte un paramètre de type CancellationToken, elle doit généralement lancer une exception si on lui passe un token qui est déjà annulé. Jusqu’à maintenant, ce comportement devait être configuré manuellement. Dans FakeItEasy 3.0.0, les méthodes d’un fake lanceront une OperationCanceledException par défaut si on les appelle avec un token déjà annulé. Les méthodes asynchrones renverront une tâche annulée.

Techniquement, c’est également un breaking change, mais la plupart des utilisateurs ne devraient pas être affectés.

Lancer une exceptionde façon asynchrone

FakeItEasy permet de configurer une méthode pour qu’elle lance une exception grâce à la méthode Throws. Mais pour les méthodes asynchrones, il y a en fait deux façons de signaler une erreur :

  • lancer une exception de façon synchrone, avant même de renvoyer une tâche (c’est ce que fait Throws)
  • renvoyer une tâche échouée (cela devait être fait manuellement jusqu’à maintenant)

Dans certains cas la différence peut être importante pour l’appelant, s’il n’await pas directement la méthode asynchrone. FakeItEasy introduit une méthode ThrowsAsync pour configurer une méthode pour qu’elle renvoie une tâche échouée :

A.CallTo(() => foo.BarAsync()).ThrowsAsync(new Exception("foo"));

Configuration des setters de propriétés sur les fakes non-naturels

Les fakes non-naturels (c’est-à-dire Fake<T>) ont maintenant une méthode CallsToSet, qui fait la même chose que A.CallToSet sur les fakes naturels :

var fake = new Fake<IFoo>();
fake.CallsToSet(foo => foo.Bar).To(0).Throws(new Exception("The value of Bar can't be 0"));

API améliorée pour spécifier des attributs supplémentaires

L’API pour spécifier des attributs supplémentaires sur les fakes n’était pas très pratique; il fallait créer une collection de CustomAttributeBuilders, qui eux-mêmes devaient être créés en spécifiant le constructeur et les valeurs des arguments. La méthode WithAdditionalAttributes a été supprimée dans FakeItEasy 3.0.0 et remplacée par une méthode plus simple WithAttributes qui accepte des expressions :

var foo = A.Fake<IFoo>(x => x.WithAttributes(() => new FooAttribute()));

C’est un breaking change.

Autres changements notables

Dépréciation des fakes auto-initialisés (self-initializing fakes)

Les fakes "auto-initialisés" permettent d’enregistrer les appels faits sur un vrai objet, et de faire rejouer les résultats obtenus par un fake (voir l’article de Martin Fowler à ce sujet). Cette fonctionnalité était utilisée par très peu de gens, et n’avait pas vraiment sa place dans la librairie principale de FakeItEasy. Elle est donc dépréciée et sera retirée dans une version future. Nous envisageons de fournir une fonctionnalité équivalente en tant que package distinct.

Corrections de bugs

  • Créer plusieurs fakes d’un même type avec des attributs supplémentaires différents génère maintenant plusieurs types de fake distincts. (#436)
  • Tous les appels non-void tentent maintenant de renvoyer un Dummy par défaut, même après avoir été reconfigurés par Invokes ou DoesNothing (#830)

La liste complète des changements est disponible dans les notes de versions.

Les autres personnes ayant contribué à cette version sont :

Un grand merci à eux !

css.php