DEV Community

Cover image for Technique Avancée d'OpenRewrite : Orchestrer des refactorings complexes avec les `ScanningRecipe`
Kosmik for Onepoint

Posted on • Originally published at jtama.github.io

Technique Avancée d'OpenRewrite : Orchestrer des refactorings complexes avec les `ScanningRecipe`

⚠️ 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 :

  1. Une phase d’analyse (scan) : Un premier visiteur parcourt tous les fichiers sources pour collecter des informations, sans rien modifier.
  2. Une phase de génération (generate) : À partir des informations collectées, cette étape peut créer de nouveaux fichiers sources.
  3. 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) {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode
  1. Beaucoup, beaucoup trop d’annotations qui nuisent à la lisibilité du code

La recette doit :

  1. Trouver la classe RocketController grâce à son annotation.
  2. Générer une nouvelle interface IRocketController dans un module différent.
  3. Modifier RocketController pour qu’elle implémente IRocketController, 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;
            }
        };
    }
    //...
}
Enter fullscreen mode Exit fullscreen mode
  1. L'Accumulator est une simple classe statique interne qui sert de conteneur pour les données que nous collectons.
  2. On vérifie que la classe est bien celle ciblée par l’annotation @LearnToFly et qu’elle n’implémente pas déjà l’interface.
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode
  1. Pour chaque classe à traiter, on appelle une méthode qui va fabriquer la structure de notre nouvelle interface.
  2. La transformation principale se fait via un petit visiteur interne. Pour chaque méthode, on supprime son corps avec withBody(null).
  3. 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);
        }
    };
}
Enter fullscreen mode Exit fullscreen mode
  1. Si la ClassDeclaration en cours de visite est l’une de nos cibles, on attache les données la concernant (toExtract) comme message sur le curseur.
  2. Dans visitClassDeclaration, on vérifie si un message est présent. Si oui, on sait qu’on doit modifier cette classe.
  3. On ajoute la clause implements et on supprime l’annotation @LearnToFly. Un autre visiteur (non montré ici) s’occupe d’ajouter @Override aux 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);
    }
Enter fullscreen mode Exit fullscreen mode
  1. 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());
    }

}
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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)