Formulare mit Thymeleaf: Die Schulverwaltung legt Hinweise für den Flurmonitor an

https://bildung.social/@oerinformatik/

https://oer-informatik.de/flurmonitor-hinweise-formulare

Thymeleaf, Formular, th:object, th:field, Bean Validation, BindingResult, Post-Redirect-Get, Flash-Attribut, CSRF, Spring Security, Spring MVC, Scrum

tl/dr; (ca. 30 min Lesezeit): Bisher steht der Hinweis der Schulverwaltung fest im Template des Flurmonitors. In diesem Artikel bekommt er eine eigene Entity mit Repository und Service, und der Monitor zeigt nur Hinweise, die noch gelten. Die Verwaltung legt Hinweise über ein Formular an: Ein Formular-Objekt nimmt die Eingaben auf, th:object, th:field und th:errors verbinden es mit dem Template, Bean Validation prüft es mit @Valid, und das BindingResult meldet Fehler zurück an die Felder. Nach dem Speichern leitet der Controller um (Post-Redirect-Get), damit Neuladen keinen zweiten Hinweis anlegt. Eine zweite Filterkette von Spring Security sorgt für Anmeldung per Formular und CSRF-Schutz [AI-Assisted]. (Zuletzt geändert am 11.11.2026)

Dieser Artikel ist Teil einer Artikelreihe über den Flurmonitor mit Thymeleaf: Projektübersicht / Scrum-Projektstart / Thymeleaf einbinden / Templates / Ansichtsmodell / Integrationstest der Web-Schicht / Konfiguration und Formatierung / Aktualisierung / Hinweise mit Formularen / Robustheit und Betrieb / GUI-Test mit Selenium / Akzeptanztest / Change Request Keycloak

Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.

Dieser Artikel baut auf der Kann-Liste des Artikels Automatisch aktualisieren auf.

Als Formulare-Grundlagen kann ich nach Durchlesen des Artikels…

  • … eine neue Entity mit Repository und Service anlegen und ihre Daten im Template eines Monitors anzeigen.

  • … ein Formular-Objekt mit Bean-Validation-Annotationen (@NotBlank, @Size, @NotNull, @FutureOrPresent) schreiben und die Meldungen in messages.properties ablegen.

  • … ein Formular mit th:action, th:object, th:field und th:errors an dieses Formular-Objekt binden.

  • … im Controller Eingaben mit @Valid prüfen, das BindingResult auswerten und bei Fehlern dieselbe Seite mit den Eingaben zurückgeben.

  • … den Ablauf Post-Redirect-Get beschreiben und mit redirect: und Flash-Attributen umsetzen.

Darüber hinaus kann ich als Formulare-Expertenwissen…

  • … erklären, wogegen ein CSRF-Token schützt, warum es bei Formularen mit Sitzung nötig ist und warum die REST-API ohne Sitzung darauf verzichten kann.

  • … zwei Filterketten von Spring Security mit securityMatcher und @Order für Verwaltungsseiten und API trennen.

  • … begründen, warum bei Fehlern kein Redirect folgt und warum Meldungen nach einem Redirect ein Flash-Attribut brauchen.

  • … Formular, Validierung, CSRF-Schutz und Umleitung mit @WebMvcTest, @WithMockUser und csrf() testen.

Nach Tag 7 von Sprint 2 aktualisiert sich der Flurmonitor selbst. Eine Story steht noch fest im Sprint Backlog: MON-08, die Hinweise der Schulverwaltung. Seit dem ersten Template zeigt der Monitor einen gelben Kasten mit dem Satz „Am Freitag endet der Unterricht schon um 12:15 Uhr“, der fest im HTML steht. Damit Heike Brinkmann aus dem Sekretariat Hinweise selbst anlegen kann, braucht das Projekt zum ersten Mal ein Formular, das Daten an den Server schickt und dort speichert. Der Ablauf, um den sich dieser Artikel dreht, heißt Post-Redirect-Get: Der Browser schickt das Formular per POST, der Server speichert und antwortet mit einer Umleitung, und erst die folgende GET-Anfrage zeigt die Seite mit der Erfolgsmeldung.

Sequenzdiagramm Post-Redirect-Get: Die Verwaltung füllt das Formular aus, der Browser schickt POST /verwaltung/hinweise mit text, bis, wichtigkeit und _csrf an den HinweisVerwaltungController. Der prüft mit @Valid und erhält ein BindingResult. Ohne Fehler ruft er legeAn am HinweisService auf, der per INSERT speichert, und antwortet mit 302 Found und Location /verwaltung/hinweise; der Browser schickt daraufhin GET /verwaltung/hinweise und erhält die Liste mit der Meldung „gespeichert“. Neu laden wiederholt nur das GET. Bei Fehlern antwortet der Controller direkt mit 200 OK und dem Formular mit Eingaben und Fehlermeldungen (mit KI erstellt)

Stand im Projekt: Sprint 2, Tag 8

MON-08 („Hinweise anzeigen“, 2 Story Points) ist die letzte feste Story im Sprint. Im Daily Scrum an Tag 8 fällt Oksana Melnyk auf, dass MON-08 allein wenig bringt: Ohne gespeicherte Hinweise gibt es nichts anzuzeigen, und der fest eingetragene Satz müsste weiter im Template gepflegt werden. MON-07 („Hinweise anlegen“, 5 Story Points) steht als Option am Ende des Sprint Backlogs. Weil nach Tag 7 noch drei Projekttage bleiben und die Velocity aus Sprint 1 bei 10 Story Points lag, fragt das Team Product Owner Thomas Becker, ob es MON-07 nach vorn ziehen darf. Er stimmt zu; das Sprint-Ziel („Monitor im Alltag“) bleibt dasselbe, und die Arbeitsvereinbarung „höchstens zwei Stories gleichzeitig“ ist mit MON-07 und MON-08 eingehalten.

