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
- Approche impérative en C♯
- Présentation du besoin métier
- Première implémentation dans l’optique Make it work!
- Extract method with «Owner return»
- Extract method with «Condition»
- Extract method with «Multiple values»
- Traitement de la duplication
- Utilisation du
switch
- Réduction du niveau d’indentation
- Réduction de la duplication de texte
- Conclusion
- Approche fonctionnelle en F♯
- Modélisation du domaine en F♯
- Approche orientée objet en C♯
- 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.
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ètreout 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)
deIDictionary‹'K, 'V›
, on ne spécifie que le paramètrekey
et l’on récupère en sortie un tuplebool* string
, exactement comme avec nos dernières méthodesTryCompteXxx()
. Par contre, lors du design de fonctions F♯, ce n’est pas la manière idiomatique : on utilise plutôt le typeOption
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 typeOption
, 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 NuGetJetBrains.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 deNullReferenceException
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 là.
💡 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 !