Java Compiler Plugin erstellen

Java Compiler Plugin erstellen

1. Überblick

Java 8 bietet eine API zum Erstellen vonJavacPlugins. Leider ist es schwierig, eine gute Dokumentation dafür zu finden.

In diesem Artikel zeigen wir den gesamten Prozess des Erstellens einer Compiler-Erweiterung, die*.class Dateien benutzerdefinierten Code hinzufügt.

2. Konfiguration

Zunächst müssen wir JDKstools.jarals Abhängigkeit für unser Projekt hinzufügen:


    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. Erstellen wir es in unserem Beispiel:

Erstellen wir es in unserem Beispiel:

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

Im Moment drucken wir nur "Hallo", um sicherzustellen, dass unser Code erfolgreich aufgenommen und in die Kompilierung aufgenommen wurde.

Unser Endziel wird darin bestehen, ein Plugin zu erstellen, das Laufzeitprüfungen für jedes mit einer gegebenen Anmerkung gekennzeichnete numerische Argument hinzufügt und eine Ausnahme auslöst, wenn das Argument nicht mit einer Bedingung übereinstimmt.

Es ist noch ein weiterer Schritt erforderlich, um die Erweiterung umJavac:it should be exposed through the ServiceLoader framework. erkennbar zu machen

Um dies zu erreichen, müssen wir eine Datei mit dem Namencom.sun.source.util.Plugin mit Inhalt erstellen, der der vollständig qualifizierte Klassenname (com.example.javac.SampleJavacPlugin) unseres Plugins ist, und ihn im VerzeichnisMETA-INF/services ablegen.

Danach können wirJavac mit dem Schalter-Xplugin:MyPlugin aufrufen:

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

Beachten Sie, dasswe must always use a String returned from the plugin’s getName() method as a -Xplugin option value.

3. Plugin-Lebenszyklus

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

Um über nachfolgende Ereignisse informiert zu werden, müssen wir einen Rückruf registrieren. Diese kommen vor und nach jeder Verarbeitungsstufe pro Quelldatei an:

  • PARSE - Erstellt einAbstract Syntax Tree (AST)

  • ENTER - Quellcode-Importe werden aufgelöst

  • ANALYZE - Parser-Ausgabe (ein AST) wird auf Fehler analysiert

  • GENERATE - Binärdateien für die Zielquelldatei generieren

Es gibt zwei weitere Ereignistypen -ANNOTATION_PROCESSING undANNOTATION_PROCESSING_ROUND, aber wir sind hier nicht an ihnen interessiert.

Wenn wir beispielsweise die Kompilierung verbessern möchten, indem wir einige Überprüfungen basierend auf Quellcode-Informationen hinzufügen, ist es sinnvoll, dies im Ereignishandler vonPARSE finishedzu tun:

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. AST-Daten extrahieren

We can get an AST generated by the Java compiler through the TaskEvent.getCompilationUnit(). Die Details können über die SchnittstelleTreeVisitor überprüft werden.

Beachten Sie, dass nur einTree-Element, für das dieaccept()-Methode aufgerufen wird, Ereignisse an den angegebenen Besucher sendet.

Wenn wir beispielsweiseClassTree.accept(visitor) ausführen, wird nurvisitClass() ausgelöst. Wir können nicht erwarten, dass beispielsweisevisitMethod() auch für jede Methode in der angegebenen Klasse aktiviert wird.

Wir könnenTreeScanner verwenden, um das Problem zu überwinden:

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

In diesem Beispiel musssuper.visitXxx(node, value) aufgerufen werden, um die untergeordneten Knoten des aktuellen Knotens rekursiv zu verarbeiten.

5. Ändern Sie AST

Um zu zeigen, wie wir den AST ändern können, fügen wir Laufzeitprüfungen für alle numerischen Argumente ein, die mit einer@Positive-Anmerkung gekennzeichnet sind.

Dies ist eine einfache Anmerkung, die auf Methodenparameter angewendet werden kann:

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

Hier ist ein Beispiel für die Verwendung der Anmerkung:

public void service(@Positive int i) { }

Am Ende soll der Bytecode so aussehen, als ob er aus einer Quelle wie dieser kompiliert wurde:

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

