Suche
Java-Trickkiste

Classpath-Scan im Eigenbau [Aus der Java-Trickkiste]

Arno Haase

Viele moderne Frameworks können zur Initialisierung den Classpath nach annotierten Klassen durchsuchen (z. B. EJB 3, JPA, Spring und sogar Servlet-API 3!). Ich halte diesen Architekturstil für problematisch (Startzeit, Testbarkeit, Security etc.), aber das ist ein Thema für eine andere Kolumne. Und egal wie man zu ihnen steht, Classpath-Scans begegnen einem heutzutage auf Schritt und Tritt – ein Grund, sich die Mechanismen dahinter anzuschauen.

Codeanalyse

Ich persönlich schreibe häufiger Code, der Systeme scannt, um Architekturinformationen aus dem Code zu extrahieren. Das erfüllt einen komplett anderen Zweck als die Scans von Frameworks, aber es beruht auf derselben Technologie.

Nehmen wir ein fiktives Spring-basiertes System an. Dort soll die Konvention gelten, dass alle Serviceklassen auf den Namen Service enden, und nur solche Service-Beans sollten mit der Annotation @Service versehen sein. Zum Überprüfen dieser Konventionen müssen wir für jede Klasse des Systems überprüfen, ob sie den Namenskonventionen für Services genügt und ob sie die @Service-Annotation hat. Bauen wir also einen Scanner, der das tut.

Wo liegen die Klassen?

Der erste Teil dieses Problems besteht darin, eine Liste aller Klassen im System zu erstellen. Java hat kein API dafür, also müssen wir selbst Hand anlegen. Im Allgemeinen sind die Klassen auf mehrere JAR-Dateien oder Verzeichnisse verteilt. Die Information, was alles zu unserem System gehört, steht in den Build- und Deploy-Skripten. Wenn alle Stricke reißen, können wir diese auswerten. Es gibt aber einen einfacheren Weg, alle Stellen zusammenzusuchen, an denen Code liegt. Dazu müssen wir den Analysecode als Teil der Anwendung starten, z. B. aus der IDE heraus (Listing 1).

public List<URL> getRootUrls () {
  List<URL> result = new ArrayList<> ();

  ClassLoader cl = Thread.currentThread().getContextClassLoader();
  while (cl != null) {
    if (cl instanceof URLClassLoader) {
      URL[] urls = ((URLClassLoader) cl).getURLs();
      result.addAll (Arrays.asList (urls));
    }
    cl = cl.getParent();
  }
  return result;
}

Diese Methode nimmt an, dass alle Klassen des Systems von einem einzigen Classloader geladen werden. Das gilt z. B. für Spring-Anwendungen und einfache Main-Klassen, die aus der IDE gestartet werden. Für EJB- und OSGi-Anwendungen gelten kompliziertere Regeln, aber wir beschränken uns hier auf den einfachen Fall.

Die meisten Container verwenden die JDK-Klasse URLClassLoader direkt oder eine Subklasse von ihr, und das macht sich der Code zu Nutze. In der Praxis liegen die Klassen entweder in Dateiverzeichnissen oder in JAR-Dateien, und genau diesen Fall deckt der URLClassLoader ab.

Der Code in Listing 1 startet mit dem Context-Classloader [1] und betrachtet von dort aus der Reihe nach dessen Eltern-Classloader. Für jeden dieser Classloader prüft er, ob es ein URLClassLoader ist, und sammelt in diesem Fall die URLs zu dessen Classpath ein. So liefert er eine Liste mit URLs mit allen Orten, an denen Klassen des Systems liegen.

Aufmacherbild: Burnt clay bricks, yellow helmet with gloves, trowel and goggles von Shutterstock/ Urheberrecht: Taurus

[ header = Seite 2: Vom URL zu den Klassen  ]

Vom URL zu den Klassen

Als Nächstes iterieren wir für jeden dieser URLs über die dort abgelegten Klassen. Dabei müssen wir unterscheiden, ob der URL auf ein Dateiverzeichnis oder auf eine JAR-Datei zeigt (Listing 2).

 for (URL url: getRootUrls ()) {
  File f = new File (url.getPath());
  if (f.isDirectory()) {
    visitFile (f);
  }
  else {
    visitJar (url);
  }
}

