Migration von Grails 1.3.7 nach 3.1.9 - Erstellen von Testdaten - Teil 2

von Sebastian Jäger

Wie schon im ersten Teil dieser Reihe beschrieben, stand ich während der Migration einer Applikation der Version 1.3.7 auf 3.1.9 vor dem Problem, dass das Plugin Fixtures, welches zum Erstellen und Einspielen von Testdaten verwendet wurde, nicht mehr weiterentwickelt wird. Hier will ich nun darstellen, welche Anforderungen wir an eine neue Lösung hatten, welche Möglichkeiten sich boten und was schließlich dazu führte, eine eigene Lösung zu implementieren. Abschließend werde ich das Prinzip meiner Lösung näher erläutern und Codebeispiele geben.

Anforderungen

Eine besondere Funktionalität des Fixtures-Plugins ist die Möglichkeit, den einzelnen Testdaten-Instanzen einen eindeutigen Namen zu geben und diese in Test Cases zu referenzieren. Da Testdaten vorhanden waren, die natürlich wieder verwendet werden sollten, ist es zusätzlich von Vorteil, wenn die Daten möglichst ähnlich dargestellt werden. Damit ist es möglich mit Hilfe der Suchen und Ersetzen Funktion der IDE wiederkehrende Muster zu finden und diese an das neue Format anzupassen. Eine weitere Anforderung war die Robustheit gegenüber Änderungen. Zum Beispiel sollte es keine großen Anpassung der Fixtures nach sich ziehen, wenn einem Attribut der Constraint bindable:false hinzugefügt wird. Diese Änderung würde bei der Erstellung einer Instanz erzwingen, dass dieses Attribut nicht innerhalb einer Map übergeben wird, sondern einzeln gesetzt werden muss.

Alternativen

Das Grails-Plugin Build-Test-Data wurde für die Erstellung von Testdaten implementiert. Es analysiert automatisch Constraints einer Domainklasse und erstellt valide Instanzen. Möglich wäre es, die schon vorhandenen Daten zu verwenden und daraus Instanzen zu erstellen - jedoch nicht, ohne eine eigene Erweiterung die Daten zu speichern und wieder zu verwenden. 

Da ohne eine eigene Implementierung keine Möglichkeit gegeben war, kam die Idee auf, die einzelnen Instanzen manuell per Konstruktor-Aufruf zu erstellen. Hierzu müsste jedoch die Syntax der Test-Daten stark geändert werden. Auch könnte es zu Problemen führen, wenn es Änderungen an den Constraints gibt. Wird, wie oben beschrieben, zu einem Attribut der Constraint bindable:false hinzugefügt, müssten alle Instanzen der Domainklasse angepasst werden.

Da keine der Lösungen zufriedenstellend verwendet werden konnte, habe ich mich dazu entschlossen, eine eigene Lösung zu implementieren, um Test-Daten zu erstellen. Mit der eigenen Implementierung ist es wieder möglich, die Testdaten in Test Cases zu referenzieren.

Implementierung der eigenen Logik

Es soll die Möglichkeit geboten werden, Daten zu definieren, die automatisch gespeichert werden. Für komplexere Beziehungen soll es möglich sein, separate Daten zu beschreiben, die zunächst erstellt und nicht explizit gespeichert werden sollen, da diese von anderen Daten abhängen und dort ein kaskadierendes Speichern notwendig ist. Abschließend kann eine Funktion implementiert sein, in der beliebige Operationen für Modifikationen ausführbar sind.

Data-Klasse

Da alle Klassen einem bestimmten Aufbau folgen müssen, um eine korrekte Funktionsweise zu gewährleisten, habe ich eine abstrakte Oberklasse implementiert, von der geerbt wird. Diese besitzt folgende leere Funktionen, die dazu verwendet werden um zum einen die Daten auszulesen und zum andern ggf. spezielle Beziehungen herzustellen:

  • getPreData: Diese Methode muss überschrieben werden und eine Map mit Daten zurückgeben.
  • getData: Diese Methode muss ebenso eine Map der Daten zurück geben.
  • post: Diese Methode kann überschrieben werden. 

Beide get Methoden müssen eine Map zurückgeben, in der die Daten enthalten sind. Als Schlüssel wird jeweils der Name verwendet, unter dem die Testdaten Instanz referenziert werden kann, und als Wert eine Map, die die Attribute und deren Werte enthält. Zusätzlich muss der Schlüssel domainName enthalten sein, der die Domainklasse definiert. Da der Aufbau der neuen Daten ähnlich dem der Fixtures ist lassen sich diese schnell passend migrieren. Wie nachfolgend zu sehen müssen vor allem die Klassen sowie Methoden-Deklarationen erweitert werden.

fixture{
  fixtureName1(DomainName, description: "Beispiel des Aufbaus", parent: null)
  fixtureName2(DomainName, description: "Ein weiteres Fixture", parent: fixtureName1)
}
class FirstData extends Data{
  def getData(){
    return [
      fixtureName1: [domainName: DomainName, description: "Beispiel des Aufbaus", parent: null],
      fixtureName2: [domainName: DomainName, description: "Ein weiteres Fixture", parent: { -> DataLoader.data.fixtureName1}]
    ]
  }
}

Sollen Beziehungen zwischen Instanzen hergestellt werden, realisiert man diese mit Closures. Dabei wird, wie oben zu sehen eine Referenz auf die gewünschte Instanz zurückgeben. Da die Closure erst beim Erstellen der Daten ausgewertet wird, muss die Reihenfolge beachtet werden, in der man Daten erstellt. Bei Beziehungen, die mit belongsTo markiert sind, greift ein kaskadierendes Speichern, das die abhängige Instanz mitspeichert. Hierfür wurde die Methode getPre geschaffen, die es ermöglicht, Daten zu erstellen, die nicht explizit gespeichert werden, aber nach dem Erstellen der Hauptdaten auf Persistenz überprüft werden. In der Methode post kann man beliebige Operationen ausführen. In der migrierten Applikation sind in wenigen Domainklassen Hilfsfunktionen implementiert um Beziehungen zwischen Instanzen herzustellen. Dabei wird nicht nur die Referenz gespeichert sondern auch Attributwerte an verschiedensten Stellen in der Datenbank geändert. Es bietet sich an dies, wie in dem nachfolgenden Codeausschnitt zu sehen, in der post Methode zu verwenden.

class MedizinischeData extends Data {
  // getPreData und getData...
  
  def post(){
    MedizinischeDaten medizinischeDaten = DataLoader.data.produkt1.medizinischeDaten
    ProduktDaten produktDaten = DataLoader.data.produkt2
    
    medizinischeDaten.addToAlkoholfreieProdukteListe(produktDaten)
  }
}

DataLoader-Klasse

DataLoader ist eine abstrakte Klasse, die die Map data enthält, in der alle erstellten Instanzen gespeichert werden. Mit der Funktion loadDataClass ist es möglich, alle Daten, die in der übergebenen Klasse definiert sind, zu erstellen und speichern. Tritt ein Fehler in einer der Daten Instanzen auf, wird eine Exception geworfen. Zuerst wird ein Objekt der übergebenen Klasse erstellt und die preData sowie data ausgelesen. Sind preData vorhanden, werden diese vor den Hauptdaten erstellt, anschließend gespeichert und die preData auf Persistenz geprüft. Zum Schluss wird die Funktion post ausgeführt. Im folgenden Sequenzdiagramm ist der Ablauf bildlich darstellt:

Eine Exception wird erzeugt, wenn

  1. ein Name zur Referenzierung der Daten doppelt verwendet wird
  2. keine Beziehung (null) hergestellt werden kann
  3. das Speichern einer Instanz nicht möglich ist
  4. ein preData-Aufruf nicht gespeichert wurde

Der folgende Code- Block enthält einen ersten Entwurf der Logik, wie das Erstellen der Daten realisiert werden kann.

abstract class DataLoader {
    /**
     * Enthält gespeicherte Instanzen
     */
    def static data = [:]

    /**
     * Erstellt und speichert die Daten der Klasse "entity"
     * @param entity - Klasse der Datendatei
     */
    private static def loadDataClass(Class entity) {
        Object dataObject = entity.newInstance()
        def preData = dataObject.getPreData()
        def preDataNotToSave = []
        def data = dataObject.getData()
        def dataToSave = []

        if (preData) {
            preData.each { preName, propertyList ->
                createDataEntry(preName, propertyList)
                preDataNotToSave << preName
            }
        }
        data.each { dataName, propertyList ->
            createDataEntry(dataName, propertyList)
            dataToSave << dataName
        }
        saveData(dataToSave)
        checkPreData(preDataNotToSave)
        dataObject.post()
    }

    // Speichert alle Daten deren Name in der übergebenen Liste enthalten sind.
    private static def saveData(def dataToSave) {
        dataToSave.each { dataName ->
            data[dataName].save(failOnError: true)
        }
    }

    // Überprüft alle PreDatas die in der Liste enthalten sind auf Persistenz.
    private static def checkPreData(def preDataNotToSave) {
        preDataNotToSave.each { preData ->
            if (!data[preData].id) {
                throw new Exception("${preData} wurde nicht gespeichert!")
            }
        }
    }

    // Überprüft Datenname und ob alle Beziehungen gültig sind. Erstellt neue Instanz.
    private static def createDataEntry(def dataName, def propertyList) {
        if (data."${dataName}" == null) {
            data[dataName] = propertyList.domainName.newInstance()
        } else {
            throw new Exception("Doppelter Datenname: ${dataName}")
        }

        propertyList.each { propertyName, propertyValue ->
            if (propertyName != "domainName") {
                if (propertyValue instanceof Closure) {
                    def closureResult = propertyValue()
                    if (closureResult instanceof List) {
                        // bei one-to-many Beziehungen
                        closureResult.each { listEntry ->
                            if (listEntry == null) {
                                throw new Exception("Ungültige Beziehung")
                            }
                        }
                    } else if (closureResult == null) {
                        throw new Exception("Ungültige Beziehung")
                    }
                    data[dataName][propertyName] = closureResult
                } else {
                    data[dataName][propertyName] = propertyValue
                }
            }
        }
    }
}

Fazit

Diesd Implementierung funktioniert in dem migrierten Projekt sehr gut, wenngleich noch einige Verbesserungen möglich sind. Zum einen könnte die Performance verbessert werden und zum anderen könnten die Implementierung dahingehend verbessert werden, dass eine Fehlverwendung vermieden wird.

Kategorien: GrailsGroovy

Zurück