Tag Archives: .NET

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.

Tout faire ou presque avec le pipeline de HttpClient

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

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

Le pipeline

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

Schéma du pipeline de HttpClient

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

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

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

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

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

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

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

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

Tests unitaires

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Exemple d’utilisation avec FakeItEasy :

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

Logging

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

public class LoggingHandler : DelegatingHandler
{
    private readonly ILogger _logger;

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

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

Réessayer les requêtes échouées

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

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

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

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

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

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

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

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

Autres cas d’utilisations

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

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

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