Dynamische Proxies im praktischen Einsatz

JDBC-Treiber selbstgebaut [Java-Trickkiste]

Arno Haase

Quelle: S&S Media

Vor vielen Jahren hatte ich mit einem System zu tun, das bei bestimmten Eingabewerten Daten verfälscht in der Datenbank ablegte. Das Phänomen gab dem ganzen Team Rätsel auf, zumal das unser erstes Projekt mit einem O/R Mapper war und wir also keine Intuition für mögliche Fehlerursachen hatten.

Zur Analyse habe ich einen JDBC-Treiber implementiert, der alle Aufrufe protokolliert und dann an den eigentlichen Datenbanktreiber weiterreicht. Und ich war überrascht, wie einfach und schnell das mit dynamischen Proxies geht.

InvocationHandler

Ein dynamischer Proxy ist eine synthetische, zur Laufzeit des Programms erzeugte Klasse, die ein oder mehrere Interfaces implementiert und Aufrufe an einen InvocationHandler delegiert. Dieser Mechanismus ist u. a. nützlich, wenn man Interfaces generisch implementieren will, z. B. um Aufrufe zu protokollieren und dann weiterzuleiten. Das ist eine einfache, leichtgewichtige Form von aspektorientierter Programmierung (AOP). Listing 1 zeigt eine generische Implementierung eines solchen loggenden InvocationHandlers.

public class LoggingInvocationHandler implements InvocationHandler {
  private final Object inner;

  public LoggingInvocationHandler(Object inner) {
    this.inner = inner;
  }

  public Object invoke(Object proxy, Method method, Object[] args) 
  throws Throwable {
    try {
      final Object result = method.invoke(inner, args);
      System.out.println("invoking " + method + ": " + 
        (args != null ? Arrays.asList(args) : "") + 
        " -> " + result);
      return result;
    }
    catch(InvocationTargetException exc) {
      throw exc.getCause();
    }
  }
}


Die Klasse erhält im Konstruktor das „innere“ Objekt, das die eigentliche Arbeit leistet und an das die Aufrufe weitergeleitet werden sollen. Die Arbeit passiert in der Methode invoke(), die vom Interface InvocationHandler vorgegeben ist. Jeder Methodenaufruf auf dem dynamischen Proxy löst einen Aufruf dieser Methode aus. Sie hat drei Parameter. Der erste davon ist das Proxy-Objekt, das den Aufruf an diesen InvocationHandler delegiert hat. Er kann hilfreich sein, wenn mehrere dynamische Proxies sich einen InvocationHandler teilen, meist kann man ihn aber getrost ignorieren. Die restlichen beiden Parameter enthalten die aufgerufene Methode als Method-Objekt sowie die Parameter des Aufrufs als Objekt-Array.

Unsere Implementierung ruft als Erstes per Reflection die Methode mit ihren Parametern auf. Anschließend gibt sie die Details des Aufrufs nach System.out aus. Sie könnte beliebige andere Dinge tun – die Aufrufdauer messen, Wertebereiche von Parametern überprüfen, Ergebnisse teurer Aufrufe cachen oder auch Berechtigungsprüfungen auf Basis von Annotationen an der Zielmethode durchführen. Spring AOP funktioniert intern zum Beispiel so.

Dieser gesamte Code steckt noch in einem try-Block mit einem Handler für InvocationTargetException. Das ist der Mechanik von Reflection in Java geschuldet: Ein Aufruf von Method.invoke() wickelt alle Exceptions in InvocationTargetExceptions ein, und wir packen sie hier wieder aus.

Proxy erzeugen

Als einfaches Beispiel können wir mit unserem InvocationHandler alle Aufrufe auf einer ArrayList protokollieren (Listing 2). Das Erzeugen einer Proxy-Instanz erfolgt dabei durch einen Aufruf von Proxy.newProxyInstance().

List<String> inner = new ArrayList<>();
List<String> l = (List<String>) Proxy.newProxyInstance(
  Thread.currentThread().getContextClassLoader(), 
  new Class<?>[]{List.class}, 
  new LoggingInvocationHandler(inner));
l.add("a");


Der Aufruf hat drei Parameter, von denen der erste der ClassLoader ist, mit dem der Proxy erzeugt wird. Hier ist der Kontext-ClassLoader fast immer die richtige Wahl. Der zweite Parameter enthält die Liste aller Interfaces, die der Proxy implementieren soll. In diesem Fall ist das nur eines, nämlich List.

Der dritte Parameter ist schließlich der InvocationHandler. Dort erzeugen wir einen LoggingInvocationHandler und übergeben ihm die eigentliche Liste, an die er delegieren soll. Das anschließende Hinzufügen eines Elements zur Liste wird jetzt tatsächlich protokolliert.

JDBC-Treiber

Um daraus einen JDBC-Treiber zu machen, brauchen wir als Startpunkt eine Klasse, die java.sql.Driver implementiert (Listing 3).

public class LoggingJdbcDriver implements Driver {
  public static final String PREFIX = "jdbc:logger:";

