Création d’un plugin de compilation Java

Création d'un plugin de compilation Java

1. Vue d'ensemble

Java 8 fournit une API pour créer les pluginsJavac. Malheureusement, il est difficile de trouver une bonne documentation à ce sujet.

Dans cet article, nous allons montrer l'ensemble du processus de création d'une extension de compilateur qui ajoute du code personnalisé aux fichiers*.class.

2. Installer

Tout d'abord, nous devons ajouter lestools.jar du JDK en tant que dépendance pour notre projet:


    com.sun
    tools
    1.8.0
    system
    ${java.home}/../lib/tools.jar

Every compiler extension is a class which implements com.sun.source.util.Plugin interface. Créons-le dans notre exemple:

Créons-le dans notre exemple:

public class SampleJavacPlugin implements Plugin {

    @Override
    public String getName() {
        return "MyPlugin";
    }

    @Override
    public void init(JavacTask task, String... args) {
        Context context = ((BasicJavacTask) task).getContext();
        Log.instance(context)
          .printRawLines(Log.WriterKind.NOTICE, "Hello from " + getName());
    }
}

Pour l'instant, nous imprimons simplement "Bonjour" pour nous assurer que notre code est correctement récupéré et inclus dans la compilation.

Notre objectif final sera de créer un plugin qui ajoute des vérifications d'exécution pour chaque argument numérique marqué d'une annotation donnée, et de lever une exception si l'argument ne correspond pas à une condition.

Une étape supplémentaire est nécessaire pour rendre l'extension détectable parJavac:it should be exposed through the ServiceLoader framework.

Pour ce faire, nous devons créer un fichier nommécom.sun.source.util.Plugin avec un contenu qui est le nom de classe complet de notre plugin (com.example.javac.SampleJavacPlugin) et le placer dans le répertoireMETA-INF/services.

Après cela, nous pouvons appelerJavac avec le commutateur-Xplugin:MyPlugin:

example/tutorials$ javac -cp ./core-java/target/classes -Xplugin:MyPlugin ./core-java/src/main/java/com/example/javac/TestClass.java
Hello from MyPlugin

Notez quewe must always use a String returned from the plugin’s getName() method as a -Xplugin option value.

3. Cycle de vie du plugin

Aplugin is called by the compiler only once, through the init() method.

Pour être averti des événements ultérieurs, nous devons enregistrer un rappel. Ceux-ci arrivent avant et après chaque étape de traitement par fichier source:

  • PARSE - construit unAbstract Syntax Tree (AST)

  • ENTER - les importations de code source sont résolues

  • ANALYZE - la sortie de l'analyseur (un AST) est analysée pour les erreurs

  • GENERATE - génération de binaires pour le fichier source cible

Il existe deux autres types d’événements -ANNOTATION_PROCESSING etANNOTATION_PROCESSING_ROUND, mais ils ne nous intéressent pas ici.

Par exemple, lorsque nous voulons améliorer la compilation en ajoutant des vérifications basées sur les informations du code source, il est raisonnable de le faire au gestionnaire d'événementsPARSE finished:

public void init(JavacTask task, String... args) {
    task.addTaskListener(new TaskListener() {
        public void started(TaskEvent e) {
        }

        public void finished(TaskEvent e) {
            if (e.getKind() != TaskEvent.Kind.PARSE) {
                return;
            }
            // Perform instrumentation
        }
    });
}

4. Extraire les données AST

We can get an AST generated by the Java compiler through the TaskEvent.getCompilationUnit(). Ses détails peuvent être examinés via l'interfaceTreeVisitor.

Notez que seul un élémentTree, pour lequel la méthodeaccept() est appelée, distribue les événements au visiteur donné.

Par exemple, lorsque nous exécutonsClassTree.accept(visitor), seulvisitClass() est déclenché; nous ne pouvons pas nous attendre à ce que, disons,visitMethod() soit également activé pour chaque méthode de la classe donnée.

Nous pouvons utiliserTreeScanner pour surmonter le problème:

