Groovy: Das Metaobjekt-Protokoll

Aus Wikibooks

Hinter dem Begriff Meta-Objekt-Protokoll (MOP) verbirgt sich in Groovy der Mechanismus, den die Sprache zur externen Modifikation des Verhaltens von Objekten zur Verfügung stellt. Wir werden hier versuchen, seine prinzipielle Wirkungsweise darzustellen, ohne uns allzu sehr in seinen Tiefen zu verlieren.

Metaklassen[Bearbeiten]

Wie oben beschrieben, wird in Groovy die gesamte Kommunikation zwischen Objekten über Metaklassen abgewickelt. Der Aufruf einer anderen Methode ‒ egal ob im eigenen oder einem fremden Objekt ‒ erfolgt immer vermittelt über die für das Zielobjekt zuständige Metaklassen-Instanz. Diese ist in der Regel bei der MetaClassRegistry für die Klasse des Zielobjekts eingetragen. Unter bestimmten Voraussetzungen kann aber davon abweichend einem spezifischen Zielobjekt individuell eine eigene Metaklassen-Instanz mit einem abweichenden Verhalten zugeordnet werden.

Jede Metaklassen-Instanz »kennt« die Klasse, für die sie zuständig ist. Die wichtigsten Methoden, die sie zur Verfügung stellt, sind folgende:

  • void initialize() schließt den Initialisierungsprozess der Instanz ab und muss aufgerufen werden, bevor eine der folgenden Methoden verwendet wird. Auf die Initialisierung selbst wollen wir hier nicht näher eingehen.
  • Object invokeConstructor(Object[] args) erzeugt eine neue Instanz der betreffenden Klasse.
  • Object invokeMethod (Object object, String method, Object[] args) ruft bei dem übergebenen Objekt, das eine Instanz der betreffenden Klasse sein muss, die Methode mit dem angegebenen Namen und den Argumenten auf und liefert das Ergebnis des Aufrufs zurück.
  • Object getProperty (Object object, String property) liest bei dem übergebenen Objekt, das eine Instanz der betreffenden Klasse sein muss, die Property mit dem angegebenen Namen aus und liefert deren Wert zurück.
  • void setProperty (Object object, String property, Object wert) schreibt bei dem übergebenen Objekt, das eine Instanz der betreffenden Klasse sein muss, den übergebenen Wert in die Property mit dem angegebenen Namen.

Wenn Sie eine eigene Metaklassen-Implementierung haben, können Sie diese folgendermaßen ins Spiel bringen.

Wenn die Metaklasse einer gesamten Klasse zugeordnet werden soll, dann können Sie sie direkt bei der MetaClassRegistry registrieren. Dabei handelt es sich um eine Singleton-Instanz, die sich am besten über einen statischen Aufruf von groovy.lang.GroovySystem holen. Die folgende Anweisung registriert eine Metaklasse für die Klasse java.lang.Integer.

GroovySystem.metaClassRegistry.setMetaClass (Integer.class, new MeineMetaClass())

Eine andere Möglichkeit besteht darin, eine Klasse einfach auf eine bestimmte Art zu benennen. Bilden Sie den Namen der Metaklasse so, dass Sie vor dem Package-Pfad der Zielklasse groovy.runtime.metaclass. setzen und nach dem Namen der Zielklasse MetaClass anfügen. Wenn wir also die Metaklasse für Integer folgendermaßen benennen, brauchen wir sie nicht explizit anzumelden, da sie von der MetaClassRegistry bei einem Zugriff auf ein Integer-Objekt automatisch gefunden wird.

groovy.runtime.metaclass.java.lang.IntegerMetaClass

