Accueil Nos publications Blog Design d’un moteur de mapping en .NET

Design d’un moteur de mapping en .NET

Récemment, j’ai travaillé à la mise en place de règles de traitement de données venant d’une API externe afin de les normaliser à notre convenance. Les règles en elles-mêmes n’étaient pas très compliquées, mais suffisamment nombreuses (une cinquantaine) et similaires pour réfléchir à une manière de les coder sans duplication ni complexité élevée.

L’écriture de cette série d’articles repose sur l’envie d’exposer une solution trouvée en C♯, selon l’approche orientée objet et qui se base sur un mélange de Design Patterns avec une implémentation plus « moderne » tirant parti des capacités fonctionnelles de C♯. Cela sera l’objet de nos quatrième et cinquième parties.

En revanche, même si j’apprécie l’orienté objet, je ne le considère ni comme la panacée, ni comme le nec plus ultra du design. Il est plus intéressant de pouvoir suivre différentes approches et de choisir celle qui nous semble la mieux adaptée, plutôt que de se limiter à ce qui nous est familier ! D’où l’idée d’étudier des solutions alternatives, tant en matière de design que de paradigmes de programmation.

Nous commencerons par un basique : l’approche impérative en C♯, ce qui nous permettra de bien appréhender le problème et de faire quelques gammes de Clean Code. Nous ferons en deuxième et troisième parties de la programmation fonctionnelle en F♯. Cela nous permettra en outre de creuser les fonctionnalités offertes par ces langages afin de « voir ce qu’ils ont dans le ventre ». Mais avant tout, commençons par regarder plus en détail le besoin métier.

🏷 Sommaire
  1. Approche impérative en C♯
  2. Approche fonctionnelle en F♯
  3. Modélisation du domaine en F♯
  4. Approche orientée objet en C♯
  5. Avancée en orienté objet en C♯

Présentation du besoin métier

Pour les besoins d’un site Web de location longue durée de voitures, nous avons établi un partenariat avec un fournisseur de données automobiles. Leur API fournit en particulier la liste des équipements disponibles en série ou en option pour un véhicule donné. Certains équipements comportent un ensemble de déclinaisons (Variations). En voici le format simplifié :

type Equipment = { Schema: int; Label: string; Variations: Variation[] }
and  Variation = { Schema: int; Location: char; Value: string }

Le Schema est un identifiant permettant de distinguer les équipements entre eux. Selon les cas, les déclinaisons ont leur propre Schema ou portent celui de l’équipement parent et se différencient alors par leur Location, la localisation dans la voiture : avant / arrière, conducteur / passager, etc. Le libellé (Label) peut être générique pour s’appliquer aux différentes déclinaisons.

L’objectif est d’obtenir une liste aplatie des équipements, en déterminant un libellé précis pour chaque déclinaison. L’aplatissement n’est pas une problématique ici : nous nous appuierons sur un SelectMany(). L’enjeu est de déterminer le libellé pour les nombreux cas possibles. En voici une sélection représentative sous la forme d’une table de décision :

Equipment Label Variation Location New Label
7408 Configuration du moteur 7408 (idem)
7407 Nombre de cylindres
15304 Puissance en ch / régime tr/mn 15304 Puissance (ch)
15305 Régime de puissance maxi (tr/mn)
23502 Garantie an(s) / km 23502 Garantie : durée (ans)
23503 Garantie : kilométrage
24002 Assistance an(s) / km 24002 Assistance : durée (ans)
24003 Assistance : kilométrage
7403 Cylindrée (litres / cm3) 7403 Cylindrée (litres)
7402 Cylindrée (cm3)
23301 Vitres électriques AV / AR 23301 F Vitres électriques AV
23301 R Vitres électriques AR
17811 Siège conducteur / passager chauffant 17811 D Siège conducteur chauffant
17811 P Siège passager chauffant
17818 Siège conducteur / passager massant 17818 D Siège conducteur massant
17818 P Siège passager massant
14103 Pneus AV 14103 F Pneus AV : largeur
14104 F Pneus AV : profil
Pneus AR 14103 R Pneus AV : largeur
14104 R Pneus AV : profil
53405 Informations de recharge (rapide) A / V / h 53405 F ou D Informations de recharge (rapide) : ampérage (A)
53404 Informations de recharge (rapide) : voltage (V)
53403 Informations de recharge (rapide) : durée (heures)