  public boolean acceptsURL(String url) throws SQLException {
    return url.startsWith(PREFIX);
  }

  public Connection connect(String url, Properties info) throws SQLException {
    if (acceptsURL(url)) {
      final Driver innerDriver = DriverManager.getDriver(
        url.substring(PREFIX.length()));
      final Connection innerConn = innerDriver.connect(
        url.substring(PREFIX.length()), info);
      return (Connection) Proxy.newProxyInstance(
        Thread.currentThread().getContextClassLoader(), 
        new Class[] {Connection.class}, 
        new LoggingInvocationHandler(innerConn));
    }
    else {
      return null;
    }
  }

  ... // weitere Methoden, die hier egal sind
}


Der Treiber soll auf alle URLs reagieren, die mit „jdbc:logger:“ beginnen. Danach soll dann der „eigentliche“ URL kommen, an den der Treiber delegiert. Die Überprüfung, ob unser Treiber für einen URL zuständig ist, erfolgt in der Methode acceptsURL(). Die prüft einfach, ob der URL mit unserem Präfix beginnt.

Die eigentliche „Implementierung“ unseres Treibers steckt in der Methode connect(). Die holt sich anhand der Connection-URL die „eigentliche“ Datenbankverbindung vom „richtigen“ Treiber und verpackt die in einen dynamischen Proxy mit LoggingInvocationHandler.

Das Driver-Interface enthält noch eine Reihe weiterer Methoden (Version des Treibers etc.), deren Implementierung für unseren Treiber aber durchweg egal ist.

Transitives Logging

Dieser Treiber loggt alle Aufrufe auf Connections, die er erzeugt. Wenn die Connection aber z. B. ein PreparedStatement erzeugt und zurückliefert, passiert da noch kein Logging.

Listing 4 zeigt eine Erweiterung der invoke-Methode, die generisch auch Statements, ResultsSets und andere Objekte in dynamische Proxies verpackt, die als Rückgabewert einen unserer Proxies verlassen.

// Dieses Listing ersetzt das return-Statement aus Listing 1
if (result == null) {
  return null;
}
if (method.getReturnType().isInterface()) {
  return Proxy.newProxyInstance(
    Thread.currentThread().getContextClassLoader(), 
    new Class[] {method.getReturnType()}, 
    new LoggingInvocationHandler(result));
}
return result;


Und weil das Ergebnis auch wieder einen LoggingInvocationHandler hat, funktioniert das auch mehrstufig, also z. B. wenn eine Connection ein Statement erzeugt, das seinerseits ein ResultSet liefert.

Verwendung

Listing 5 zeigt den Treiber schließlich „in der Praxis“. Die erste Zeile startet einen H2-Datenbankserver, und die zweite Zeile registriert den Treiber. „Richtige“ Treiber registrieren sich über den ServiceLoader automatisch, sodass dieser Aufruf überflüssig wäre. Das ist zwar nicht wirklich kompliziert, sprengt aber den Rahmen dieser Kolumne.

org.h2.tools.Server.main(new String[] {"-tcp", "-web"});
DriverManager.registerDriver(new LoggingJdbcDriver());

final Connection conn = DriverManager.getConnection(
  "jdbc:logger:jdbc:h2:tcp://localhost/abc", 
  "sa", 
  "");
final PreparedStatement stmt = conn.prepareStatement(
  "SELECT * FROM INFORMATION_SCHEMA.TABLES where table_name like ?");
stmt.setString(1, "I%");
final ResultSet rs = stmt.executeQuery();
while (rs.next()) {
  System.out.println(rs.getString("TABLE_NAME"));
}
conn.close();


Der eigentliche Code erzeugt eine Connection und liest alle Tabellennamen der Datenbank aus, die mit dem Buchstaben „I“ beginnen. Das ist inhaltlich nicht besonders spannend, aber es zeigt, wie unser Treiber alle Aufrufe protokolliert.

Fazit

Mit dynamischen Proxies kann man mit wenig Aufwand synthetische Klassen mit generischen Implementierungen erzeugen. Ihre wichtigste Beschränkung ist, dass sie nur auf Interfaces funktionieren. Man kann mit ihnen keine Subklassen bestehender Klassen erzeugen. Außerdem verstecken sie den eigentlichen Implementierungstyp. Wenn wir z. B. in Listing 2 überprüfen wollten, ob die Liste eine ArrayList ist, oder die Referenz sogar auf den Typ ArrayList casten wollten, würde das fehlschlagen. Trotzdem sind dynamische Proxies ein extrem nützliches Werkzeug, um querschnittliche Funktionalität generisch zu implementieren.

Und der Bug aus der Einleitung? Mithilfe des loggenden Wrappers fanden wir heraus, dass der kommerzielle Datenbanktreiber einen Bug hatte, der beim Zusammenspiel von Batch-Updates und BigDecimal als Spaltentyp mit bestimmten Werten reproduzierbar die Zahlen verfälschte. Wir haben daraufhin auf Batch-Updates verzichtet.

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: