Il y a 4 ans -
Temps de lecture 11 minutes
Appliquez vos décisions d’architecture avec ArchUnit (1/2)
ArchUnit est une bibliothèque qui propose une fluent API pour tester l’architecture d’applications Java.
L’objectif de cet article est de vous donner un aperçu des possibilités techniques d’ArchUnit. Il sera suivi par un second article qui apportera une vision plus théorique sur l’intégration d’ArchUnit par rapport aux problématiques de gestion et de documentation de l’architecture.
Introduction
Périmètre
ArchUnit est une bibliothèque qui est utilisée pour mettre en place des tests automatisés dans le but de vérifier l’application de décisions d’architecture.
Martin Fowler définit l’architecture logicielle comme l’ensemble des décisions qui sont à la fois importantes et sur lesquelles il est difficile de revenir après coup. ArchUnit ne cible pas toutes les décisions d’architecture : il n’est bien évidemment pas question de vérifier l’application de décisions au niveau d’une entreprise, mais uniquement celles qui concernent la conception d’une application.
Intégration dans votre environnement
ArchUnit est conçu pour Java et Kotlin. Il est possible de l’utiliser avec n’importe quel autre langage de la JVM, comme Scala, mais un certain nombre de contournements sont alors nécessaires. La fluent API, dont le plus gros atout est l’expressivité, devient alors au contraire contre-intuitive.
ArchUnit supporte tous les principaux frameworks de test et s’intègre donc vraisemblablement sans problème dans votre environnement de développement. Au-delà de la bibliothèque de base, des surcouches sont disponibles pour JUnit 4 et JUnit 5. Celles-ci fournissent des annotations pour une meilleure lisibilité et plus de concision.
Au moment de la rédaction de cet article, la version 0.10.2 est la dernière en date. Le code source est disponible sur le dépôt GitHub officiel d’ArchUnit, avec notamment un jeu d’exemples très clairs.
Glossaire
Afin d’éviter les ambiguïtés dans la suite de l’article, il est primordial de bien comprendre la distinction entre ce que nous appellerons une « décision » et une « règle ».
Une décision désigne quelque chose qui affecte le code source et que l’équipe décide d’appliquer. Une décision peut tout à fait avoir une origine extérieure à l’équipe, à condition que celle-ci ait une autorité reconnue par l’équipe.
Une règle désigne le test correspondant à une décision, tel qu’implémenté avec ArchUnit. On peut considérer qu’il s’agit de la formalisation de la décision en tant que test.
Avantages
L’intérêt principal d’ArchUnit est le même que celui de l’automatisation des tests de manière plus générale : vous savez si vous êtes conforme à l’attendu ou non, au moment de l’implémentation du test tout d’abord, puis à chaque fois que votre application est modifiée. Assurer ainsi la non-régression est particulièrement pertinent dans la mesure où cela garantit que votre architecture ne partira pas dans des directions incohérentes.
Par ailleurs, il arrive fréquemment que des décisions ne soient pas connues de tous les membres d’une équipe. Et quand bien même elles le seraient, une erreur est vite arrivée. On remédie généralement à ce problème par des revues de code. La définition de règles avec ArchUnit y apporte une réponse qui a l’avantage d’être automatique et déterministe, et par conséquent plus fiable. De plus, le temps ainsi économisé en revue peut être accordé à des problèmes plus importants, comme par exemple la discussion des décisions d’architecture.
API et cas d’usage
Bien qu’ArchUnit ne prétende pas gérer tous les cas possibles et imaginables, les 3 couches d’API proposées permettent de s’adapter à de nombreux contextes :
- La couche Core met à disposition une API extrêmement flexible, qui peut être utilisée pour implémenter à peu près n’importe quelle règle.
- La couche Lang simplifie l’API de la couche Core pour fournir une fluent API qui peut être utilisée pour implémenter la plupart des règles avec une syntaxe très expressive.
- La couche Library propose des syntaxes très spécifiques à un certain nombre de règles prédéfinies. Celles-ci sont ainsi encore plus simples qu’avec la couche Lang, mais ceci n’est disponible que pour un nombre de cas d’usage très limité.
La couche Library
Le cas d’usage principal d’ArchUnit est la vérification de la structure globale d’une application : comment l’application est-elle découpée, et comment les sous-parties résultantes interagissent-elles ?
Étant donné que ce type de règles est quasiment toujours pertinent, la couche Library propose quelques syntaxes très spécifiques dans l’objectif de les rendre aussi expressives et concises que possible.
Architecture en couches
La couche Library peut typiquement être utilisée pour définir des règles sur les architectures en couches. Dans l’exemple ci-dessous, l’application est découpée en 3 couches, avec une couche Service, qui contient toute la logique métier, définie pour ne pas dépendre des autres couches dans une logique qui a trait aux architectures hexagonales.
[java]@ArchTest
public static final ArchRule layer_dependencies_are_respected = layeredArchitecture()
.layer("Controller").definedBy("fr.xebia.archunit.controller..")
.layer("Service").definedBy("fr.xebia.archunit.service..")
.layer("Adapter").definedBy("fr.xebia.archunit.adapter..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller", "Adapter")
.whereLayer("Adapter").mayNotBeAccessedByAnyLayer();[/java]
La définition d’une couche se base sur une syntaxe dans laquelle fr.xebia.archunit.controller..
prend en compte tout le contenu du package controller
et de ses sous-packages.
Structure interne des couches
Il est également possible de définir des règles qui portent sur la structure interne des couches.
Par exemple, en considérant que la couche Adapter se compose de plusieurs packages, dont chacun correspond à un adaptateur utilisé pour échanger avec un système externe différent, on peut définir une règle selon laquelle les adaptateurs ne dépendent jamais les uns des autres.
[java]@ArchTest
public static final ArchRule adapters_do_not_depend_on_one_another = slices()
.matching("fr.xebia.archunit.(adapter).(*)..").namingSlices("$1 ‘$2’")
.ignoreDependency("fr.xebia.archunit.adapter.*..", "fr.xebia.archunit.adapter.common..")
.should().notDependOnEachOther();[/java]
On pourra pour cela définir des slices (littéralement, des tranches) de l’application par le biais d’une syntaxe de pattern matching. Par exemple, fr.xebia.archunit.adapter.*..
prend en compte toutes les classes des packages qui correspondent à ce pattern, c’est-à-dire tous les sous-packages directs du package adapter
.
Des règles peuvent être définies pour assurer que les slices soient complètement indépendants les uns des autres, ou que leurs dépendances ne soient jamais cycliques.
On notera également la possibilité de définir des exceptions à la règle, ici dans le cas d’un package common
qui contient des ressources partagées par les différents adaptateurs.
L’astérisque peut être utilisé pour mettre en place des structures cohérentes dans des couches similaires.
La couche Lang
Bien que la couche Library nous propose une syntaxe sur-mesure, optimale aussi bien en terme d’expressivité que de concision, il s’agit de quelque chose qui n’est disponible que pour un nombre de règles très limité.
La couche Lang propose donc une fluent API qui peut être utilisée pour implémenter la majorité des règles avec une syntaxe qui reste très expressive.
Garder la main sur les dépendances externes
Par exemple, il est possible de vérifier les dépendances externes qui sont utilisées dans les différentes parties de votre code. Cette problématique, à laquelle on répond traditionnellement en créant des modules Maven/Gradle distincts, peut être résolue avec ArchUnit en créant des contraintes au niveau d’un package ou d’une classe.
[java]@ArchTest
public static final ArchRule only_adapters_pull_model_dependencies = noClasses()
.that().resideOutsideOfPackage("fr.xebia.archunit.adapter..")
.should().accessClassesThat().resideInAnyPackage("com.fasterxml..");[/java]
Cette vérification ne s’applique pas aux annotations. Ainsi, dans l’exemple ci-dessus, même si des annotations Jackson sont utilisées dans le code source, la règle sera considérée comme satisfaite. Il s’agit d’un problème connu et identifié comme bloquant pour la sortie de la version 1.0.0.
Il existe un problème similaire au niveau de la prise en compte des paramètres de types. Pour reprendre l’exemple précédent, une classe qui manipule des listes de parsers JSON ne sera pas considérée comme dépendante de la bibliothèque Jackson.
Conventions de nommage
ArchUnit permet de définir des règles par rapport au nommage des classes, des méthodes, etc.
[java]@ArchTest
public static final ArchRule controllers_should_be_suffixed_as_such = classes()
.that().areAnnotatedWith(Controller.class)
.should().haveSimpleNameEndingWith("Controller");[/java]
Cette fonctionnalité n’est intéressante que pour des règles complexes, ou dont l’application est très localisée, sans quoi d’autres outils plus adaptés permettront de mettre en place ces règles de manière moins coûteuse.
La couche Core
Dans la majorité des cas, les couches Library et Lang devraient couvrir tous vos besoins. Toutefois, pour implémenter des règles très spécifiques, ArchUnit expose la couche qui leur sert de base : la couche Core.
La couche Core propose une API extrêmement flexible qui peut être utilisée pour implémenter à peu près n’importe quelle règle.
À titre d’exemple, jusqu’à la version 0.10.0 d’ArchUnit, la couche Lang ne permettait pas de définir de règles au niveau des méthodes. Afin de contourner cette limitation, il était toutefois possible d’utiliser la couche Core directement.
[java]@ArchTest
public static final ArchRule all_entry_points_shoud_be_annotated_with_log_description = all(new AbstractClassesTransformer<JavaMethod>("methods") {
@Override
public Iterable<JavaMethod> doTransform(final JavaClasses javaClasses) {
return StreamSupport.stream(javaClasses.spliterator(), false)
.flatMap(javaClass -> javaClass.getMethods().stream())
.collect(toList());
}
})
.that(HasOwner.Functions.Get.<JavaClass>owner().is(resideInAPackage("fr.xebia.archunit.controller..")))
.and(modifier(PUBLIC).as("are public"))
.should(new ArchCondition<JavaMethod>("annotated with " + LogDescription.class) {
@Override
public void check(final JavaMethod method, final ConditionEvents events) {
boolean typeMatches = method.isAnnotatedWith(LogDescription.class);
final String message = format("%s annotated with %s",
method.getFullName(), method.getAnnotations().stream()
.map(annotation -> annotation.getType().getSimpleName())
.collect(toList()));
events.add(new SimpleConditionEvent(method, typeMatches, message));
}
});[/java]
Comme on peut l’observer ci-dessus, la couche Core est verbeuse et manipule des concepts très génériques, ce qui aboutit à des règles difficiles à comprendre et à maintenir. C’est un problème qui peut être facilement résolu par l’implémentation d’une couche intermédiaire, dans une logique similaire à celle de la couche Lang d’ArchUnit. Cette couche additionnelle pourra ensuite être partagée entre les modules sous la forme d’une bibliothèque spécifique. Pour la même règle, on aboutit ainsi à une syntaxe beaucoup plus simple :
[java]@ArchTest
public static final ArchRule all_entry_points_shoud_be_annotated_with_log_description = all(methods())
.that(areDefinedInAPackage("fr.xebia.archunit.controller.."))
.and(arePublic())
.should(beAnnotatedWith(LogDescription.class));[/java]
Regrouper les règles
Étant donné que les règles sont définies sous la forme de constantes publiques, il est possible de les définir dans une classe et de les utiliser dans une autre, ce qui est particulièrement utile quand on veut partager des règles entre différents modules.
[java]public class HexagonalArchitectures {
@ArchTest
public static final ArchRule adapters_should_not_depend_on_one_another = …
}
@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = "fr.xebia.archunit")
public class ArchitectureTest {
@ArchTest
public static final ArchRule adapters_should_not_depend_on_one_another = HexagonalArchitectures.adapters_should_not_depend_on_one_another;
}[/java]
Il est également possible d’importer l’ensemble des règles définies dans une classe. Cela rend la définition des règles considérablement moins verbeuse ; il est donc intéressant de considérer la constitution d’ensembles cohérents au moment de la définition des règles.
[java]@RunWith(ArchUnitRunner.class)
@AnalyzeClasses(packages = "fr.xebia.archunit")
public class ArchitectureTest {
@ArchTest
public static final ArchRules hexagonal_architecture = ArchRules.in(HexagonalArchitectures.class);
}[/java]
Nous avons désormais un bon aperçu des possibilités techniques offertes par ArchUnit. Mais alors comment l’intégrer dans votre manière de gérer l’architecture de manière plus globale ? Que peut-il vous apporter du point de vue de la documentation ? Et quels sont les pièges à éviter lors de sa mise en œuvre ? Rendez-vous dans un deuxième article pour en savoir plus !
Commentaire