Beachten Sie, dass Metaklassen immer genau für die Instanzen der einen Klasse gelten, für die Sie registriert sind, und nicht etwa für Instanzen davon abgeleiteter Klassen. Wenn Sie also beispielsweise eine Metaklasse für die Klasse java.util.HashMap registrieren, dann gilt sie nicht für Objekte des davon abgeleiteten Typs java.util.LinkedHashMap. Demzufolge ist es auch völlig sinnlos, Metaklassen für abstrakte Klassen (java.util.AbstractMap) oder Interfaces (java.util.Map) zu registrieren.

Warnung: Indem Sie einer Klasse oder einem einzelnen Objekt eine spezielle Metaklasse zuordnen, können Sie massiv in das Verhalten der betreffenden Klasse oder des Objekts eingreifen. Die von Groovy standardmäßig verwendete MetaClassImpl ist ein recht komplexes Gebilde, das durch eigene Kreationen zu ersetzen zumindest bei den Standardklassen wegen möglicherweise auftretender äußerst undurchsichtiger Folgewirkungen generell nicht zu empfehlen ist.

Die Groovy-Standardbibliothek bietet einige spezielle Metaklassen-Implementierungen, die als anstelle von MetaClassImpl eingesetzt werden können und zusätzliche Möglichkeiten bieten. An zwei Beispielen werden wir zeigen, wie Sie mit ausgetauschten Metaklassen arbeiten können. Auch hier ist aber eine gewisse Vorsicht wegen möglicherweise unerwarteter Nebenwirkungen durchaus am Platze.

Die DelegatingMetaClass[Bearbeiten]

Die Klasse java.lang.DelegatingMetaClass tut nichts weiter, als alle Methodenaufrufe an eine andere Metaklasse weiterzuleiten. Man kann ihr die Metaklasse, an die delegiert werden soll, direkt übergeben, man kann aber auch die Klasse übergeben, für die sie zuständig sein soll; in diesem Fall erzeugt sie sich selbst eine MetaClassImpl-Instanz und leitet an diese weiter.

Für sich gesehen, bewirkt der Einsatz der DelegatingMetaClass also wenig; man kann aber eine eigene Metaklasse von ihr ableiten und hat dann die Möglichkeit, in das Geschehen einzugreifen, in dem man eine oder mehrere der oben genannten MetaClass-Methoden überschreibt.

Beim Behandeln des Interface GroovyInterceptable haben wir eine abstrakte Basisklasse ProtokollObjekt als Beispiel entwickelt, das die automatische Protokollierung bei davon abgeleiteten Klassen ermöglicht. Dabei haben wir auch bemerkt, dass die Notwendigkeit, von dieser Klasse abzuleiten, wegen der resultierenden Beschränkungen bezüglich der Klassenhierarchie nur begrenzt anwendbar ist. Solche Querschnittsaufgaben wie Protokollierung können Sie mit Metaklassen völlig transparent implementieren. Die folgende Klasse ProtokollDelegatingMetaClass funktioniert analog zur erwähnten Klasse ProtokollObjekt. Die Klasse, für die sie zuständig sein soll, wird ihr als Konstruktor-Argument übergeben. Auch hier geschieht nichts weiter, als dass der Aufruf von invokeMethod() abgefangen wird, die hier eine andere Signatur als die gleichnamige Methode des GroovyObject hat, da das Objekt, an dem die Methode ausgeführt werden soll, als Argument übergeben werden muss. Zwischen zwei Protokolleinträgen wird der Methodenaufruf an die DelegatingMetaClass weitergegeben, von der wir abgeleitet haben, und diese reicht ihn an die MetaKlasse weiter, die ursprünglich zuständig war.

import java.util.logging.*

class ProtokollDelegatingMetaClass extends DelegatingMetaClass {
  Logger logger
  String klassenname
  def ProtokollDelegatingMetaClass(Class zurKlasse) {
    super(zurKlasse)
    klassenname = zurKlasse.name
    logger = Logger.getLogger (klassenname)
    initialize()
  }