Dies bedeutet, dass für jedes mit@Positive gekennzeichnete Argument, das gleich oder kleiner als 0 ist, einIllegalArgumentException ausgelöst werden soll.

5.1. Wo man instrumentiert

Lassen Sie uns herausfinden, wie wir Zielorte finden können, an denen die Instrumentierung angewendet werden soll:

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

Der Einfachheit halber haben wir hier nur primitive numerische Typen hinzugefügt.

Als Nächstes definieren wir eineshouldInstrument()-Methode, die prüft, ob der Parameter einen Typ in der Menge TARGET_TYPES sowie die Annotation@Positivehat:

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

Dann setzen wir diefinished()-Methode in unsererSampleJavacPlugin-Klasse fort und überprüfen alle Parameter, die unsere Bedingungen erfüllen:

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

In diesem Beispiel haben wir die Parameterliste umgekehrt, da möglicherweise mehr als ein Argument mit@Positive. markiert ist. Da jede Prüfung als allererste Methodenanweisung hinzugefügt wird, verarbeiten wir sie RTL, um die korrekte Reihenfolge sicherzustellen.

Da jede Prüfung als allererste Methodenanweisung hinzugefügt wird, verarbeiten wir sie RTL, um die richtige Reihenfolge sicherzustellen.

5.2. Instrumentieren

Das Problem ist, dass "AST lesen" im API-Bereich vonpublicliegt, während "AST ändern" -Operationen wie "Nullprüfungen hinzufügen"privateAPI sind.

Um dies zu beheben,we’ll create new AST elements through a TreeMaker instance.

Zuerst müssen wir eineContext-Instanz erhalten:

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

Dann können wir dasTreeMarker-Objekt durch dieTreeMarker.instance(Context)-Methode erhalten.

Jetzt können wir neue AST-Elemente erstellen, z. B. kann einif-Ausdruck durch einen Aufruf vonTreeMaker.If() konstruiert werden:

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

Bitte beachten Sie, dass wir die richtige Stack-Trace-Zeile anzeigen möchten, wenn eine Ausnahme von unserem Check ausgelöst wird. Aus diesem Grund passen wir die AST-Werksposition an, bevor wir mitfactory.at(((JCTree) parameter).pos) neue Elemente erstellen.

Die MethodecreateIfCondition() erstellt die Bedingung "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));
}

Als nächstes erstellt diecreateIfBlock()-Methode einen Block, derIllegalArgumentException: zurückgibt

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

Nachdem wir neue AST-Elemente erstellen können, müssen wir sie in den vom Parser vorbereiteten AST einfügen. Wir können dies erreichen, indem wirpublic API Elemente inprivate API-Typen umwandeln:

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. Testen des Plugins

Wir müssen unser Plugin testen können. Es beinhaltet Folgendes:

  • Kompilieren Sie die Testquelle

  • Führen Sie die kompilierten Binärdateien aus und stellen Sie sicher, dass sie sich wie erwartet verhalten

Dazu müssen wir einige Hilfsklassen einführen.

SimpleSourceFile macht den Text der angegebenen Quelldatei fürJavac verfügbar:

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 enthält das Kompilierungsergebnis als Byte-Array:

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 stellt sicher, dass der Compiler unseren Bytecode-Inhaber verwendet:

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

Schließlich ist all das an die In-Memory-Kompilierung gebunden:

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

Danach müssen wir nur noch die Binaries ausführen:

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

Ein Test könnte so aussehen:

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

Hier kompilieren wir eineTest-Klasse mit einerservice()-Methode, deren Parameter mit@Positive. kommentiert ist. Dann führen wir dieTest-Klasse aus, indem wir einen doppelten Wert von festlegen -1 für den Methodenparameter.

Wenn der Compiler mit unserem Plugin ausgeführt wird, gibt der Test einIllegalArgumentException für den negativen Parameter aus.

7. Fazit

In diesem Artikel haben wir den gesamten Prozess zum Erstellen, Testen und Ausführen eines Java Compiler-Plugins gezeigt.

Der vollständige Quellcode der Beispiele istover on GitHub.