public void finished(TaskEvent e) {
    if (e.getKind() != TaskEvent.Kind.PARSE) {
        return;
    }
    e.getCompilationUnit().accept(new TreeScanner() {
        @Override
        public Void visitClass(ClassTree node, Void aVoid) {
            return super.visitClass(node, aVoid);

        @Override
        public Void visitMethod(MethodTree node, Void aVoid) {
            return super.visitMethod(node, aVoid);
        }
    }, null);
}

Dans cet exemple, il est nécessaire d'appelersuper.visitXxx(node, value) pour traiter de manière récursive les enfants du nœud actuel.

5. Modifier AST

Pour montrer comment nous pouvons modifier l'AST, nous allons insérer des contrôles d'exécution pour tous les arguments numériques marqués d'une annotation@Positive.

Il s'agit d'une simple annotation pouvant être appliquée aux paramètres de méthode:

@Documented
@Retention(RetentionPolicy.CLASS)
@Target({ElementType.PARAMETER})
public @interface Positive { }

Voici un exemple d'utilisation de l'annotation:

public void service(@Positive int i) { }

En fin de compte, nous voulons que le bytecode ait l'air d'avoir été compilé à partir d'une source comme celle-ci:

public void service(@Positive int i) {
    if (i <= 0) {
        throw new IllegalArgumentException("A non-positive argument ("
          + i + ") is given as a @Positive parameter 'i'");
    }
}

Cela signifie que nous voulons qu'unIllegalArgumentException soit lancé pour chaque argument marqué avec@Positive qui est égal ou inférieur à 0.

5.1. Où instrumenter

Voyons comment nous pouvons localiser les emplacements cibles où l'instrumentation doit être appliquée:

private static Set TARGET_TYPES = Stream.of(
  byte.class, short.class, char.class,
  int.class, long.class, float.class, double.class)
 .map(Class::getName)
 .collect(Collectors.toSet());

Pour plus de simplicité, nous n'avons ajouté que des types numériques primitifs ici.

Ensuite, définissons une méthodeshouldInstrument() qui vérifie si le paramètre a un type dans l'ensemble TARGET_TYPES ainsi que l'annotation@Positive:

private boolean shouldInstrument(VariableTree parameter) {
    return TARGET_TYPES.contains(parameter.getType().toString())
      && parameter.getModifiers().getAnnotations().stream()
      .anyMatch(a -> Positive.class.getSimpleName()
        .equals(a.getAnnotationType().toString()));
}

Ensuite, nous allons continuer la méthodefinished() dans notre classeSampleJavacPlugin en appliquant une vérification à tous les paramètres qui remplissent nos conditions:

public void finished(TaskEvent e) {
    if (e.getKind() != TaskEvent.Kind.PARSE) {
        return;
    }
    e.getCompilationUnit().accept(new TreeScanner() {
        @Override
        public Void visitMethod(MethodTree method, Void v) {
            List parametersToInstrument
              = method.getParameters().stream()
              .filter(SampleJavacPlugin.this::shouldInstrument)
              .collect(Collectors.toList());

              if (!parametersToInstrument.isEmpty()) {
                Collections.reverse(parametersToInstrument);
                parametersToInstrument.forEach(p -> addCheck(method, p, context));
            }
            return super.visitMethod(method, v);
        }
    }, null);

Dans cet exemple, nous avons inversé la liste des paramètres car il est possible que plusieurs arguments soient marqués par@Positive. Comme chaque vérification est ajoutée en tant que toute première instruction de méthode, nous les traitons RTL pour garantir un ordre correct.

Comme chaque contrôle est ajouté en tant que toute première instruction de méthode, nous les traitons RTL pour assurer un ordre correct.

5.2. Comment instrumenter

Le problème est que «lire AST» se trouve dans la zone APIpublic, tandis que les opérations «modifier AST» comme «ajouter des vérifications nulles» sont desprivateAPI.

Pour résoudre ce problème,we’ll create new AST elements through a TreeMaker instance.

Tout d'abord, nous devons obtenir une instanceContext:

@Override
public void init(JavacTask task, String... args) {
    Context context = ((BasicJavacTask) task).getContext();
    // ...
}

Ensuite, nous pouvons obtenir l'objetTreeMarker via la méthodeTreeMarker.instance(Context).

Maintenant, nous pouvons construire de nouveaux éléments AST, par exemple, une expressionif peut être construite par un appel àTreeMaker.If():

private static JCTree.JCIf createCheck(VariableTree parameter, Context context) {
    TreeMaker factory = TreeMaker.instance(context);
    Names symbolsTable = Names.instance(context);

    return factory.at(((JCTree) parameter).pos)
      .If(factory.Parens(createIfCondition(factory, symbolsTable, parameter)),
        createIfBlock(factory, symbolsTable, parameter),
        null);
}

Veuillez noter que nous souhaitons afficher la ligne de trace de pile correcte lorsqu'une exception est générée à partir de notre contrôle. C'est pourquoi nous ajustons la position d'usine AST avant de créer de nouveaux éléments avecfactory.at(((JCTree) parameter).pos).

La méthodecreateIfCondition() crée la condition «parameterId <0»if:

private static JCTree.JCBinary createIfCondition(TreeMaker factory,
  Names symbolsTable, VariableTree parameter) {
    Name parameterId = symbolsTable.fromString(parameter.getName().toString());
    return factory.Binary(JCTree.Tag.LE,
      factory.Ident(parameterId),
      factory.Literal(TypeTag.INT, 0));
}

Ensuite, la méthodecreateIfBlock() construit un bloc qui renvoie unIllegalArgumentException:

private static JCTree.JCBlock createIfBlock(TreeMaker factory,
  Names symbolsTable, VariableTree parameter) {
    String parameterName = parameter.getName().toString();
    Name parameterId = symbolsTable.fromString(parameterName);

    String errorMessagePrefix = String.format(
      "Argument '%s' of type %s is marked by @%s but got '",
      parameterName, parameter.getType(), Positive.class.getSimpleName());
    String errorMessageSuffix = "' for it";

    return factory.Block(0, com.sun.tools.javac.util.List.of(
      factory.Throw(
        factory.NewClass(null, nil(),
          factory.Ident(symbolsTable.fromString(
            IllegalArgumentException.class.getSimpleName())),
            com.sun.tools.javac.util.List.of(factory.Binary(JCTree.Tag.PLUS,
            factory.Binary(JCTree.Tag.PLUS,
              factory.Literal(TypeTag.CLASS, errorMessagePrefix),
              factory.Ident(parameterId)),
              factory.Literal(TypeTag.CLASS, errorMessageSuffix))), null))));
}

Maintenant que nous sommes en mesure de créer de nouveaux éléments AST, nous devons les insérer dans l'AST préparé par l'analyseur. Nous pouvons y parvenir en convertissant les élémentspublic API en types d'APIprivate:

private void addCheck(MethodTree method, VariableTree parameter, Context context) {
    JCTree.JCIf check = createCheck(parameter, context);
    JCTree.JCBlock body = (JCTree.JCBlock) method.getBody();
    body.stats = body.stats.prepend(check);
}

6. Test du plugin

Nous devons pouvoir tester notre plugin. Cela implique ce qui suit:

  • compiler la source de test

  • lancez les fichiers binaires compilés et assurez-vous qu'ils se comportent comme prévu

Pour cela, nous devons introduire quelques classes auxiliaires.

SimpleSourceFile expose le texte du fichier source donné auxJavac:

public class SimpleSourceFile extends SimpleJavaFileObject {
    private String content;

    public SimpleSourceFile(String qualifiedClassName, String testSource) {
        super(URI.create(String.format(
          "file://%s%s", qualifiedClassName.replaceAll("\\.", "/"),
          Kind.SOURCE.extension)), Kind.SOURCE);
        content = testSource;
    }

    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) {
        return content;
    }
}

SimpleClassFile contient le résultat de la compilation sous forme de tableau d'octets:

public class SimpleClassFile extends SimpleJavaFileObject {

    private ByteArrayOutputStream out;

    public SimpleClassFile(URI uri) {
        super(uri, Kind.CLASS);
    }

    @Override
    public OutputStream openOutputStream() throws IOException {
        return out = new ByteArrayOutputStream();
    }

    public byte[] getCompiledBinaries() {
        return out.toByteArray();
    }

    // getters
}

SimpleFileManager garantit que le compilateur utilise notre détenteur de bytecode:

public class SimpleFileManager
  extends ForwardingJavaFileManager {

    private List compiled = new ArrayList<>();

    // standard constructors/getters

    @Override
    public JavaFileObject getJavaFileForOutput(Location location,
      String className, JavaFileObject.Kind kind, FileObject sibling) {
        SimpleClassFile result = new SimpleClassFile(
          URI.create("string://" + className));
        compiled.add(result);
        return result;
    }

    public List getCompiled() {
        return compiled;
    }
}

Enfin, tout cela est lié à la compilation en mémoire:

public class TestCompiler {
    public byte[] compile(String qualifiedClassName, String testSource) {
        StringWriter output = new StringWriter();

        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
        SimpleFileManager fileManager = new SimpleFileManager(
          compiler.getStandardFileManager(null, null, null));
        List compilationUnits
          = singletonList(new SimpleSourceFile(qualifiedClassName, testSource));
        List arguments = new ArrayList<>();
        arguments.addAll(asList("-classpath", System.getProperty("java.class.path"),
          "-Xplugin:" + SampleJavacPlugin.NAME));
        JavaCompiler.CompilationTask task
          = compiler.getTask(output, fileManager, null, arguments, null,
          compilationUnits);

        task.call();
        return fileManager.getCompiled().iterator().next().getCompiledBinaries();
    }
}

Après cela, il suffit d’exécuter les fichiers binaires:

public class TestRunner {

    public Object run(byte[] byteCode, String qualifiedClassName, String methodName,
      Class[] argumentTypes, Object... args) throws Throwable {
        ClassLoader classLoader = new ClassLoader() {
            @Override
            protected Class findClass(String name) throws ClassNotFoundException {
                return defineClass(name, byteCode, 0, byteCode.length);
            }
        };
        Class clazz;
        try {
            clazz = classLoader.loadClass(qualifiedClassName);
        } catch (ClassNotFoundException e) {
            throw new RuntimeException("Can't load compiled test class", e);
        }

        Method method;
        try {
            method = clazz.getMethod(methodName, argumentTypes);
        } catch (NoSuchMethodException e) {
            throw new RuntimeException(
              "Can't find the 'main()' method in the compiled test class", e);
        }

        try {
            return method.invoke(null, args);
        } catch (InvocationTargetException e) {
            throw e.getCause();
        }
    }
}

Un test pourrait ressembler à ceci:

public class SampleJavacPluginTest {

    private static final String CLASS_TEMPLATE
      = "package com.example.javac;\n\n" +
        "public class Test {\n" +
        "    public static %1$s service(@Positive %1$s i) {\n" +
        "        return i;\n" +
        "    }\n" +
        "}\n" +
        "";

    private TestCompiler compiler = new TestCompiler();
    private TestRunner runner = new TestRunner();

    @Test(expected = IllegalArgumentException.class)
    public void givenInt_whenNegative_thenThrowsException() throws Throwable {
        compileAndRun(double.class,-1);
    }

    private Object compileAndRun(Class argumentType, Object argument)
      throws Throwable {
        String qualifiedClassName = "com.example.javac.Test";
        byte[] byteCode = compiler.compile(qualifiedClassName,
          String.format(CLASS_TEMPLATE, argumentType.getName()));
        return runner.run(byteCode, qualifiedClassName,
        "service", new Class[] {argumentType}, argument);
    }
}

Ici, nous compilons une classeTest avec une méthodeservice() qui a un paramètre annoté avec@Positive. Ensuite, nous exécutons la classeTest en définissant une valeur double de -1 pour le paramètre de méthode.

À la suite de l'exécution du compilateur avec notre plugin, le test lancera unIllegalArgumentException pour le paramètre négatif.

7. Conclusion

Dans cet article, nous avons présenté le processus complet de création, de test et d'exécution d'un plug-in Java Compiler.

Le code source complet des exemples peut être trouvéover on GitHub.