“Ein Spion am rechten Ort ersetzt 20.000 Mann an der Front.”
Napoleon I. Bonaparte
Dieser Beitrag dient dazu die Mechanismen zu erläutern, die hinter manchem bekannten Framework eingesetzt werden. Als Entwickler ist es immer hilfreich zu wissen, welche Dramen sich tatsächlich hinter der Bühne abspielen. Nur wenn es keine wirklichen Alternativen gibt, die gibt es aber meist zuhauf, sollte auf diese Mechanismen für eigene Projekte zurückgegriffen werden.
Manchmal reichen die üblichen Möglichkeiten der Programmiersprache nicht aus und die exotischeren Ansätze müssen zum Einsatz kommen. Angefangen bei ClassLoader, Reflections, Dynamic Proxy Classes, Service Provider Interface über AnnotationProcessor und JavaAgents bis hin zum Modifizieren des Bytecodes.
Mithilfe des ClassLoaders können Klassen flexibel nachgeladen werden, um über ein implementiertes Interface verwendet zu werden. Auf diese Weise können einfache Plugin-Mechanismen realisiert werden. Mit Reflections auf Klassenelemente zuzugreifen, gestaltet eigene Plugin Mechanismen noch flexibler. Dynamic Proxy Classes bieten die Möglichkeit, statt einer festen Implementierung eines oder mehrere Interfaces durch eine Klasse, ein Delegieren der Methodenaufrufe an eine andere Klasse zu nutzen.
Das Service Provider Interface ist eine ausgefeilte Implementierung eines Plugin-Mechanismus und in der Regel die bessere Alternative. Einsatzszenarien zum Service Provider Interface gibt es hier beispielsweise in den Beiträgen Du sprechen ANSEL?, Kalenderspielereien mit Java (3) und FreshMarker Built-Ins und Plug-Ins.
Mit Annotationen und einem AnnotationProcessor kann während der Kompilierung eigener Code generiert werden. Zum Einsatz des AnnotationProcessor sei hier auf die Beiträge Unterschiede finden mit dem Java Annotation Prozessor und Hamcrest Matcher Generator.
In diesem Beitrag soll sich aber alles um JavaAgents, die Java Instrumentation API und die Bibliothek ByteBuddy drehen.
Ein JavaAgent ist ein Java Programm, dass ein anderes Java Programm mit Hilfe der Java Instrumentation API manipulieren kann. Das geschieht entweder mit dem Start des Programms und nennt sich dann Static Instrumentation und erst später und wird dann als Dynamic Instrumentation bezeichnet.
Dieser Beitrag beleuchtet die Static Instrumentation. Sie ist die einfachere Variante und benötigt nur eine zusätzliche Option beim starten der eigenen Anwendung.
java -javaagent:agent.jar=gonzo -jar application.jar
Die zusätzliche Option -javaagent
startet zuerst den Java Agent aus agent.jar
und danach wird wie gewohnt die Anwendung aus application.jar
gestartet.
Ein JavaAgent besitzt keine statische main
Methode, sondern eine premain
Methode. Zum einen wird damit ausgedrückt, dass diese Methode vor der eigentlichen main
Methode der Anwendung ausgeführt wird und außerdem besitzt diese Methode kein String
Array Parameter der sich aus der Kommandozeile bedient. Der einzige Parameter, den der Anwender dem JavaAgent übergeben kann ist der -javaagent
Option mit einem =
angefügt. Im obigen Beispiel ist es also der Wert gonzo
.
public class Main { public static void premain(String agentArgument, Instrumentation instrumentation) { try { handleInstrumentation(instrumentation); } catch (IOException e) { throw new IllegalStateException(e); } } // ... }
Der JavaAgent erhält neben dem Argument aus dem -javaagent
Option noch eine Instanz der Instrumentation
Klasse. Mit dieser Klasse lassen sehr einige sehr interessante Dinge anstellen. Für diesen Beitrag wird aber nur die Möglichkeit betrachtet, mit einem ClassFileTransformer
den Bytecode aus Java Klassendateien zu manipulieren.
Die Anwendungsfälle reichen dabei vom Ersetzen veralteter Klassen, Hinzufügen weiterer Interfaces an eine Klasse, Entfernen von Annotationen oder zusätzliche Code um Methodenaufrufe zu monitoren oder abzusichern.
Jeder Entwickler, der in seiner Jugend mit Assembler herumgespielt hat, runzelt jetzt natürlich die Stirn. Wer will schon mit Bytecode jonglieren? Glücklicherweise gibt es Bibliotheken wie Javassist und ByteBuddy.
In diesem Beitrag wird nur die Bibliothek ByteBuddy vorgestellt. Mit ihr hat, bewusst oder unbewusst, sicherlich schon jeder Java Entwickler gearbeitet. Immerhin wird sie in Frameworks wie Mockito, Hibernate, Bazel, Jackson, Selenium, Spock und vielen anderen verwendet.
JavaAgents sind mit ByteBuddy, dank der AgentBuilder
Klasse, einfach zu erstellen. Der folgende Ausschnitt der eigenen handleInstrumentation
zeigt die Deklaration des AgentBuilder
für die hier vorgestellten Beispiele.
private static void handleInstrumentation(Instrumentation instrumentation) throws IOException { AgentBuilder agentBuilder = new Default() .ignore(not(nameStartsWith("java.text.").or(nameStartsWith("org.freshmarker.")))) .with(NoOp.INSTANCE).with(RedefinitionStrategy.REDEFINITION) .with(new Adapter() { @Override public void onError(String typeName, ClassLoader classLoader, JavaModule module, boolean loaded, Throwable throwable) { System.err.println("on error: " + typeName + " " + throwable); } }); // ... }
In der dritten Zeile wird Menge der Klassen eingeschränkt, die überhaupt betrachtet werden sollen. In diesem Fall werden nur Klassen betrachtet die in den Packages java.text
, org.freshmarker
und deren Sub-Packages zu finden sind. In der Klasse ElementMatchers
stehen, neben der hier verwendeten nameStartsWith
Matcher-Methode, noch eine große Zahl weiterer Methoden bereit.
In der vierten Zeile werden einige Konfigurationen gesetzt um die Beispiel-Klasse aus dem Package java.text
modifizieren zu können. Ab der fünften Zeile wird ein hilfreicher Adapter
hinzugefügt, der Auskunft über Fehler bei der Instrumentalisierung liefert. Nicht ist nervenaufreibender als unsichtbare Fehler bei der Modifizierung von Java Klassen.
Das erste Beispiel ergänzt Klassen um ein Interface JsonSource
und um eine Implementierung der von ihm bereitgestellten toJson
Methode.
public interface JsonSource { String toJson() throws JsonProcessingException; }
Dazu wird dem AgentBuilder
mitgeteilt, bei welchen Klassen diese Modifizierung wie zu erfolgen hat.
agentBuilder .type((isAnnotatedWith(ToJson.class))) .transform((builder, typeDescription, classLoader, module, domain) -> builder .implement(JsonSource.class) .defineMethod("toJson", String.class, Visibility.PUBLIC) .throwing(JsonProcessingException.class) .intercept(MethodDelegation.to(ToJsonImplementation.class))) .installOn(instrumentation);
Die Modifizierung erfolgt nur auf Klassen die mit der Annotation ToJson
. Auf diese Klassen wird ein Transformer
angesetzt, der mit einem Builder
die Klasse modifiziert. Wenn eine entsprechende Klasse geladen wird, dann fügt der Builder
mit der Methode implement
das Interface JsonSource
und mit der throwing
Methode die JsonProcessiongException
hinzu. Danach wird mit defineMethod
eine neue öffentliche Methode toJson
mit dem Rückgabetyp String
hinzugefügt.
Die Implementierung der Methode findet sich in der Klasse ToJsonImplementation
. Über die Methode MethodDelegation#to
wird eine passende Zuordnung gesucht. Für diesen Beitrag enthält die Klasse ToJsonImplementation
aber nur eine einzige Methode.
public class ToJsonImplementation { public static String intercept(@This Object that) throws JsonProcessingException { return new ObjectMapper().writeValueAsString(that); } }
Mit der Annotation @This
wird die aktuelle Instanz der Klasse übergeben. Da die zu implementierende Methode toJson
keine Parameter besitzt, ist kein weiterer Parameter notwendig. Der Rückgabewert der Methode wir mit Hilfe eines Jackson ObjectMapper
erzeugt. Tritt dabei ein Fehler auf, dann wird eine JsonProcessingException
geworfen.
Person person = new Person("Jens", "Kaiser"); ((JsonSource)person).toJson();
Für eine Klasse Person
mit der Annotation @ToJson
führt der obige Code zur nachfolgenden Ausgabe. Wer dies selbst ausprobieren möchte, wird bemerken, dass die eigene Entwicklungsumgebung unglücklich mit dem obigen Code ist. Da der JavaAgent nicht involviert ist, kann Person
eigentlich nicht auf JsonSource
gecastet werden. Es fehlt die entsprechende implements
Klausel an der Java Klasse. Die Probleme werden mit dem Einsatz von JavaAgents also nicht unbedingt kleiner.
{"lastname":"Kaiser","firstname":"Jens"}
Damit der JavaAgent aber tatsächlich funktioniert ist noch eine Kleinigkeit zu erledigen. Der JavaAgent benötigt in seiner META-INF/MANIFEST.MF Datei noch ein paar zusätzliche Einträge.
Premain-Class: de.schegge.bytebuddy.Main Agent-Class: de.schegge.bytebuddy.Main Can-Redefine-Classes: true Can-Retransform-Classes: true
Mit den Einträgen wird der JVM mitgeteilt, welcher Java Klassen als Einstiegspunkte für Dynamic und Static Instrumentation vorhanden sind und was die JavaAgents dürfen.
Das zweite Beispiel modifiziert die Klasse MessageFormat
, damit Implementierungen des Interface Temporal
mit date
und time
Platzhaltern verwendet werden können. Die folgende Zeile Code verursacht normalerweise eine RuntimeException
.
MessageFormat.format("test: {0,date} {0,time}", LocalDateTime.now());
Um das Problem zu umgehen, wird wiederum der AgentBuilder
in der handleInstrumentation
Methode ergänzt.
agentBuilder .type(is(MessageFormat.class)) .transform((builder, typeDescription, classLoader, module, domain) -> builder .visit(Advice.to(MessageFormatterInterceptor.class) .on(hasMethodName("format").and(not(isStatic()))))) .installOn(instrumentation);
In diesem Fall wird keine MethodDelegation
sondern ein Advice
verwendet. Der Advice wird in diesem Fall nur auf die nicht statische Methode format
angewendet.
Ein Advice
bietet die Möglichkeit eine Methode vor und nach dem eigentlichen Methodenaufruf auszuführen. Die folgende Klasse enthält die Methoden enter
und exit
. In der Methode enter wird der Methodennamen und die aktuellen Parameter der Methode ausgegeben. Danach werden alle SimpleDateFormat
Instanzen in der MessageFormat
Instanz durch DateTimeFormatter
ausgetauscht.
public class MessageFormatterInterceptor { @Advice.OnMethodEnter public static void enter(@This MessageFormat format, @Origin Method method, @AllArguments Object[] arguments) { System.out.println("Intercepted Enter >> " + method.getName() + " " + Arrays.asList(arguments)); Format[] formats = format.getFormats(); for (int i = 0; i < formats.length; i++) { if (formats[i] instanceof SimpleDateFormat simpleDateFormat) { format.setFormat(i, DateTimeFormatter.ofPattern(simpleDateFormat.toPattern()).toFormat()); } } } @Advice.OnMethodExit public static void exit(@Origin Method method, @Return Object result) { System.out.println("Intercepted Result >> " + method.getName() + " " + result); } }
Die exit
Methode wird in diesem Fall nur aus didaktischen Gründen genutzt. Nach der Ausführung der format
Methode wird hier das Ergebnis ausgegeben.
Intercepted Result >> format test: 26.08.2023 18:13:11
Die obige Implementierung ist stark vereinfacht, weil nicht prüft wird, ob die SimpleDateFormat
Instanzen für Parameter vom Typ Date
gedacht sind. Einfacher ist auch hier ein klassischer Ansatz ohne JavaAgents, wie im Beitrag MessageFormat aufgefrischt vorgestellt.
Damit ist dieser Beitrag über Static Instrumentation durch JavaAgents und ByteBuddy an sein Ende gelangt. Zum Thema Dynamic Instrumentation oder anderen Einsatzmöglichkeiten für ByteBuddy wird es beizeiten Beiträge geben.