Een Java Compiler-plug-in maken

1. Overzicht

Java 8 biedt een API voor het maken van Javac plug-ins. Helaas is het moeilijk om er goede documentatie voor te vinden.

In dit artikel laten we het hele proces zien van het maken van een compilerextensie waaraan aangepaste code wordt toegevoegd *.klasse bestanden.

2. Installatie

Eerst moeten we JDK's toevoegen tools.jar als afhankelijkheid voor ons project:

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

Elke compilerextensie is een klasse die implementeert com.sun.source.util.Plugin koppel. Laten we het in ons voorbeeld maken:

Laten we het in ons voorbeeld maken:

public class SampleJavacPlugin implementeert Plugin {@Override public String getName () {return "MyPlugin"; } @Override public void init (JavacTask-taak, String ... args) {Context context = ((BasicJavacTask) -taak) .getContext (); Log.instance (context) .printRawLines (Log.WriterKind.NOTICE, "Hallo vanaf" + getName ()); }}

Voorlopig drukken we gewoon "Hallo" af om er zeker van te zijn dat onze code met succes wordt opgepikt en opgenomen in de compilatie.

Ons uiteindelijke doel is om een ​​plug-in te maken die runtime-controles toevoegt voor elk numeriek argument dat is gemarkeerd met een bepaalde annotatie, en een uitzondering genereert als het argument niet overeenkomt met een voorwaarde.

Er is nog een noodzakelijke stap om de extensie vindbaar te maken door Javac:het moet worden blootgesteld via de ServiceLoader kader.

Om dit te bereiken, moeten we een bestand maken met de naam com.sun.source.util.Plugin met inhoud die de volledig gekwalificeerde klassenaam van onze plug-in is (com.baeldung.javac.SampleJavacPlugin) en plaats deze in het META-INF / diensten directory.

Daarna kunnen we bellen Javac met de -Xplugin: MyPlugin schakelaar:

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

Let daar op we moeten altijd een Draad geretourneerd van de plug-ins getName () methode als een -Xplugin Optie waarde.

3. Levenscyclus van plug-ins

EEN plug-in wordt slechts één keer aangeroepen door de compiler, via de in het() methode.

Om op de hoogte te worden gehouden van volgende gebeurtenissen, moeten we een terugbelverzoek registreren. Deze komen voor en na elke verwerkingsfase per bronbestand aan:

  • PARSE - bouwt een Abstracte syntaxisboom (AST)
  • ENTER - import van broncode is opgelost
  • ANALYSEREN - parseruitvoer (een AST) wordt geanalyseerd op fouten
  • GENEREREN - binaire bestanden genereren voor het doelbronbestand

Er zijn nog twee soorten evenementen - ANNOTATION_PROCESSING en ANNOTATION_PROCESSING_ROUND maar we zijn hier niet in hen geïnteresseerd.

Als we bijvoorbeeld de compilatie willen verbeteren door enkele controles toe te voegen op basis van broncode-informatie, is het redelijk om dat te doen op de PARSE is klaar gebeurtenis handler:

public void init (JavacTask-taak, String ... args) {task.addTaskListener (nieuwe TaskListener () {public void gestart (TaskEvent e) {} public void voltooid (TaskEvent e) {if (e.getKind ()! = TaskEvent .Kind.PARSE) {return;} // Instrumentatie uitvoeren}}); }

4. Extraheer AST-gegevens

We kunnen een AST krijgen die is gegenereerd door de Java-compiler via de TaskEvent.getCompilationUnit (). De details kunnen worden onderzocht via de TreeVisitor koppel.

Merk op dat alleen een Boom element, waarvoor het aanvaarden() methode wordt aangeroepen, verzendt gebeurtenissen naar de gegeven bezoeker.

Bijvoorbeeld wanneer we uitvoeren ClassTree.accept (bezoeker), enkel en alleen visitClass () wordt geactiveerd; we kunnen dat bijvoorbeeld niet verwachten visitMethod () wordt ook geactiveerd voor elke methode in de gegeven klasse.

We kunnen gebruiken TreeScanner om het probleem op te lossen:

openbare leegte voltooid (TaskEvent e) {if (e.getKind ()! = TaskEvent.Kind.PARSE) {return; } e.getCompilationUnit (). accept (nieuwe TreeScanner () {@Override public Void visitClass (ClassTree node, Void aVoid) {return super.visitClass (node, aVoid); @Override public Void visitMethod (MethodTree node, Void aVoid) { retourneer super.visitMethod (node, aVoid);}}, null); }

In dit voorbeeld is het nodig om te bellen super.visitXxx (knooppunt, waarde) om de kinderen van het huidige knooppunt recursief te verwerken.

5. Wijzig AST

Om te laten zien hoe we de AST kunnen wijzigen, voegen we runtime-controles in voor alle numerieke argumenten die zijn gemarkeerd met een @Positief annotatie.

Dit is een eenvoudige annotatie die kan worden toegepast op methodeparameters:

@Documented @Retention (RetentionPolicy.CLASS) @Target ({ElementType.PARAMETER}) openbaar @interface Positief {}

Hier is een voorbeeld van het gebruik van de annotatie:

openbare ongeldige dienst (@Positive int i) {}

Uiteindelijk willen we dat de bytecode eruitziet alsof deze is gecompileerd vanuit een bron als deze:

public void service (@Positive int i) {if (i <= 0) {throw new IllegalArgumentException ("Een niet-positief argument (" + i + ") wordt gegeven als een @Positive parameter 'i'"); }}

Dit betekent dat we een IllegalArgumentException worden gegooid voor elk argument gemarkeerd met @Positief die gelijk is aan of kleiner is dan 0.

5.1. Waar te instrumenteren

Laten we eens kijken hoe we doelplaatsen kunnen lokaliseren waar de instrumentatie moet worden toegepast:

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

Voor de eenvoud hebben we hier alleen primitieve numerieke typen toegevoegd.

Laten we vervolgens een shouldInstrument () methode die controleert of de parameter een type heeft in de TARGET_TYPES-set, evenals de @Positief annotatie:

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

Dan gaan we verder met de afgewerkt() methode in onze VoorbeeldJavacPlugin klasse met het toepassen van een controle op alle parameters die aan onze voorwaarden voldoen:

openbare leegte voltooid (TaskEvent e) {if (e.getKind ()! = TaskEvent.Kind.PARSE) {return; } e.getCompilationUnit (). accept (nieuwe TreeScanner () {@Override public Void visitMethod (MethodTree-methode, Void v) {List parametersToInstrument = method.getParameters (). stream () .filter (SampleJavacPlugin.this :: shouldInstrument). collect (Collectors.toList ()); if (! parametersToInstrument.isEmpty ()) {Collections.reverse (parametersToInstrument); parametersToInstrument.forEach (p -> addCheck (methode, p, context));} retourneer super.visitMethod (methode , v);}}, null); 

In dit voorbeeld hebben we de parameterlijst omgekeerd omdat er een mogelijk geval is dat meer dan één argument wordt gemarkeerd door @Positief. Omdat elke cheque wordt toegevoegd als de allereerste methode-instructie, verwerken we ze RTL om de juiste volgorde te garanderen.

5.2. Hoe te instrumenteren

Het probleem is dat "lees AST" in het openbaar API-gebied, terwijl "wijzig AST" -bewerkingen zoals "add null-checks" een privaat API.

Dit behandelen, we zullen nieuwe AST-elementen maken via een TreeMaker voorbeeld.

Eerst moeten we een Context voorbeeld:

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

Dan kunnen we het TreeMarker object door de TreeMarker.instance (context) methode.

Nu kunnen we nieuwe AST-elementen bouwen, bijvoorbeeld een als expressie kan worden geconstrueerd door een aanroep naar TreeMaker.If ():

privé statisch JCTree.JCIf createCheck (VariableTree-parameter, contextcontext) {TreeMaker factory = TreeMaker.instance (context); Namen symbolenTable = Names.instance (context); return factory.at (((JCTree) parameter) .pos) .If (factory.Parens (createIfCondition (factory, symbolsTable, parameter)), createIfBlock (factory, symbolTable, parameter), null); }

Houd er rekening mee dat we de juiste stacktraceerregel willen tonen wanneer er een uitzondering wordt gegenereerd door onze cheque. Daarom passen we de AST-fabriekspositie aan voordat we er nieuwe elementen mee maken factory.at (((JCTree) parameter) .pos).

De createIfCondition () methode bouwt de “parameterId< 0″ als staat:

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

Vervolgens de createIfBlock () methode bouwt een blok dat een retourneert IllegalArgumentException:

privé statisch JCTree.JCBlock createIfBlock (TreeMaker factory, Names symbolTable, VariableTree parameter) {String parameterName = parameter.getName (). toString (); Naam parameterId = symbolsTable.fromString (parameternaam); String errorMessagePrefix = String.format ("Argument '% s' van type% s wordt gemarkeerd door @% s maar heeft '", parameternaam, parameter.getType (), Positive.class.getSimpleName ()); String errorMessageSuffix = "'ervoor"; return factory.Block (0, com.sun.tools.javac.util.List.of (factory.Throw (factory.NewClass (null, nil (), factory.Ident (symbolTable.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)))); }

Nu we nieuwe AST-elementen kunnen bouwen, moeten we ze invoegen in de AST die is voorbereid door de parser. Dit kunnen we bereiken door te gieten openbaar EENPI elementen naar privaat API-typen:

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

6. Testen van de plug-in

We moeten onze plug-in kunnen testen. Het gaat om het volgende:

  • compileer de testbron
  • voer de gecompileerde binaire bestanden uit en zorg ervoor dat ze zich gedragen zoals verwacht

Hiervoor moeten we enkele hulpklassen introduceren.

SimpleSourceFile stelt de tekst van het gegeven bronbestand bloot aan de Javac:

openbare klasse SimpleSourceFile breidt SimpleJavaFileObject {privé String-inhoud uit; openbare SimpleSourceFile (String qualClassName, String testSource) {super (URI.create (String.format ("file: //% s% s", gekwalificeerdeClassName.replaceAll ("\.", "/"), Kind.SOURCE. extensie)), Kind.SOURCE); content = testSource; } @Override openbare CharSequence getCharContent (boolean ignoreEncodingErrors) {return content; }}

SimpleClassFile bevat het compilatieresultaat als een byte-array:

openbare klasse SimpleClassFile breidt SimpleJavaFileObject {privé ByteArrayOutputStream uit; openbare SimpleClassFile (URI uri) {super (uri, Kind.CLASS); } @Override openbare OutputStream openOutputStream () gooit IOException {return out = new ByteArrayOutputStream (); } openbare byte [] getCompiledBinaries () {return out.toByteArray (); } // getters}

SimpleFileManager zorgt ervoor dat de compiler onze bytecode-houder gebruikt:

openbare klasse SimpleFileManager breidt ForwardingJavaFileManager {privélijst gecompileerd = nieuwe 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 (resultaat); resultaat teruggeven; } openbare lijst getCompiled () {retour gecompileerd; }}

Ten slotte is dat allemaal gebonden aan de compilatie in het geheugen:

openbare klasse TestCompiler {openbare byte [] compileren (String qualClassName, String testSource) {StringWriter output = nieuwe StringWriter (); JavaCompiler-compiler = ToolProvider.getSystemJavaCompiler (); SimpleFileManager fileManager = nieuwe SimpleFileManager (compiler.getStandardFileManager (null, null, null)); Lijst compilationUnits = singletonList (nieuwe SimpleSourceFile (gekwalificeerdeClassName, testSource)); Lijstargumenten = nieuwe 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 (); }}

Daarna hoeven we alleen de binaire bestanden uit te voeren:

public class TestRunner {public Object run (byte [] byteCode, String qualClassName, String methodName, Class [] argumentTypes, Object ... args) gooit Throwable {ClassLoader classLoader = new ClassLoader () {@Override protected Class findClass (String naam) gooit ClassNotFoundException {return defineClass (naam, byteCode, 0, byteCode.length); }}; Klasse clazz; probeer {clazz = classLoader.loadClass (gekwalificeerdeClassName); } catch (ClassNotFoundException e) {throw new RuntimeException ("Kan gecompileerde testklasse niet laden", e); } Methode methode; probeer {method = clazz.getMethod (methodName, argumentTypes); } catch (NoSuchMethodException e) {throw new RuntimeException ("Kan de 'main ()' - methode niet vinden in de gecompileerde testklasse", e); } probeer {return method.invoke (null, args); } catch (InvocationTargetException e) {throw e.getCause (); }}}

Een test kan er als volgt uitzien:

openbare klasse SampleJavacPluginTest {privé statische laatste String CLASS_TEMPLATE = "pakket com.baeldung.javac; \ n \ n" + "openbare klasse Test {\ n" + "openbare statische% 1 $ s-service (@Positive% 1 $ si) { \ n "+" geef i terug; \ n "+"} \ n "+"} \ n "+" "; private TestCompiler-compiler = nieuwe TestCompiler (); privé TestRunner-runner = nieuwe TestRunner (); @Test (verwacht = IllegalArgumentException.class) public void givenInt_whenNegative_thenThrowsException () gooit Throwable {compileAndRun (double.class, -1); } privé Object compileAndRun (Klasse argumentType, Object argument) gooit Throwable {String qualClassName = "com.baeldung.javac.Test"; byte [] byteCode = compiler.compile (gekwalificeerdeClassName, String.format (CLASS_TEMPLATE, argumentType.getName ())); return runner.run (byteCode, gekwalificeerdeClassName, "service", nieuwe klasse [] {argumentType}, argument); }}

Hier zijn we een Test klas met een onderhoud() methode met een parameter die is geannoteerd met @Positief. Vervolgens voeren we het Test class door een dubbele waarde van -1 in te stellen voor de method-parameter.

Als resultaat van het uitvoeren van de compiler met onze plug-in, zal de test een IllegalArgumentException voor de negatieve parameter.

7. Conclusie

In dit artikel hebben we het volledige proces van het maken, testen en uitvoeren van een Java Compiler-plug-in laten zien.

De volledige broncode van de voorbeelden is te vinden op GitHub.