Nous constatons plusieurs types d’opérations pour construire le nouveau libellé :

  • Déclinaisons 7407 et 15305 : on doit totalement remplacer le libellé.
  • Déclinaisons 23502 et 23503 : on peut se baser sur le libellé existant et remplacer la partie “an(s) / km” par respectivement “: durée (ans)” et “: kilométrage”.
  • Équipements 23301 : on peut remplacer “AV / AR” par “AV” ou “AR” lorsque la localisation est respectivement ‘F’ (Front) ou ‘R’ (Rear).
  • Équipements 17811 et 17818 : la même règle peut s’appliquer pour remplacer “conducteur / passager” selon la localisation, ‘D’ (Driver) ou ‘P’ (Passenger).
  • Équipements 14103 : le libellé est déjà correct concernant “AV / AR”. Par contre, on va ajouter une information complémentaire à la fin du libellé : “largeur” pour 14103 et “profil” pour 14104.
  • Équipements 53405 : on va non seulement remplacer “A / V / h” selon la déclinaison (53405 → “ampérage (A)”, 53404 → “voltage (V)) mais également remplacer “recharge (rapide)” par “recharge” ou “recharge rapide” selon la “localisation”: ‘F’ (Fast) ou ‘D’ (Domestic).

Première implémentation dans l’optique Make it work!

Mettons en place une première solution avec pour seul objectif que cela marche ! Créons une classe statique EquipmentMapper pourvue d’une méthode ComputeLabel(Equipment equipment, Variation variation).

💡 Les tests unitaires, donnés à titre indicatif (vu que ce n’est pas l’objet de ces articles), sont disponibles sur le GitHub de SOAT ici.

public static class EquipmentMapper
{
  public static string ComputeLabel(Equipment equipment, Variation variation)
  {
    var label = equipment.Label.Trim();

    // Totally new labels
    if (variation.Schema == 7407)
      return "Nombre de cylindres";

    if (variation.Schema == 15304)
      return "Puissance (ch)";

    if (variation.Schema == 15305)
      return "Régime de puissance maxi (tr/mn)";

    // Replacement of just a part of the label
    if (variation.Schema == 23502 || variation.Schema == 24002)
      return label.Replace("an(s) / km", ": durée (ans)");

    if (variation.Schema == 23503 || variation.Schema == 24003)
      return label.Replace("an(s) / km", ": kilométrage");

    if (variation.Schema == 7403)
      return label.Replace("litres / cm3", "litres");

    if (variation.Schema == 7402)
      return label.Replace("litres / cm3", "cm3");

    // Idem according to Location
    if (variation.Schema == 23301)
    {
      if (variation.Location == 'F')
        return label.Replace("AV / AR", "AV");

      if (variation.Location == 'R')
        return label.Replace("AV / AR", "AR");
    }

    if (variation.Schema == 17811 || variation.Schema == 17818)
    {
      if (variation.Location == 'D')
        return label.Replace("conducteur / passager", "conducteur");

      if (variation.Location == 'P')
        return label.Replace("conducteur / passager", "passager");
    }

    // Double replacement at once, one according to Schema, the other to Location
    if (variation.Schema == 53405)
    {
      if (variation.Location == 'D')
        return label.Replace("recharge (rapide) A / V / h", "recharge : ampérage (A)");

      if (variation.Location == 'F')
        return label.Replace("recharge (rapide) A / V / h", "recharge rapide : ampérage (A)");
    }

    if (variation.Schema == 53404)
    {
      if (variation.Location == 'D')
        return label.Replace("recharge (rapide) A / V / h", "recharge : voltage (V)");

      if (variation.Location == 'F')
        return label.Replace("recharge (rapide) A / V / h", "recharge rapide : voltage (V)");
    }

    if (variation.Schema == 53403)
    {
      if (variation.Location == 'D')
        return label.Replace("recharge (rapide) A / V / h", "recharge : durée (heures)");

      if (variation.Location == 'F')
        return label.Replace("recharge (rapide) A / V / h", "recharge rapide : durée (heures)");
    }

    // Add complementary info
    if (variation.Schema == 14103)
      return label + " : largeur";

    if (variation.Schema == 14104)
      return label + " : profil";

    // Default label
    return label;
  }
}