void visitFile (File f) throws IOException {
  if (f.isDirectory ()) {
    final File[] children = f.listFiles ();
    if (children != null) {
      for (File child: children) {
        visitFile (child);
      }
    }
  }
  else if (f.getName ().endsWith (".class")) {
    try (FileInputStream in = new FileInputStream (f)) {
      // TODO Analyse der Klasse
    }
  }
}

void visitJar (URL url) throws IOException {
  try (InputStream urlIn = url.openStream ();
       JarInputStream jarIn = new JarInputStream (urlIn)) {
    JarEntry entry;
    while ((entry = jarIn.getNextJarEntry ()) != null) {
      if (entry.getName ().endsWith (".class")) {
        // TODO Analyse der Klasse
      }
    }
  }
}

Wenn der URL auf ein Dateiverzeichnis zeigt, steigt die Methode visitFile(…) rekursiv in den Verzeichnisbaum ab und sucht nach Dateien, die auf .class enden. JAR-Dateien landen bei der Methode visitJar(…), die sie mit einem JarInputStream öffnet und nach Einträgen mit der Endung .class durchsucht.

Beiden Methoden fehlt noch der Code zur Verarbeitung der so gefundenen Klassen. Ein naiver Weg könnte die Klassen über ihren Namen laden und anschließend per Reflection analysieren, wie in Listing 3 skizziert.

 // SO NICHT !!!
void handlePerReflection (String className) throws Exception {
  Class cls = Class.forName (className);
        
  boolean hasSuffix = className.endsWith ("Service");
  boolean hasAnnotation = cls.getAnnotation (Service.class) != null;

  if (hasSuffix && !hasAnnotation) {
    System.out.println ("service without annotation: " + className);
  }
  if (hasAnnotation && !hasSuffix) {
    System.out.println ("wrong name: " + className);
  }
}

[ header = Seite 3: Reflection reicht nicht ]

Reflection reicht nicht

Dieser Ansatz funktioniert „im Prinzip“, hat aber eine Reihe von substanziellen Problemen und ist deshalb keine gute Wahl.

Erstens ist das Instanziieren aller Klassen ziemlich teuer. Die JVM lädt dazu nicht nur den Inhalt der Class-Datei, sondern sie validiert, linkt, materialisiert und initialisiert die Klasse auch. Das kann gerade für große Systeme erheblich Zeit kosten. Und die so geladenen Klassen belegen auch Speicherplatz, sowohl für sich selbst als auch für alle statischen Variablen. Bis Java 7 belegt das den notorisch knappen PermGen Space, aber auch mit Java 8 ist das nutzlose Belegen von Speicherplatz keine gute Idee.

Zweitens kann das Initialisieren von Klassen Seiteneffekte haben. Die JVM führt dabei alle statischen Initializer-Blöcke aus, die teure Dinge tun können – Caches initialisieren, Klassen registrieren etc. Da wir alle Klassen scannen, fassen wir dabei ziemlich viel Code an, den wir nicht kennen und dessen Funktionsweise uns vielleicht überraschen würde, wenn wir es täten.

Drittens lassen sich im Allgemeinen nicht alle Klassen laden, die im Classpath liegen, weil optionale Abhängigkeiten fehlen. So können Bibliotheken zum Beispiel sowohl commons-logging als auch slf4j unterstützen. Sie überprüfen zur Laufzeit, welche der beiden Bibliotheken im Classpath liegt, und aktivieren abhängig davon unterschiedlichen Code. Der Brute-Force-Ansatz aus Listing 3 versucht, beide Alternativen zu initialisieren, und bei einer von ihnen fehlen Abhängigkeiten – ein Error fliegt.