Beim kurzen Backlog Refinement im Anschluss schärft Thomas Becker die Abnahmekriterien. Der Formularentwurf aus den HTML-Grundlagen fragt nur ein Datum ab („Anzeigen bis einschließlich“), keine Uhrzeit. Heike Brinkmann ist das recht, also ändert er AK-MON-07.1 und ergänzt zwei Kriterien:

  • AK-MON-07.1 (geändert): Angenommen, Heike Brinkmann ist an der Verwaltungsseite angemeldet. Wenn sie den Hinweis „Linie 61 fällt morgen aus“ mit Gültigkeit bis einschließlich morgen speichert, dann erscheint er beim nächsten Laden auf allen Monitoren.

  • AK-MON-07.3: Angenommen, niemand ist angemeldet. Wenn jemand die Verwaltungsseite aufruft, dann wird er zur Anmeldung geleitet.

  • AK-MON-07.4 (neu): Angenommen, Heike Brinkmann lässt den Text leer oder gibt ein Datum in der Vergangenheit an. Wenn sie speichert, dann wird nichts gespeichert, und das Formular zeigt ihre Eingaben mit einer Meldung am betroffenen Feld.

  • AK-MON-08.2 (neu): Angenommen, ein Hinweis gilt nur für den Haltepunkt Herford. Wenn ein Monitor lädt, der nur einen anderen Haltepunkt zeigt, dann erscheint der Hinweis dort nicht.

Die Developers zerlegen beide Stories und setzen die Nummern aus dem letzten Artikel fort:

Sprint Backlog von Sprint 2, Ausschnitt für MON-07 und MON-08
Nr. Aufgabe im Sprint Backlog Abschnitt
T36 Entity Hinweis, Enum Wichtigkeit, HinweisRepository, Startdaten Entity
T37 HinweisService: anlegen, löschen, gültige Hinweise je Monitor Monitor
T38 Hinweise im Fragment aktuell statt fest im Template Monitor
T39 Verwaltungsseite mit Formular-Objekt, th:object, th:field, Bean Validation Formular-Objekt und Template
T40 Post-Redirect-Get mit Flash-Meldung, Löschen als POST PRG
T41 Zweite Filterkette: Anmeldeformular, Sitzung, CSRF-Schutz Sicherheit
T42 Tests: Verwaltung, Monitor mit Hinweisen, Repository Testen

Die Entity Hinweis

Ein Hinweis hat einen Text, ein Datum, bis zu dem er gilt, eine Wichtigkeit und optional einen Haltepunkt. Ohne Haltepunkt gilt er für alle Monitore. Das Klassendiagramm zeigt die neuen Klassen und wie sie zusammenhängen: Der Controller arbeitet mit einem eigenen Formular-Objekt und ruft den Service auf; nur der Service kennt das Repository und die Entity.

UML-Klassendiagramm: HinweisVerwaltungController nutzt HinweisFormular (text, haltepunkt 0..1, bis, wichtigkeit = INFO, mit Einschränkungen „nicht leer, höchstens 140 Zeichen“ und „Pflicht, heute oder später“) und kennt genau einen HinweisService mit legeAn, loesche, alle und gueltigeFuer. Der Service kennt das Interface HinweisRepository, das die Entity Hinweis (id, text, bis, wichtigkeit, giltAm, passtZu) nutzt. Hinweis ist mit 0..1 Haltepunkt (Rolle haltepunkt) verbunden; Hinweis und HinweisFormular nutzen die Enumeration Wichtigkeit mit INFO und WARNUNG (mit KI erstellt)

Die Entity folgt dem Muster von Angebot und Haltepunkt aus der Backend-Serie. Neu ist nur, dass die Beziehung zum Haltepunkt leer sein darf:

@Entity
public class Hinweis {

    /** Höchstlänge des Textes, wie im HTML-Formular aus den Grundlagen (maxlength="140"). */
    public static final int MAX_LAENGE = 140;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = MAX_LAENGE)
    private String text;

    /** optional: null heißt „alle Haltepunkte“. */
    @ManyToOne(fetch = FetchType.LAZY)
    private Haltepunkt haltepunkt;

    @Column(nullable = false)
    private LocalDate bis;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 10)
    private Wichtigkeit wichtigkeit;

    /** Gilt der Hinweis am Tag heute? Der letzte Tag zählt mit. */
    public boolean giltAm(LocalDate heute) {
        return !bis.isBefore(heute);
    }

    /** Gehört der Hinweis auf einen Monitor mit diesem Haltepunkt (null = Monitor zeigt alle Haltepunkte)? */
    public boolean passtZu(Long haltepunktDesMonitors) {
        return haltepunkt == null || haltepunktDesMonitors == null
                || haltepunkt.getId().equals(haltepunktDesMonitors);
    }
    // Konstruktoren, Getter und isWarnung() im Code-Stand
}

Wichtigkeit ist ein Enum mit INFO und WARNUNG. @Enumerated(EnumType.STRING) speichert den Namen statt der Position; so bleibt die Datenbank lesbar, und ein neuer Wert in der Mitte des Enums verschiebt keine gespeicherten Daten. Die Tabelle legt Hibernate wie bisher aus der Entity an (ddl-auto=create-drop), data.sql füllt zwei Hinweise ein: den bekannten Satz zum Freitag für alle Monitore und eine Warnung für den Haltepunkt Herford.