💡 Code source sur le GitHub de SOAT ici.

On obtient une méthode de plus de 80 lignes et une complexité cyclomatique de 19. On trouve bien pire dans certains systèmes Legacy mais c’est déjà suffisant pour donner le tournis aux linters comme Sonar 😵 ! Un peu de ménage serait le bienvenu.

On a attaqué tout le problème d’un seul trait. On pourrait le découper pour le rendre plus digeste. Les commentaires dans le code nous indiquent basiquement les endroits où découper. Techniquement, la manœuvre s’appelle Extract method. On pourra se servir des commentaires pour le nommage des méthodes extraites pour ensuite les supprimer. On extrait les méthodes de bas en haut de manière à ce que les méthodes extraites soient positionnées dans le fichier dans le même ordre que leur appel dans la méthode chapeau.

Dans notre situation, le refactoExtract method est plus ou moins bien géré par les IDE du fait que l’on extrait un ensemble de if sans else final, ce else indiquant qu’aucun résultat n’a été trouvé. Rider propose une option Return type permettant de choisir comment gérer cette situation :

Extract method with «Owner return»

Will add ‘return null’ and check it at the call site.

  public static string ComputeLabel(Equipment equipment, Variation variation)
  {
    var label = equipment.Label.Trim();

    var result = ComputeLabelTotallyNew(variation);
    if (result != null) return result;

    result = ComputeLabelWithReplacementBySchema(variation, label);
    if (result != null) return result;

    result = ComputeLabelWithReplacementByLocation(variation, label);
    if (result != null) return result;

    result = ComputeLabelWithDoubleReplacement(variation, label);
    if (result != null) return result;

    result = ComputeLabelWithComplementaryInfo(variation, label);
    if (result != null) return result;

    return label;
  }

  private static string ComputeLabelTotallyNew(Variation variation)
  {
    if (variation.Schema == 7407)
      return "Nombre de cylindres";

    if (variation.Schema == 15304)
      return "Puissance (ch)";

    if (variation.Schema == 15305)
      return "Régime de puissance maxi (tr/mn)";

    return null;
  }

  private static string ComputeLabelWithReplacementBySchema(Variation variation, string label) //{...}
  private static string ComputeLabelWithReplacementByLocation(Variation variation, string label) //{...}
  private static string ComputeLabelWithDoubleReplacement(Variation variation, string label) //{...}
  private static string ComputeLabelWithComplementaryInfo(Variation variation, string label) //{...}

💡 Code source sur le GitHub de SOAT ici.

Extract method with «Condition»

Fixes control flow at the call site.

C’est la manière d’opérer des méthodes bool TrySomething(..., out ...) du framework .NET :

  public static string ComputeLabel(Equipment equipment, Variation variation)
  {
    var label = equipment.Label.Trim();

    if (TryComputeLabelTotallyNew(variation, out var result))
      return result;

    if (TryComputeLabelWithReplacementBySchema(variation, label, out result))
      return result;

    if (TryComputeLabelWithReplacementByLocation(variation, label, out result))
      return result;

    if (TryComputeLabelWithDoubleReplacement(variation, label, out result))
      return result;

    if (TryComputeLabelWithComplementaryInfo(variation, label, out result))
      return result;

    return label;
  }

  private static bool TryComputeLabelTotallyNew(Variation variation, out string result)
  {
    if (variation.Schema == 7407)
    {
      result = "Nombre de cylindres";
      return true;
    }

    if (variation.Schema == 15304)
    {
      result = "Puissance (ch)";
      return true;
    }

    if (variation.Schema == 15305)
    {
      result = "Régime de puissance maxi (tr/mn)";
      return true;
    }

    result = null;
    return false;
  }

Bilan :

  • Le code appelant est plus succinct mais le code des sous méthodes est plus verbeux.
  • Le test du succès des TryComputeLabelXxx() est plus clair en se faisant sur le booléen renvoyé.
  • Par contre, les méthodes ont 2 sorties : la sortie directe de type bool et une sortie indirecte via un paramètre out string, ce qui rend la lecture plus difficile.
  • C’est pour atténuer cet inconvénient que l’on a utilisé la même convention de nommage en “TryXxx()” pour indiquer ce pattern .NET.

💡 Code source sur le GitHub de SOAT ici.

Extract method with «Multiple values»

Will return a tuple with all selected ref/out parameters.

C’est la manière intermédiaire de faire : on renvoie un tuple (bool Success, string Value). Ce n’est pas exactement le pattern .NET (*) mais on peut conserver la même convention de nommage en TryXxx indiquant la possibilité d’un échec.

(*) ☝️ Note : en F♯, quand on appelle une méthode du framework .NET de ce type, par exemple TryGetValue(string key, out string value) de IDictionary‹'K, 'V›, on ne spécifie que le paramètre key et l’on récupère en sortie un tuple bool* string, exactement comme avec nos dernières méthodes TryCompteXxx(). Par contre, lors du design de fonctions F♯, ce n’est pas la manière idiomatique : on utilise plutôt le type Option comme nous le verrons dans la deuxième partie.

  public static string ComputeLabel(Equipment equipment, Variation variation)
  {
    var label = equipment.Label.Trim();

    var result = TryComputeLabelTotallyNew(variation);
    if (result.Success) return result.Value;

    result = TryComputeLabelWithReplacementBySchema(variation, label);
    if (result.Success) return result.Value;

    result = TryComputeLabelWithReplacementByLocation(variation, label);
    if (result.Success) return result.Value;

    result = TryComputeLabelWithDoubleReplacement(variation, label);
    if (result.Success) return result.Value;

    result = TryComputeLabelWithComplementaryInfo(variation, label);
    if (result.Success) return result.Value;

    return label;
  }

  private static (bool Success, string Value) TryComputeLabelTotallyNew(Variation variation)
  {
    if (variation.Schema == 7407)
    {
      return (true, "Nombre de cylindres");
    }

    if (variation.Schema == 15304)
    {
      return (true, "Puissance (ch)");
    }

    if (variation.Schema == 15305)
    {
      return (true, "Régime de puissance maxi (tr/mn)");
    }

    return (false, null);
  }

  private static (bool Success, string Result) TryComputeLabelWithReplacementBySchema(Variation variation, string label) //{...}
  private static (bool Success, string Result) TryComputeLabelWithReplacementByLocation(Variation variation, string label) //{...}
  private static (bool Success, string Result) TryComputeLabelWithDoubleReplacement(Variation variation, string label) //{...}
  private static (bool Success, string Result) TryComputeLabelWithComplementaryInfo(Variation variation, string label) //{...}

Bilan :

  • ✔️ La verbosité du code est bien équilibrée entre le code appelant et les méthodes extraites.
  • ✔️ La signature de ces méthodes TryCompteLabelXxx() est la plus explicite : on sait si c’est un succès (par le premier élément du tuple).
  • ❌ Petit bémol : on peut accéder à la valeur (portée par le deuxième élément du tuple) en cas d’échec.
    Pour plus de sûreté, on peut s’orienter vers le type Option, disponible par exemple avec LanguageExt.
  • ❌ Ce type de contrat à la fois explicite et plus sécurisé représente néanmoins un surcoût à coder et à manipuler. C’est donc plutôt adapté à des méthodes publiques et encore plus à une API publique. Dans la mesure où il s’agit de sous-méthodes privées, les versions renvoyant une string nullable représentent le choix le plus pragmatique.

