Geheimagenten (oder geheime Agenten) bei ihrer Arbeit

Programmieren mit Java Agents: James Bond lässt grüßen

Bernd Müller

© Shutterstock/Harjis A.

Java führte in der Version 5 das Konzept eines Agenten ein. Obwohl dies bereits vor zehn Jahren geschah, scheinen Agenten in der Entwicklergemeinde recht unbekannt zu sein. Dieser Artikel soll die geheimen und unbekannten Agenten einem breiteren Entwicklerkreis bekannt machen.

Java 5.0 führte 2005 das Package java.lang.instrument ein. Die Aufgabe des Packages wird im Javadoc beschrieben als „Provides services that allow Java programming language agents to instrument programs running on the JVM. The mechanism for instrumentation is modification of the byte-codes of methods.“ Ein Java-Agent kann also mithilfe von Bytecode-Manipulationen Programme instrumentieren. Unter Instrumentieren versteht man nach Wikipedia „an ability to monitor or measure the level of a product’s performance, to diagnose errors and to write trace information“. Für den Autor ist diese Definition zu speziell. Allgemein können Java-Agenten verwendet werden, um sinnvolles Verhalten, das nicht im (ursprünglichen) Code steht, nachträglich und nur bei Bedarf einzubauen.

Da Agenten zwei verschiedene Arten der Instrumentierung erlauben, wollen wir diese getrennt behandeln. Ein Agent kann erstens eine Klasse instrumentieren, wenn diese beim Programmstart in die JVM geladen wird (Methode premain()) und zweitens eine bereits geladene Klasse instrumentieren (Methode agentmain()). Zusätzlich kann die Instrumentierung durch eine neue Binärversion der Klasse (Redefinition) oder durch Verwendung und Änderung der bestehenden Binärversion (Retransformation) erfolgen.

Pre-Main

Die Existenz einer premain()Methode ist neben einigen Konfigurationsoptionen die einzige Anforderung an einen Agenten, der beim Programmstart instrumentieren soll. Die premain()Methode hat Parameter vom Typ String und Instrumentation, wobei im zweiten Parameter eine Referenz für Instrumentierungen übergeben wird. Der einfachste Instrumentierungsagent sieht daher strukturell so aus wie in Listing 1.

public class ClassChangerAgent {
  public static void premain(String agentArgument, 
    Instrumentation instrumentation) {
    instrumentation.addTransformer(new ClassChanger());
  }
}

Die premain()Methode ist überladen und existiert auch in einer Version mit einem Parameter vom Typ String. Sie ist dann aber offensichtlich nicht zum Instrumentieren geeignet, sondern nur, um Programmcode vor der eigentlichen main()Methode ausführen zu können.

Wir entwickeln ein Beispiel, das beim Programmstart die Auswahl unter mehreren Implementierungen einer Klasse erlauben soll, in unserem Beispiel der Klasse SomeClass, die ebenso wie die Main-Klasse in Listing 2 dargestellt ist.

public class SomeClass {
  public void saySomething() {
    System.out.println("foo");
  }
}

public class Main {
  public static void main(String[] args) throws Exception {
    new SomeClass().saySomething();
  }
}

Stellen Sie sich verschiedene Versionen dieser Klasse vor, die alle dieselben öffentlichen Methoden besitzen. Um dies zu bewerkstelligen, „verstecken“ wir die verschiedenen Kompilate in Dateien, die durch den Class Loader nicht als Klassen wahrgenommen werden. In unserem Beispiel etwa in einer Datei mit Namen dummy.

Die im Agenten verwendete Klasse ClassChanger übernimmt die eigentliche Aufgabe des Austauschs der Klasse und ist in Listing 3 dargestellt.

public class ClassChanger implements ClassFileTransformer {

  @Override
  public byte[] transform(ClassLoader loader, String className,
    Class<?> classBeingRedefined, ProtectionDomain protectionDomain,
    byte[] classfileBuffer) throws IllegalClassFormatException {
    if (className.equals("de/pdbm/SomeClass")) {
      InputStream is = ClassChangerAgent.class
      .getClassLoader().getResourceAsStream("dummy");
      byte[] classBytes = classInputStreamToByteArray(is);
      return classBytes;
    }
    return classfileBuffer;
  }

  public static byte[] classInputStreamToByteArray(InputStream is) {
    try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
      int reads = is.read();
      while(reads != -1){
        baos.write(reads);
        reads = is.read();
      }
      return baos.toByteArray(); 
    } catch (IOException e) {
      // ILB
    }
    return new byte[0];
  }
}

Ein Instrumentierungstransformer muss das Interface ClassFileTransformer implementieren, das die einzige Methode transform() besitzt und als Ergebnis die neue Klassendefinition als Byte-Array zurückliefert. Der Transformer wird jeweils beim Laden einer Klasse in die JVM aufgerufen. Da wir nur die Klasse SomeClass ersetzten wollen, prüfen wir dies über den übergebenen Klassennamen im Parameter className. Falls dieser unserer Zielklasse entspricht, lesen wir unsere neue Klasse aus der Datei dummy und geben sie als Methodenergebnis zurück. Alle anderen Klassen darf die JVM unverändert laden.

Der Programmiersprachteil unseres Agenten ist damit bereits vollständig. Es fehlen jedoch noch einige Konfigurationsdetails. Agenten werden immer als JAR-Datei deployt und müssen den Klassennamen des Agenten als Wert des Attributs Premain-Class der MANIFEST.MF-Datei enthalten. Zu guter Letzt erfolgt der Aufruf des Agenten über die Kommandozeilenoption javaagent, in unserem Beispiel also

java -javaagent:agent.jar Main

Bei Verwendung des Agenten wird nicht die Klasse SomeClass geladen, die sich im Klassenpfad befindet, sondern die Klasse, die sich in der Datei dummy befindet. Der Leser kann sich sicher leicht vorstellen, dass alternativ mehrere Klassen z. B. in einer ZIP-Datei als Alternativen gehalten werden können, aus denen dann zum Programmstart über Optionen des Agenten eine ausgewählt wird. Optionen werden über den String-Parameter der premain()Methode übergeben und beim Aufruf hinter den Dateinamen des Agenten-JARs gehängt.

Was haben wir damit erreicht? Wir haben in Java SE ohne zusätzliche Bibliotheken eine analoge Möglichkeit zu CDIs Alternativen ermöglicht. Einerseits sogar einfacher als mit CDI, da wir keine XML-Datei editieren und auch kein Interface implementieren müssen, andererseits jedoch etwas aufwändiger, da wir die verschiedenen Versionen ein und derselben Klasse nicht einfach über unsere IDE verwalten können und sie nicht als Klassen über den Klassenpfad zu erkennen sein dürfen.

Agent-Main, Redefinitionen und Transformationen

Java definiert bei Agenten die vorgestellte Art und Weise der Agentenaktivierung beim Programmstart über die Kommandozeile als unabdingbaren Bestandteil einer kompatiblen Laufzeitumgebung. Das Aktivieren eines Agenten bei einer bereits laufenden VM ist ein optionaler Bestandteil und implementierungsabhängig: „An implementation may provide a mechanism to start agents sometime after the the VM has started. The details as to how this is initiated are implementation specific but typically the application has already started and its main method has already been invoked.“

Die im Folgenden dargestellte Methode ist in Oracles (OpenJDK) HotSpot, IBMs und SAPs VM sowie JRockit realisiert. Sie basiert auf der Klasse VirtualMachine des Packages com.sun.tools.attach und ist in der Datei tools.jar enthalten. Wir beginnen jedoch zuerst mit der Entwicklung des entsprechenden Agenten.

