18.2Programme mit der Java Compiler API übersetzen
Es gibt verschiedene Gründe, warum eigene Java-Programme selbst andere Programme übersetzen müssen. Ein zentraler Grund sind Codegeneratoren, die Java-Programme erzeugen; anschließend lassen sich die Exporte übersetzen und über einen eigenen Klassenlader direkt einbinden. Auch für eine IDE ist der Zugriff auf Compilermeldungen interessant. Sie kann Java-Programme übersetzen und mögliche Fehler live im Editor anzeigen.
Zum Übersetzen von Java-Programmen in Bytecode gibt es unterschiedliche Vorgehensweisen:
Nutzen der standardisierten Java Compiler API im Paket javax.tools
Rückgriff auf den Java-Compiler von Oracle oder Eclipse. Apache Commons Java Compiler Interface (JCI) unter http://tutego.de/go/jci ist eine Abstraktion, um mit einer API beide Compiler ansprechen zu können.
Aufruf eines Compilers über einen externen Prozess. Das funktioniert mit jedem Compiler, ist aber langsam, da das Starten eines externen Prozesses immer problematisch ist. Zudem kann ein externer Compiler nicht den Quellcode aus dem Speicher lesen oder die Klassendateien im Speicher ablegen, sodass ein Klassenlader gleich Zugriff auf den Bytecode hat.
Wir greifen im Folgenden auf die standardisierte Java Compiler API zurück.
18.2.1Java Compiler API
In Java 6 wurde eine Compiler-API integriert, die erstmals im JSR-199, »Java Compiler API«, definiert wurde. Damit lässt sich der Java-Compiler über eine standardisierte API aufrufen und lassen sich Optionen wie Pfade oder Quellen setzen und Diagnosemeldungen einholen.
Wir wollen im folgenden Beispiel ein einfaches Programm entwickeln, das Java-Quellcode zur Laufzeit schreibt, diesen dann mit der Java Compiler API übersetzt und anschließend über einen eigenen Klassenlader lädt.
Das Generieren einer Datei mit dem Quellcode bereitet die wenigsten Schwierigkeiten:
Listing 18.1com/tutego/insel/tools/CompileDemo.java, main() Teil 1
Files.write( javaSrc,
Collections.singleton( "class A { static { ¿
System.out.println(\"Java Compiler API\"); } }" ),
StandardCharsets.UTF_8 );
Im nächsten Schritt ist die Compiler-API gefragt:
Listing 18.2com/tutego/insel/tools/CompileDemo.java, main() Teil 2
null /*DiagnosticListener*/, null /*Locale*/, null /*Charset*/ ) ) {
Iterable<? extends JavaFileObject> files =
fm.getJavaFileObjectsFromStrings( Collections.singleton( javaSrc.toString() ) );
CompilationTask task = compiler.getTask(
null /*Writer*/, fm, null /*DiagnosticListener*/, null /*Iterable<String>*/,
null /*Iterable<String>*/, files );
task.call();
}
Folgende Typen spielen mit:
javax.tools.ToolProvider bietet Einstieg zu den JDK-Dienstprogrammen. Bisher können Entwickler zum Compiler über getSystemJavaCompiler() und zum Javadoc-Tool über getSystemDocumentationTool() Zugang bekommen; das Dokumentationstool wird in Kapitel 23, »Dienstprogramme für die Java-Umgebung«, besprochen. Die statische Methode getSystemJavaCompiler() liefert ein Objekt vom Typ JavaCompiler zurück oder null, wenn das JRE installiert ist, aber nicht das JDK bzw. wenn tools.jar nicht im Klassenpfad ist.
Vor der Übersetzung muss der Compiler initialisiert werden, etwa mit dem Ursprung der zu übersetzenden Quellen. Dazu wird dem Compiler bei getTask(…) ein Objekt vom Typ JavaFileManager mitgegeben. Vom JavaCompiler lässt sich über getStandardFileManager(…) ein StandardJavaFileManager holen, der dateiorientiert arbeitet. Es lassen sich eigene JavaFileManager entwickeln – das werden wir später machen, wenn die Klassen aus Bytefeldern stammen.
Über den JavaFileManager weiß der Compiler, wo er die Quellen zu suchen hat und wohin er die Bytecode-Dateien schreiben soll. Doch weiß er nicht, welche Klassen er genau übersetzen soll. Dazu beschreibt JavaFileObject die zu übersetzende Kompilationseinheit. Den Aufbau dieser JavaFileObject-Exemplare übernimmt in unserem Fall getJavaFileObjectsFromStrings(Iterable<String> names), das eine Sammlung von Dateinamen in JavaFileObject-Typen konvertiert. Die Alternative nimmt File-Objekte statt Strings mit Pfadnamen und heißt getJavaFileObjectsFromFiles(Iterable<? extends File> files), passt also File-Objekte in die gewünschten JavaFileObject-Typen an.
Mit dem JavaFileManager und der Liste der Kompilationseinheiten in Form von JavaFileObject-Objekten liefert das JavaCompilerTool-Objekt anschließend mit getTask(…) ein JavaCompilerTool.CompilationTask-Objekt. Ein Aufruf von call() auf dem CompilationTask startet die Übersetzung.
Die Methode getTask(…) ist besonders lang und enthält viele null-Argumente. null ist oft als Argument erlaubt und setzt dann das Standardverhalten. An der Signatur der Methode lassen sich die Parameter besser ablesen:
JavaFileManager fileManager,
DiagnosticListener<? super JavaFileObject> diagnosticListener,
Iterable<String> options,
Iterable<String> classes,
Iterable<? extends JavaFileObject> compilationUnits )
[»]Hinweis
Als viertes Argument von getTask(…) lassen sich dem Compiler die üblichen Compileroptionen übergeben, also Pfade und sonstige Einstellungen:
CompilationTask task = compiler.getTask( null, fm, null, options, null, files );
Im letzten Schritt soll ein eigener Klassenlader die Klasse laden und anschließend den Quellcode löschen. Es folgt eine Ausgabe vom Klasseninitialisierer auf dem Bildschirm:
Listing 18.3com/tutego/insel/tools/CompileDemo.java, main() Teil 3
try ( URLClassLoader classLoader = new URLClassLoader( url ) ) {
Class.forName( "A", true, classLoader ); // Java Compiler API
}
Files.delete( javaSrc );
[»]Hinweis
Der Typ CompilationTask ist methodenarm. Neben der call()-Methode gibt es gerade einmal setLocale(Locale) zum Setzen der Sprache für Fehler- und Diagnosemeldungen sowie setProcessors(Iterable<? extends Processor> processors). Die letzte Methode ermöglicht die Zuweisung von Annotations-Prozessoren. Mit ihnen haben wir uns schon in Kapitel 17, »Typen, Reflection und Annotationen«, beschäftigt.
18.2.2Fehlerdiagnose
Entwicklern ist leidvoll bekannt, dass die Übersetzung nicht immer gut geht. Der erste Hinweis ist die Rückgabe von call(); sie ist false, wenn die Übersetzung fehlschlägt. Des Weiteren bietet die Compiler-API zwei Möglichkeiten, einen Statusbericht zu bekommen:
Ein DiagnosticListener wird immer genau dann benachrichtigt, wenn der Compiler auf ein Problem stößt.
Ein DiagnosticCollector ist ein vordefinierter DiagnosticListener, der alle Meldungen sammelt und später zugänglich macht.
Sehen wir uns an, was der DiagnosticCollector an Hinweisen liefert:
Listing 18.4com/tutego/insel/tools/CompileWithDiagnosticsDemo.java, main() Ausschnitt
DiagnosticCollector<JavaFileObject> diagnostics =
new DiagnosticCollector<>();
try ( StandardJavaFileManager fm = compiler.getStandardFileManager( diagnostics, null, null ) ) {
Iterable<? extends JavaFileObject> files =
fm.getJavaFileObjectsFromFiles( Arrays.asList( new File("QQ.java"), javaSrc.toFile() ) );
CompilationTask task = compiler.getTask( null, fm, diagnostics, null, null, files );
boolean success = task.call();
System.out.println( success ); // false
}
for ( Diagnostic<?> diagnostic : diagnostics.getDiagnostics() ) {
System.out.printf( "Kind: %s%n", diagnostic.getKind() );
System.out.printf( "Quelle: %s%n", diagnostic.getSource() );
System.out.printf( "Code und Nachricht: %s: %s%n", diagnostic.getCode(),
diagnostic.getMessage( null ) );
System.out.printf( "Zeile: %s%n", diagnostic.getLineNumber() );
System.out.printf( "Position/Spalte: %s/%s%n", diagnostic.getPosition(),
diagnostic.getColumnNumber() );
System.out.printf( "Startpostion/Endposition: %s/%s%n", diagnostic.getStartPosition(),
diagnostic.getEndPosition() );
System.out.println();
}
Wir bitten den Compiler, eine nicht existierende Datei QQ.java zu übersetzen und folgenden Quellcode:
{
staticccc
{
System.outprintln("Java Compiler API")
Die Reaktion des Compilers sieht so aus:
Kind: ERROR
Quelle: QQ.java
Code und Nachricht: compiler.err.error.reading.file: error: error reading QQ.java;
QQ.java (Das System kann die angegebene Datei nicht finden)
Zeile: –1
Position/Spalte: –1/-1
Startpostion/Endposition: –1/-1
Kind: ERROR
Quelle: A.java
Code und Nachricht: compiler.err.expected: A.java:3: <identifier> expected
Zeile: 3
Position/Spalte: 19/10
Startpostion/Endposition: 19/19
Kind: ERROR
Quelle: A.java
Code und Nachricht: compiler.err.expected: A.java:5: <identifier> expected
Zeile: 5
Position/Spalte: 39/18
Startpostion/Endposition: 39/39
Kind: ERROR
Quelle: A.java
Code und Nachricht: compiler.err.illegal.start.of.type: A.java:5: illegal start of type
Zeile: 5
Position/Spalte: 40/19
Startpostion/Endposition: 40/40
Kind: ERROR
Quelle: A.java
Code und Nachricht: compiler.err.premature.eof: A.java:5: reached end of file while
parsing
Zeile: 5
Position/Spalte: 60/39
Startpostion/Endposition: 60/60
Die Nutzung eines DiagnosticListener ist nicht viel anders. Anstatt den DiagnosticCollector als Parameter zu übergeben, wird eine DiagnosticListener-Implementierung übergeben. So kann sie aussehen:
Listing 18.5com/tutego/insel/tools/ SimpleDiagnosticListener.java, SimpleDiagnosticListener
@Override
public void report( Diagnostic<? extends JavaFileObject> diagnostic ) {
// ...
}
}
18.2.3Eine im String angegebene Kompilationseinheit übersetzen
Steht der Quellcode einer Kompilationseinheit in einer Zeichenkette, so muss das Programm diese nicht erst in eine temporäre Datei schreiben. Zwar sieht die Compiler-API nicht direkt eine einfache Methode zum Übersetzen von Klassen aus Strings vor, doch viel an Infrastruktur ist schon vorhanden, sodass diese Funktionalität schnell realisiert ist.
Der erste Schritt ist die Entwicklung einer Unterklasse von JavaFileObject, die wir StringJavaFileObject nennen wollen. Das können wir dann wieder problemlos in eine Datenstruktur setzen und an getTask(…) übergeben. Die StringJavaFileObject-Klasse muss lediglich den Quellcode der Kompilationseinheit über getCharContent(boolean) verfügbar machen:
Listing 18.6com/tutego/insel/tools/StringJavaFileObject.java, StringJavaFileObject
private final CharSequence code;
public StringJavaFileObject( String name, CharSequence code ) {
super( URI.create( "string:///" + name.replace( '.', '/' ) + Kind.SOURCE.extension ),
Kind.SOURCE );
this.code = code;
}
@Override
public CharSequence getCharContent( boolean ignoreEncodingErrors ) {
return code;
}
}
Der Konstruktor erwartet den Namen der zu übersetzenden Klasse und ein CharSequence (etwa String, StringBuilder) mit der Kompilationseinheit. Der Klassenname wird an die Oberklasse weitergegeben. KIND ist eine in JavaFileObject deklarierte Aufzählung mit SOURCE (für Dateien mit der Endung .java), CLASS (für Klassendateien mit der Endung .class), HTML (für HTML-Dokumente) und OTHER.
Um den String mit dem Java-Quellcode in Bytecode zu übersetzen, wird die bekannte Zeile
fileManager.getJavaFileObjectsFromFiles( Arrays.asList( javaSrc.toFile() ) );
ersetzt durch:
Das gesamte Programm sieht so aus:
Listing 18.7com/tutego/insel/tools/CompileFromStringDemo.java, main()
StringJavaFileObject javaFile = new StringJavaFileObject( "A", src );
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager fileManager = compiler.getStandardFileManager( null, null, null );
Iterable<? extends JavaFileObject> files = Arrays.asList( javaFile );
CompilationTask task = compiler.getTask( null, fileManager, null, null, null, files );
task.call();
fileManager.close();
URL[] urls = new URL[] { Paths.get( "." ).toUri().toURL() };
try ( URLClassLoader classLoader = new URLClassLoader( urls ) ) {
Class.forName( "A", true, classLoader ); // Java Compiler API 2
}
18.2.4Wenn Quelle und Ziel der Speicher sind
Stammt nur der Quellcode aus dem Hauptspeicher, ist wenig Programmieraufwand nötig. Doch bisher legte der Compiler die Klassendateien immer auf dem Laufwerk in ein Ausgabeverzeichnis ab. Soll er nicht nur die Quellen aus dem Speicher beziehen, sondern ebenso die generierten Bytecode-Dateien im Speicher ablegen, ist mehr Aufwand nötig. Die Quellen (Units) gibt weiterhin eine Sammlung von JavaFileObject an, sodass hier nichts zu ändern ist. Jedoch ist ein neuer JavaFileManager nötig.
Das folgende Programm übersetzt eine Klassendeklaration aus einer Zeichenkette und schreibt in ein spezielles »Dateisystem«. Die gegenüber der letzten Version geänderten Stellen sind fett hervorgehoben:
Listing 18.8com/tutego/insel/tools/CompileToMemoryDemo.java, main()
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
MemClassLoader classLoader = new MemClassLoader();
try ( JavaFileManager fm = new MemJavaFileManager( compiler, classLoader ) ) {
JavaFileObject javaFile = new StringJavaFileObject( "A", src );
Collection<JavaFileObject> files = Collections.singleton( javaFile );
CompilationTask task = compiler.getTask( null, fm, null,
null, null, files );
task.call();
}
Class.forName( "A", true, classLoader ).newInstance(); // Java Compiler API 3
Die nötigen Typen im Überblick
Zwei neue Klassen, MemJavaFileManager und MemClassLoader, sind im Beispiel sichtbar. Eine weitere interne Klasse MemJavaFileObject muss ebenfalls implementiert werden:
MemJavaFileManager: Was im Text »Dateimanager« für den Compiler genannt wurde, ist natürlich ein spezieller JavaFileManager. Der Compiler ruft immer dann die Methode getJavaFileForOutput(JavaFileManager.Location location, String className, JavaFileObject.Kind kind, FileObject sibling) auf dem speziellen JavaFileManager auf, wenn er eine Datei ablegen möchte. Es ist nun Aufgabe von MemJavaFileManager und der Methode MemJavaFileObject, dem Compiler ein JavaFileObject zu geben, das die Bytes der Klassendatei speichert.
MemJavaFileObject ist genau so ein JavaFileObject, das der MemJavaFileManager bei getJavaFileForOutput(…) liefert. Der Compiler ruft die von MemJavaFileObject überschriebene Methode openOutputStream() auf und bekommt so die Möglichkeit, die Klassendatei herauszuschreiben. An der Stelle würden wir auch ansetzen, wenn der Compiler einen anderen Speicherort wählen sollte, etwa eine Datenbank, oder den Bytecode gleich verschlüsselt ablegen soll.
MemClassLoader ist ein Klassenlader und das letzte Teilchen. Der erzeugte Bytecode pro Klasse oder Schnittstelle steht ja in einem MemJavaFileObject-Objekt. Der eigene Klassenlader macht diese erzeugen Bytefelder für das Class.forName(String) zugänglich.
Zu den drei Klassen:
Listing 18.9com/tutego/insel/tools/MemJavaFileManager.java
import javax.tools.*;
import javax.tools.JavaFileObject.Kind;
public class MemJavaFileManager extends
ForwardingJavaFileManager<StandardJavaFileManager> {
private final MemClassLoader classLoader;
public MemJavaFileManager( JavaCompiler compiler, MemClassLoader classLoader ) {
super( compiler.getStandardFileManager( null, null, null ) );
this.classLoader = classLoader;
}
@Override
public JavaFileObject getJavaFileForOutput( Location location,
String className,
Kind kind,
FileObject sibling ) {
MemJavaFileObject fileObject = new MemJavaFileObject( className );
classLoader.addClassFile( fileObject );
return fileObject;
}
}
Listing 18.10com/tutego/insel/tools/MemJavaFileObject.java
import java.io.*;
import java.net.*;
import javax.tools.*;
class MemJavaFileObject extends SimpleJavaFileObject {
private final ByteArrayOutputStream baos = new ByteArrayOutputStream( 8192 );
private final String className;
MemJavaFileObject( String className ) {
super( URI.create( "string:///" + className.replace( '.', '/' ) + Kind.CLASS.extension ),
Kind.CLASS );
this.className = className;
}
String getClassName() {
return className;
}
byte[] getClassBytes() {
return baos.toByteArray();
}
@Override public OutputStream openOutputStream() {
return baos;
}
}
Listing 18.11com/tutego/insel/tools/MemClassLoader.java
import java.util.*;
public class MemClassLoader extends ClassLoader {
private final Map<String, MemJavaFileObject> classFiles =
new HashMap<>();
public MemClassLoader() {
super( ClassLoader.getSystemClassLoader() );
}
public void addClassFile( MemJavaFileObject memJavaFileObject ) {
classFiles.put( memJavaFileObject.getClassName(), memJavaFileObject );
}
@Override
protected Class<?> findClass( String name ) throws ClassNotFoundException {
MemJavaFileObject fileObject = classFiles.get( name );
if ( fileObject != null ) {
byte[] bytes = fileObject.getClassBytes();
return defineClass( name, bytes, 0, bytes.length );
}
return super.findClass( name );
}
}