☝️ Remarque : pour plus de sûreté dans la manipulation des null, on peut :

  • Utiliser les types références Nullables de C♯ 8.0 👍
    Cela va devenir le nouveau standard des bases de code en C♯.
  • Utiliser l’un des attributs [CanBeNull] ou [NotNull] ⚠️
    • Fournis dans le package NuGet JetBrains.Annotations.
    • Utilisables sur les paramètres d’entrée et le retour d’une méthode.
    • Permet à ReSharper de nous prévenir de tout risque de NullReferenceException lors de leurs usages.
    • Analysables dans les usines de CI.
    • Revers de la médaille : cela ne marche que sur les postes ayant ReSharper ou Rider, ce qui peut poser des problèmes d’homogénéité des pratiques au sein d’une équipe de développement.
    • Plus d’informations ici et .

💡 Code source sur le GitHub de SOAT ici.

Traitement de la duplication

Quel que soit le type de méthodes ComputeLabelXxx(), on constate dans le code appelant une répétition du pattern :

result = ComputeLabelXxx();
if (result != null) return result;

On peut éliminer cette duplication en passant à un traitement ensembliste. Mais pour ne pas appeler toutes les méthodes à chaque fois, il faut utiliser des évaluations différées en encapsulant nos appels dans des Func‹string›</codeem>(ce qui permet au passage d’uniformiser les signatures)</em> et en s’appuyant sur le fonctionnement des <code class="language-plaintext">IEnumerable :

  public static string ComputeLabel(Equipment equipment, Variation variation) =>
    ComputeLabel(equipment.Label.Trim(), variation);

  private static string ComputeLabel(string label, Variation variation) =>
    new Func<string>[]
    {
      () => ComputeLabelTotallyNew(variation),
      () => ComputeLabelWithReplacementBySchema(variation, label),
      () => ComputeLabelWithReplacementByLocation(variation, label),
      () => ComputeLabelWithDoubleReplacement(variation, label),
      () => ComputeLabelWithComplementaryInfo(variation, label),
    }
    .Select(f => f())
    .Where(s => s != null)
    .DefaultIfEmpty(label)
    .First();

🎁 Bonus : la complexité cyclomatique de ComputeLabel() est désormais de 1, le minimum possible ! 🎉

Utilisation du switch

Un dernier changement possible consiste à remplacer les if par un switch dans les méthodes TryComputeXxx(). Ce refacto est proposé par Rider via le Quick Fix “💡 Convert ‘if’ to ‘switch’ statement”. Cela donne un code plus concis :

  private static string TryComputeLabelTotallyNew(Variation variation)
  {
    switch (variation.Schema)
    {
      case 7407:  return "Nombre de cylindres";
      case 15304: return "Puissance (ch)";
      case 15305: return "Régime de puissance maxi (tr/mn)";
      default:    return null;
    }
  }

En C♯ 8, cela peut même être converti en “switch expression”, ce que propose aussi Rider. On peut alors basculer en “Expression body”, ce qui donne au final :

  private static string TryComputeLabelTotallyNew(Variation variation) =>
    variation.Schema switch
    {
      7407  => "Nombre de cylindres",
      15304 => "Puissance (ch)",
      15305 => "Régime de puissance maxi (tr/mn)",
      _     => null,
    };

C’est quasiment la syntaxe du pattern matching en F♯ :

let tryCompteLabelTotallyNew (variation: Variation) =
  match variation.Schema with
  | 7407  -> "Nombre de cylindres"
  | 15304 -> "Puissance (ch)"
  | 15305 -> "Régime de puissance maxi (tr/mn)"
  | _     -> null

☝️ Note : il n’est pas recommandé d’aligner verticalement les flèches (=> ou ->) dans un code de production, sauf à disposer d’un outil qui formate automatiquement le code de cette manière. Je l’ai fait ici pour faciliter la comparaison des deux codes.

Réduction du niveau d’indentation

Pour les méthodes comportant des if imbriqués, cette imbrication de conditions est un nid à bugs car il est plus difficile de lire la condition globale associée à chaque cas. Il vaut mieux chercher à “aplatir le code” en combinant les conditions, quitte à avoir un peu de code dupliqué.

