Jedes Sprachkonstrukt, das man z.B. in einer internen Groovy-DSL verwenden möchte, muss vom Groovy-Parser als gültiger Groovy-Code erkannt werden. Möchte man auf AST Transformation verzichten, müssen die Sprachkonstrukte auch semantisch Groovy entsprechen. Um überhaupt eine interne DSL erstellen zu können, müssen deshalb oft vorhandene Sprachkonstrukte der Hostsprache Groovy genutzt werden und auf so untypische Weiße verwendet werden, dass sie zwar gültiger Groovy-Code bleiben, aber vom Aussehen her auch als Sprachelemente einer "neuen" Sprache interpretiert werden könnten. Das macht die Entwicklung der DSL schwierig, da oft auf etablierte Konventionen verzichtet werden muss. Als Beispiel für dieses Problem wird in diesem Artikel die Erarbeitung der Syntax der Akzeptanztest-DSL für die Erstellung von Constraints vorgestellt.
Erstellung von Constraints mit der DSL
Mit der DSL soll es unter anderem möglich sein, Einschränkungen (bzw. Constraints) formulieren zu können. Idealerweise sollte der Benutzer die Constraints so formulieren können, wie er es bereits, von verschiedenen Programmiersprachen, gewohnt ist. Das könnte folgendermaßen aussehen:
Testuser = User.where { Nickname == "Testuser" } Poweruser = User.where { postcount > 100 }
Die hier formulierten Constraints sollen keinen Wahrheitswert zurück liefern. Sie sollen verwendet werden, um SQL-Queries zu generieren. Auf den ersten Blick scheint das ohne weiteres möglich zu sein, da Groovy Operator Overloading unterstützt. Auf den zweiten Blick stellt sich aber heraus, dass man Vergleichsoperatoren nicht individuell überladen kann. Am besten deutlich wird das an einem Codebeispiel:
class Foo implements Comparable { def plus(other) { //Hier werden die Aktionen durchgeführt, die nötig //sind um other zu dieser Instanz zu addieren. } def minus(other) { //Für die Subtraktion ist das ebenfalls möglich. } def equals(other) { //Auch den Vergleich auf Gleichheit kann man überladen. } int compareTo(other) { //Für die Operationen >,<,>= und <= gibt es allerdings nur //eine Methode. } }
Diese Methode compareTo muss einen Wert zurückgeben, der größer, gleich oder kleiner null ist. Je nachdem ob other größer, gleich oder kleiner dieser Instanz der Klasse Foo ist. Groovy wendet auf diese Zahl dann den eigentlichen Vergleichsoperator an, um einen Wahrheitswert zu erzeugen. Im eigenen Code kann man also nicht ohne weiteres feststellen, welcher Vergleich durchgeführt wird. Dies ist aber nötig, um später einen SQL-Constraint erzeugen zu können. Die Möglichkeit zum individuellen überladen der einzelnen Vergleichsoperatoren soll, nach diesem Ticket, erst mit Version 2.0 möglich werden. In der Zwischenzeit wäre es gut eine andere Möglichkeit zu finden um dieses Problem doch so zu lösen.
Manipulation des abstrakten Syntaxbaumes
Groovy bietet die Möglichkeit, während der Kompilierung von Groovy- zu Bytecode, eigenen Code ausführen zu lassen, welcher in den Prozess eingreift. So kann man Modifikationen am abstrakten Syntaxbaum (AST) vornehmen, bevor aus diesem Bytecode generiert wird. In Groovy gibt es dafür die lokale- und die globale AST-Transformation. Diese AST-Transformationen müsste im Benutzer-Code durchgeführt werden, da die Closure, welche die Constraints enthält, bereits zu Bytecode kompiliert wurde, wenn sie der where Methode übergeben wird. Bei der lokalen AST-Transformation müssen die Stellen im Quelltext durch Annotationen markiert werden, bei denen die Transformationen durchgeführt werden sollen. Das würde in der DSL technischen Overhead bedeuten, welchen man den Benutzer nur schwer erklären könnte. Für die DSL käme also nur die globale AST-Transformation in Frage.
Die Idee ist jetzt, Vergleichsoperation, wie postcount > 100, durch Methodenaufrufe, wie greater(postcount, 100) zu ersetzen. Dabei müssen die Vergleichsoperationen, auf welche diese Transformation angewendet werden soll, sehr genau ausgesiebt werden, damit Nicht-DSL Groovy-Code weiterhin funktioniert. Denkbar wäre es, die Transformation nur auf Closures auszuführen, welche an where Methoden übergeben werden, welche genau eine Closure als Parameter übernehmen und in Klassen vorkommen, welche von AcceptanceUnit (im Moment die Superklasse für die DSL) erben. Das hätte allerdings den Nachteil, dass die Transformation auch auf Closures für where Methoden durchgeführt wird, bei denen das nicht erwünscht ist (z.B. bei where Methoden von Klassen von Dritt-Anbietern). Würde man das in Kauf nehmen bleibt das Problem, dass man den gesamten AST der entsprechenden Klassen durchforsten müsste um alle where-Methoden Aufrufe abfangen zu können. Das wäre mit großem Aufwand verbunden. Das Debugging ist ebenfalls nicht ohne weiteres möglich. Dabei spielt es keine Rolle, ob man das über einen Debugger oder über "println" macht. Das Problem ist, dass die AST-Transformation nicht als Teil des eigenen Programms läuft, sondern als Teil des Compilers.
Auswertung des Stacks
Eine andere Herangehensweise ist, die compareTo Methode in einer eigenen Klasse zu implementieren und sich in einem Debugger den Stack beim Aufruf dieser Methode anzusehen.
Dabei kann man feststellen, dass die eigene compareTo Methode, über Zwischenschritte, von der Methode compareGreaterThan der Klasse ScriptBytecodeAdapter aufgerufen wird. Für andere Vergleichsoperatoren wird die compareTo von anderen Methoden aufgerufen. Man kann sich in der compareTo Methode den aktuellen Stack besorgen und anhand der Methoden- und Klassennamen erkennen, welche Vergleichsoperation durchgeführt werden soll.
@Override int compareTo(other) { def typ = null def stack = Thread.currentThread().getStackTrace() for(int index = 0; index < stack.length; index++) { if(stack[index].getClassName() != "org.codehaus.groovy.runtime.ScriptBytecodeAdapter") continue switch(stack[index].getMethodName()) { case "compareLessThanEqual": typ = Constraint.Typ.LESS_OR_EQUAL; index = stack.length; break case "compareLessThan": typ = Typ.LESS; index = stack.length; break ... } ... }
Das funktioniert für die Operatoren <, <=, >, >=. Dadurch kommt man aber zu einem weiteren Problem. Damit die Funktion compareTo für den Vergleich verwendet wird, muss die zugehörige Klasse das Interface implementieren. Sobald eine Klasse dieses Interface implementiert, gelten in Groovy aber andere Regeln für den Vergleich auf Gleichheit. In diesen Fällen überprüft Groovy, ob die zu vergleichenden Objekte Instanzen der gleichen Klasse sind. Ist das nicht der Fall, geht groovy davon aus, dass die Objekte nicht gleich sein können und ruft die benutzerdefinierte Funktion gar nicht erst auf. Somit kann man auf diese Weiße im Usercode keinen "Gleichheits"-Constraint erstellen.
Manipulation des Verhaltens von ScriptBytecodeAdapter zur Laufzeit mithilfe von MetaClass
Mithilfe von MetaClass kann man zur Laufzeit einer Klasse Methoden und Attribute hinzufügen. Das verändern vorhandener Methoden ist ebenfalls möglich. Die zuständige Instanz von MetaClass für eine bestimmte Klasse erhält man über das Attribut metaClass. Das funktioniert nicht nur für Groovy-Klassen, sondern auch für Javaklassen, wie das folgende Beispiel zeigt.
String.metaClass.hashCode = {-> return 42 } def test = "teststring" println test.hashCode() //Ausgabe: 42 def test2 = "another teststring" println test2.hashCode() //Ausgabe: 42
Die Idee ist nun, mithilfe dieses Mechanismus die Vergleichsmethoden, wie compareGreaterThan der Klasse ScriptBytecodeAdapter, zu überschreiben und ein eigenes Verhalten zu implementieren. Diese Methoden sind statisch, aber im Normalfall kann man auch diese überschreiben:
String.metaClass.static.test= {-> println("test called") } String.test() //Ausagbe: test called
Für die Klasse ScriptBytecodeAdapter funktioniert dieser Mechanismus leider nicht. Auch für DefaultTypeTransformation ist das nicht möglich. Die selbst erstellte Methode wird in dem Fall einfach ignoriert und es wird die bereits vorhandene Methode verwendet. Das gleiche gilt z.B. auch, wenn man versucht die toString Methode der Klasse String zu überschreiben:
String.metaClass.toString = {-> return "A" } def test = "B" println test.toString() //Ausgabe: B
Veränderungen am Groovy-Quellcode
Groovy ist unter der Apache 2 Lizenz veröffentlicht und somit OpenSource Software. Man könnte also am Groovy Quellcode Veränderungen vornehmen, um das gewünschte Verhalten zu erreichen. Die Stellen im Quellcode welche verändert werden müssten, sind bereits identifiziert (in den Vergleichsmethoden wie compareGreaterThan der Klasse ScriptBytecodeAdapter). Die Veränderungen zu programmieren ist einfach und schnell möglich und da Groovy Maven als Abhängigkeitenverwaltungs- und Buildtool verwendet, ist die modifizierte Groovy-Version auch schnell erstellt. Allerdings wäre die DSL dann auf diese modifizierte Version angewiesen und man müsste sich selbst um die Wartung dieser Groovy-Version kümmern.
Auswertung des abstrakten Syntaxbaumes zur Laufzeit
Nachdem die alternative Syntax für die Formulierung von Constraints, welche im nächsten Abschnitt beschrieben wird, bereits implementiert wurde, habe ich von einer weiteren Möglichkeit erfahren, mit der man eventuell die Formulierung von Constraints doch wie geplant implementieren könnte.
Die Klasse MetaClass hat die Funktion getClassNode(). Mit dieser Funktion erhält man zur Laufzeit den abstrakten Syntaxbaum der entsprechenden Klasse. Das könnte man für die Closure nutzen, welche der where Methode als Parameter übergeben wurde. Dadurch besteht das Problem nicht, die Stellen im Code ausfiltern zu müssen, wie es im Abschnitt "Manipulation des abstrakten Syntaxbaumes" beschrieben ist. Da das Ganze zur Laufzeit passiert, muss der AST nicht manipuliert werden, sondern kann einfach ausgewertet werden.
Damit das funktioniert muss dafür gesorgt werden, dass der Quelltext der entsprechenden Closure zur Laufzeit zur Verfügung steht, da der AST ja aus dem Quelltext erzeugt wird. Die Akzeptanztests werden dem Programm später als Groovy-Skripte übergeben. Somit ist diese Bedingung leicht zu erfüllen.
Alternative Syntax für die Formulierung von Constraints
Da es mit den getesteten Ansätzen nicht gelungen ist, die Syntax für die Formulierung von Constraints wie geplant zu implementieren, wurde eine alternative Syntax implementiert. Diese Syntax lehnt sich an der Abfragesyntax von GORM an. Ein Constraint wird dabei durch einen Methodenaufruf beschrieben. Der Methode werden die Operatoren als Parameter übergeben. Das Beispiel von oben sieht mit der aktuellen Implementierung wie folgt aus:
Testuser = User.where { equal(Nickname, "Testuser") } Poweruser = User.where { greater(postcount,100) }