  def invokeMethod (Object object, String name, Object[] args) {
    logger.info "Aufruf $klassenname.$name $args"
    try {
      def ergebnis = super.invokeMethod(object,name,args)
      logger.info "Ergebnis $klassenname.$name: $ergebnis"
      return ergebnis
    } catch (Throwable thr) {
      logger.info "Exception in $klassenname $name: $thr.message"
      throw thr
    }
  }
}

Beachten Sie, dass der Konstruktor mit einem Aufruf von initialize() endet; bevor diese Methode ausgeführt worden ist, kann eine Metaklasse grundsätzlich nicht angewendet werden.

Zum Ausprobieren schreiben wir ein kurzes Skript namens TestProtokollDelegatingMetaClass.groovy, das noch mal eine Mini-Klasse namens MeineKlasse zum ausprobieren definiert, unsere Metaklasse bei der MetaClassRegistry für MeineKlasse anmeldet und schließlich einige Methodenaufrufe an einer Instanz von MeineKlasse durchführt.

// Eine Miniaturklasse als Versuchstier
class MeineKlasse {
  def inhalt
}

// Instanz der Metaklasse erzeugen und mit der Zielklasse initialisieren
metaKlasse = new ProtokollMetaClass(MeineKlasse)
// Metaklasse für die Zielklasse registieren
org.codehaus.groovy.runtime.InvokerHelper.instance.
metaRegistry.setMetaClass(MeineKlasse, metaKlasse )
// Instanz der Versuchsklasse anlegen
// und einige Methodenaufrufe durchführen
mk = new MeineKlasse()
println mk.toString()
mk.setInhalt("Wichtige Daten")
println mk.getInhalt()

Schließlich führen wir das Skript in der Konsole aus, und neben den println()-Ausgaben erscheinen die erwarteten Protokollausgaben.

> groovy TestProtokollDelegatingMetaClass
MeineKlasse@641e9a
Wichtige Daten
21.02.2007 21:06:57 gjdk.java.util.logging.Logger_GroovyReflector invoke
INFO: Aufruf MeineKlasse.toString {}
21.02.2007 21:06:57 gjdk.java.util.logging.Logger_GroovyReflector invoke
INFO: Ergebnis MeineKlasse.toString: MeineKlasse@641e9a
21.02.2007 21:06:57 gjdk.java.util.logging.Logger_GroovyReflector invoke
INFO: Aufruf MeineKlasse.setInhalt {"Wichtige Daten"}
21.02.2007 21:06:57 gjdk.java.util.logging.Logger_GroovyReflector invoke
INFO: Ergebnis MeineKlasse.setInhalt: null
21.02.2007 21:06:57 gjdk.java.util.logging.Logger_GroovyReflector invoke
INFO: Aufruf MeineKlasse.getInhalt {}
21.02.2007 21:06:57 gjdk.java.util.logging.Logger_GroovyReflector invoke
INFO: Ergebnis MeineKlasse.getInhalt: Wichtige Daten

Wir haben unsere Metaklasse hier explizit mit setMetClass() bei der MetaClassRegistry für die ganze Klasse MeineKlasse angemeldet, damit wurde sie automatisch auch für die eine konkrete Instanz zuständig. Alternativ hätten wir sie auch einfach folgendermaßen nennen können:

groovy.runtime.metaclass.MeineKlasseMetaClass

Die bloße Existenz dieser Klasse im Klassenpfad hätte genügt, und eine ausdrückliche Anmeldung wäre nicht mehr nötig gewesen. In diesem Fall wäre es natürlich nicht besonders sinnvoll, denn die ProtokollDelegatingMetaClass ist ja prinzipiell für viele Zielklassen anwendbar, und man würde wohl nicht für jeden Anwendungsfall eine Kopie mit eigenem Namen anlegen wollen.

Schließlich hätten wir sie auch der einigen Instanz von MeineKlasse individuell zuordnen können:

mk = new MeineKlasse()
mk.metaClass = new ProtkollDelegatingMetaClass(MeineKlasse)

