Membuat Java Compiler Plugin

1. Gambaran keseluruhan

Java 8 menyediakan API untuk membuat plugin Javac . Malangnya, sukar untuk mendapatkan dokumentasi yang baik untuknya.

Dalam artikel ini, kita akan menunjukkan keseluruhan proses membuat pelanjutan pengkompil yang menambahkan kod tersuai pada fail * .class .

2. Persediaan

Pertama, kita perlu menambahkan tools.jar JDK sebagai pergantungan untuk projek kita:

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

Setiap peluasan penyusun adalah kelas yang menerapkan antara muka com.sun.source.util.Plugin . Mari buat dalam contoh kami:

Mari buat dalam contoh kami:

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

Buat masa ini, kami hanya mencetak "Hello" untuk memastikan bahawa kod kami berjaya diambil dan disertakan dalam penyusunan.

Matlamat akhir kami adalah untuk membuat plugin yang menambahkan pemeriksaan runtime untuk setiap argumen berangka yang ditandai dengan anotasi tertentu, dan membuang pengecualian jika argumen tidak sesuai dengan syarat.

Terdapat satu langkah lagi yang perlu untuk membuat peluasan dapat dijumpai oleh Javac: perluasan itu didedahkan melalui kerangka ServiceLoader

Untuk mencapai matlamat ini, kita perlu mewujudkan satu fail bernama com.sun.source.util.Plugin dengan kandungan yang nama kelas yang memenuhi syarat plugin kita ( com.baeldung.javac.SampleJavacPlugin ) dan meletakkannya di META-INF / perkhidmatan direktori .

Selepas itu, kita boleh memanggil Javac dengan suis -Xplugin: MyPlugin :

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

Perhatikan bahawa kita mesti selalu menggunakan String yang dikembalikan dari kaedah getName () plugin sebagai nilai pilihan -Xplugin .

3. Kitaran Hayat Plugin

A plugin dipanggil oleh pengkompil hanya sekali, melalui init () kaedah.

Untuk diberitahu mengenai peristiwa berikutnya, kita harus mendaftarkan panggilan balik. Ini tiba sebelum dan selepas setiap peringkat pemprosesan setiap fail sumber:

  • PARSE - membina Tree Syntax Abstrak (AST)
  • ENTER - import kod sumber diselesaikan
  • ANALISA - output penghurai (AST) dianalisis untuk kesilapan
  • GENERATE - menghasilkan binari untuk fail sumber sasaran

Terdapat dua lagi jenis acara - ANNOTATION_PROCESSING dan ANNOTATION_PROCESSING_ROUND tetapi kami tidak berminat dengannya di sini.

Sebagai contoh, apabila kita ingin meningkatkan penyusunan dengan menambahkan beberapa pemeriksaan berdasarkan maklumat kod sumber, adalah wajar untuk melakukannya di pengendali acara selesai PARSE :

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. Ekstrak Data AST

Kita boleh mendapatkan AST yang dihasilkan oleh penyusun Java melalui TaskEvent.getCompilationUnit () . Perinciannya dapat diperiksa melalui antara muka TreeVisitor .

Perhatikan bahawa hanya elemen Pohon , yang mana kaedah penerimaan () dipanggil, menghantar acara kepada pengunjung yang diberikan.

Sebagai contoh, apabila kita melaksanakan ClassTree.accept (pelawat) , hanya visitClass () yang dicetuskan; kita tidak boleh menjangkakan bahawa, katakanlah, visitMethod () juga diaktifkan untuk setiap kaedah dalam kelas yang diberikan.

Kita boleh menggunakan TreeScanner untuk mengatasi masalah tersebut:

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

Dalam contoh ini, perlu memanggil super.visitXxx (node, value) untuk memproses anak-anak nod semasa secara berulang.

5. Ubahsuai AST

Untuk menunjukkan bagaimana kita dapat mengubah AST, kita akan memasukkan pemeriksaan runtime untuk semua argumen berangka yang ditandai dengan anotasi @Positif .

Ini adalah anotasi sederhana yang dapat digunakan untuk parameter kaedah:

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

Berikut adalah contoh penggunaan anotasi:

public void service(@Positive int i) { }

Pada akhirnya, kami mahu bytecode kelihatan seolah-olah disusun dari sumber seperti ini:

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

Maksudnya ini adalah bahawa kita mahu IllegalArgumentException dilemparkan untuk setiap argumen yang ditandai dengan @Positive yang sama atau kurang dari 0.

5.1. Di mana Instrumen

Mari kita ketahui bagaimana kita dapat mencari tempat sasaran di mana instrumentasi harus digunakan:

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

Untuk kesederhanaan, kami hanya menambahkan jenis angka primitif di sini.

Seterusnya, mari tentukan kaedah harusInstrument () yang memeriksa apakah parameter mempunyai jenis dalam set TARGET_TYPES serta anotasi @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())); }

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.

Kod sumber lengkap contoh boleh didapati di GitHub.