Die JSR 303 und JSR 380 Spezifikationen für Annotationen zur Validierung von Feldern sind sehr praktisch, insbesondere im Umfeld von Spring und Hibernate, stellen sie auf einfache Weise konsistente Daten sicher. Doch nicht immer ist der Einsatz der Standardimplementierung trivial und muss den Bedürfnissen angepasst werden. In diesem Fall habe ich eine Prüfung für Usernamen genauer unter die Lupe genommen.

Am Anfang

Meine Anforderungen sind recht klar: In einem Formular kann sich ein Benutzer neu auf meiner Webseite registrieren. Neben obligatorischem Usernamen und Passwort muss er auch noch Emailadresse, Vor- und Familiennamen angeben.
Der Username darf nicht leer sein, darf im System noch nicht vergeben worden sein, und soll auch nach Möglichkeit nicht aus einer Liste von reservierten Namen stammen (wie z. B. “Administrator” oder “Support”). Auch soll der Username maximal 255 Zeichen lang sein.
Zu den anderen Feldern gibt es analoge Anforderungen, die zu diesem Zeitpunkt jedoch nicht relevant sind.

Erste Version

In einer ersten Version habe ich ein Formularbean geschrieben, das die entsprechenden Felder entgegen nehmen sollte:

public class RegisterForm {
    @NotNull("{error.registration.username.null}")
    @UniqueUsername("{error.registration.username.alreadyinuse}")
    @NonReservedUsername("{error.registration.username.alreadyinuse}")
    @Size(min=3, max=255)
    private String username;
 
    @NotNull("{error.registration.password.null}")
    @Size(min=8, max=255)
    private String password;
    private String verifiedPassword;
 
    @NotNull("{error.registration.email.null}")
    @UniqueEmail("{error.registration.email.alreadyinuse}")
    @NonTemporaryEmail("{error.registration.email.temporarymail}")
    private String email;
 
    @NotNull
    @Size(max=255)
    private String firstname;
 
    @NotNull
    @Size(max=255)
    private String lastname;
 
    ...
}

Die Annotationen NotNull, Size und Email gibt es bereits in der Standardimplementierung der Validation API von Hibernate. Die beiden Annotationen UniqueUsername und NonReservedUsername sind von mir geschriebene Constraints. UniqueUsername soll dafür sorgen, dass es in der Datenbank nicht bereits einen registrierten Benutzer mit dem gleichen Usernamen gibt. Die Constraint NonReservedUsername soll den Usernamen gegen eine Liste von reservierten Wörtern (Blacklist) abgleichen.

Das Problem mit der Reihenfolge

Nach einer ersten Implementierung meiner beiden Constraints funktionierte fast alles soweit gut, bis auf den Punkt, dass für den Usernamen immer alle Validierungen ausgeführt wurden. Wenn ich beispielsweise das Feld mit “x” befüllte und es sich offensichtlich nicht um einen gültigen Wert handelte (erkannt von der Size Constraint), wurde trotzdem geprüft, ob es in der Datenbank bereits einen Benutzer mit diesem Namen gibt (UniqueUsername Constraint) und ob es ein reservierter Username ist (NonReservedUsername Constraint).
Diese Prüfungen sind kostspielig, da sie Abfragen gegen eine Datenbank machen. Sie sind aber vor allem nicht notwendig, da bereits eine grundlegendere Prüfung fehlschlägt. Es sieht auch etwas komisch aus, wenn als Fehlermeldung alle fehlschlagenden Bedingungen angezeigt werden: “Der Benutzername darf nicht kürzer als 3 Zeichen sein. Der Benutzername ‘x’ darf nicht verwendet werden.”.

Was die API sagt

Zur Steuerung der Reihenfolge bietet die Validation API zwei Methoden an:

ReportAsSingleViolation

Hierbei werden verschiedene Constraints zusammen gefasst in einer neuen Constraint:

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
 
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
@Documented
@Constraint(validatedBy = {UsernameValidator.class})
@Target({TYPE, FIELD, ANNOTATION_TYPE})
@Retention(RUNTIME)
@NotNull
@Size(min=3, max=255)
public @interface Username {
    String message() default "Username not valid.";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
}

An dieser Stelle hat sich noch nicht viel geändert, ausser dass man nun statt drei Annotationen nur noch eine verwenden kann:

public class RegisterForm {
    @Username("{error.registration.username.alreadyinuse}")
    @NonReservedUsername("{error.registration.username.alreadyinuse}")
    private String username;
    ...

Hier werden alle eingebundenen Validierungen ausgeführt und liefern jeweils eigene Fehlermeldungen.

Mit der ReportAsSingleViolation-Annotation kann nun gesteuert werden, dass die Meldungen aller zusammengefassten Constraints nur noch als einzelne Fehlermeldung ausgegeben werden:

@Documented
@Constraint(validatedBy = {UsernameValidator.class})
@Target({TYPE, FIELD, ANNOTATION_TYPE})
@Retention(RUNTIME)
@NotNull
@Size(min=3, max=255)
@ReportAsSingleViolation
public @interface Username {
    ...

Dies verhindert jedoch auch, dass bei den verschiedenen Constraints auch verschiedene Texte angezeigt werden. Hier wird beim Fehlschlagen irgend einer Constraint stets die Fehlermeldung von Username ausgegeben.
Zusätzlich werden weiterhin alle Validierungen durchgeführt.

Groupsequences

Mit Hilfe der “groups”-Property an den Constraints und Dummy-Hilfsklassen / Interfaces (hier First, Second, Third und OrderedChecks) kann man einzelne Constraints in Gruppen zusammenfassen.
Die GroupSequence Annotation gibt anschließend die Reihenfolge der Gruppen vor:

@GroupSequence({ First.class, Second.class, Third.class })
public class RegisterForm {
    @NotNull("{error.registration.username.null}", groups = First.class)
    @UniqueUsername("{error.registration.username.alreadyinuse}", groups = Third.class)
    @NonReservedUsername("{error.registration.username.alreadyinuse}", groups = Second.class)
    @Size(min=3, max=255, groups = First.class)
    private String username;
    ...
}

Für einzelne Attribute ist das praktisch, fasst man jedoch meherere Attribute in Gruppen zusammen, wird die nächste Gruppe erst ausgewertet, wenn die vorherigen Gruppen komplett valide sind:

“If at least one constraints fails in a sequenced group none of the constraints of the following groups in the sequence get validated.”

– Hibernate Validator Referenz

Im folgenden Beispiel werden die weniger aufwändigen Nicht-Null Prüfungen in einer ersten Gruppe zusammengefaßt:

@GroupSequence({ First.class, Second.class, Third.class })
public class RegisterForm {
    @NotNull("{error.registration.username.null}", groups = First.class)
    @UniqueUsername("{error.registration.username.alreadyinuse}", groups = Third.class)
    @NonReservedUsername("{error.registration.username.alreadyinuse}", groups = Second.class)
    @Size(min=3, max=255, groups = First.class)
    private String username;
 
    @NotNull("{error.registration.password.null}", groups = First.class)
    private String password;
    ...
}

Nun wird aber bei der Eingabe von “test” im Usernamen und einem leeren Passwort nur das leere Passwort angemäkelt, da die erste Gruppe (First.class) noch nicht komplett valide ist. Der Constraint UniqueUsername bleibt daher erst einmal außen vor.

Wie es gelöst wurde

Da ich bereits die Constraints und Validatoren für UniqueUsername und NonReservedUsername geschrieben habe, scheute ich mich nicht, eine neue Constraint samt Validator zu schreiben, welche alle Prüfungen durchführt und intern die Reihenfolge der Prüfungen berücksichtigt.

Dabei versuchte ich vorhandene Validatoren von Hibernate wieder zu verwenden (also nach Möglichkeit nicht zu wiederholen), musste aber dabei feststellen, dass die Entwickler von Hibernate große Teile, ja fast alle Validatoren, zu private APIs deklarierten und diese damit nicht ohne weiteres versionsübergreifend referenzierbar waren. (Z. B. org.hibernate.validator.internal.constraintvalidators.EmailValidator). Meiner Ansicht nach auch ein großer Nachteil der Hibernate-Persistenz-API.

Also schrieb ich alles selber nach. Die Annotation selber erhielt Felder für die anpassbaren Meldungsschlüssel für die verschiedenen Fehlerfälle.

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
 
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
@Documented
@Constraint(validatedBy = {UsernameValidator.class})
@Target({TYPE, FIELD, ANNOTATION_TYPE})
@Retention(RUNTIME)
public @interface Username {
 
    int maxSize() default ModelConstants.TEXT_LENGTH_SHORT;
    String message() default "Username not valid.";
    String notBlankMessage() default "Username may not be empty.";
    String tooLongMessage() default "Username is too long.";
    String reservedMessage() default "Username is not allowed.";
    String notUniqueMessage() default "Username already in use.";
 
    Class<?>[] groups() default {};
 
    Class<? extends Payload>[] payload() default {};
}

Der Validator wird durch den Springcontainer instantiiert. Da er Zustandsvariablen hat, muss er als Prototyp deklariert sein. Er verwendet intern weitere Services, um z. B. nach vorhandenen Usernamen zu prüfen.

import com.google.common.base.Strings;
import de.cinarconsulting.account.service.UserService;
import de.cinarconsulting.account.registration.service.ReservedUsernameService;
import de.cinarconsulting.common.service.MessageService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
 
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
 
@Component
@Scope(BeanDefinition.PROTOTYPE)
public class UsernameValidator implements ConstraintValidator<Username, String> {
 
    @Autowired
    private UserService userService;
 
    @Autowired
    private ReservedUsernameService reservedUsernameService;
 
    @Autowired
    private MessageService messageService;
 
    private Username constraintAnnotation;
 
    @Override
    public void initialize(final Username constraintAnnotation) {
        this.constraintAnnotation = constraintAnnotation;
    }
 
    @Override
    public boolean isValid(String username, final ConstraintValidatorContext context) {
        boolean blank = Strings.isNullOrEmpty(username);
        if (blank) {
            messageService.addConstraintViolation(context, constraintAnnotation.notBlankMessage(), username);
            return false;
        }
 
        username = username.trim();
 
        boolean tooLong = username.length() > constraintAnnotation.maxSize();
        if (tooLong) {
            messageService.addConstraintViolation(context, constraintAnnotation.tooLongMessage(), username);
            return false;
        }
 
        boolean reserved = reservedUsernameService.isReservedUsername(username);
        if (reserved) {
            messageService.addConstraintViolation(context, constraintAnnotation.reservedMessage(), username);
            return false;
        }
 
        boolean usernameExists = userService.existsByUsername(username);
        if (usernameExists) {
            messageService.addConstraintViolation(context, constraintAnnotation.notUniqueMessage(), username);
            return false;
        }
        return true;
    }
 
}

Die Klasse MessageService dient lediglich dazu, Hilfsmethden für lokalisierbare Texte (MessageSource) bereit zu stellen.

import org.springframework.context.MessageSource;
...
@Service("messageService")
public class MessageService {
 
    @Autowired
    private MessageSource messageSource;
 
    public String getMessage(String key, Object... parameters) {
        final Locale locale = LocaleContextHolder.getLocale();
        return messageSource.getMessage(key, parameters, locale);
    }
 
    public void addConstraintViolation(ConstraintValidatorContext context, String key, Object... parameters) {
        if (key.startsWith("{") && key.endsWith("}")) {
            key = key.substring(1, key.length() - 1);
        }
        String message = getMessage(key, parameters);
        context.disableDefaultConstraintViolation();
        context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
    }
 
    public void addDefaultConstraintViolation(ConstraintValidatorContext context, Object... parameters) {
        String defaultTemplate = context.getDefaultConstraintMessageTemplate();
        addConstraintViolation(context, defaultTemplate, parameters);
    }
 
    ...

Die Formularbean erhielt nun statt der vielen einzelnen Constraints nur noch eine einzige.

public class RegisterForm {
    @Username(
            notBlankMessage = "{error.registration.username.blank}",
            maxSize = 255,
            tooLongMessage = "{error.registration.username.tooLong}",
            reservedMessage = "{error.registration.username.alreadyinuse}",
            notUniqueMessage = "{error.registration.username.alreadyinuse}"
    )
    private String username;
 
    ...

Referenzen