Introduction aux Source Generators

Pour ce billet, j'ai eu l'envie d'aborder le sujet des Source Generators. En effet, j'ai dans l'idée de créer un petit package pour générer automatiquement des DTOs, ainsi que le mapping entre domain model et DTOs. Et pourquoi pas aller plus loin en générant, par exemple, des controllers d'Api depuis une specification OpenApi dans un contexte design-first. Et les Source Generators sont la solution toute designée pour ce cas d'usage.

Mais pourquoi faire ? Il existe des tas de librairies qui le font très bien ?

Alors, oui, il existe de nombreuses librairies qui le font déjà et qui le feront certainement mieux que je ne pourrai jamais le faire. Mais l'idée, ici, est d'explorer les Source Generators sur un cas d'usage concret, d'effleurer leur potentiel et voir dans quel cas je peux en avoir l'utilité au quotidien.

Mais avant d'aller plus loin, prenons le temps d'explorer les Source Generators pour comprendre ce qu'ils sont et à quoi ils servent exactement.

Les Source Generators sont une fonctionnalité introduite avec .NET 5 qui offrent la possibilité d'inspecter notre code et de générer du code C# qui sera ajouté à notre code source à la compilation. Les Source Generators font partie du compilateur Roslyn de .NET et exploitent les API .NET Standard 2.0.

Tout ça c'est intéressant. Mais finalement à quoi ça sert ? Pourquoi je n'écrirais pas mon code moi même ? Puis aujourd'hui avec IntelliSense, Copilot et ChatGPT je peux aussi générer du code rapidement.

Alors, effectivement. Mais ce sont des outils aux usages diamétralement opposés. Les outils susmentionnés sont des assistants que l'on utilise durant la rédaction de code pour gagner du temps et ils peuvent d'ailleurs même nous aider à écrire nos Source Generators. Nous devons les guider pour qu'ils nous proposent du code. À la différence des Source Generators pour lesquels il est nécessaire de rédiger le code qui servira à générer... du code.

Les Source Generators offrent effectivement des API pour analyser notre code et générer du code à la compilation. Inutile d'écrire un prompt à GPT ou Copilot chaque fois que l'on a une nouvelle classe à créer. Ici, il suffit de recompiler sa solution et le code sera généré et ajouté à votre code source et utilisable dans votre projet. On parle ici de méta-programmation.

Le schéma ci-dessus nous montre le processus d'analyse, de génération et de compilation des Source Generators. Le compilateur Roslyn commence par construire l'arbre syntaxique et le modèle sémantique de notre solution. Ce qui correspond à la structure de notre code, reprenant l'ensemble des classes, leurs méthodes, leurs boucles, leurs variables etc., tandis que le modèle sémantique correspond aux types et à la portée de votre code, la résolution des noms, etc.

Une fois cette opération réalisée, Roslyn exécute l'ensemble des Source Generators présent dans notre code et ajoute ensuite le code généré à l'arbre syntaxique et au modèle sémantique de notre solution, pour finalement compiler l'ensemble du code que nous avons produit et le code généré ensemble.

Une fois cette opération réalisée, il nous est tout à fait possible d'utiliser le code généré dans notre solution en déclarant une nouvelle instance de notre classe auto-généré dans notre code par exemple ou en utilisant une méthode auto-générée qui vient étendre le fonctionnement de notre code existant.

Il est à noter que ce code n'est pas directement ajouté à la solution sous forme de fichier disponible à la modification, mais comme résultat de compilation. Toute modification de ces fichiers dans un IDE moderne vous retournera un warning vous alertant que vous tentez de modifier un code généré qui sera mis à jour à la prochaine compilation, écrasant de ce fait vos modifications en cours. Pour apporter une modification à un code auto-généré il convient de modifier le Source Generator à l'origine de ce code, ou d'étendre la classe généré pour y ajouter le comportement souhaité.

Reflection vs Source Generator

Lorsque l'on regarde le fonctionnement des Source Generators on ne peut s'empécher de penser à la Reflection qui est partie intégrante de .NET depuis sa première version, publiée en 2002.

Pour rappel, la Reflection permet d'analyser son code au runtime, d'inspecter ou d'intéragir avec notre code pour découvrir ses membres (méthodes, champs, propriétés, etc.), et même invoquer ses membres à l'exécution.

La réflexion est utilisée dans une variété de scénarios, notamment :

  • Sérialisation et désérialisation : JSON.NET utilise la réflexion pour découvrir les propriétés d'un objet à sérialiser ou à désérialiser.

  • Injection de dépendances : Les conteneurs d'injection de dépendances utilisent la réflexion pour découvrir les dépendances d'un type et pour créer des instances de ces types.

  • Mapping d'objet : AutoMapper utilisent la réflexion pour découvrir comment mapper un objet d'un type à un autre.

  • ORM (Object-Relational Mapping) : Entity Framework utilisent la réflexion pour mapper les objets aux tables de base de données.

  • Tests unitaires : Les frameworks de tests unitaires utilisent la réflexion pour découvrir les méthodes de test à exécuter.

À la lecture de tout cela, on se rend tout de suite compte de la principale différence entre la Reflection et les Source Generators. L'un est exécuté au runtime et est à utiliser avec parcimonie car il peut rapidement être très couteux en performance et l'autre est exécuté à la compilation et nous permet donc d'avoir un code compilé et performant sans analyse supplémentaire au runtime.

Voici un tableau comparatif entre ces deux solutions:

CaractéristiquesReflectionSource Generators
PerformanceLa réflexion peut être coûteuse en termes de performances, ca la phase d'analyse est executée à l'exécution.Les générateurs de source améliorent les performances en déplaçant le travail à la compilation plutôt qu'à l'exécution.
SécuritéLa réflexion peut poser des problèmes de sécurité car elle permet d'accéder à des membres privés et protégés.Les générateurs de source sont plus sûrs car ils ne permettent pas d'accéder directement à des membres privés ou protégés.
MaintenanceLa réflexion peut rendre le code plus difficile à comprendre et à maintenir.Les générateurs de source peuvent rendre le code plus lisible et plus facile à maintenir en générant du code clair et explicite.
FlexibilitéLa réflexion est très flexible et permet d'inspecter et de manipuler le code à l'exécution.Les générateurs de source sont moins flexibles car ils génèrent du code à la compilation et ne peuvent pas le modifier à l'exécution.
ComplexitéLa réflexion peut être complexe à utiliser correctement et peut entraîner des erreurs subtiles.Les générateurs de source sont plus simples à utiliser.
CompatibilitéLa réflexion est compatible avec toutes les versions de .NET.Les générateurs de source sont compatible avec .NET 5 ou ultérieure.

Les cas d'usages

Génération de code boilerplate

Il arrive souvent que nous écrivions du code répétitif, qui ne nécessite aucune reflexion mais qui soit indispensable à de nombreux endroit du code. Je pense machinalement à l'interface INotifyPropertyChanged dans les applications WPF, permettant le data binding. Cela nécessite d'ajouter une méthode OnPropertyChanged et de l'appeler dans chaque setter de notre classe pour notifier de chaque changement. Un autre exemple, serait simplement la création de DTO dans notre couche Présentation. Ce code répétitif peut-etre considéré comme un boilerplate.

Dans ce context, les Source Generators peuvent vous faire gagner du temps en générant automatiquement le code boilerplate nécessaire.

Performance

Les Source Generators peuvent être utilisés pour améliorer les performances de votre application. En générant du code à la compilation, vous pouvez déplacer certaines charges de calcul du runtime à la compilation, en évitant la reflection ou en générant du code spécifique et donc moins coûteux en terme de performance. Tout cela peut rendre votre application plus rapide à l'exécution. C'est le cas notamment des NuGet MediatR et AutoMapper qui connaissent des équivalent Source Generated.

DSLs (Domain Specific Languages)

Les Source Generator peuvent vous aider à transformer un DSL en code C#. Un DSL, est un langage conçu pour résoudre un ensemble spécifique de problèmes. Par exemple, SQL est un DSL pour interroger et manipuler des bases de données relationnelles et HTML est un DSL pour décrire la structure et la présentation des documents web. On peut penser par exemple à un source generator qui transformerait une specification OpenAPI en controller C# dans le cas d'un projet Design-First, comme le fait NSwag.

Conclusion

En somme, les Source Generators sont un outil puissant qui ouvre de nouvelles possibilités pour les développeurs .NET et permettent de générer du code à la compilation, ce qui peut améliorer les performances, réduire le code boilerplate et rendre le code plus lisible et plus facile à maintenir. De plus, ils peuvent être utilisés pour convertir des DSL en code C#, ce qui peut simplifier le développement dans des domaines spécifiques.

Cependant, comme tout outil, ils doivent être utilisés judicieusement. Il est important de bien comprendre comment ils fonctionnent et de prendre en compte les implications en termes de maintenance et de performance. Avec une utilisation réfléchie, les Source Generators peuvent être un atout précieux pour tout projet .NET.

Dans un prochain article, nous explorerons un peu plus les Source Generators au travers d'exemple concret reprenant la consigne présenté dans l'introduction de ce billet.

Did you find this article valuable?

Support Yassine FERNANE by becoming a sponsor. Any amount is appreciated!