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”.

Comment mener à bien un projet de migration technique

Cet article raconte l’histoire d’une migration vers .net6.0.

Ses personnages (par ordre d’apparition) :

cover Lena Seb Angelin Benoit
.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.


Le commencement

Il était une fois, Léna, une dev, qui regardait le tableau de bord Jira, cherchant un nouveau défi à affronter :

Lena lasse devant Jira

Elle se tourne donc vers son collègue, et lui demande :

mini Lena Seb, t’aurais pas un sujet intéressant à me filer ? 😇 un truc que je pourrais finir en une semaine.

mini Seb 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…

mini Lena C’est quoi ? 🤔

Seb annonce

mini Seb 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 :

mini Lena … 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.

Il est nécessaire de partager

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.

Lena partage sur Slack

Problèmes rencontrés

Niveau de difficulté 1 💀

L’identification de certains changements a été facile, je pense notamment à :

  1. Certaines méthodes qui ont été retirées, comme 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.
  • Pour Microsoft, une instance de 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

  1. Un changement de comportement sur 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.

Niveau de difficulté 2 💀💀

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();

Niveau de difficulté 3 💀💀💀

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 :

  1. Le problème se reproduit avec Dapper, il ne s’agirait donc pas d’un changement spécifique à EF Core.
  2. Les autres équipes n’ont pas le même souci, il s’agirait donc d’une dépendance exclusive à nos codes source, CloudControl, Timmi Timesheet et Timmi Project.

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 from false to true. 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.

Angelin partage sur Slack

Je termine ce passage avec le témoignage d’Angelin sur le sujet :

Angelin Temoigne

Tester c’est douter

Une fois les problèmes corrigés, vient l’étape la plus pénible de toutes : tester.

Tester c'est douter

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 à :

  1. Préparer des environnements de test au préalable.
  2. Découper le cahier de recette de non-régression en tâches.
  3. Se répartir les tâches.
  4. Tester, tous ensemble sur Zoom.

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 !

La fin de l’histoire

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 !

Liens utiles

Microsoft Breaking changes in EF Core 6.0

Microsoft Migrate from ASP.NET Core 5.0 to 6.0

Microsoft Released: General Availability of Microsoft.Data.SqlClient 4.0

Andrew Lock : Upgrading a .NET 5 “Startup-based” app to .NET 6