Das Ergebnis des Skriptaufrufs wäre in jedem Fall dasselbe.

Die ProxyMetaClass[Bearbeiten]

Während die eben vorgestellte DelegatingMetaClass eine Art Schweizer Messer für diverse Modifikationen der Kommunikation zwischen Objekten darstellt, ist die ebenfalls in der Groovy-Laufzeitbibliothek enthaltene ProxyMetaClass ganz auf das Abfangen von Methodenaufrufen abgestimmt. Für einen konkreten Verwendungszweck passen wir sie nicht durch Überschreiben von Methoden an, sondern mit Hilfe eines speziellen Objekts, das das Interface groovy.lang.Interceptor implementiert und der ProxyMetaClass übergeben wird.

Das Interceptor-Interface sieht (ohne Kommentare) folgendermaßen aus:

// Java
package groovy.lang;
public interface Interceptor {
 Object beforeInvoke(Object object, String methodName, Object[] arguments);
 Object afterInvoke(Object object, String methodName, Object[] arguments, Object result);
 boolean doInvoke();
}

Die drei Methoden, die das Interface definiert, sind folgendermaßen zu verstehen:

  • Die Methode beforeInvoke() wird jedes Mal vor jedem Aufruf einer Methode im Zielobjekt aufgerufen und erhält eine Referenz auf das Zielobjekt, den Methodennamen und die Aufrufargumente. Der Rückgabewert dieser Methode spielt nur dann eine Rolle, wenn der nachfolgende Aufruf von doInvoke() das Ergebnis false liefert.
  • Das Ergebnis von doInvoke() entscheidet darüber, ob der Methodenaufruf überhaupt an die eigentlich zuständige Metaklasse und damit an das Zielobjekt weitergeleitet werden soll. Ist dies nicht der fall, wird der Rückgabewert von beforeInvoke() anstelle des Methodenaufruf-Ergebnisses weiterverwendet.
  • In jedem Fall wird abschließend die Methode afterInvoke() aufgerufen. Sie hat dieselben Parameter wie beforeInvoke() und zusätzlich noch das Ergebnis des Methodenaufrufs (das auch von beforeInvoke() stammen kann). Der Rückgabewert von afterInvoke() wird letztendlich an den ursprünglichen Aufrufer zurückgegeben.

Wenn wir diese Variante ausprobieren wollen, müssen wir also eine entsprechende Klasse erstellen, die Interceptor implementiert. Wir nennen sie ProtokollInterceptor:

import java.util.logging.*

class ProtokollInterceptor implements Interceptor {
  Logger logger
  String klassenname
  
  // Konstruktor merkt sich nur den Klassennamen
  // und legt einen Logger an.
  def ProtokollInterceptor (Class klasse) {
    klassenname = klasse.name
    logger = Logger.getLogger (klassenname)
  }

  // Methodenaufruf protokollieren
  def beforeInvoke(objekt, String name, Object[] args) {
    logger.info "Aufruf $klassenname.$name $args"
  }

  // Methodenausgang protokollieren
  def afterInvoke(objekt, String name, Object[] args, ergebnis) {
    logger.info "Ergebnis $klassenname.$name: $ergebnis"
    ergebnis
  }
  
  // Aufruf soll immer ausgeführt werden
  boolean doInvoke() {
    true
  }

}

Zum Ausprobieren benutzen wir das folgende Skript TestProtokollInterceptor.groovy. Es verwendet zum Instanziieren der Metaklasse die von ProxyMetaClass zur Verfügung gestellte statische Factory-Methode getInstance(). Die Registrierung wird übrigens von dieser Metaklasse selbst übernommen; dafür gibt es eine Methode use(), der Sie eine Closure übergeben können. Sie registriert sich selbst bei der MetaClassRegistry für ihre Zielklasse, führt die Closure aus und deregistriert sich anschließend. Auf diese Weise kann sie sehr gezielt eingesetzt werden.