Das Repository braucht zwei abgeleitete Abfragen. Spring Data bildet das SQL aus dem Methodennamen, @EntityGraph lädt den Haltepunkt in derselben Abfrage mit, weil das Template ihn anzeigt und open-in-view ausgeschaltet ist:

public interface HinweisRepository extends JpaRepository<Hinweis, Long> {

    /** Hinweise, die am Tag tag noch gelten: zuerst Warnungen, dann nach Ablauf sortiert. */
    @EntityGraph(attributePaths = "haltepunkt")
    List<Hinweis> findByBisGreaterThanEqualOrderByWichtigkeitDescBisAsc(LocalDate tag);

    /** Alle Hinweise für die Verwaltungsseite, die zuletzt ablaufenden zuerst. */
    @EntityGraph(attributePaths = "haltepunkt")
    List<Hinweis> findAllByOrderByBisDescIdDesc();
}

Die Sortierung OrderByWichtigkeitDesc sortiert die gespeicherten Texte, nicht die Reihenfolge im Enum. Dass WARNUNG vor INFO kommt, liegt am Alphabet. Bei einer dritten Stufe müsste das Team das prüfen; ein Test gegen die Startdaten hält die Erwartung fest.

Hinweise auf dem Monitor

Der HinweisService kennt die Uhr der Anwendung (das Clock-Bean aus dem Ansichtsmodell-Artikel) und filtert die gültigen Hinweise nach dem Haltepunkt des Monitors:

public List<Hinweis> gueltigeFuer(Long haltepunktDesMonitors) {
    LocalDate heute = LocalDate.now(uhr);
    return hinweise.findByBisGreaterThanEqualOrderByWichtigkeitDescBisAsc(heute).stream()
            .filter(hinweis -> hinweis.passtZu(haltepunktDesMonitors))
            .toList();
}

Der FlurmonitorController bekommt den Service als dritte Abhängigkeit über den Konstruktor und legt die Hinweise in der Methode fuellen(…) ins Model. Weil Seite und Fragment aktuell diese Methode gemeinsam nutzen, kommen die Hinweise mit jedem Nachladen alle 30 Sekunden mit:

private void fuellen(Model model, MonitorEinstellung einstellung, String quelle) {
    model.addAttribute("ansicht", aufbereitung.ansicht(einstellung));
    model.addAttribute("hinweise", hinweisService.gueltigeFuer(einstellung.haltepunkt())); // MON-08
    model.addAttribute("quelle", quelle);
    model.addAttribute("takt", einstellungen.taktInSekunden());
}

Im Template ersetzt eine Schleife den festen Kasten. Er steht weiterhin vor den Tabellen, damit auch ein Screenreader den Hinweis zuerst vorliest. Eine Warnung ist nicht nur gelb, sondern beginnt mit dem Wort „Warnung:“ (MON-09: Information nie nur über Farbe):

<aside class="col-12" th:unless="${hinweise.isEmpty()}">
  <div th:each="hinweis : ${hinweise}" class="alert hinweis-kasten mb-2"
       th:classappend="${hinweis.warnung} ? 'alert-warning' : 'alert-info'">
    <h2 class="h4 alert-heading" th:text="#{flurmonitor.hinweis.ueberschrift}">Hinweis der Schulverwaltung</h2>
    <p class="mb-0"><strong th:if="${hinweis.warnung}" th:text="#{flurmonitor.hinweis.warnung}">Warnung:</strong>
      <span th:text="${hinweis.text}">Am Freitag endet der Unterricht schon um 12:15 Uhr.</span>
      <small class="d-block mt-1" th:text="#{flurmonitor.hinweis.bis(${#temporals.format(hinweis.bis,
          #messages.msg('flurmonitor.format.bis'))})}">gilt bis Fr 20.11.</small></p>
  </div>
</aside>

${hinweis.warnung} ruft isWarnung() auf; für Boolean-Getter mit is gilt in Thymeleaf dieselbe Kurzform wie für get. th:text maskiert den Text, den die Verwaltung eingibt. Tippt jemand <script> in das Formular, steht auf dem Monitor genau diese Zeichenfolge, das Skript läuft nicht. th:utext würde den Text ungeprüft als HTML einsetzen und ist deshalb für Eingaben tabu.

Einen Cache wie bei den Angeboten bekommen die Hinweise nicht. Die Abfrage ist klein, und ein neuer Hinweis soll beim nächsten Nachladen erscheinen. Wer später doch cacht, muss den Namen in spring.cache.cache-names eintragen und beim Anlegen und Löschen mit @CacheEvict leeren, wie im letzten Artikel beschrieben.

Ein Formular-Objekt mit Bean Validation

Ein Formular schickt seine Felder als Name-Wert-Paare (text=…&bis=2026-11-17&wichtigkeit=WARNUNG). Spring MVC könnte jedes Feld einzeln mit @RequestParam entgegennehmen. Bei vier Feldern, Pflichtangaben und Fehlermeldungen wird das schnell unübersichtlich: Für jedes Feld wären eigene Prüfungen nötig, und bei einem Fehler müsste der Controller alle Eingaben wieder einzeln ins Model legen, damit das Formular sie erneut zeigt. Spring MVC bietet dafür das Formular-Objekt (engl. form backing bean): eine einfache Klasse mit einem Feld je Formularfeld. Spring füllt es beim Absenden über Data Binding (Felder werden über die Setter nach Namen befüllt und in den passenden Typ umgewandelt) und gibt es mit den Eingaben an das Template zurück, auch wenn sie ungültig sind.