Und viertens erlaubt Reflection keinen Zugriff auf den eigentlichen Bytecode der Methoden, der gerade für Analysewerkzeuge sehr nützlich sein kann. Das werden wir in der detaillierteren Codeanalyse in der nächsten Folge ausnutzen. Wenn Reflection aber aus all diesen Gründen keine Gute Wahl ist – welche Alternativen haben wir, um den Inhalt von Klassen zu analysieren?

ASM, die Bytecodebibliothek

Das Dateiformat von Class-Dateien ist gut dokumentiert [2], und man kann sie wie beliebige andere Dateien parsen und auswerten. Das ist weniger kompliziert, als es klingt. Es gibt aber auch ASM, eine freie und offene Bibliothek, die genau das schon sehr leichtgewichtig und effizient tut [3].

Um mit ASM eine Klasse zu parsen, erzeugt man eine ClassReader-Instanz (Listing 4), die die Details der Klasse an einen ClassVisitor meldet. Dort kann man eine ganze Reihe an Methoden implementieren, je nachdem, wofür man sich interessiert. Unser Code definiert dazu eine Klasse MyClassVisitor, die von ClassVisitor erbt. ClassVisitor hat für alle Methoden „leere“ Default-Implementierungen, sodass man nur diejenigen Methoden überschreiben muss, für die man sich interessiert.

Unser Visitor überschreibt die Methode visit(…), die Informationen zu Namen, Sichtbarkeit, Typparametern, Superklasse und Interfaces liefert. Diese Methode wird einmal zu Beginn der Verarbeitung der Klasse aufgerufen, und unsere Implementierung überprüft die Namenskonvention für Serviceklassen.

Außerdem überschreibt er die Methode visitAnnotation(…), die für jede Annotation einmal aufgerufen wird. Diese Implementierung überprüft, ob es sich um die Annotation @Service des Spring Frameworks handelt. Der Name der Annotation erscheint hier in der internen Repräsentation von Java: Ein vorangestellter Buchstabe L bedeutet, dass es sich um einen Referenztyp handelt, die Packages sind durch „/“ und nicht durch Punkte voneinander getrennt, und am Ende steht ein Semikolon [2].

 

void handleClass (InputStream in) throws IOException {
  MyClassVisitor cv = new MyClassVisitor ();
  new ClassReader (in).accept (cv, 0);

  if (cv.hasSuffix && !cv.hasAnnotation) {
    System.out.println ("service without annotation: " + cv.className);
  }
  if (cv.hasAnnotation && !cv.hasSuffix) {
    System.out.println ("wrong name: " + cv.className);
  }
}

class MyClassVisitor extends ClassVisitor {
  public boolean hasSuffix;
  public boolean hasAnnotation;
  public String className;

  MyClassVisitor () {
    super (Opcodes.ASM5);
  }

  @Override public void visit (int version, 
        int access, String name, String signature, 
        String superName, String[] interfaces) {
    className = name.replace('/', '.');
    hasSuffix = name.endsWith("Service");
  }

  @Override public AnnotationVisitor visitAnnotation (
        String desc, boolean visible) {
    if (desc.equals ("Lorg/springframework/stereotype/Service;")) {
      hasAnnotation = true;
    }
    return null;
  }
}

Fazit

Damit haben wir einen kompletten Classpath-Scanner implementiert, der alle Class-Dateien auf unsere Namenskonventionen überprüft, ohne eine einzige Klasse zu laden. Statt Konventionen zu überprüfen, könnten wir selektiv Klassen laden und registrieren – und tatsächlich nutzen moderne Frameworks und Container solche Mechanismen für ihre Annotations-getriebene Funktionalität.

Geschrieben von
Arno Haase
Arno Haase
Arno Haase ist freiberuflicher Softwareentwickler. Er programmiert Java aus Leidenschaft, arbeitet aber auch als Architekt, Coach und Berater. Seine Schwerpunkte sind modellgetriebene Softwareentwicklung, Persistenzlösungen mit oder ohne relationaler Datenbank und nebenläufige und verteilte Systeme. Arno spricht regelmäßig auf Konferenzen und ist Autor von Fachartikeln und Büchern. Er lebt mit seiner Frau und seinen drei Kindern in Braunschweig.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: