CodeGym /Kurse /Spring Boot /Konverter und Formatierer des Katalogs

Konverter und Formatierer des Katalogs

Spring Boot
Level 11, Lektion 3
Verfügbar

1. Rolle von Konvertern und Formatierern

Wenn Sie früher Controller „auf die einfache Art“ geschrieben haben, sind Ihnen wahrscheinlich zwei unangenehme Szenarien begegnet: entweder werden die Controller-Parameter zu einem String-Zoo (und Sie parsen alles von Hand) oder Sie verwenden Domain-Typen, aber die Anwendung fängt an, wegen Kleinigkeiten wie Groß-/Kleinschreibung oder Bindestrichen zu zicken. Konverter und Formatierer lösen genau dieses alltägliche Problem: Sie ermöglichen es, den Controller typisiert und sauber zu lassen und die Regeln zur String-Konvertierung an einem Ort zu sammeln.

Bei Daten war alles angenehm: für LocalDate reichte spring.mvc.format.* und Java-Code war kaum nötig. Aber track=java-backend ist kein standardmäßiger temporaler Typ. MVC weiß, dass ein String angekommen ist, aber nicht unsere Domain-Vereinbarung, und hier brauchen wir eigene Regeln.

Stellen Sie sich unseren Katalogfilter vor. Wir möchten, dass die API Anfragen wie diese akzeptiert:

GET /api/catalog/courses?track=java-backend&level=basic&launchedAfter=2026-01-01

Aus menschlicher Sicht ist das eine normale URL: kleine Buchstaben, Bindestrich, ISO-Datum. Aus Sicht des Java-Enums CourseTrack ist das eigentlich JAVA_BACKEND, und wenn man alles „standardmäßig“ lässt, erwartet Spring oft die Zeichenkette exakt wie den Namen der Enum-Konstante. Das heißt, Sie müssten schreiben:

track=JAVA_BACKEND&level=BASIC

Und das sieht schon nach „API für Roboter, die Menschen hassen“ aus. Wir möchten, dass „menschliche“ Werte problemlos akzeptiert werden und im Controller alles typisiert bleibt.

Um zu verstehen, wo wir eingreifen, ist es hilfreich, ein stark vereinfachtes Schema im Kopf zu behalten:

flowchart TD
  A["URL: track=java-backend"] --> B["MVC binding"]
  B --> C["ConversionService"]
  C --> D["CourseTrack.JAVA_BACKEND"]
  D --> E["Methodenparameter des Controllers: CourseTrack track"]

Unser Beitrag heute ist es, dem ConversionService beizubringen, „java-backend“ genauso sicher zu verstehen wie „JAVA_BACKEND“.

2. Eingebautes MVC-Binding ohne Code

Bevor Sie eigene Konverter schreiben, ist es wichtig, nicht in die klassische Anfängerfalle zu tappen: „Ich weiß nicht, wie Spring funktioniert, also schreibe ich alles selbst.“ Spring MVC kann bereits eine Vielzahl von Typen konvertieren, und oft ist der richtige Schritt, nichts zu schreiben, sondern lediglich den Parametertyp im Controller korrekt zu deklarieren. Sonst kann man sich ein Mini-Projekt „Spring in Spring schreiben“ einrichten – das macht genau die ersten fünf Minuten Spaß, dann wird es traurig.

boolean, int und viele andere primitive/Wrapper-Typen konvertiert Spring ohne unser Zutun. Das bedeutet, dass Filter wie featuredOnly, publishedOnly und limit bereits jetzt typisiert gehalten werden können:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
class DemoController {

    @GetMapping("/demo")
    String demo(@RequestParam(defaultValue = "false") boolean featuredOnly,
                @RequestParam(defaultValue = "20") int limit) {
        // Spring konvertiert die String-Query-Parameter selbst in boolean/int
        // defaultValue greift, wenn der Parameter überhaupt nicht übergeben wurde
        return "featuredOnly=" + featuredOnly + ", limit=" + limit;
    }
}

Bei temporalen Typen ist die Logik dieselbe: LocalDate, LocalTime und LocalDateTime werden bereits durch den standardmäßigen MVC-Mechanismus abgedeckt, wenn Sie einmal das Format über spring.mvc.format.* festgelegt haben. Die Regel ist einfach: Eingebaute Typen und Daten übernehmen wir zuerst aus dem vorhandenen Verhalten; eigenen Code fügen wir nur dort hinzu, wo die Anfrage ein Domain-Vokabular erhält.

Genau das passiert mit track und level. Das sind keine einfachen int und keine standardmäßigen temporalen Typen, daher brauchen wir jetzt eigene Converter und Formatter.

3. Converter vs Formatter: unterschiedliche Aufgaben

An dieser Stelle taucht meist die Frage auf: „Okay, ich verstehe, dass es einen ConversionService gibt, aber warum gibt es in Spring gleich zwei Interfaces – Converter und Formatter? Konnte man nicht eines nehmen, um die Leute nicht zu verwirren?“. Man hätte es können, aber dann hätte Spring die praktische Trennung der Zuständigkeiten verloren. Converter ist einfach und geradlinig: „Wandle Typ S in Typ T um“. Formatter ist ein „menschlicheres“ Interface: Es kann sowohl einen String in einen Typ parsen als auch den Typ zurück in einen String ausgeben, und das auch noch unter Berücksichtigung der Locale.

Halten wir den Unterschied in einer kleinen Tabelle fest, denn Tabellen sind eine legale Form der „Liste“, die nicht einmal @EnableWebMvc deaktivieren kann:

Mechanismus Richtung Typischer Fall in MVC Locale Wann wählen
Converter<S, T> String → T Query-Parameter, Path-Variablen: „java-backend“ → CourseTrack nein wenn nur die Eingabe „verstanden“ werden muss
Formatter<T> String ↔ T Formulare, Ausgabe/Parse, wenn ein stabiles Textformat benötigt wird ja wenn beide Richtungen wichtig sind: sowohl parse als auch print

In unserem catalog-service kommen Anfragen als Strings an, und wir benötigen Domain-Typen. Daher ist Converter<String, CourseTrack> die ideale „erste Stufe“.

Und Formatter<CourseLevel> ist nützlich, wenn Sie ein stabiles Textformat in beide Richtungen haben möchten. Auch wenn Sie derzeit hauptsächlich die Eingabe parsen, diszipliniert die Existenz von print(...) oft: Sie legen vorab fest, „wie dieser Typ in einer URL in normaler Form aussieht“.

Wichtig ist noch: Sowohl Converter als auch Formatter sollten klein, deterministisch und ohne Geschäftslogik sein. Ihre Aufgabe ist die Formatumwandlung, nicht die Entscheidungsfindung. Wenn Sie sich dabei ertappen, „lassen Sie uns im Konverter einen Service aufrufen, um zu prüfen, ob dieser Track in der Datenbank existiert“, dann machen Sie keine Konvertierung mehr, sondern eine Mischung aus Validierung/Geschäftslogik (und das wird sehr schwer zu debuggen sein).

4. CourseTrackConverter: String → CourseTrack

Wenn wir ein Enum über einen Query-Parameter entgegennehmen, ist der eigentliche Schmerz nicht das Enum selbst, sondern dass die Benutzer (und Sie selbst in einer Woche) die Werte unterschiedlich schreiben werden. Jemand schreibt basic, jemand BASIC, jemand junior-plus und jemand junior_plus. Und das ist alles „fast dasselbe“, aber der Computer ist ein prinzipientreues und nachtragendes Wesen.

Daher führen wir eine einfache, verständliche Normalisierungsregel für Zeichenketten ein. Für unsere Enums wird sie so aussehen: Leerzeichen an den Rändern entfernen, in Großbuchstaben umwandeln und Bindestriche durch Unterstriche ersetzen. Das ist genau das Format, das Enum.valueOf(...) „mag“.

Eine kleine Hilfsfunktion – sie kann direkt im Konverter/Formatter gehalten werden, um keine zusätzlichen Klassen zu erzeugen:

private static String normalizeEnumKey(String raw) {
    // Normalisiert die Eingabe aus der URL in das Format der Enum-Konstanten: BASIC, JAVA_BACKEND usw.
    if (raw == null) return null;

    return raw.trim()          // Leerzeichen an den Rändern entfernen
            .toUpperCase()     // in Großbuchstaben umwandeln
            .replace('-', '_'); // Bindestriche in der URL in Unterstriche für das Enum umwandeln
}

Ja, das sieht aus wie „drei Zeilen für zwei Zeichen“. Aber genau diese drei Zeilen verwandeln eine API, die „wegen Groß-/Kleinschreibung kaputtgeht“, in eine, die „wie ein normales System funktioniert“. Und diese Regel verwenden Sie dann an beiden Stellen: sowohl im Converter als auch im Formatter.

Jetzt erstellen wir den ersten echten Domain-Konverter. Die Logik ist einfach: Eingabe ist ein String aus der URL, Ausgabe ist CourseTrack. Wenn der String leer ist oder nur aus Leerzeichen besteht, geben wir null zurück, damit der Filter „nicht angewendet wird“ (der Parameter ist ja required = false). Wenn der String keiner Enum-Konstante entspricht – werfen wir eine Ausnahme, und MVC gibt 400 zurück.

import org.springframework.core.convert.converter.Converter;

class CourseTrackConverter implements Converter<String, CourseTrack> {

    @Override
    public CourseTrack convert(String source) {
        // Diese Methode wird von Spring aufgerufen, wenn es einen String aus der URL in CourseTrack bindet
        // Implementierung unten
        return null;
    }
}

Fügen wir eine richtige Implementierung hinzu. Beachten Sie zwei Dinge: Wir behandeln „Leere“ und geben eine verständliche Ausnahme. Das ist kein „vollständiger Error-Contract“, sondern nur eine menschenlesbare Nachricht statt „irgendwas ist irgendwo kaputt“.

import org.springframework.core.convert.converter.Converter;

class CourseTrackConverter implements Converter<String, CourseTrack> {

    @Override
    public CourseTrack convert(String source) {
        // Wenn der Parameter nicht übergeben oder leer ist – Filter nicht anwenden
        if (source == null || source.isBlank()) return null;

        // Normalisiert das menschenlesbare Format (java-backend) in den Enum-Key (JAVA_BACKEND)
        String key = source.trim().toUpperCase().replace('-', '_');

        // Wenn der Key mit keiner Konstante übereinstimmt – wirft valueOf eine IllegalArgumentException,
        // und Spring MVC gibt 400 zurück (Konvertierungsfehler der Eingabedaten)
        return CourseTrack.valueOf(key);
    }
}

Wenn Sie jetzt denken: „Ist valueOf nicht gefährlich? Es wirft doch IllegalArgumentException“ – ja, das tut es. Und in diesem Fall ist das in Ordnung: Spring MVC fängt den Konvertierungsfehler und gibt 400 zurück. Auf dem Niveau des Boot-Kurses reicht uns das; wir entwerfen keine separate Fehlerschicht.

Damit Sie den Unterschied im Verhalten spüren, stellen Sie sich zwei Realitäten vor:

— In der Realität ohne unseren Konverter muss der Benutzer die internen Enum-Namen kennen, und die Anfrage track=java-backend schlägt fehl.

— In der Realität mit dem Konverter wird die Anfrage track=java-backend zu CourseTrack.JAVA_BACKEND und gelangt problemlos zum Service, während der Controller sauber bleibt.

Und noch eine kleine, aber wichtige Regel: Der Konverter sollte keine Abhängigkeiten haben. Er sollte nicht „schlau“ sein und entscheiden, was der Benutzer gemeint hat. Normalisierung ist okay. Ein Wörterbuch mit hundert Synonymen und einer Abhängigkeit vom Repository – das nicht (jedenfalls im Rahmen unserer aktuellen Lernarchitektur).

5. CourseLevelFormatter: String ↔ CourseLevel

Mit CourseLevel könnten wir genau denselben Converter<String, CourseLevel> machen – und in vielen Projekten wird das auch so gemacht. Aber laut Tagesplan ist es wichtig zu sehen, dass Formatter ein zweiseitiger Vertrag ist: Er parst nicht nur die Eingabe, sondern kann das Objekt auch zurück in einen String „nach unserem Standard“ ausgeben. Das ist nützlich, wenn Sie irgendwo Links, Textdarstellungen erstellen oder einfach ein einheitliches Format haben möchten, „wie das nach außen aussieht“.

Damit sich optionale Filter symmetrisch verhalten, vereinbaren wir noch eine Regel: Ein leeres level behandeln wir genauso wie das Fehlen des Parameters. Das heißt, ?level= aktiviert keinen „besonderen Modus“, sondern wendet den Filter einfach nicht an. Alles, was nach der Normalisierung keiner Enum-Konstante entspricht, bleibt weiterhin ein Client-Fehler.

Minimale Implementierung des Formatters:

import java.util.Locale;
import org.springframework.format.Formatter;

class CourseLevelFormatter implements Formatter<CourseLevel> {

    @Override
    public CourseLevel parse(String text, Locale locale) {
        // Leeren Filterwert behandeln wir wie das Fehlen des Parameters
        if (text == null || text.isBlank()) return null;

        // parse: Den String aus der Anfrage zerlegen und in ein Enum umwandeln
        String key = text.trim().toUpperCase().replace('-', '_');
        return CourseLevel.valueOf(key);
    }

    @Override
    public String print(CourseLevel object, Locale locale) {
        // print: Das Objekt als String im „kanonischen“ Format ausgeben
        // locale beeinflusst die Groß-/Kleinschreibung (z. B. gibt es in manchen Sprachen Besonderheiten)
        return object.name().toLowerCase(locale);
    }
}

Ja, die Methode print(...) mag jetzt „überflüssig“ erscheinen. Aber sie fixiert eine wichtige Sache: Wenn wir das Level als String anzeigen müssen (z. B. irgendwo in der API, Dokumentation, Logik zur Linkerstellung), möchten wir ein einheitliches „kanonisches“ Format. Im obigen Beispiel wird es basic, junior_plus (wenn einfach toLowerCase(...)), und das ist schon besser als überall BASIC.

Wenn Sie es ganz „hübsch“ haben möchten, können Sie mit Bindestrichen ausgeben. Im Rahmen der Vorlesung zeige ich kurz, wie das aussieht:

@Override
public String print(CourseLevel object, Locale locale) {
    // Macht das externe Format „URL-freundlicher“: junior_plus -> junior-plus
    return object.name().toLowerCase(locale).replace('_', '-');
}

Das ist kein zwingender Schritt, aber es zeigt die Idee: Ein Formatierer geht nicht nur ums Parsen, sondern auch darum, „wie wir diesen Typ nach außen darstellen“.

6. Registrierung im WebMvcConfigurer

Konverter und Formatierer „im luftleeren Raum“ zu schreiben, ist sinnlos: Solange Sie sie nicht registriert haben, weiß MVC nichts von ihnen (so wie die meisten Menschen nichts von Ihrer Motivation, abends zu lernen). Der richtige Ort für die Registrierung in einem Boot-Projekt ist Ihre WebConfiguration, die WebMvcConfigurer implementiert, und speziell die Methode addFormatters(...).

Minimal sieht das so aus:

import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        // Registrieren Sie Ihre eigenen Regeln zusätzlich zur Autoconfiguration von Spring Boot
        registry.addConverter(new CourseTrackConverter());
        registry.addFormatter(new CourseLevelFormatter());
    }
}

Beachten Sie: Wir setzen nicht @EnableWebMvc. Wir deaktivieren nicht die Autoconfiguration von Boot. Wir fügen lediglich kleine Regeln zusätzlich zum bereits funktionierenden MVC hinzu.

Und noch ein wichtiger Punkt: Hier erstellen wir Konverter mit new, weil sie zustandslos sind und keine Abhängigkeiten benötigen. Das ist in Ordnung. Wenn Sie den Konverter komplexer machen möchten (z. B. mit Einstellungen), dann wäre es sinnvoll, ihn als @Bean zu deklarieren und das Benötigte zu injizieren – aber das ist ein Schritt, den Sie nur bei tatsächlichem Bedarf tun sollten.

7. Typen direkt im Controller

Der schönste Moment in dieser ganzen Geschichte ist, dass Sie aufhören, „String“-Code im Controller zu schreiben. Der Controller sieht jetzt so aus, als ob die Eingabedaten bereits in Ordnung wären, und Sie müssen sie nur noch an den Service weitergeben. Das ist genau das „dünner Controller, dicker Service“ – nur ohne Fanatismus.

Ein Beispiel für die Signatur unseres /api/catalog/courses nach dem Hinzufügen der Konvertierung:

import java.time.LocalDate;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
class CourseCatalogController {

    @GetMapping("/api/catalog/courses")
    List<CourseCard> findCourses(@RequestParam(required = false) CourseTrack track,
                                 @RequestParam(required = false) CourseLevel level,
                                 @RequestParam(required = false) LocalDate launchedAfter,
                                 @RequestParam(defaultValue = "20") int limit) {
        // Beim Eintritt in die Methode: Spring hat bereits den String aus der URL in Ihre Domain-Typen konvertiert
        // limit kommt als int (oder der defaultValue, wenn der Parameter nicht übergeben wurde)
        return List.of(); // hier wird der Service-Aufruf service.find(...) stehen
    }
}

Beachten Sie, dass es in dieser Methode keine „vorbereitende“ Arbeit gibt. Kein toUpperCase() und kein replace('-', '_') im Controller. Der Controller weiß nicht, dass der Benutzer java-backend geschickt haben könnte; er erhält einfach CourseTrack.JAVA_BACKEND. Das ist eine sehr gute Trennung der Verantwortlichkeiten.

Jetzt werden Anfragen, die Sie im Browser oder Postman senden können, viel freundlicher:

GET /api/catalog/courses?track=java-backend&level=basic&limit=5

Wenn Sie Flags hinzufügen möchten, wird auch das eingebaute Binding nicht enttäuschen:

GET /api/catalog/courses?track=spring&featuredOnly=yes&limit=3

Ja, yes für boolean funktioniert oft auch dank des eingebauten String → Boolean Konverters in Spring (und das ist ein netter Bonus, den viele nicht bemerken, bis sie es ausprobieren).

Und wenn der Benutzer Müll schickt:

GET /api/catalog/courses?track=totally-not-a-track

Spring kann den String nicht in ein Enum umwandeln und gibt 400 zurück. Im Rahmen unseres aktuellen Kurses ist das ein „ausreichend korrektes“ Verhalten: Wir bauen keinen großen Error-Contract, tun aber auch nicht so, als ob alles gut wäre.

8. Typische Fehler bei der Arbeit mit Konvertern und Formatierern

Meistens scheitern Konverter und Formatierer nicht an der Komplexität, sondern an falscher „Gier“: Der Entwickler fängt an, mehr Logik hineinzustecken als nötig, und verwandelt eine einfache Konvertierungsschicht in eine Mini-Business-Engine. Das zweite häufige Problem ist der falsche Registrierungsort, weshalb Sie einen perfekten Konverter geschrieben haben, aber MVC erfährt nie davon (der Konverter ist traurig, der Controller parst Strings von Hand, die Welt wird ein kleines bisschen weniger schön).

Fehler Nr. 1: Konvertierung direkt im Controller durchführen.
Das sieht harmlos aus: „Ich habe doch nur toUpperCase() gemacht.“ Aber dann fügen Sie einen zweiten Endpunkt hinzu, einen dritten, und plötzlich haben Sie zehn Stellen, an denen das Format „angepasst“ werden muss. Der Konverter/Formatter ist genau dafür da, solche Regeln zentral zu halten.

Fehler Nr. 2: Einen Konverter schreiben, obwohl Spring das bereits kann.
Wenn Sie einen LimitConverter für int oder einen FeaturedOnlyConverter für boolean erstellt haben, haben Sie das Projekt höchstwahrscheinlich ohne Nutzen verkompliziert. Das eingebaute Binding ist Ihr Freund. Eigenes nur dort, wo „Out-of-the-Box“ wirklich nicht ausreicht.

Fehler Nr. 3: Der Konverter hängt von CourseCatalogService oder dem Repository ab.
Sobald im Konverter ein Service auftaucht, hört er auf, eine „Konvertierung“ zu sein, und wird zu Geschäftslogik an einer unerwarteten Stelle. Das macht die Anwendung schwerer verständlich und kann zu zirkulären Abhängigkeiten führen. Der Konverter sollte einfach sein, ohne DI und ohne Seiteneffekte.

Fehler Nr. 4: Die Eingabe nicht normalisieren und auf einen idealen Benutzer hoffen.
Im echten Leben schreiben Leute SPRING, spring, Spring, spring und sogar spring-boot, wo Sie SPRING erwarten. Wenn Sie die Eingabe nicht normalisieren (zumindest trim + uppercase + replace), erhalten Sie „zufällige“ 400-Fehler, die wie eine Instabilität der API aussehen.

Fehler Nr. 5: Globales Datumsformat und Formatierer/Konverter für Domain-Typen verwechseln.
spring.mvc.format.date=iso beeinflusst, wie LocalDate und Co. geparst werden. Ihre Enum-Konverter sind eine andere Geschichte; sie werden nicht „von selbst aus YAML kommen“. Die gute Nachricht: Beide Mechanismen leben gut nebeneinander, solange Sie sie in den richtigen Grenzen halten.

1
Aufgabe
Spring Boot, Level 11, Lektion 3
Gesperrt
`Converter` für einen menschenlesbaren Query-Parameter
`Converter` für einen menschenlesbaren Query-Parameter
1
Aufgabe
Spring Boot, Level 11, Lektion 3
Gesperrt
`Formatter` mit `parse(...)` und `print(...)`
`Formatter` mit `parse(...)` und `print(...)`
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION