Magisches Validieren in Spring Boot

Any sufficiently advanced technoloy is indistinguishable from magic.

Clarke’s Third Law

Spricht man mit manchen Kollegen über das Spring Boot Framework, dann erscheint ihnen vieles daran suspekt. Aus einigen wenigen Klassen werden durch ein Dutzend Annotationen an der richtigen Stelle vollwertige REST Schnittstellen.

Vermutlich erinnern die Annotationen an verfluchte Hexenzeichen, die dem markierten Objekt magische Eigenschaften verleihen. Annotationen sind aber nur Konfigurationshinweise für den Framework, damit der eigentliche Anwendungscode nicht zwischen den, ansonsten notwendigen Boilerplate Code, verloren geht.

Manchmal aber wird man auch noch als erfahrener Entwickler von den Möglichkeiten von Spring Boot überrascht. Aber der Reihe nach.

Ein wunderbarer Anwendungsfall für Annotationen in Spring Boot ist die Validierung der Parameter der REST Schnittstelle.

@RestController
public class AncestorController {
  @GetMapping("/ancestors/{generation}")
  public List<Person> get(@RequestParam("generation") int generation) {
    if (generation < 1 || generation > 10) {
      createBadRequest();
    }
    ...
  }
}

In obigen Codeschnipsel kümmert sich einer unserer REST Controller persönlich um die Validierung des Eingabeparameters generation. In unserem fiktiven Use-Case darf die Anzahl der angefragten Generationen nur die Werte von 1 bis 10 enthalten. Alles andere führt zu einer Antwort mit einem HTTP Status 400.

Glücklicherweise unterstützt Spring Boot den Framework JSR 380 – Bean Validation 2.0. Dazu müssen wir nur die gewünschten Validierungen an dem Parameter annotieren. In unserem Beispiel sind es die Annotationen @Min(1) und @Max(10). Die Prüfung übernimmt nun der Framework und aus unserer Controller Methode können wir die Überprüfungen entfernen.

@RestController
public class AncestorController {
  @GetMapping("/ancestors/{generation}")
  public List<Person> get(@RequestParam("generation") @Min(1) @Max(10) int generation) {
    ...
  }
}

Da sich Spring Boot schon um die Bereitstellung des Wertes im Parameter generation kümmert, ist es nicht verwerflich auch die Prüfung des Wertebereiches an den Framework zu delegieren.

Das nächste Beispiel zeigt die Möglichkeiten mit eigenen Validatoren zu arbeiten. In den meisten Spring Boot Beispielen wird dafür eine eigene Annotation verwendet. Hier werden wir aber die Standard Annotation @NotEmpty verwenden.

@PostMapping
public String multipart(@RequestParam("file") MultipartFile file) throws IOException {
  if (file.isEmpty()) {
    throw new EmptyUploadException(file.getName());
  }
  if (contentService.isEmptyContent(file)) {
    throw new EmptyUploadException(file.getName());			
  }
  ...
}

Die obige Methode dient zum Upload einer Datei und prüft, ob der Parameter file leer ist und ob der Inhalt etwas Leeres darstellt. Zum Beispiel ein Bild mit der Breite und Höhe 0 oder ein Excel-Sheet ohne ein einziges gefülltes Feld.

Leider unterstützt die Standard Implementierung des Bean Validation Frameworks nicht den Datentyp MultipartFile, Eine einfache Möglichkeit wäre, statt der Annotation @NotEmpty, eine eigene Annotation @NotEmptyFile zu verwenden.

@Documented
@Constraint(validatedBy = NotEmptyFileValidator.class)
@Target( { ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface NotEmptyFile {
  String message() default "empty file";
  Clas<?>[] groups() default {};
  Class<? extends Payload>[] payload() default {};
}

In ihrer @Constraint Annotation steht unter validatedBy die ConstraintValidator Klasse, die sich um die eigentliche Arbeit kümmert. Da wir diese sowieso benötigen, hier die erste Implementierung.

public class NotEmptyFileValidator implements ConstraintValidator<NotFileEmpty, MultipartFile> {
  @Override
  public boolean isValid(MultipartFile value, ConstraintValidatorContext context) {
    return !value.isEmpty();
  }
}

In der isValid Methode wird der übergebene MultipartFile darauf geprüft, ob er leer ist. Der ConstraintValidator hat als generischen Parameter auch den Typ der Annotation, um in einer hier nicht implementierten Methode, Parameter aus der Annotation auslesen zu können.

Da wir aber keine eigene Annotation verwenden wollen, ändern wir den generischen Parameter auf @NotEmpty und haben nun ein kleines Problem. An der @NotEmpty Annotation ist unser ConstraintValidator nicht eingetragen. Genaugenommen ist dort kein einziger ConstraintValidator eingetragen. Wir können also hoffen, auf einen anderen Weg, unser Ziel zu erreichen.

Der Retter ist der Java Service Loader, für den wir einfach im META-INF/services Verzeichnis unseres Projektes eine Datei mit dem Namen javax.validation.ConstraintValidator erstellen müssen. In diese Datei kommt der volle Name unserer ConstraintValidator Klasse. Der Framework kennt dann unseren ConstraintValidator und kann ihn einsetzen, sobald er eine Klasse vom Typ MultpartFile entdeckt.

Damit sind wir schon ein ganzes Stück gekommen, ohne einen einzigen Zauber bemühen zu müssen. Unser Controller Code hat sich während dessen schon vereinfacht.

@PostMapping
public String multipart(@RequestParam("file") @NotEmpty MultipartFile file) throws IOException {
  if (contentService.isEmptyContent(file)) {
    throw new EmptyUploadException(file.getName());			
  }
  ...
}

Schön wäre es natürlich noch, wenn wir auch den contentService Code in unseren Validator verschieben könnten. Ich habe lange darüber nachgedacht, wie man das automatische Laden über den Java Service Loader mit dem Spring Boot Framework verheiraten könnte. Wenn der Service Loader den ConstraintValidator lädt, wie kann dann Spring Boot sein Dependency Injektion anwenden? Und wenn wir eine Spring Boot Komponente aus dem ConstraintValidator machen, wie können wir ihn dann mit der @NotEmpty Annotation verknüpfen?

Letzten Endes war es Sherlock Holmes, der mich auf die richtige Spur brachte.

How often have I said to you that when you have eliminated the impossible, whatever remains,however improbable, must be the truth?

Sherlock Holmes

Ist in der Software Entwicklung ist häufig das Unmögliche der einzige richtige Weg,

public class NotEmptyFileValidator implements ConstraintValidator {
  @Autowired
  private FileContentService service;

  @Override
  public boolean isValid(MultipartFile value, ConstraintValidatorContext context) {
    return !value.isEmpty() && !service.isEmptyContent(value);
  }
}

In einer Spring Boot Applikation können Services in ConstraintValidator Instanzen injected werden, ohne irgendetwas anderes als die @Autowired Annotation zu verwenden.

Wenn das nicht Zauberei ist!