Von Buddies und Agenten – dynamische Codegenerierung in Java

“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.

Schreibe einen Kommentar