Pour continuer à utiliser les switch, on peut au choix utiliser une clause case xxx when yyy ou fournir un tuple en entrée du switch :

  private static string ComputeLabelWithReplacementByLocation(Variation variation, string label) =>
    variation.Location switch
    {
      'F' when variation.Schema == 23301 => label.Replace("AV / AR", "AV"),
      'R' when variation.Schema == 23301 => label.Replace("AV / AR", "AR"),
      'D' when variation.Schema == 17811 ||
               variation.Schema == 17818 => label.Replace("conducteur / passager", "conducteur"),
      'P' when variation.Schema == 17811 ||
               variation.Schema == 17818 => label.Replace("conducteur / passager", "passager"),
      _ => null,
    };

  private static string ComputeLabelWithDoubleReplacement(Variation variation, string label) =>
    (variation.Schema, variation.Location) switch
    {
      (53405, 'D') => label.Replace("recharge (rapide) A / V / h", "recharge : ampérage (A)"),
      (53405, 'F') => label.Replace("recharge (rapide) A / V / h", "recharge rapide : ampérage (A)"),
      (53404, 'D') => label.Replace("recharge (rapide) A / V / h", "recharge : voltage (V)"),
      (53404, 'F') => label.Replace("recharge (rapide) A / V / h", "recharge rapide : voltage (V)"),
      (53403, 'D') => label.Replace("recharge (rapide) A / V / h", "recharge : durée (heures)"),
      (53403, 'F') => label.Replace("recharge (rapide) A / V / h", "recharge rapide : durée (heures)"),
      _ => null,
    };

La version avec le tuple (Schema, Location) est plus concise, similaire à la manière de faire en F♯, mais demande plus de modifications manuelles du code, alors que la conversion en “switch case when” est gérée directement dans Rider.

Réduction de la duplication de texte

On peut également essayer d’enlever les duplications de texte, en testant séparément Schema et Location :

  private static string ComputeLabelWithReplacementByLocation(Variation variation, string label)
  {
    switch (variation.Schema)
    {
      case 23301:
        var locationLabel = variation.Location == 'F' ? "AV" : "AR";
        return label.Replace("AV / AR", locationLabel);

      case 17811:
      case 17818:
        locationLabel = variation.Location == 'D' ? "conducteur" : "passager";
        return label.Replace("conducteur / passager", locationLabel);

      default:
        return null;
    }
  }

  private static string ComputeLabelForBattery(Variation variation, string label)
  {
    var result = TryGetBatteryLabel(variation);
    if (!result.Success)
    {
      return null;
    }

    var rapide = variation.Location == 'F' ? " rapide" : "";
    return label.Replace("recharge (rapide) A / V / h", $"recharge{rapide} : {result.Value}");
  }

  private static string GetBatteryLabel(Variation variation) =>
    variation.Schema switch
    {
      53405 => "ampérage (A)",
      53404 => "voltage (V)",
      53403 => "durée (heures)",
      _ => null,
    };

Le code est plus optimisé mais perd en clarté : je préfère la version précédente. C’est le signe que nous pouvons nous arrêter là.

💡 Code source sur le GitHub de SOAT ici.

Conclusion

Nous avons vu différents refactorings, certains étant proposés sous plusieurs variantes par ReSharper/Rider. Cela nous a conduit à un résultat de qualité : on suit les principes Clean Code de nommage et de découpage en petites fonctions ; le code ne présente pas de duplication sauf ponctuellement et de manière délibérée, dans le but de rendre le code plus concis et plus lisible.

Seuls les switch sont matière à débat en clean code : en effet, ils augmentent localement la complexité cyclomatique et pourraient être remplacés par un appel à un objet polymorphique. Nous verrons cela dans l’article consacré à l’approche orientée objet.

Enfin, grâce aux traitements ensemblistes avec LINQ, on a réussi à améliorer le pattern result = TryComputePartN(); if (result != null) return result; else [N+1...]. Continuons cette piste, mais cette fois en codant en F♯ pour bénéficier de quelques nice features de ce language compatible avec la plateforme .NET et pourtant si peu utilisé à ce jour. En route !