Vor etwa einem Jahr haben wir beschlossen, die Bean Validation API zu testen, und am Anfang waren wir begeistert über die einfach zu bedienende, Annotation-basierte Schnittstelle. Doch als wir Cross-field Validierung durchführen wollten, mussten unseren Eindruck revidieren. Leider bietet die Bean Validation API keinen einfachen Weg der Validierung eines Felds basierend auf dem Wert eines anderen Feldes. Da wir im Einklang mit dem API-Design bleiben wollten, sind wir den steinigen Weg gegangen und unsere haben eigene Class-level Validation Annotations und eigene Validators geschrieben.

Das ist leider eine langweilige, sich wiederholende Arbeit, die die Benutzung der Reflection API erfordert. Solche Aufgaben sollten möglichst weit vereinfacht und automatisiert werden. Deshalb begann ich zu erforschen, wie andere dieses Problem gelöst haben. In diesem Artikel skizziere und analysiere ich einige der Lösungen, auf die ich gestoßen bin.

Der Standardansatz

Das Erstellen einer neuen Validierungslogik besteht aus zwei Schritten. Es müssen eine Constraint Annotation und ein Validator, der die Validierungslogik enthält, geschrieben werden. Der Einfachheit halber werden wir einen Constraint auf Class-level implementieren. Er wird verwendet, um sicherzustellen, dass der Wert eines Feldes größer ist als der Wert eines anderen Feldes des Objekts, das validiert werden soll.

Dies ist die Definition der Annotation:

/**
* Compares two of the fields of an object.
*/
@Target({ TYPE, ANNOTATION_TYPE })
@Retention(RUNTIME)
@Constraint(validatedBy = GreaterThanValidator.class)
public @interface GreaterThan {

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    String field();

    String greaterThan();

    /**field > greaterThan*/
    Class<? extends Comparator<Object>> comparator();

    @Target({ TYPE, ANNOTATION_TYPE })
    @Retention(RUNTIME)
    @interface List {
        GreaterThan [] value();
    }
}

Die “field” und “greaterThan” Felder enthalten die Namen der Bean-Eigenschaften, die verglichen werden sollen. Für diesen Vergleich brauchen wir auch einen Comparator. Schließlich fügen wir ein “Liste” Feld hinzu, so dass wir mehr als eine Instanz der Annotation auf Class-level nutzen können. Diese Definition der Annotation bedeutet bereits eine Menge Arbeit für die bescheidene Aufgabe des Vergleichs von zwei Werten. Das ist aber gar nichts im Vergleich zur Definition des Validators:

/**
* Validates the GreaterThan constraint
*/
public class GreaterThanValidator implements
ConstraintValidator<GreaterThan, Object> {

    /** field > greaterThan ? */
    private String field;
    private String greaterThan;
    private Comparator<Object> comparator;

    /**
    * This will be called before isValid to initialize the validator
    */
    @Override
    public void initialize(GreaterThan ann) {

        field = ann.field();
        greaterThan = ann.greaterThan();
        Class<? extends Comparator<Object>> comparatorClass = ann.comparator();
        try {
            comparator = comparatorClass.newInstance();
        } catch (Exception e) {
            throw new IllegalArgumentException("Can't instantiate comparator",e);
        }
    }

    /**
    * This will be called to validate an object
    */
    @Override
    public boolean isValid(Object validateThis, ConstraintValidatorContext ctx) {
        if (validateThis == null) {
            throw new IllegalArgumentException("validateThis is null");
        }
        Field fieldObj = null;
        Field greaterThanObj = null;
        // Find getters the properties
        try {
            fieldObj=validateThis.getClass().getDeclaredField(field);
        } catch (Exception e) {
            throw new IllegalArgumentException("Invalid field name",e);
        }
        try {
            greaterThanObj = validateThis.getClass().getDeclaredField(greaterThan);
        } catch (Exception e) {
            throw new IllegalArgumentException("Invalid greaterThan name",e);
        }
        if (fieldObj == null || greaterThanObj == null) {
            throw new IllegalArgumentException("Invalid field names");
        }

        try {
            fieldObj.setAccessible(true);
            greaterThanObj.setAccessible(true);
            // get field values
            Object fieldVal = fieldObj.get(validateThis);
            Object largerThanVal = greaterThanObj.get(validateThis);
            // compare
            return fieldVal == null && largerThanVal == null
            || fieldVal != null && largerThanVal != null
            && comparator.compare(fieldVal, largerThanVal) > 0;
        } catch (Exception e) {
            throw new IllegalArgumentException("Can't validate object", e);
        }

    }

}

Eine Menge Arbeit für einen einfachen größer-als-Vergleich, und es kommt nicht einmal produktiv einsetzbarer Code heraus, müssten Sie doch noch ein paar Zeilen für die Verwendung in Ihren eigenen Projekten hinzuzufügen. Natürlich könnte man sich Arbeit sparen, indem Sie einige der Details in Superklassen einfügen, aber es gibt schnellere Wege, um dieselben Ergebnisse zu erzielen. Ein weiteres Problem mit dieser Annotation ist, dass sie keine compile-time Sicherheit bietet. Tippfehler in den Feldnamen oder die Angabe der falschen Comparator-Klasse führen zu Laufzeitfehlern.

Der folgende Ansatz löst einige dieser Probleme. Er ist aus einer Forumsdiskussion zum Thema entstanden (siehe http://stackoverflow.com/questions/1972933/cross-field-validation-with-hibernate-validator-jsr-303).

Der @AssertTrue Ansatz

Die @AssertTrue Annotation ist eine der Annotations der Bean Validation API, die auch für Methoden anwendbar sind. Sie stellt sicher, dass der Rückgabewert der annotierten Methode true ist, wenn sie während der Validierung aufgerufen wird. Wir fügen also einfach eine öffentliche Methode namens isConstraint1Satisfied hinzu und annotieren sie wie folgt:

public class AssertTrueExample {

public int i=0;
public int j=0;

@AssertTrue(message="Constraint 1 is not satisfied")
public boolean isConstraint1Satisfied()
{
return i==j;
}
}

Auf diese Weise können wir unser Objekt validieren, ohne zu in Schweiß auszubrechen. Mal sehen, was die Autoren der JSR-303 Spezifikation dazu zu sagen haben.

“Validieren von Daten ist eine allgemeine Aufgabe, die sich in einer Anwendung von der Präsentations-Schicht bis zur Persistenz-Schicht stellt. Oft sind die gleichen Validierungslogiken in jeder Schicht implementiert, was zeitaufwändig und fehleranfällig ist. Um diese Probleme zu vermeiden, bündeln Entwickler oft die Validierungslogik direkt in das Domain-Modell, und stopfen Domain-Klassen mit Validierungscode voll, der im übrigen nichts anderes darstellt als Metadaten über die Klasse selbst. […]“

Nun, die @AssertTrue Lösung verstößt offensichtlich gegen diese Trennung von Belangen (separation of concerns), um die es in der Bean Validation API geht, und stellt die Validierungslogik in die Model-Klasse. Sie sind vielleicht nicht einverstanden damit, aber meiner Meinung nach ist oft zu wenig durch die Trennung von Belangen gewonnen, wenn es um interne Abhängigkeiten geht. Manchmal ist es sogar schädlich. Ich gebe Ihnen ein Beispiel.

In einem unserer Projekte mussten wir die gültigen Parameter für den Aufruf einer Legacy-Statistik Bibliothek in ein Objekt kapseln. Die Gültigkeit der Parameter für diesen Aufruf ist stark abhängig von dem Wert anderer Parameter, und es gab keine Möglichkeit, diese Constraints aufzuteilen in separate wiederverwendbare Constraint Annotations. Wir erzeugten eine Annotation und einen Validator, die wir einmal benutzten; es gab darüberhinaus keine Chance, diese Annotation jemals wieder zu verwenden.

Die Autoren der JSR-303 Spezifikation sind der Meinung, dass Validierungslogik im Domain-Model oft oder immer Unordnung bedeutet, aber ich würde zögern, diese besondere Art von Validierungscode so zu nennen. Sicher, Validierungscode für eine E-Mail-Adresse und ähnliches kann man als Unordnung bezeichnen, da E-Mail-Adressen reichlich in den meisten IT-Systemen vorkommen, und jeder weiß, was eine E-Mail-Adresse ist und wie sie aussieht; daher enthält die Validierungslogik wenig Informationen für jemanden der den Code liest. Die Validierungslogik für die Parameter-Klasse ist aber ein ganz anderes Thema. Sie gehört zur Model-class, ist nicht wieder verwendbar und enthält viele wichtige Informationen. Sie ist mehr oder weniger die Klasse, und deshalb macht es wenig Sinn, es in eine Annotation zu extrahieren. Ich denke, der @AssertTrue Ansatz (und auch der @ScriptAssert Ansatz) passt für diese Verwendung viel besser als jeder andere Ansatz, dem ich bis jetzt begegnet bin.

Der @ScriptAssert Ansatz

Hibernate Validator 4.1 brachte uns die @ScriptAssert Class-level Annotation. Sie ist eine geniale Anwendung der Java Scripting API. Statt dass Sie Ihren Validierungscode in Java schreiben, können Sie eine der vielen unterstützten Skriptsprachen verwenden. Dieser Ansatz teilt mehr oder weniger die Stärken und Schwächen des @AssertTrue Ansatzes, ist aber prägnanter und hat den zusätzlichen Vorteil, das Interface der Klasse nicht mit Validierungsmethoden zu verstopfen. Das letzte Beispiel zeigt, wie es wäre, wenn wir die @ScriptAssert Annotation nutzen würden:

@ScriptAssert(lang="javascript",script="_this.i==_this.j")
public static class ScriptAssertExample
{

int i=0;
int j=0;

}

Der große Nachteil verglichen mit dem @AssertTrue Ansatz ist natürlich, dass der Gebrauch einer Skriptsprache den Verlust der compile-time Sicherheit bedeutet.

Fazit

Der wichtigste Aspekt der drei Ansätze für Cross-field Validierung, die hier betrachtet wurden, ist, wohin sie die Validierungslogik setzen; in die Model-class oder in eine externe Validierungsklasse. In diesem Artikel habe ich in erster Linie umrissen, warum es eine schlechte Idee ist, jedes Mal einen Validator und eine Annotation zu kreieren, wenn die Annotationen der Bean Validation API nicht Ihren Bedürfnissen entsprechen. Aber verstehen Sie mich nicht falsch, ich sage nicht, dass das Hinzufügen der Validierungslogik in die Model-class in jedem Fall eine gute Idee ist. Es gibt mindestens zwei Dinge, die berücksichtigt werden müssen:

  • Ist es unwahrscheinlich, dass die Validierungslogik wiederverwendet werden kann?
  • Enthält sie wichtige Informationen, die unbedingt in die Model-class gehören?

Ich denke, wenn Sie eine dieser Fragen mit “Ja” beantworten, können Sie die Validierungslogik tatsächlich in die Model-class einfügen, ohne ein schlechtes Gewissen zu haben.

Im nächsten Artikel werde ich einen hybriden Ansatz präsentieren, der auf dem Strategiemuster basiert. Dieser Ansatz ermöglicht eine einfache Implementierung neuer wiederverwendbarer Constraints, ohne dass ein Validator für einen Constraint nötig ist. Er lässt Sie wählen, wo Sie die Validierungslogik implementieren wollen, und ermöglicht es Ihnen, nicht nur auf Typen, sondern auch auf Felder zu zielen.