Warum nicht gleich die Entity? Sie hätte dann Setter für alles, was im Formular steht, auch für Felder, die niemand von außen setzen darf (z. B. die id). Ein manipuliertes Formular mit einem zusätzlichen Feld id=1 würde dann einen fremden Hinweis überschreiben (Mass Assignment). Außerdem erwartet die Entity einen Haltepunkt, das Formular liefert nur dessen Nummer. Das Formular-Objekt trennt also Oberfläche und Datenmodell, so wie in der Backend-Serie die DTOs der REST-API.

public class HinweisFormular {

    @NotBlank(message = "{hinweis.text.leer}")
    @Size(max = Hinweis.MAX_LAENGE, message = "{hinweis.text.lang}")
    private String text;

    /** leer (null) = alle Haltepunkte. */
    private Long haltepunkt;

    @NotNull(message = "{hinweis.bis.leer}")
    @FutureOrPresent(message = "{hinweis.bis.vergangen}")
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) // <input type="date"> schickt 2026-11-20
    private LocalDate bis;

    @NotNull
    private Wichtigkeit wichtigkeit = Wichtigkeit.INFO;

    // Getter und Setter für alle Felder
}

Die Annotationen stammen aus Bean Validation (Jakarta Validation, Referenzimplementierung Hibernate Validator), das der Starter spring-boot-starter-validation schon für die REST-API ins Projekt gebracht hat. Die Feldregeln stehen damit an einer Stelle und gelten für jeden Aufruf:

Annotationen im Formular-Objekt HinweisFormular
Annotation Bedeutung hier für
@NotBlank nicht null, nicht leer, nicht nur Leerzeichen Text
@Size(max = 140) höchstens 140 Zeichen Text
@NotNull Pflichtangabe Datum, Wichtigkeit
@FutureOrPresent heute oder später Datum
@DateTimeFormat Spring-Annotation: in welchem Format der Text in ein Datum umgewandelt wird Datum

Steht die Meldung in geschweiften Klammern ({hinweis.text.leer}), sucht Spring Boot sie als Schlüssel in messages.properties. Die Texte liegen damit neben den übrigen Texten der Oberfläche und lassen sich übersetzen; {max} setzt den Wert aus der Annotation ein:

hinweis.text.leer=Bitte einen Hinweistext eingeben.
hinweis.text.lang=Der Hinweistext darf höchstens {max} Zeichen lang sein.
hinweis.bis.leer=Bitte ein Datum angeben, bis zu dem der Hinweis angezeigt wird.
hinweis.bis.vergangen=Das Datum liegt in der Vergangenheit.
# Umwandlungsfehler beim Data Binding (z. B. kein gültiges Datum): Code typeMismatch.<objekt>.<feld>
typeMismatch.formular.bis=Bitte ein gültiges Datum angeben.

Die letzte Zeile betrifft keinen Bean-Validation-Fehler, sondern einen Fehler beim Data Binding: Schickt ein Browser ohne Datumsauswahl „morgen“ statt eines Datums, scheitert schon die Umwandlung in LocalDate. Spring legt dann einen Fehler mit dem Code typeMismatch an und sucht den Text unter typeMismatch.<Objektname>.<Feld>.

Zwei Einschränkungen sind wichtig. Erstens prüft @FutureOrPresent gegen die Uhr der JVM, nicht gegen das Clock-Bean des Projekts; im Profil vorfuehrung mit fester Uhr kann das auseinanderlaufen. Für die Verwaltung im Alltag ist das richtig, für Tests mit festen Daten muss man es wissen. Zweitens ersetzen die Prüfungen im Browser (required, maxlength im HTML) die Prüfung auf dem Server nicht: Jede Anfrage lässt sich ohne Browser abschicken. Das Formular trägt deshalb novalidate, damit beim Ausprobieren die Meldungen des Servers zu sehen sind; im Betrieb könnte man es weglassen.

Das Formular im Template

Das Template verwaltung/hinweise.html entsteht aus dem statischen Formular der HTML-Grundlagen. Drei Attribute verbinden es mit dem Formular-Objekt:

  • th:object="${formular}" am <form> legt das Objekt fest, auf das sich die Felder darin beziehen.

  • th:field="*{text}" an einem Eingabefeld setzt id, name und den aktuellen Wert. Der Sternchen-Ausdruck *{…} bezieht sich auf das Objekt aus th:object, *{text} heißt also ${formular.text}.

  • th:errors="*{text}" gibt die Fehlermeldungen zu diesem Feld aus. ${#fields.hasErrors('text')} fragt, ob es welche gibt, und th:errorclass="is-invalid" hängt die Bootstrap-Klasse für ein rot markiertes Feld an, wenn das Feld einen Fehler hat.

<form th:action="@{/verwaltung/hinweise}" th:object="${formular}" method="post" novalidate>
  <div class="mb-3">
    <label for="text" class="form-label" th:text="#{hinweis.text}">Hinweistext</label>
    <textarea th:field="*{text}" class="form-control" th:errorclass="is-invalid" rows="3"
              maxlength="140" required aria-describedby="text-fehler"></textarea>
    <p id="text-fehler" class="invalid-feedback" th:if="${#fields.hasErrors('text')}" th:errors="*{text}">
      Bitte einen Text eingeben.</p>
  </div>
  <div class="mb-3">
    <label for="haltepunkt" class="form-label" th:text="#{hinweis.haltepunkt}">Gilt für Haltepunkt</label>
    <select th:field="*{haltepunkt}" class="form-select">
      <option value="" th:text="#{hinweis.alle}">alle Haltepunkte</option>
      <option th:each="h : ${haltepunkte}" th:value="${h.id}" th:text="|${h.ort}, ${h.name}|">
        Gütersloh, Hauptbahnhof, ZOB Steig 3</option>
    </select>
  </div>
  <!-- Feld "bis" wie "text", mit type="date" -->
  <fieldset class="mb-3">
    <legend class="form-label fs-6" th:text="#{hinweis.wichtigkeit}">Wichtigkeit</legend>
    <div class="form-check form-check-inline"
         th:each="w : ${T(de.dbh.mitfahrlabor.domain.Wichtigkeit).values()}">
      <input type="radio" class="form-check-input" th:field="*{wichtigkeit}" th:value="${w}">
      <label class="form-check-label" th:for="${#ids.prev('wichtigkeit')}"
             th:text="#{${'hinweis.wichtigkeit.' + w}}">Information</label>
    </div>
  </fieldset>
  <button type="submit" class="btn btn-primary" th:text="#{verwaltung.speichern}">Hinweis speichern</button>
</form>

Was th:field erzeugt, zeigt der Blick in den Seitenquelltext nach einem Fehler. Aus dem Textfeld wird <textarea id="text" name="text" …>, aus dem Datum <input type="date" id="bis" name="bis" value="2026-11-09" …>: Der Wert steht wieder im Feld, umgewandelt mit demselben Format wie beim Einlesen. Bei den Radiobuttons zählt th:field die IDs hoch (wichtigkeit1, wichtigkeit2) und setzt checked beim gewählten Wert; #ids.prev(…) liefert die ID des gerade erzeugten Buttons für das passende <label>. Die leere Option der Auswahlliste schickt einen leeren Text, aus dem Spring beim Umwandeln in Long den Wert null macht. Das passt zu „alle Haltepunkte“.

th:action statt action hat einen zweiten Zweck: Thymeleaf ergänzt bei POST-Formularen automatisch ein verstecktes Feld _csrf, sobald Spring Security den CSRF-Schutz für die Seite eingeschaltet hat. Was es damit auf sich hat, steht im Abschnitt Anmelden und CSRF-Schutz.

Das Bildschirmfoto zeigt die Seite, nachdem Heike Brinkmann ohne Text und mit gestrigem Datum gespeichert hat. Beide Felder sind markiert, die Meldungen stehen direkt darunter, die übrigen Eingaben (Warnung) sind erhalten:

Bildschirmfoto der Verwaltungsseite „Hinweise für die Flurmonitore“, angemeldet als Sekretariat-HB: Das Textfeld ist rot umrandet mit der Meldung „Bitte einen Hinweistext eingeben.“, das Datumsfeld zeigt den 9. November 2026 mit der Meldung „Das Datum liegt in der Vergangenheit.“, als Wichtigkeit ist Warnung gewählt. Darunter die Tabelle „Vorhandene Hinweise“ mit zwei Einträgen und je einem Button „Löschen“

Prüfen im Controller: @Valid und BindingResult

Der HinweisVerwaltungController ist ein @Controller unter /verwaltung/hinweise. Die GET-Methode legt ein leeres Formular-Objekt unter dem Namen formular ins Model, dazu die Liste der Hinweise und die Haltepunkte für die Auswahl. Die POST-Methode nimmt das Formular entgegen:

@PostMapping
public String anlegen(@Valid @ModelAttribute("formular") HinweisFormular formular, BindingResult pruefung,
        Model model, Principal angemeldet, RedirectAttributes weiterleitung) {
    if (pruefung.hasErrors()) {
        fuellen(model, angemeldet);
        return SEITE; // kein Redirect: Eingaben und Fehlermeldungen stehen nur in diesem Request
    }
    Hinweis neu = hinweisService.legeAn(formular.getText(), formular.getHaltepunkt(), formular.getBis(),
            formular.getWichtigkeit());
    weiterleitung.addFlashAttribute("meldung", "hinweis.gespeichert");
    weiterleitung.addFlashAttribute("neueId", neu.getId());
    return ZURUECK_ZUR_SEITE; // "redirect:/verwaltung/hinweise"
}

@ModelAttribute("formular") sagt Spring, unter welchem Namen das Objekt im Model liegt; er muss zum th:object im Template passen. @Valid löst nach dem Data Binding die Bean Validation aus. Das BindingResult sammelt alle Fehler aus Umwandlung und Validierung. Es muss als Parameter direkt hinter dem geprüften Objekt stehen. Fehlt es dort, wirft Spring bei einem Fehler eine MethodArgumentNotValidException, und statt des Formulars erscheint eine Fehlerseite.

Bei Fehlern gibt die Methode einfach den View-Namen der Seite zurück. Das Formular-Objekt mit den Eingaben und das BindingResult liegen schon im Model; th:field und th:errors finden sie dort. Die übrigen Daten der Seite (Liste, Haltepunkte, Kontoname) legt die private Methode fuellen(…) wieder hinein, die auch die GET-Methode nutzt.

Fachliche Prüfungen, die keine Annotation abdeckt, landen ebenfalls im BindingResult: Mit pruefung.rejectValue("feld", "schluessel") hängt der Controller einen eigenen Fehler an ein Feld, etwa wenn ein Datensatz mit der eingegebenen Nummer nicht existiert. Das Template zeigt ihn dann genauso an wie die Fehler aus den Annotationen.

Post-Redirect-Get

Ohne Fehler ruft der Controller den Service auf und antwortet nicht mit einer Seite, sondern mit redirect:/verwaltung/hinweise. Spring macht daraus die HTTP-Antwort 302 Found mit dem Kopf Location: /verwaltung/hinweise. Der Browser folgt der Umleitung von sich aus mit einer GET-Anfrage. Dieses Muster heißt Post-Redirect-Get (PRG).

Warum der Umweg? Würde der Controller nach dem Speichern direkt die Seite zurückgeben, wäre die letzte Anfrage im Browser der POST. Drückt Heike Brinkmann dann F5 oder kommt sie mit „Zurück“ auf die Seite, fragt der Browser „Formular erneut senden?“, und wer „Ja“ klickt, legt den Hinweis ein zweites Mal an. Nach dem Redirect ist die letzte Anfrage dagegen ein GET; Neuladen liest nur die Liste noch einmal. Außerdem lässt sich die Adresse nach dem Speichern als Lesezeichen ablegen, und der Verlauf enthält keine POST-Einträge.

Antworten auf das Absenden des Formulars
Situation Antwort des Controllers Was Neuladen bewirkt
Formular ohne Fehler, direkt die Seite zurückgegeben 200 OK mit HTML sendet den POST erneut: zweiter Hinweis
Formular ohne Fehler, Post-Redirect-Get 302 Found, danach GET mit 200 OK wiederholt nur das GET
Formular mit Fehlern 200 OK, Formular mit Eingaben und Meldungen sendet den ungültigen POST erneut, es wird wieder nichts gespeichert

Bei Fehlern leitet der Controller bewusst nicht um. Ein Redirect beginnt einen neuen Request; das Formular-Objekt mit den Eingaben und das BindingResult wären verloren, und die Verwaltung stünde vor einem leeren Formular ohne Hinweis, was falsch war. Ein wiederholter ungültiger POST schadet nicht, weil er wieder an der Prüfung scheitert.

Die Erfolgsmeldung muss den Redirect überleben. Ein normales Model-Attribut tut das nicht: Spring MVC verwirft das Model, wenn ein Controller umleitet. Dafür gibt es Flash-Attribute. RedirectAttributes.addFlashAttribute(…) legt den Wert kurz in der Sitzung ab; Spring holt ihn bei der nächsten Anfrage heraus, legt ihn ins Model und löscht ihn sofort. Die Meldung erscheint also genau einmal. Das Template zeigt sie als Status:

<p th:if="${meldung}" class="alert alert-success" role="status" th:text="#{${meldung}}">Hinweis gespeichert.</p>

Im Flash-Attribut steht nur der Schlüssel hinweis.gespeichert; der Text kommt aus messages.properties. addAttribute(…) (ohne „Flash“) würde den Wert dagegen als Parameter an die Adresse hängen (/verwaltung/hinweise?meldung=…), wo jeder ihn sieht und ändern kann. Das passt für Werte wie eine Seitennummer, nicht für Meldungen.

Auch das Löschen ist ein POST mit Umleitung: Jeder Eintrag der Liste hat ein kleines Formular mit dem Button „Löschen“ und th:action="@{/verwaltung/hinweise/{id}/loeschen(id=${hinweis.id})}". Ein Link mit GET wäre einfacher, aber GET-Anfragen sollen nichts ändern. Ein Browser, der Links vorab lädt, oder ein Werkzeug, das alle Links einer Seite prüft, würde sonst Hinweise löschen.

Anmelden und CSRF-Schutz

AK-MON-07.3 verlangt eine Anmeldung. Die Sicherheitskonfiguration aus der Backend-Serie meldet mit HTTP Basic und ohne Sitzung an und hat den CSRF-Schutz ausgeschaltet. Für die REST-API ist das passend; für Seiten, die ein Mensch im Browser bedient, nicht: HTTP Basic zeigt nur das graue Anmeldefenster des Browsers, eine Abmeldung gibt es nicht, und Formulare brauchen CSRF-Schutz.

CSRF (Cross-Site Request Forgery) nutzt aus, dass der Browser das Sitzungs-Cookie bei jeder Anfrage an die Anwendung mitschickt, egal von welcher Seite die Anfrage ausgeht. Ist Heike Brinkmann angemeldet und öffnet in einem anderen Tab eine präparierte Seite, kann diese unbemerkt ein Formular an /verwaltung/hinweise abschicken, und der Server hielte es für ihres. Der Schutz: Jedes Formular der Anwendung enthält ein geheimes, zufälliges Token, das zur Sitzung gehört. Spring Security lehnt jeden POST, PUT, PATCH oder DELETE ohne passendes Token mit 403 Forbidden ab. Eine fremde Seite kennt das Token nicht. Thymeleaf fügt es über th:action selbst ein:

<form action="/verwaltung/hinweise" method="post" novalidate><input type="hidden" name="_csrf" value="kW3x…"/>

Die REST-API braucht das nicht, weil sie keine Sitzung und kein Cookie hat: Ohne Authorization-Kopf, den eine fremde Seite nicht setzen kann, kommt keine Anfrage durch.

Das Projekt bekommt deshalb eine zweite Filterkette. securityMatcher(…) legt fest, für welche Adressen sie gilt, @Order(1) lässt Spring Security sie zuerst prüfen. Alle anderen Anfragen landen wie bisher in der vorhandenen Kette, die jetzt @Order(2) trägt:

@Bean
@Order(1)
SecurityFilterChain verwaltungsfilter(HttpSecurity http) throws Exception {
    http.securityMatcher("/verwaltung/**", "/login", "/logout")
            .authorizeHttpRequests(regeln -> regeln
                    .requestMatchers("/verwaltung/**").hasRole("ORGA")
                    .anyRequest().permitAll())
            .formLogin(anmeldung -> anmeldung.defaultSuccessUrl("/verwaltung/hinweise"))
            .logout(abmeldung -> abmeldung.logoutSuccessUrl("/login?logout"));
    return http.build();
}

Ohne weitere Angaben gilt in dieser Kette der Standard von Spring Security: Sitzung nach der Anmeldung, CSRF-Schutz an, eine erzeugte Anmeldeseite unter /login. Wer ohne Anmeldung /verwaltung/hinweise aufruft, wird dorthin umgeleitet und nach der Anmeldung zurückgeschickt. Die Anmeldung prüft die vorhandene NutzerAnmeldung gegen die Tabelle nutzer. Heike Brinkmann bekommt dort ein Konto mit dem Pseudonym Sekretariat-HB und der Rolle ORGA, die bisher nur Andrea Wolf als Administratorin hat. Eine eigene Rolle für die Verwaltung wäre genauer; das Team notiert das für den Change Request mit Keycloak am Ende der Serie, der die Anmeldung der Verwaltung ohnehin ersetzt. Auch das Abmelden ist ein POST mit CSRF-Token, damit eine fremde Seite niemanden abmelden kann.

Formulare testen

Die Tests der Verwaltung sind @WebMvcTests wie in der Testreihe zur Web-Schicht: Controller, Template und Sicherheitskonfiguration laufen echt, der Service ist gemockt. @WithMockUser meldet für einen Test eine Person mit Rolle an, csrf() aus spring-security-test fügt der Anfrage ein gültiges Token hinzu:

@Test
@WithMockUser(roles = "ORGA")
void gueltigerHinweisWirdGespeichertUndUmgeleitet() { // Post-Redirect-Get
    assertThat(mvc.post().uri("/verwaltung/hinweise").with(csrf())
            .param("text", "Linie 61 fällt morgen aus.")
            .param("haltepunkt", "")
            .param("bis", MORGEN.toString())
            .param("wichtigkeit", "WARNUNG"))
            .hasStatus(HttpStatus.FOUND)
            .hasRedirectedUrl("/verwaltung/hinweise")
            .flash().containsEntry("meldung", "hinweis.gespeichert");
    then(hinweisService).should().legeAn("Linie 61 fällt morgen aus.", null, MORGEN, Wichtigkeit.WARNUNG);
}

@Test
@WithMockUser(roles = "ORGA")
void ungueltigeEingabenZeigenDieSeiteMitMeldungen() { // AK-MON-07.4
    assertThat(mvc.post().uri("/verwaltung/hinweise").with(csrf())
            .param("text", "   ")
            .param("bis", "2020-01-01")
            .param("wichtigkeit", "INFO"))
            .hasStatusOk()
            .hasViewName(HinweisVerwaltungController.SEITE)
            .bodyText()
            .contains("Bitte einen Hinweistext eingeben.", "Das Datum liegt in der Vergangenheit.",
                    "value=\"2020-01-01\"", "is-invalid");
    then(hinweisService).should(never()).legeAn(any(), any(), any(), any());
}

MORGEN ist LocalDate.now().plusDays(1), weil @FutureOrPresent gegen die echte Uhr prüft. Weitere Tests derselben Klasse decken ab: Ohne Anmeldung kommt 302 zur Anmeldeseite (AK-MON-07.3), mit Rolle NUTZER 403, ohne CSRF-Token 403 und kein Aufruf des Service, ein zu langer Text und ein Text statt Datum landen als Feldfehler im BindingResult (.model().extractingBindingResult("formular").hasFieldErrors("text", "bis")), und eine Flash-Meldung erscheint auf der Seite.

Für MON-08 prüft der FlurmonitorControllerTest mit gemocktem Service, dass eine Warnung als alert-warning mit dem Wort „Warnung:“ vor der ersten Tabelle steht (AK-MON-08.1) und dass auch das Fragment aktuell die Hinweise enthält. Ein HinweisServiceTest mit fester Uhr deckt die Regel für Haltepunkte ab (AK-MON-08.2), ein @DataJpaTest die Abfragen und ihre Sortierung gegen die Startdaten. Die vorhandenen @WebMvcTests des Flurmonitors brauchen ein @MockitoBean HinweisService, weil der Controller eine neue Abhängigkeit hat; ohne Vorgabe liefert der Mock eine leere Liste, und die bisherigen Erwartungen bleiben gültig.

Den Ablauf im Browser prüft Jonas Wolff zusätzlich von Hand: Anmelden, einen Hinweis speichern, mit F5 neu laden und in der Liste nachsehen, dass er nur einmal da ist. In Sprint 3 übernimmt das ein Selenium-Test.

Code-Stand

Den vollständigen Stand dieses Artikels findest Du im Ordner src/a08-hinweise-formulare des Backend-Repositorys. Neue Abhängigkeiten braucht er nicht: Bean Validation und Spring Security sind seit der Backend-Serie im Projekt. Gegenüber dem Stand a07 sind neu bzw. geändert:

Änderungen im Code-Stand a08 gegenüber a07
Datei Änderung
domain/Hinweis.java, domain/Wichtigkeit.java neu: Entity und Enum
repository/HinweisRepository.java neu: zwei abgeleitete Abfragen mit @EntityGraph
service/HinweisService.java neu: anlegen, löschen, alle, gültige je Monitor
web/verwaltung/HinweisFormular.java neu: Formular-Objekt mit Bean Validation
web/verwaltung/HinweisVerwaltungController.java neu: Seite, Anlegen mit Post-Redirect-Get, Löschen
web/FlurmonitorController.java Hinweise im Model
web/SicherheitsKonfiguration.java zweite Filterkette für /verwaltung/** mit Anmeldeformular und CSRF-Schutz
templates/flurmonitor.html Hinweise aus dem Model statt fest im Template
templates/verwaltung/hinweise.html neu: Verwaltungsseite
messages.properties, messages_en.properties Texte der Hinweise, der Verwaltung und der Validierung
data.sql Konto Sekretariat-HB (Passwort wie alle Demo-Konten), zwei Hinweise
Tests unter test/… neu: HinweisVerwaltungControllerTest, HinweisServiceTest, HinweisRepositoryTest; Flurmonitor-Tests mit @MockitoBean HinweisService, Smoke-Test erwartet die Umleitung zur Anmeldung
sprintplan.md, product-backlog.md (neben dem Projektordner) Sprint Backlog T36–T42, geänderte und neue Abnahmekriterien

Der Startcode für die Übungen mit den Mitfahrgesuchen liegt wie bisher im Unterordner uebung-mitfahrgesuche. Der Platzhalter-Service für die Gesuche kann dort jetzt ein Gesuch zurückziehen.

Nächster Schritt: Robustheit und Sprint Review

Nach Tag 9 erfüllen MON-07 und MON-08 die Definition of Done: Alle Abnahmekriterien sind durch Tests belegt, Heike Brinkmann hat in der Sichtprüfung einen Hinweis angelegt und ihn auf dem Monitor im Foyer erscheinen sehen. Im Sprint sind damit 18 Story Points fertig, 13 feste und 5 aus der Option. Offen ist noch MON-10, die verständliche Störungsanzeige. Im Artikel Robustheit und Betrieb bekommt der Monitor eigene Fehlerseiten, Logging und Health-Checks, und das Team schließt Sprint 2 mit Review und Retrospektive ab.

In den Übungsaufgaben zu diesem Artikel ordnest Du den Ablauf von Post-Redirect-Get, baust ein Formular zum Zurückziehen eines Mitfahrgesuchs und findest heraus, warum Hinweise doppelt gespeichert werden.

Leitfragen

  1. Warum nimmt der Controller ein eigenes Formular-Objekt entgegen und nicht die Entity Hinweis?

Das Formular-Objekt enthält genau die Felder des Formulars und nimmt auch ungültige Eingaben auf. Die Entity hätte dann Setter für Felder wie id, die niemand über das Formular setzen darf (Mass Assignment), und sie erwartet einen Haltepunkt statt einer Nummer. siehe Abschnitt „Formular-Objekt“

  1. Was bewirken th:object, th:field und th:errors?

th:object legt am <form> das Formular-Objekt fest. th:field="*{feld}" setzt id, name und den aktuellen Wert des Feldes, bei Radiobuttons und Auswahllisten auch checked bzw. selected. th:errors="*{feld}" gibt die Fehlermeldungen zu diesem Feld aus. siehe Abschnitt „Das Formular im Template“

  1. Wo muss das BindingResult stehen, und was passiert, wenn es fehlt?

Direkt hinter dem Parameter mit @Valid. Fehlt es, wirft Spring bei einem Validierungsfehler eine MethodArgumentNotValidException, und statt des Formulars mit Meldungen kommt eine Fehlerseite. siehe Abschnitt „Prüfen im Controller“

  1. Welches Problem löst Post-Redirect-Get?

Gibt der Controller nach dem Speichern direkt eine Seite zurück, ist die letzte Anfrage im Browser der POST. Neuladen oder „Zurück“ schickt ihn erneut, und der Datensatz wird doppelt gespeichert. Mit der Umleitung ist die letzte Anfrage ein GET, das gefahrlos wiederholt werden kann. siehe Abschnitt „Post-Redirect-Get“

  1. Warum leitet der Controller bei Fehlern nicht um?

Eine Umleitung beginnt einen neuen Request. Das Formular-Objekt mit den Eingaben und das BindingResult mit den Meldungen wären verloren. Ein wiederholter ungültiger POST schadet nicht, weil er erneut an der Prüfung scheitert. siehe Abschnitt „Post-Redirect-Get“

  1. Wie kommt die Meldung „Hinweis gespeichert“ über die Umleitung?

Als Flash-Attribut über RedirectAttributes.addFlashAttribute(…). Spring legt es kurz in der Sitzung ab, gibt es bei der nächsten Anfrage ins Model und löscht es danach. Ein normales Model-Attribut wird bei einer Umleitung verworfen. siehe Abschnitt „Post-Redirect-Get“

  1. Wogegen schützt das CSRF-Token, und warum braucht die REST-API es nicht?

Gegen Anfragen, die eine fremde Seite im Namen einer angemeldeten Person abschickt; der Browser sendet das Sitzungs-Cookie mit, das Token kennt die fremde Seite aber nicht. Die REST-API hat keine Sitzung und kein Cookie, sie verlangt bei jeder Anfrage einen Authorization-Kopf, den eine fremde Seite nicht setzen kann. siehe Abschnitt „Anmelden und CSRF-Schutz“

  1. Wie trennt die Sicherheitskonfiguration Verwaltung und API?

Mit zwei SecurityFilterChain-Beans. Die Kette für die Verwaltung gilt per securityMatcher nur für /verwaltung/**, /login und /logout und wird mit @Order(1) zuerst geprüft; sie nutzt Anmeldeformular, Sitzung und CSRF-Schutz. Alle übrigen Anfragen gehen an die bisherige Kette mit HTTP Basic ohne Sitzung. siehe Abschnitt „Anmelden und CSRF-Schutz“


Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Formulare mit Thymeleaf: Die Schulverwaltung legt Hinweise für den Flurmonitor an” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/flurmonitor-hinweise-formulare veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/java-springboot/Backend. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: