Bonsoir.
Vous aussi, vous estimez qu’avec JavaScript et ses dérivés, vous manquez parfois d’outils. Nous vous comprenons. Vous êtes habitués à avoir des librairies utiles et reconnues dans vos langages préférés.
Vous vous êtes décidés à faire un back Node.js et vous aimeriez vous assurer que l’architecture hexagonale mise en place sur votre projet soit respectée par l’équipe. L’heureuse époque de votre dernier projet Java où vous aviez mis en place ArchUnit vous revient soudain. Après quelques recherches sur les internet, vous trouverez quelques packages npm qui ont essayé de reproduire ArchUnit mais ils manquent de fonctionnalités.
Il est temps de mettre en place un outil qui répondra à vos attentes. Cet outil, c’est arch-unit-ts.
Nous allons dans cet article découvrir comment nous avons développé la première version de l’outil. Pour pouvoir l’utiliser, nous vous invitons à consulter le README du projet et à regarder ce live qui explique le fonctionnement de la librairie sur un exemple.
Liens
https://github.com/arch-unit-ts/arch-unit-ts
https://www.npmjs.com/package/arch-unit-ts
Inspirations et Objectifs
Pour le code qui a été produit, nous avons essayé de rester au plus proche de ce qui a été fait dans ArchUnit. L’API est donc la même, ce qui permet aux développeurs qui connaissent ArchUnit de ne pas être perdus. De plus, les personnes qui souhaitent contribuer afin d’obtenir une fonctionnalité pas encore disponible peuvent s'inspirer du code Java.
Nous avons voulu, pour le MVP, être capables de reproduire le test HexagonalArchTest présent dans JHipsterLite afin de tester l’architecture d’un projet TypeScript (back ou front). Nous avons donc, dans un premier temps, développé uniquement les fonctionnalités nécessaires pour cela. Depuis, nous avons ajouté d’autres fonctions.
Lecture des fichiers
Afin de pouvoir analyser la structure d’un projet, il faut pouvoir lire les fichiers qui le composent et voir les interactions entre ceux-ci.
Pour cela, nous nous sommes inspirés de ts-arch-unit et nous avons utilisé ts-morph. Cette librairie nous permet de lire un ensemble de fichiers et d’en extraire les imports.
const tsMorphProject = new Project({ tsConfigFilePath: 'tsconfig.json', });
tsMorphProject.addSourceFilesAtPaths(`${rootPackagePath.get()}/**/*.ts`);
Ce morph project contient des répertoires (Directory), les répertoires contiennent des fichiers sources (SourceFile) et les fichiers sources contiennent des imports (ImportDeclaration).
Pour représenter notre arborescence de fichiers, nous avons créé un ensemble de classes qui reprend la terminologie d’ArchUnit :
- TypeScriptProject : Initialise le projet ts-morph pour la lecture des fichiers
- TypeScriptPackage : Représente un dossier dans l’arborescence
- TypeScriptClass : Représente un fichier TypeScript
- Dependency : Représente une dépendance, c’est à dire les imports de chaque fichier TypeScript
La génération de l'arborescence se fait itérativement :
- TypeScriptProject crée son TypeScriptPackage (root).
- TypeScriptPackage crée un ensemble de TypeScriptPackage pour chaque répertoire qu’il contient et crée un ensemble de TypeScriptClass pour chaque fichier TypeScript qu’il contient
- TypeScriptClass crée un ensemble de Dependency pour chacun des imports présent dans le fichier
Focus sur TypeScriptClass et Dependency
Dans les sections qui suivent, nous verrons comment les classes sont filtrées et les imports vérifiés. Ces opérations se feront principalement par l’intermédiaire des classes TypeScriptClass et Dependency.
export class TypeScriptClass {
readonly name: ClassName;
readonly packagePath: RelativePath;
readonly dependencies: Dependency[];
}
Les premiers filtres que nous avons implémentés se font souvent sur le nom de la classe ou sur le chemin du package.
export class Dependency {
readonly owner: TypeScriptClass;
readonly typeScriptClass: TypeScriptClass;
}
La propriété “owner” désigne la classe dans laquelle la dépendance est utilisée et la propriété “typeScriptClass” désigne la classe TypeScript de la dépendance.
Syntaxe de l’API
Afin de comprendre comment fonctionne ArchUnitTs, nous allons nous concentrer sur cet exemple :
classes()
.that()
.resideInAPackage('domain')
.should()
.onlyDependOnClassesThat()
.haveSimpleNameStartingWith('Domain')
.check(archProject.allClasses())
L’API d’arch-unit est fluent. Nous avons trois parties distinctes :
- la définition du périmètre des classes que nous voulons tester (prédicats de classe)
classes()
.that()
.resideInAPackage('domain')
- les règles que ces classes doivent respecter (prédicats de dépendance)
.should()
.onlyDependOnClassesThat()
.haveSimpleNameStartingWith('Domain')
- le lancement de la vérification sur un ensemble de classes
.check(archProject.allClasses())
Que ce soit dans la première ou la seconde partie, nous allons stocker un ensemble de prédicats qui nous serviront de filtres, soit sur les classes, soit sur les dépendances.
Fonctionnement de l’API
Voici la classe abstraite dont les différents prédicats dépendent :
export abstract class DescribedPredicate<T> {
readonly description: string;
protected constructor(description: string) {
this.description = description;
}
abstract test(t: T): boolean;
}
SimpleNameStartingWithPredicate est un exemple de prédicat simple qui permet de vérifier que le nom de la classe commence par un préfixe.
class SimpleNameStartingWithPredicate extends DescribedPredicate<TypeScriptClass> {
private readonly prefix: string; constructor(prefix: string) {
super(`simple name starting with ${prefix}`);
this.prefix = prefix;
}
public test(input: TypeScriptClass): boolean {
return input.getSimpleName().startsWith(this.prefix);
}
}
L’avantage d’utiliser des prédicats est qu’il est possible de les chaîner. Nous pouvons notamment compter AndPredicate (exemple ci-après), OrPredicate et NeverPredicate (inversion de prédicats).
class AndPredicate<T> extends DescribedPredicate<T> {
private readonly current: DescribedPredicate<T>;
private readonly other: DescribedPredicate<T>;
constructor(current: DescribedPredicate<T>, other: DescribedPredicate<T>) {
super(current.description + ' and ' + other.description);
this.current = current;
this.other = other;
}
public test(input: T): boolean {
return this.current.test(input) && this.other.test(input);
}
}
À noter que les prédicats ont une description. Celle-ci permet d’afficher un message d’erreur précis en cas d’échec lors de la vérification des règles.
À chaque fois qu’un prédicat est ajouté, il est concaténé au précédent avec l’utilisation d’un AndPredicate ou d’un OrPredicate (en fonction du mode en cours, and() ou or()).
Lors de l’appel du check, l’ensemble des classes est filtré selon les prédicats de classe puis, les dépendances de ces classes sont vérifiées selon les prédicats de dépendance. Un rapport d’erreur est construit et une exception est levée si des violations ont été constatées.
Contrairement à ArchUnit Java, le rapport d’erreur est simplifié. Nous n’avons pas (encore) implémenté le détail de l’endroit précis dans la classe ou l’erreur a été constatée (par exemple, tel attribut dans tel constructeur). Nous avons indiqué uniquement l’import qui est en erreur.
Les erreurs sont représentées ainsi :
Architecture violation : Rule ${ruleMessage}
Errors :
Dependency ${dependencyPath1} in ${classPath1}
Dependency ${dependencyPath2} in ${classPath1}
Dependency ${dependencyPath1} in ${classPath2}
...
Déploiement
Pour le déploiement sur npm, nous avons créé un compte arch-unit-ts dédié. Pour faciliter les montées de version et la publication, nous avons ajouté quelques entrées dans la partie script du package.json.
"version:patch": "npm version patch --git-tag-version false",
"version:minor": "npm version minor --git-tag-version false",
"version:major": "npm version major --git-tag-version false",
"dopublish": "npm i && npm run build && npm publish"
Problèmes rencontrés
Temps d'exécution des tests
Pour les tests de notre projet, nous avons décidé de construire une arborescence de test sur laquelle nous lançons l’ensemble de nos tests d’intégration. Nous n'avons pas réussi à réaliser un chargement unique du TypeScriptProject afin qu’il soit partagé entre l’ensemble de nos tests (que ce soit avec Jest ou avec Vitest). Comme le chargement des fichiers avec ts-morph est coûteux, le temps d'exécution de nos tests est long (actuellement une minute pour ~250 tests).
Dépendances cycliques
L'écosystème Java étant plus mature sur la gestion des dépendances cycliques, nous n’avions pas fréquemment eu à gérer ce type de problèmes.
Par exemple, les interdépendances entre TypeScriptClass et Dependency ont généré des erreurs de type ‘circular dependency’.
Nous avons donc regroupé au sein d’un même fichier les classes qui étaient dépendantes entre elles.
Afin de s’assurer que ce type de problèmes ne se reproduise pas, nous avons ajouté dans notre CI la commande suivante qui permet de vérifier les dépendances cycliques au sein de nos fichiers TypeScript :
npx madge --circular --extensions ts ./
Et la 1.0 ?
Nous avons réussi à aboutir à notre objectif, pouvoir tester une architecture hexagonale comme dans JHipsterLite. Si vous souhaitez le mettre en place, tout est décrit dans le README du projet.
Conclusion
La découverte du code source d’ArchUnit nous a permis d’en apprendre beaucoup sur l’utilisation de prédicats et la publication d’une libraire sur npm.
Nous attendons avec impatience vos retours et, peut-être, vos futures contributions sur ce beau projet.