Erstellen eines Java Compiler Plugins

1. Übersicht

Java 8 bietet eine API zum Erstellen von Javac- Plugins. 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. Setup

Zuerst müssen wir JDKs tools.jar als Abhängigkeit für unser Projekt hinzufügen :

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

Jede Compiler-Erweiterung ist eine Klasse, die die Schnittstelle com.sun.source.util.Plugin implementiert. 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 bestimmten Anmerkung gekennzeichnete numerische Argument hinzufügt und eine Ausnahme auslöst, wenn das Argument nicht mit einer Bedingung übereinstimmt.

Es gibt noch einen weiteren notwendigen Schritt, um die Erweiterung für Javac erkennbar zu machen : Sie sollte über das ServiceLoader- Framework verfügbar gemacht werden .

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

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

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

Beachten Sie, dass wir immer einen String verwenden müssen, der von der getName () -Methode des Plugins als Optionswert -Xplugin zurückgegeben wird .

3. Plugin-Lebenszyklus

Ein Plugin wird vom Compiler nur einmal über die Methode init () aufgerufen .

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 einen abstrakten Syntaxbaum (AST)
  • ENTER - Quellcode-Importe werden aufgelöst
  • ANALYSE - Die Parser-Ausgabe (ein AST) wird auf Fehler analysiert
  • GENERATE - Generieren von Binärdateien für die Zielquelldatei

Es gibt zwei weitere Ereignistypen - ANNOTATION_PROCESSING und ANNOTATION_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 PARSE- Ereignishandler zu 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

Wir können einen AST vom Java-Compiler über TaskEvent.getCompilationUnit () generieren lassen . Die Details können über die TreeVisitor- Oberfläche überprüft werden.

Beachten Sie, dass nur ein Tree- Element, für das die accept () -Methode aufgerufen wird, Ereignisse an den angegebenen Besucher sendet.

Wenn wir beispielsweise ClassTree.accept (Besucher) ausführen , wird nur visitClass () ausgelöst. Wir können nicht erwarten, dass beispielsweise visitMethod () auch für jede Methode in der angegebenen Klasse aktiviert ist.

Wir können TreeScanner verwenden , um das Problem zu lösen:

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 muss super.visitXxx (Knoten, Wert) 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- Annotation 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 möchten wir, dass der Bytecode so aussieht, 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, eine IllegalArgumentException 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 eine shouldInstrument () -Methode, die prüft, ob der Parameter einen Typ im TARGET_TYPES-Set sowie in der @ Positive- Annotation hat:

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

Then we'll continue the finished() method in our SampleJavacPlugin class with applying a check to all parameters that fulfill our 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); 

In this example, we've reversed the parameters list because there's a possible case that more than one argument is marked by @Positive. As every check is added as the very first method instruction, we process them RTL to ensure the correct order.

5.2. How to Instrument

The problem is that “read AST” lays in the public API area, while “modify AST” operations like “add null-checks” are a private API.

To address this, we'll create new AST elements through a TreeMaker instance.

First, we need to obtain a Context instance:

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

Then, we can obtain the TreeMarker object through the TreeMarker.instance(Context) method.

Now we can build new AST elements, e.g., an if expression can be constructed by a call to 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); }

Please note that we want to show the correct stack trace line when an exception is thrown from our check. That's why we adjust the AST factory position before creating new elements through it with factory.at(((JCTree) parameter).pos).

The createIfCondition() method builds the “parameterId< 0″ if condition:

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

Next, the createIfBlock() method builds a block that returns an IllegalArgumentException:

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

Now that we're able to build new AST elements, we need to insert them into the AST prepared by the parser. We can achieve this by casting public API elements to private API types:

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. Testing the Plugin

We need to be able to test our plugin. It involves the following:

  • compile the test source
  • run the compiled binaries and ensure that they behave as expected

For this, we need to introduce a few auxiliary classes.

SimpleSourceFile exposes the given source file's text to the Javac:

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 holds the compilation result as a 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 ensures the compiler uses our bytecode holder:

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

Finally, all of that is bound to the in-memory compilation:

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

After that, we need only to run the binaries:

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

A test might look like this:

public class SampleJavacPluginTest { private static final String CLASS_TEMPLATE = "package com.baeldung.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.baeldung.javac.Test"; byte[] byteCode = compiler.compile(qualifiedClassName, String.format(CLASS_TEMPLATE, argumentType.getName())); return runner.run(byteCode, qualifiedClassName, "service", new Class[] {argumentType}, argument); } }

Here we're compiling a Test class with a service() method that has a parameter annotated with @Positive. Then, we're running the Test class by setting a double value of -1 for the method parameter.

As a result of running the compiler with our plugin, the test will throw an IllegalArgumentException for the negative parameter.

7. Conclusion

In this article, we've shown the full process of creating, testing and running a Java Compiler plugin.

Den vollständigen Quellcode der Beispiele finden Sie auf GitHub.