Die Agentenklasse muss statt der Methode premain() nun die Methode agentmain() implementieren, die aber analog in den beiden bereits vorgestellten überladenen Alternativen existiert. Das entsprechende Attribut in der Manifest-Datei heißt nun Agent-Class statt Premain-Class. Bei diesen Agenten wird unterschieden, ob eine bereits geladene Klasse durch eine komplett neue Version ersetzt wird, wie wir dies bereits in unserem ersten Beispiel getan haben, oder ob mit Rückgriff auf die bereits geladene Klasse Teile des Bytecodes transformiert werden. Für die erste Variante muss das Attribut Can-Redefine-Class im Manifest auf true gesetzt werden, für die zweite Variante gilt dies für das Attribut Can-Retransform-Class.

Um das prinzipielle Vorgehen zu demonstrieren, realisieren wir den durch Wikipedia definierten Anwendungsfall des Monitorings. Listing 4 zeigt eine einfache Klasse, für deren Methode foo() die Aufrufhäufigkeit bzw. Laufzeit bestimmt werden soll.

public class ClassToMonitor {
  public void foo() {
    // some computation
  }
}

Um die Aufrufhäufigkeit bzw. Laufzeit zu bestimmen, benötigen wir einen Mechanismus, um den vorhandenen Code zu manipulieren. Unter den existierenden Bytecode-Manipulationswerkzeugen ist Javassist eine Möglichkeit, dies mit wenig Aufwand zu realisieren. Bevor wir jedoch die eigentliche Codetransformation näher betrachten, zeigt Listing 5 den Agenten.

public class Agent {

  public static void agentmain(String agentArgs,
    Instrumentation instrumentation) {
    instrumentation.addTransformer(new MonitorTransformer(), true);
    Class<?>[] classes = instrumentation.getAllLoadedClasses();
    for (Class<?> c : classes) {
      if (c.getName().equals("de.pdbm.ClassToMonitor")) {
        try {
          instrumentation.retransformClasses(c);
        } catch (UnmodifiableClassException e) {
          // ILB
        }
      }
    }
  }

}

Um den Codeumfang für ein Beispiel minimal halten zu können, haben wir das Registrieren des Transformers (Methode addTransformer()) und den Aufruf des Transformers (Methode retransformClasses()) in ein und derselben Methode realisiert. Im allgemeinen Fall würde wahrscheinlich das Delegator-Pattern zu verwenden sein, da die Methode retransformClasses() alle registrierten Transformer aufruft. Für unseren Fall ist die gewählte Struktur aber adäquat und minimal. Der eigentliche Transformer ist in Listing 6 dargestellt und verwendet das bereits erwähnte Javassist.

public class MonitorTransformer implements ClassFileTransformer {

  @Override
  public byte[] transform(ClassLoader loader, String className,
    Class<?> classBeingRedefined, ProtectionDomain protectionDomain,
    byte[] classfileBuffer) throws IllegalClassFormatException {
    ClassPool pool = ClassPool.getDefault();
    try {
      pool.insertClassPath(
        new ByteArrayClassPath("de.pdbm.ClassToMonitor",
          classfileBuffer));
      CtClass cc = pool.get("de.pdbm.ClassToMonitor");
      CtMethod method = cc.getDeclaredMethod("foo");
      method.insertBefore("de.pdbm.Monitor.counter++;");
      // method.insertBefore("de.pdbm.Monitor.count();");
      return cc.toBytecode();
    } catch (NotFoundException | CannotCompileException | IOException e) {
      // ILB
    }
    return classfileBuffer;
  }

}

Die Klasse MonitorTransformer implementiert das bereits bekannte Interface ClassFileTransformer. Die Javassist-Methode ClassPool.getDefault() ist eine Fabrikmethode und der Einstieg in die Verwendung von Javassist. Der zurückgegebenen ClassPool-Instanz wird mit insertClassPath() der Bytecode übergeben, der über den entsprechenden Methodenparameter übergeben wurde. Der folgende Aufruf der get()-Methode liefert die nachgefragte Klasseninstanz zurück. Der Aufruf von insertClassPath() ist optional. Ohne ihn wird die folgende get()-Methode die Klasse im Klassenpfad suchen und sie dort ebenfalls finden.

