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!