Ceci est mon tout premier article technique sur la toile ! Me voilà donc obligée de choisir un sujet intéressant. C'est pour cette raison que j'ai décidé d'analyser sous plusieurs angles (technique, organisationnel, …), un projet de migration technique.
Chez Timmi nous gérons les solutions de la gamme "temps et activités".
Cet article raconte l'histoire d'une migration vers .net6.0.
Ses personnages (par ordre d'apparition) :
.Net6.0 | Léna | Seb | Angelin | Benoit |
Le boss de fin | Moi-même | Notre lead dev de stature internationale, personne ne sait pourquoi on l'appelle ainsi | Guardian of CloudControl | Notre product manager préféré |
Toute ressemblance avec des personnes existantes ou ayant existé est purement fortuite.
Il était une fois, Léna, une dev, qui regardait le tableau de bord Jira, cherchant un nouveau défi à affronter :
Elle se tourne donc vers son collègue, et lui demande :
Seb, t'aurais pas un sujet intéressant à me filer ? 😇 un truc que je pourrais finir en une semaine.
Hmmm, il y a un sujet, mais je sais pas trop, une semaine c'est peut-être un peu juste… Et en plus, les product managers seront peut-être pas chauds…
C'est quoi ? 🤔
Par contre il va falloir convaincre les product managers ! mais j'ai ma petite idée sur comment faire 😈
Ce qu'il faut savoir, c'est que chez Timmi, le verbe préféré des devs, c'est "profiter" :
"On va en profiter pour faire ceci", "on va en profiter pour faire cela", …
Il est souvent utilisé comme argument, certes léger, pour justifier des refactos tant rêvés…
Il parait aussi que "profiter" est le verbe le plus craint des product managers. En même temps, on ne peut que comprendre la position de ces derniers : la dernière fois qu'un développeur a prononcé ce maudit mot, la roadmap a pris du retard…
Léna avance d'un pas sûr, malgré sa crainte de voir sa demande rejetée :
… alors on va en "profiter" pour migrer le code vers .net6.0 😇
En réalité, les choses ne se sont pas déroulées de cette manière; Nous n'avons eu nul besoin d'argumenter pour convaincre nos product managers; chez Lucca on aime être à la pointe des technologies, et tout le monde y est sensible.
Bon, passons aux choses sérieuses.
J'imagine que ce qui vous intéresse c'est surtout de savoir comment j'ai procédé pour effectuer la migration.
Quand j'ai attaqué la migration, au premier obstacle, j'ai cherché à savoir si quelqu'un d'autre avait rencontré le même problème. J'ai commencé à ce stade à prendre des notes.
"ça pourrait servir aux autres", m'étais-je dit.
J'ai vite compris que, tant que ces notes resteraient enterrées sur mon bureau, ça ne servirait à rien ni à personne, de toute évidence, il fallait que je partage mes découvertes au fur et à mesure que j'avançais :
Avoir un endroit centralisé, que tout développeur peut consulter à tout moment, et surtout interagir pour demander de l'aide ou proposer une solution, permet de gagner du temps.
Le choix de l'outil était évident dans mon cas, il s'agit de Slack, notre outil de communication interne.
L'identification de certains changements a été facile, je pense notamment à :
WithCulture()
de IStringLocalizer
, ce genre de problèmes est visible à la compilation.WithCulture()
permettait de récupérer des traductions, en appliquant une culture différente de celle de l'utilisateur connecté :
var cultureToApply = new CultureInfo(cultureNameToApply);
var translateValue = stringLocalizer.WithCulture(cultureToApply)[translateKey];
Désormais il faut renseigner la culture à la main avant d'appeler stringLokalizer.GetString()
:
CultureInfo.CurrentUICulture = new CultureInfo(cultureNameToApply);
var translateValue = stringLocalizer.GetString(translateKey);
La suppression de cette méthode m'a intriguée; dans la plupart des cas, il est inutile de l'utiliser, vu que la culture de l'utilisateur connecté est appliquée par défaut.
Nous avons pourtant un cas d'usage spécifique, qui consiste à envoyer un mail dans la langue du destinataire, qui n'est pas forcément la CultureInfo.CurrentUICulture
.
Apparemment cette méthode a été retirée pour rendre les implémentations personnalisées de IStringLocalizer
plus faciles :
WithCulture()
donne l'impression qu'une instance de IStringLocalizer
correspond à un combo d'une CultureInfo
et une Resource
.IStringLocalizer
devrait correspondre uniquement à une Resource
.Pour plus d'informations sur cette décision de la part de Microsoft : ResourceManagerWithCultureStringLocalizer class and WithCulture interface member marked Obsolete and will be removed
System.Text.Json.JsonSerializer.Deserialize
, qui a impacté une partie de nos tests Web :❗ System.NotSupportedException : Deserialization of types without a parameterless constructor, a singular parameterized constructor, or a parameterized constructor annotated with 'JsonConstructorAttribute' is not supported.
La solution évidente était d'ajouter un constructeur public
sans paramètre à la classe en question.
Migrer vers .net 6, c'est également passer à la version 6 d'EF Core. C'est lors de la mise à jour de la version de notre ORM favori que nous avons été confrontés à des problèmes moins évidents à identifier.
Je pense notamment à l'exception suivante, détectée grâce à un test d'intégration :
❗ InvalidOperationException: Unable to translate a collection subquery in a projection since either parent or the subquery doesn't project necessary information required to uniquely identify it and correctly generate results on the client side. This can happen when trying to correlate on keyless entity type. This can also happen for some cases of projection before 'Distinct' or some shapes of grouping key in case of 'GroupBy'. These should either contain all key properties of the entity that the operation is applied on, or only contain simple property access expressions.
J'ai rencontré ce problème lors de l'utilisation d'un Union
entre 2 requêtes n'ayant pas la même table d'origine, suivi par un Include
d'une collection.
Prenons un exemple concret, le code suivant lève l'exception en question :
var courses = context.Parents.SelectMany(p => p.Children)
.Union(context.Children)
.Include(c => c.Courses)
.ToList()
Le message d'erreur nous redirige vers Some queries with correlated collection that also use Distinct or GroupBy are no longer supported.
Mais cette page parle surtout de l'interdiction d'utiliser des GroupBy
ou Distinct
dans une requête imbriquée, vu le risque de renvoyer des doublons.
J'ai analysé l'exemple donné par Microsoft, dans le but d'identifier l'origine du problème dans mon cas :
class Parent
{
int Id;
}
class Child
{
int ParentId;
string School;
List<Course> Courses;
}
class Course {}
Etant donné 2 parents P1
et P2
et leurs 2 enfants C1
et C2
, ces derniers sont tous deux inscrits à la même école S
:
Child
| Parent
| School
C 1
| P 1
| S
C 1
| P 2
| S
C 2
| P 1
| S
C 2
| P 2
| S
Pour récupérer la liste des écoles fréquentées, il suffirait de faire :
var distinctSchools = context.Children
.GroupBy(c => c.School)
.Select(g => g.Key);
Ce qui donne en SQL :
SELECT School
FROM Children
GROUP BY School
Pour notre exemple la requête ci-dessus retourne un seul élément :
School
S
Théoriquement on pourrait accomplir la même chose en partant de la table Parents
, et en utilisant une projection contenant la requête plus haut :
var distinctSchools = context.Parents
.Select(p => p.Children
.GroupBy(c => c.School)
.Select(g => g.Key));
Cependant, pour effectuer la jointure entre les Parents
et la requête de projection, EF Core a besoin d'ajouter la colonne ParentId
à cette dernière, et par conséquent cette colonne se trouve nommée dans la clause GROUP BY
:
SELECT c.School
FROM Parents p
JOIN
(SELECT c.School, cpa.ParentId
FROM Children c
JOIN ChildParentAssociations cpa
ON c.Id = cpa.ChildId
GROUP BY c.School, cpa.ParentId) c
ON c.ParentId = p.Id
Cet ajout a un impact sur le résultat final de la requête, qui envoie cette fois-ci 2 résultats :
School
| Parent
S
| P 1
S
| P 2
J'ai appris qu'on pourrait rencontrer le même problème avec un Include
. Pour appliquer un Include
, EF Core requiert que chaque élément parent soit identifiable de façon unique.
Explication de Smit Patel, développeur dotnet :
In EF Core, in order to do collection include, it requires each record of the parent (on which the collection will be populated) uniquely identifiable.
Revenons alors à notre exemple où on essaie de combiner 2 requêtes :
Requête | identifiant unique de la ligne |
---|---|
context.Parents.SelectMany(p => p.Children) |
(la clé primaire de la table d'origine : ParentId , la clé primaire de la table de sélection: ChildId ) |
context.Children |
ChildId |
Le fait d'avoir une table d'origine différente n'empêche pas l'application de l'Union
, vu qu'il suffit d'avoir le même nombre et les mêmes types de colonnes dans les 2 requêtes.
Mais dès qu'on ajoute un Include
à la requête, EF Core n'a aucun moyen d'identifier de façon unique les lignes résultantes de l'intersection :
But once you apply set operation with above query to something else which has different origin, there is no way to uniquely identify rows anymore. So we cannot do collection include.
Pour implémenter correctement le comportement attendu (envoyer une ligne avec un identifiant unique), il suffit de charger séparement la liste Courses
, en faisant un Load()
:
var children = context.Parents.SelectMany(p => p.Children)
.Union(context.Children)
.ToList();
context.Children
.Include(c => c.Courses)
.Load();
J'ai laissé le meilleur pour la fin !
Avec Angelin, nous sommes les seuls à avoir rencontré le problème décrit ci-bas.
Etant donné une chaîne de connexion SQL Server :
Data Source=.;Initial Catalog=CC;user=xxx;Password=xxx;MultipleActiveResultSets=True;App=CC
La connexion à la base de données échoue avec une erreur :
❗ The certificate received from the remote server was issued by an untrusted certificate authority
Pour identifier le problème, Angelin a tenté de l'isoler, et a réussi à le reproduire dans un projet séparé, en quelques lignes avec Dapper :
using SqlConnection connection = new SqlConnection(connectionString);
var query = connection.Query<int>("SELECT 1 FROM Table");
var result = query.Single();
Le code ci-dessus lève une exception si connectionString
comporte Encrypt=True
ou aucune valeur pour Encrypt
.
Il s'agit bien d'un breaking change, qui concerne la valeur par défaut du paramètre de chiffrement.
En effet, en ajoutant Encrypt=False
ou TrustServerCertificate=True
à la chaîne de connexion, le problème semble résolu.
Un mystère demeure : pourquoi les autres équipes ne sont-elles pas impactées par ce changement ?
Nous avons investigué avec Angelin, et raisonné par élimination :
En comparant les dépendances, nous avons fini par trouver le coupable ! il s'agit d'un breaking change dans la version v4.0.0 de Microsoft.Data.SqlClient
.
Encrypt default value set to true :
The default value of the
Encrypt
connection setting has been changed fromfalse
totrue
. With the growing use of cloud databases and the need to ensure those connections are secure, it's time for this backwards-compatibility-breaking change.
Je termine ce passage avec le témoignage d'Angelin sur le sujet :
Une fois les problèmes corrigés, vient l'étape la plus pénible de toutes : tester.
Et moi je dis "c'est bien de douter !"
Mais cela ne rend pas la tâche plus amusante.
J'ai commencé cette phase en lançant les tests existants.
Nos tests chez Timmi, sont pour la plupart des tests d'intégration, avec création d'une vraie base de données.
Même s'il est souvent recommandé de privilégier les tests unitaires, pour cette migration j'étais plutôt contente d'avoir des tests d'intégration. Ces derniers m'ont permis de détecter la majorité de breaking changes (notamment celui là : Niveau de difficulté 2 💀💀).
L'étape suivante consiste à faire une recette manuelle.
Pour ce type de test (manuel, pénible, mais critique et nécessaire) nous appliquons depuis peu le concept de "recette croisée" qui se résume à :
Cela permet d'aller plus vite, et d'impliquer tous les membres de l'équipe.
Je vous partage le résultat de cet exercice, effectué pour nos 2 logiciels, Timmi Timesheet et Timmi Project :
A la grande surprise de notre product manager préféré, nous n'avons trouvé aucune régression !
J'ai tiré de cette migration une immense satisfaction.
J'étais fière de notre couverture de tests, ces derniers m'ont permis d'avancer plus vite.
Et j'ai particulièrement apprécié la collaboration interne ainsi qu'externe à l'équipe.
Le mot de fin de cet article :
Ensemble on va plus loin vite !
Breaking changes in EF Core 6.0
Migrate from ASP.NET Core 5.0 to 6.0
Released: General Availability of Microsoft.Data.SqlClient 4.0
Andrew Lock : Upgrading a .NET 5 "Startup-based" app to .NET 6