Wir haben uns für Javassist entschieden, da Javassist es erlaubt, direkt Java-Statements zu verwenden und keine Bytecode-Verwendung erzwingt. Die Methode insertBefore() übersetzt den Parameter als Java-Statement und fügt den entsprechenden Bytecode am Anfang der Methode ein – in diesem Fall das Inkrementieren einer öffentlichen Klassenvariable. Alternativ könnte auch ein Methodenaufruf eingefügt werden, wie das auskommentierte Beispiel zeigt. Auf diesem Weg könnten z. B. auch Logging-Nachrichten erzeugt werden. Soll statt der Aufrufhäufigkeit die Laufzeit der Methode bestimmt werden, könnte mit insertBefore() und insertAfter() jeweils die Systemzeit (System.currentTimeMillis()) am Anfang und am Ende der Methodenausführung bestimmt und über deren Differenz die Laufzeit berechnet werden.

Was nun noch fehlt, ist die Aktivierung des Agenten mithilfe der Klasse VirtualMachine. Diese ist im Javadoc beschrieben als „a Java virtual machine to which this Java virtual machine has attached. The Java virtual machine to which it is attached is sometimes called the target virtual machine, or target VM. An application (typically a tool such as a managemet console or profiler) uses a VirtualMachine to load an agent into the target VM. For example, a profiler tool written in the Java Language might attach to a running application and load its profiler agent to profile the running application.“

Zentral sind hierbei die Fabrikmethode attach(), die die ausführende VM an die VM bindet, deren Prozess-ID als Parameter übergeben wurde, und die Methode loadAgent(), die den Agenten über das angegebene Agenten-JAR lädt und dessen agentmain()-Methode dann automatisch ausgeführt wird. Listing 7 zeigt den entsprechenden Programmcode.

public class Attacher {

  private static final String JAR_FILE_PATH = "...";

  public static void main(String[] args) throws Exception {
    if (args.length != 1) {
      System.exit(1);
    }
    VirtualMachine vm = VirtualMachine.attach(args[0]);
    vm.loadAgent(JAR_FILE_PATH, "");
    vm.detach();
  }

}

Beim gezeigten Beispiel muss die Prozess-ID als Parameter der Main-Methode manuell übergeben werden. Soll das Attachen in derselben VM z. B. über Betätigen einer Schaltfläche im UI geschehen, so kann über ManagementFactory.getRuntimeMXBean().getName() die Prozess-ID der eigenen VM erfragt und so das Verfahren automatisiert werden.

Soll das Monitoring der Anwendung beendet werden, so kann natürlich durch das gezeigte Vorgehen die ursprüngliche, nicht veränderte Klasse, erneut geladen werden.

Fazit

Wir haben Javas Konzept eines Agenten und das Instrumentierungs-API vorgestellt, das bereits mit Java 5 eingeführt wurde. Damit ist es möglich, Klassen beim Laden in die VM zu verändern. Es können sogar bereits geladene Klassen verändert werden. Das API ist geradezu erschreckend einfach, jedoch in der Entwicklergemeinde relativ unbekannt. Das liegt vor allem daran, dass das Manipulieren von Klassen in der Regel kein Bestandteil fachlicher Anforderungen ist. Auch wenn Sie als Anwendungsentwickler in der Zukunft keine Agenten realisieren werden, hoffen wir, dass wir mit diesem Artikel dazu beitragen konnten, einmal mehr zu zeigen, wie mächtig Java ist. JPA-Provider, Monitoring- und Testüberdeckungswerkzeuge sowie viele andere Frameworks nutzen die gezeigten Möglichkeiten seit Langem.

Aufmacherbild: secret agent banner von Shutterstock / Urheberrecht: Harjis A. 

Geschrieben von
Bernd Müller
Bernd Müller
Bernd Müller ist Informatiker und als Hochschulprofessor und GmbH-Geschäftsführer tätig. Er ist Autor mehrerer Java-EE-Bücher, Sprecher auf verschiedenen Konferenzen und war Mitglied der JCP-Expert-Groups für JSF 2.2 und JPA 2.1.
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.