- ⚠️ WARNING
- Le sujet qui va être traité est avancé. En cas de doute, je vous conseille d’aller lire les articles précédents de cette série.
Mais que se passe-t-il si notre besoin dépasse les frontières d’un seul fichier ?
Oui, mais si ma recette a besoin d'analyser le contenu de la classe A pour décider de modifier la classe B et de générer une toute nouvelle classe C ?
C’est précisément le cas d’usage où les recettes classiques, qui traitent les fichiers de manière isolée, ne suffisent plus. Pour ces scénarios, OpenRewrite fournit un outil spécifique : la ScanningRecipe.
Une ScanningRecipe découpe le refactoring en trois phases distinctes :
-
Une phase d’analyse (
scan) : Un premier visiteur parcourt tous les fichiers sources pour collecter des informations, sans rien modifier. -
Une phase de génération (
generate) : À partir des informations collectées, cette étape peut créer de nouveaux fichiers sources. -
Une phase de modification (
visit) : Un second visiteur parcourt à nouveau les fichiers pour appliquer les modifications, en s’aidant des données de la phase d’analyse.
Cas d'usage : Extraire une interface
Pour illustrer ce processus, nous allons implémenter une recette qui extrait une interface de toutes les classes annotées avec @LearnToFly.
Pour une classe comme celle-ci :
package com.foo.fighter;
//imports
@LearnToFly
public class RocketController {
@Operation(summary = "Launches a rocket to a given destination", description = "Check availability and other constraints before doing anything.")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Successfully launched"),
@ApiResponse(responseCode = "404", description = "Not found - The destination isn't in the solar system")
})
@GetMapping("/rocket/{destination}")<1>
public String launch(String destination) {
// ...
}
}
- Beaucoup, beaucoup trop d’annotations qui nuisent à la lisibilité du code
La recette doit :
-
Trouver la classe
RocketControllergrâce à son annotation. -
Générer une nouvelle interface
IRocketControllerdans un module différent. -
Modifier
RocketControllerpour qu’elle implémenteIRocketController, et supprimer l’annotation@LearnToFly.
Analysons l’implémentation (disponible sur le dépôt d’exemples).
Phase 1 : La collecte d'informations
La première étape est une mission de reconnaissance. Le Scanner parcourt le code source pour identifier les classes cibles et stocke les informations dans un Accumulator, un conteneur de données partagé entre les phases.
public class ExtractInterface extends ScanningRecipe<ExtractInterface.Accumulator> {
public static class Accumulator { ①
Map<String, ToExtract> toBeExtracted = new HashMap<>();
}
@Override
public Accumulator getInitialValue(ExecutionContext ctx) {
return new Accumulator();
}
@Override
public TreeVisitor<?, ExecutionContext> getScanner(Accumulator acc) {
return new JavaIsoVisitor<ExecutionContext>() {
@Override
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration classDecl, ExecutionContext ctx) {
if (isClass.test(classDecl) && !alreadyImplementsInterface.test(classDecl)) { ②
acc.toBeExtracted.put(classDecl.getType().getFullyQualifiedName(),
new ToExtract(classDecl.getSourcePath(), ...)); ③
}
return classDecl;
}
};
}
//...
}
- L'
Accumulatorest une simple classe statique interne qui sert de conteneur pour les données que nous collectons. - On vérifie que la classe est bien celle ciblée par l’annotation
@LearnToFlyet qu’elle n’implémente pas déjà l’interface. - Si la classe correspond, on stocke les informations nécessaires (comme son
sourcePath) dans l'Accumulator. Le FQDN de la classe sert de clé.
À la fin de cette phase, aucune modification n’a été faite, mais notre Accumulator contient une "liste de tâches" de toutes les classes à traiter.
Phase 2 : La génération de l'interface
Cette méthode est exécutée après le Scanner. Elle utilise les données de l'Accumulator pour construire de nouveaux SourceFile.
@Override
public Collection<SourceFile> generate(Accumulator acc, ExecutionContext ctx) {
return acc.toBeExtracted.values().stream()
.map(toExtract -> {
// ... logique pour déterminer le chemin de la nouvelle interface ...
return (SourceFile) getExtractedInterface(toExtract) ①
.withSourcePath(newInterfacePath)
.withMarkers(Tree.randomId());
})
.collect(Collectors.toList());
}
private J.CompilationUnit getExtractedInterface(ToExtract toExtract) {
JavaTemplate interfaceTemplate = JavaTemplate.builder("public interface #{any(String)} {}")
.build();
// ...
J.CompilationUnit anInterface = (J.CompilationUnit) new JavaIsoVisitor<Integer>() {
@Override
public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, Integer p) {
return method.withBody(null); ②
}
@Override
public J.VariableDeclarations visitVariableDeclarations(J.VariableDeclarations multiVariable, Integer p) {
return null; ③
}
}.visit(toExtract.getOriginalTree(), 0);
// ...
return anInterface;
}
- Pour chaque classe à traiter, on appelle une méthode qui va fabriquer la structure de notre nouvelle interface.
- La transformation principale se fait via un petit visiteur interne. Pour chaque méthode, on supprime son corps avec
withBody(null). - On supprime également toutes les déclarations de variables en retournant
null.
Le résultat est une collection de J.CompilationUnit qui représentent nos nouvelles interfaces, prêtes à être ajoutées au projet.
Phase 3 : La modification des classes existantes
Dernière étape : modifier les classes originales. Pour cela, on réutilise la technique des messages. On parcourt les ClassDeclaration et si l’une d’elles correspond à une entrée de notre Accumulator, on lui attache un message.
La déclaration de la classe
@Override
public TreeVisitor<?, ExecutionContext> getVisitor(Accumulator acc) {
return new JavaIsoVisitor<ExecutionContext>() {
@Override
public J.ClassDeclaration visitClassDeclaration(J.ClassDeclaration cd, ExecutionContext ctx) {
ToExtract toExtract = acc.toBeExtracted.get(cd.getType().getFullyQualifiedName());
if (toExtract != null) {
getCursor().putMessage(TARGET_CLASS, toExtract); ①
var annotations = cd.getLeadingAnnotations();
annotations.removeIf(ann -> targetAnnotationMatcher.matches(ann));
maybeRemoveImport(targetAnnotation); ②
cd = cd.withLeadingAnnotations(annotations)
.withImplements(List.of(TypeTree.build(target.extractedInterfaceName()))); ③
cd = super.visitClassDeclaration(cd, executionContext);
return cd;
}
return super.visitClassDeclaration(cd, ctx);
}
};
}
- Si la
ClassDeclarationen cours de visite est l’une de nos cibles, on attache les données la concernant (toExtract) comme message sur le curseur. - Dans
visitClassDeclaration, on vérifie si un message est présent. Si oui, on sait qu’on doit modifier cette classe. - On ajoute la clause
implementset on supprime l’annotation@LearnToFly. Un autre visiteur (non montré ici) s’occupe d’ajouter@Overrideaux méthodes.
Les déclarations de méthodes
@Override
public J.MethodDeclaration visitMethodDeclaration(J.MethodDeclaration method, ExecutionContext executionContext) {
if (getCursor().getNearestMessage(TARGET_CLASS) != null) { ①
return JavaTemplate.builder("@Override").build().apply(getCursor(), method.getCoordinates().replaceAnnotations());
}
return super.visitMethodDeclaration(method, executionContext);
}
- Si la classe a été marquée de sceau rouge de l’extraction, on ajoute l’annotation
@Overrideà toutes les déclarations de méthodes.
Avant/Après
Voici par exemple une classe avant extraction :
HostelController open
import com.github.jtama.app.reservation.Reservation;
import com.github.jtama.toxic.BigDecimalUtils;
import com.github.jtama.toxic.LearnToFly;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.SecurityContext;
import java.util.List;
@Path("/api/hostels")
@Produces(MediaType.APPLICATION_JSON)
@LearnToFly
public class HostelController {
@Inject
HostelReservationService hostelReservationService;
@GET
public List<Hostel> getAll() {
return Hostel.listAll();
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed("ADMIN")
@Transactional
public Hostel create(Hostel hostel) {
return Hostel.persistIfNotExists(hostel);
}
@POST
@Path("/{name}/book")
@RolesAllowed("USER")
public Reservation book(@PathParam("name")String name, @QueryParam("month") Integer month, @Context SecurityContext security) {
BigDecimalUtils.valueOf(month.longValue());// Because I can !
return hostelReservationService.book(name, month, security.getUserPrincipal().getName());
}
}
L'interface extraite :
IHostelController open
import com.github.jtama.app.reservation.Reservation;
import com.github.jtama.toxic.LearnToFly;
import jakarta.annotation.security.RolesAllowed;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.SecurityContext;
import java.util.List;
@Path("/api/hostels")
@Produces(MediaType.APPLICATION_JSON)
@LearnToFly
public interface IHostelController {
@GET List<Hostel> getAll();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@RolesAllowed("ADMIN")
@Transactional Hostel create(Hostel hostel);
@POST
@Path("/{name}/book")
@RolesAllowed("USER") Reservation book(@PathParam("name")String name, @QueryParam("month") Integer month, @Context SecurityContext security);
}
Et la classe après extraction :
IHostelController open
import com.github.jtama.app.reservation.Reservation;
import jakarta.inject.Inject;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.SecurityContext;
import java.math.BigDecimal;
import java.util.List;
public class HostelController implements IHostelController {
@Inject
HostelReservationService hostelReservationService;
@Override
public List<Hostel> getAll() {
return Hostel.listAll();
}
@Override
public Hostel create(Hostel hostel) {
return Hostel.persistIfNotExists(hostel);
}
@Override
public Reservation book(@PathParam("name")String name, @QueryParam("month") Integer month, @Context SecurityContext security) {
BigDecimal.valueOf(month.longValue());// Because I can !
return hostelReservationService.book(name, month, security.getUserPrincipal().getName());
}
}
C'est dans la boîte
J’espère vous avoir convaincu qu’il n’y a encore une fois rien de sorcier. Des concepts séparément simples qui une fois réunis nous permettent des réécritures relativement complexes.
Encore une fois, vos cas d’usages sont la limite ! Je vous ferai bientôt part d’un nouveau joujou pour visualiser vos projets d’une nouvelle manières.
Et comme d'habitude, tout le code est disponible dans le dépôt Openrewrite refactoring as code.
Top comments (0)