class MeineKlasse {
  def inhalt
}

metaKlasse = ProxyMetaClass.getInstance(MeineKlasse)
metaKlasse.interceptor = new ProtokollInterceptor(MeineKlasse)
metaKlasse.use {
  mk = new MeineKlasse()
  println mk.toString()
  mk.setInhalt("Wichtige Daten")
  println mk.getInhalt()
}

Wenn Sie dieses Skript starten, sieht das Ergebnis genau so aus wie oben bei dem Skript TestProtokollDelegatingMetaClass.groovy. Man kann die use()-Methode übrigens auch ganz gezielt auf eine einzige Instanz der Zielklasse anwenden, indem man diese zusätzlich als erstes Argument angibt:

mk = new MeineKlasse()
metaKlasse.use (mk) {
  ...
}

Die Groovy-Laufzeitbibliothek liefert übrigens schon zwei Standard-Implementierungen des Interceptor-Interface mit, mit denen die häufigsten Anwendungsfälle der ProxyMetaClass abgedeckt sind. Sie sind sehr einfach anzuwenden, deshalb genügt hier eine kurze Beschreibung dieser Klassen.

  • Die Klasse groovy.lang.TracingInterceptor definiert einen Interzeptor, der ähnlich wir unser obiges Beispiel jeden Methodenaufruf protokolliert, allerdings werden die Protokollzeilen in einen Writer geschrieben, den Sie über die Property writer setzen können. Standardmäßig protokolliert der TracingInterceptor auf der Konsole.
  • Der groovy.lang.BenchmarkInterceptor dient zur Erstellung von Metriken. Er zählt einfach jeden Methodenaufruf mit. Mit Hilfe einer von ihm zur Verfügung gestellten Methode statistic() können sie dann am Ende eine Auflistung der Ergebnisse abholen.

Die ExpandoMetaClass[Bearbeiten]

Eine neue Errungenschaft der Groovy-Version 1.1 ist die aus dem Grails-Projekt übernommene ExpandoMetaClass. Sie ermöglicht es auf geradezu faszinierende Art und Weise, vorhandene Klassen mit zusätzlichen Methoden zu versehen, ohne auf vergleichsweise umständliche Konstruktionen wie die Kategorienklassen zurückgreifen zu müssen. Sie weisen der ExpandoMetaClass einfach eine Closure als virtuelle Property zu, und schon steht die Closure der zugeordneten Klasse als neue Methode zur Verfügung.

Haben Sie nicht auch schon immer zwei String-Methoden ltrim() und rtrim() vermisst, mit denen man an einem String die Leerzeichen links bzw. rechts entfernen kann? Selbst als vordefinierte Groovy-Methoden gibt es sie noch nicht ‒ also machen wir sie uns einfach selbst. Folgendes Beispiel zeigt ein Skript namens ExpandoMetaClassSkript.groovy, in diem diese beiden Methoden verfügbar gemacht werden.

emc = new ExpandoMetaClass (String,true)
emc.rtrim = {
    def str = delegate
    while (str && str[-1]==' ') str = str[0..-2]
    str
}
emc.ltrim = {
    def str = delegate
    while (str && str[0]==' ') str = str[1..-1]
    str
}
emc.initialize()

println '|'+'  abc  '.ltrim()+'|'
println '|'+'  abc  '.rtrim()+'|'

Gleich am Anfang sehen Sie, dass Sie die ExpandoMetaClass nicht explizit registrieren müssen, vielmehr genügt es, als Konstruktor-Argument die Zielklasse und true anzugeben. Sie dürfen nicht vergessen, die Methode initialize() aufzurufen, nachdem alle Methoden-Closures zugewiesen sind und bevor die zugeordnete Klasse verwendet wird.

Innerhalb der Closure können mit Hilfe der Variablen delegate auf das Objekt zugreifen, an dem die Methode auszuführen ist. Genau dies tun die beiden Closures, dann schneiden sie in einer Schleife links bzw. rechts die Leerzeichen ab und übergeben den Rest als Ergebnis. Wenn Sie das Skript ausführen, finden wir tatsächlich eine links und eine rechts gekürzte Version des übergebenen Strings:

> groovy ExpandoMetaClassSkript
|abc |
| abc|

Groovy erhielt auch eine neue vordefinierte Methode namens getMetaClass(), die auf Klassen anwendbar ist. Sie ordnet der jeweiligen Klasse eine eigene ExpandoMetaClass zu, sofern dies nicht schon geschehen ist, und gibt diese zurück. Die Definition der Methoden-Closures im ExpandoMetaClassSkript.groovy hätte also auch in dieser Art formuliert werden können:

String.metaClass.ltrim = { ... }

Die Initialisierung der Metaklasse ist in diesem Fall nicht erforderlich.

Anzumerken ist, dass die in die hinzugefügten Methoden auch dann effektiv sind, wenn es bereits eine entsprechende direkt implementierte Methode gibt. Sie können also mit der ExpandoMetaClass die Originalmethoden des jeweiligen Typs überschreiben und damit dessen Verhalten ändern.

Die ExpandoMetaClass bietet noch einige weitere Möglichkeiten, die wir hier kurz zusammenfassen wollen:

  • Wenn Sie eine Methode hinzufügen und dabei sicher gehen wollen, dass keine vorhandene Methode überschrieben wird, benutzen Sie den <<-Operator anstelle des Gleichheitszeichens bei der Zuweisung der Closure:
emc.ltrim << { ... }
  • Statische Methoden können Sie zuordnen, in dem Sie diese nicht der Metaklasse selbst sondern der Property static zuordnen. Das static ein reserviertes Wort ist, muss es in Anführungszeichen gesetzt werden.
emc.'static'.statischeMethode = { ... }
  • Analog können Sie auch einen virtuellen Konstruktor definieren, indem Sie die Closure der Property constructor zuweisen:
emc.constructor = {arg1, arg2 -> ... }
Eine Konstruktor-Closure muss eine neue Instanz der jeweiligen Klasse liefern. Diese Möglichkeit eignet besonders sich gut dazu, statische Factory-Methoden als Konstruktoren der zu erzeugenden Klasse zu tarnen. Achten Sie aber darauf, dass Sie keine Endlosschleife programmieren, wenn Sie versuchen, den echten Konstruktor der Klasse aufzurufen.
  • Sie können auch einer Klasse eine zusätzliche Property hinzufügen. Setzen Sie dazu einfach deren Vorgabewert in der ExpandoMetaClass:
String.metaClass.meineProperty = 42
Dann hat beispielsweise der Ausdruck "abc".meineProperty den Wert 42. Der Property-Wert kann auf Instanzebene geändert werden, er ist aber Thread-lokal und nur von begrenzter Haltbarkeit.
  • Überladen von Methoden ist möglich. Sie können der ExpandoMetaClass durchaus mehrere Closures unter demselben Namen zuweisen, die sich in Anzahl oder Typ der Parameter unterscheiden.
  • Standardmäßig wirkt die ExpandoMetaClass nur auf Instanzen der Klasse ein, der sie direkt zugeordnet ist, und nicht auf Instanzen abgeleiteter Klassen. Wenn Sie dies ändern möchten, müssen Sie zu Beginn Ihres Programms folgenden Aufruf tätigen:
ExpandoMetaClass.enableGlobally()

Nun können Sie die ExpandoMetaClass auch abstrakten Klassen und Interfaces zuordnen, und sie wirkt sich auf alle Klassen auf, die von diesen abgeleitet sind bzw. sie implementieren ‒ sofern sie die betreffenden Methoden dort nicht überschrieben sind.