CodeGym /Kurse /Spring REST & MVC /Ausnahmebehandlung in Spring

Ausnahmebehandlung in Spring

Spring REST & MVC
Level 20, Lektion 1
Verfügbar

1. @ExceptionHandler innerhalb des Controllers

Der lokale try/catch hat bereits sein Problem gezeigt: Der error contract beginnt lokal zu leben, und der Happy-Path versinkt in der Fehlerbehandlung. Jetzt ist der nächste Schritt erforderlich – zu verstehen, mit welchem Mechanismus Spring eine Ausnahme überhaupt abfängt, anstatt manuelles catch zu verwenden, und wie diese Regel zunächst an einem einzelnen Controller gezeigt und dann auf die gesamte Web-Schicht angehoben werden kann.

Eine Ausnahme im Service bedeutet hier nicht „die Anwendung ist abgestürzt“. Es ist ein Signal: Das Szenario kann unter den aktuellen Bedingungen nicht fortgesetzt werden. Spring MVC kann ein solches Signal abfangen und in eine verwaltete HTTP-Antwort umwandeln – wenn wir eine Regel vorgeben.

Am einfachsten beginnt man mit der lokalen Variante. @ExceptionHandler ist eine Methode, die Spring aufruft, wenn während der Verarbeitung einer Anfrage eine Ausnahme eines bestimmten Typs ausgelöst wird. Der lokale @ExceptionHandler kann direkt innerhalb des @RestController platziert werden und gilt dann für alle Endpunkte dieses Controllers. Dies ist ein guter Lernschritt: Sie sehen die Mechanik in reiner Form und verstehen sofort, dass try/catch innerhalb des Endpunkts nicht mehr nötig ist.

In diesem Stadium ist es nicht wichtig, wie schön Sie ProblemDetail gestalten. Wichtig ist, dass eine einheitliche Regel entsteht: „Diese Exception wird in eine solche HTTP-Antwort umgewandelt“. Das ist der Beginn eines echten „error contracts“, auch wenn er vorerst noch minimalistisch ist.

Controller ohne try/catch

Zuerst lassen wir den Controller einfach dünn und lagern die Fehlerbehandlung in eine separate Methode derselben Klasse. So ist die Mechanik von @ExceptionHandler ohne Ablenkung sichtbar.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
// Basis-URL für alle Endpunkte des Controllers
@RequestMapping("/api/v1/tasks")
class TaskController {

    private final TaskService taskService;

    // Injizieren den Service über den Konstruktor (einfacher zu testen und Abhängigkeiten expliziter)
    TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

    @GetMapping("/{taskId}")
    // Controller beschreibt die Route: Eingabe holen -> Service aufrufen -> DTO zurückgeben
    // Fehler (z. B. TaskNotFoundException) werden hier nicht abgefangen: sie werden von der Exception-Handling-Schicht behandelt
    TaskDetailsResponse getById(@PathVariable String taskId) {
        return taskService.getById(taskId);
    }
}

Beachten Sie die wichtige „pädagogische Magie“: Es gibt sie nicht. Der Controller sagt einfach „gib mir die Aufgabe nach ID“. Wenn die Aufgabe nicht existiert, ist das eine Exception, und dann kommt die Fehlerbehandlungsschicht zum Einsatz.

2. Lokaler @ExceptionHandler im Controller

Jetzt fügen wir in denselben Controller eine Methode hinzu, die die Exception in eine Antwort „übersetzt“. Lokal sieht das sehr geradlinig aus: Wir fangen TaskNotFoundException, erstellen eine ProblemDetail und geben 404 zurück.

Diese Variante ist nicht die endgültige Architektur (wir werden bald auf Duplikate stoßen), aber zum Verständnis der Mechanik ist sie ideal: Sie sehen, wie Spring den Handler automatisch statt eines try/catch einsetzt, und das hört auf, wie schwarze Magie auszusehen.

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;

// Spring ruft diese Methode automatisch auf, wenn während der Anfrageverarbeitung eine TaskNotFoundException ausgelöst wird
@ExceptionHandler(TaskNotFoundException.class)
ResponseEntity<ProblemDetail> handleTaskNotFound(TaskNotFoundException ex) {
    // Erstellt den Antwortkörper nach dem Problem-Details-Standard
    ProblemDetail problem =
            ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());

    // Kurzer Titel für den Client (praktisch für UI/Logging auf Client-Seite)
    problem.setTitle("Task not found");

    // Geben HTTP-Status und Antwortkörper explizit zurück
    return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
}

Ja, hier geben wir ResponseEntity<ProblemDetail> zurück. Das ist am ehrlichsten: Der Status ist sichtbar, der Körper auch. Man könnte auch einfach ProblemDetail zurückgeben, aber am Anfang ist es praktisch, den Status explizit in ResponseEntity zu halten, damit Sie nicht das Gefühl haben, „Spring hat sich das irgendwo selbst gedacht“.

3. Wie @ExceptionHandler im Allgemeinen funktioniert

Es ist wichtig, diesen Mechanismus auf der Ebene „was passiert im Großen und Ganzen“ zu verstehen, sonst werden Sie später Symptome statt Ursachen behandeln. Wenn Spring MVC eine Controller-Methode ausführt, geschieht dies innerhalb seiner eigenen Request-Pipeline. Wenn währenddessen eine Exception ausgelöst wird, bricht Spring nicht einfach ab (wie ein Anfänger manchmal denkt), sondern versucht, einen Weg zu finden, die Exception in eine HTTP-Antwort umzuwandeln.

Schematisch kann man das so darstellen:

flowchart TD
    A[HTTP request] --> B[DispatcherServlet]
    B --> C[Controllermethode]
    C -->|ok| D[DTO-Antwort]
    C -->|throws Exception| E["Suche @ExceptionHandler"]
    E --> F[ProblemDetail + Status]
    F --> G[HTTP response]
    D --> G

An dieser Stelle reicht es, sich zwei Dinge zu merken. Erstens: @ExceptionHandler ist kein „separater Code, den Sie selbst aufrufen“, sondern eine Regel, die Spring automatisch anwendet. Zweitens: Wenn es keine Regel gibt, behandelt Spring den Fehler „wie es standardmäßig kann“, und das ist fast nie das, was Sie in einer Production-like-API haben möchten.

4. Grenzen des lokalen @ExceptionHandler

Der lokale Handler innerhalb eines Controllers ist ein gutes Lernfahrrad mit Stützrädern: Sie lernen darauf, in die Pedale zu treten. Aber wenn Sie versuchen, damit in die Berge zu fahren (d.h. die API weiterzuentwickeln), beginnt der Schmerz: Dieselben Fehler treten in verschiedenen Controllern auf, und Sie kopieren entweder Handler oder fangen an, einen „gemeinsamen Basis-Controller“ zu bauen (was bereits ein eigener Zoo von Anti-Patterns ist).

Im Task Tracker API werden wir unvermeidlich mehrere Controller haben. Auch wenn Sie bisher nur den TaskController implementiert haben, gibt es konzeptionell noch CommentController, AttachmentController, möglicherweise TagController. Aus Sicht des API-Clients ist es völlig normal zu erwarten, dass „not found“ für eine Aufgabe und „not found“ für einen Kommentar die gleiche Form haben. Aber wenn Sie die Handler lokal belassen, kann die Antwortform leicht auseinanderdriften: Mal ist der title „Task not found“, mal „Not Found“, mal vergessen Sie, detail zu setzen, und der Client lebt plötzlich in einer Welt voller Überraschungen.

Um dies praktisch zu spüren, vergleichen Sie zwei Situationen: In einem Controller schreiben Sie problem.setTitle("Task not found"), in einem anderen problem.setTitle("Not Found"). Scheinbar eine Kleinigkeit. Aber für den Client sind das bereits unterschiedliche Antworten, also unterschiedliche Verarbeitungszweige, unterschiedliche Tests, unterschiedliche Fehler. Und schon ist Ihre API „irgendwie einheitlich“, aber in Wirklichkeit hat sie mehrere verschiedene Fehlersprachen.

5. @RestControllerAdvice für die gesamte Web-Schicht

Der lokale @ExceptionHandler ist nützlich, um die Mechanik einmal ohne manuelles try/catch zu sehen. Aber sobald dieselben Fehler in mehreren Controllern auftreten, muss die Regel auf die Ebene der gesamten Web-Schicht angehoben werden. Zunächst reicht ein globaler Advice statt einer Reihe lokaler Handler. Vorerst ohne Vererbung von ResponseEntityExceptionHandler, ohne gemeinsamen Helper und ohne machine-readable code: Hier ist die Mechanik selbst wichtig.

Wenn Sie eine weitere Analogie brauchen: Der lokale @ExceptionHandler ist, wenn jeder kleine Laden selbst entscheidet, was mit Kundenbeschwerden zu tun ist. @RestControllerAdvice ist, wenn Sie eine einheitliche Support-Abteilung haben und der Kunde immer eine Antwort im gleichen Stil erhält, unabhängig davon, in welche Abteilung er geht.

Erster Entwurf des GlobalExceptionHandler

Wir erstellen einen minimalen globalen Handler. Wir „designen“ noch keinen super-idealen Error Contract, fügen keine komplexen Felder hinzu und veranstalten kein Architekturfestival. Unser Ziel ist es, den Mechanismus zu zeigen: Eine Exception eines bestimmten Typs wird stabil in eine ProblemDetail mit dem benötigten HTTP-Status umgewandelt.

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
// Ein gemeinsamer Exception-Handler für die REST-Web-Schicht
class GlobalExceptionHandler {

    @ExceptionHandler(TaskNotFoundException.class)
    // Wandelt die Domain-Exception in eine korrekte HTTP-Antwort um
    ResponseEntity<ProblemDetail> handleTaskNotFound(TaskNotFoundException ex) {
        // detail aus der Exception-Message übernehmen (für „erwartete“ Domain-Fehler ist das in Ordnung)
        ProblemDetail problem =
                ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());

        // title – eine kurze menschenlesbare Bezeichnung
        problem.setTitle("Task not found");

        // Setzen 404 explizit, damit es wie eine „Zuordnungstabelle“ lesbar ist
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);
    }
}

Danach bleibt Ihr TaskController völlig ohne Fehlerbehandler. Er wirft eine Exception – und die Support-Abteilung (Advice) fängt sie auf.

6. @ControllerAdvice und @RestControllerAdvice

Technisch könnte man auch @ControllerAdvice schreiben, und @RestControllerAdvice ist derselbe Mechanismus plus @ResponseBody. Aber da wir eine JSON-first-REST-API haben, behalten wir im Projekt @RestControllerAdvice als primäre Schreibweise. So haben wir nicht zwei gleichberechtigte Varianten derselben Fehlerbehandlungsschicht.

Wenn jemand @ControllerAdvice schreibt und separat den Body zurückgibt, ist das kein anderer Architekturpfad, sondern nur eine andere Form derselben Idee. Für unser Projekt ist es nützlicher, sofort eine einheitliche Schreibweise zu verwenden und die grundlegende Lösung nicht auf zwei Stile zu verteilen.

7. Auswahl des Handlers in Spring

Sobald Sie mehrere @ExceptionHandler-Methoden hinzufügen, stellt sich unweigerlich die Frage: „Wenn mehrere passen, wer gewinnt?“ Hier gibt es eine gute Nachricht: Spring versucht, sich vorhersagbar zu verhalten. Es bevorzugt den spezifischsten Handler. Das bedeutet: Wenn Sie einen Handler für TaskNotFoundException und einen allgemeinen Handler für RuntimeException haben, gewinnt bei einer TaskNotFoundException der erste.

Diese Logik ist nützlich, denn sonst könnten Sie versehentlich spezifische Fehler mit einem allgemeinen Handler abfangen und die Genauigkeit der Statuscodes verlieren.

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(TaskNotFoundException.class)
    // Der spezifischste Handler: wird für TaskNotFoundException aktiv
    ResponseEntity<ProblemDetail> handleTaskNotFound(TaskNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()));
    }

    @ExceptionHandler(RuntimeException.class)
    // „Sicherheitsnetz“: damit auch unerwartete Fehler ein kontrolliertes Format haben
    ResponseEntity<ProblemDetail> handleRuntime(RuntimeException ex) {
        // Wichtig: Bei unerwarteten Fehlern ist es nicht immer sicher, ex.getMessage() nach außen zu geben
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error"));
    }
}

Beachten Sie die Nuance: Der allgemeine Handler für RuntimeException ist ein „Sicherheitsnetz“, nicht der „Hauptpfad“. Er wird benötigt, damit auch unerwartete Fehler ein kontrolliertes Antwortformat haben. Wenn Sie jedoch alles in ihn hineinpacken (oder, schlimmer, einen einzigen Handler für Exception erstellen und sich damit zufriedengeben), verlieren Sie schnell die semantische Genauigkeit der API.

8. Überprüfung der Regeln mit .http

Wenn Sie Infrastrukturänderungen vornehmen (und ein globaler Fehlerbehandler ist Infrastruktur der Web-Schicht), ist es sehr nützlich, das Verhalten sofort manuell mit einer einfachen Anfrage zu überprüfen. Nicht weil wir Spring nicht vertrauen, sondern weil Sie das Ergebnis als Client sehen müssen. Sonst kann es leicht passieren, dass Sie einen schönen Handler schreiben, der gar nicht aufgerufen wird (ja, das kommt vor, besonders wenn Sie sich im Package/Scanning/Annotation geirrt haben).

Unten ist ein einfaches .http-Szenario: Wir fragen nach einer nicht existierenden Aufgabe.

### Task not found -> ProblemDetail
# Anfrage speziell an eine nicht existierende ID: wir möchten 404 + ProblemDetail sehen
GET http://localhost:8080/api/v1/tasks/00000000-0000-0000-0000-000000000000
Accept: application/problem+json

Wenn alles richtig konfiguriert ist, sehen Sie eine Antwort in etwa dieser Form (die konkreten Felder können abweichen, aber die Idee sollte übereinstimmen):

HTTP/1.1 404
Content-Type: application/problem+json
{
  "type": "about:blank",
  "title": "Task not found",
  "status": 404,
  "detail": "Task '00000000-0000-0000-0000-000000000000' not found",
  "instance": "/api/v1/tasks/00000000-0000-0000-0000-000000000000"
}

Hier ist nicht wichtig, dass type gleich about:blank ist (das ist der normale Standard), oder wie künstlerisch Sie title geschrieben haben. Wichtig ist, dass Sie einen vorhersagbaren Status 404 und einen vorhersagbaren Körper im Problem-Details-Format erhalten haben, und nicht eine HTML-Seite, keinen Stack Trace und keinen „einfachen String“.

9. Typische Fehler bei der Ausnahmebehandlung

Wenn Sie zum ersten Mal @ControllerAdvice einführen, kann es leicht passieren, dass das Projekt „formal korrekt“, aber unbequem wird. Denken Sie daran: Ziel ist nicht, möglichst viele Annotationen zu verkleben, sondern dass die API für den Client vorhersagbar bleibt und der Code für den Entwickler lesbar ist (insbesondere für Sie in zwei Wochen, wenn Sie vergessen haben, was Sie heute „genial“ erfunden haben).

Fehler Nr. 1: Nur einen Handler für Exception zu behalten und zu glauben, die Arbeit sei erledigt.
Technisch „funktioniert“ das: Sie fangen alles ein und geben immer eine ProblemDetail zurück. Aber letztendlich versteht der Client nicht mehr, was passiert ist. 404 wird zu 500, ein Konflikt von Geschäftsregeln wird zu „Unexpected error“, und die API verliert ihre semantische Genauigkeit. Der allgemeine Handler ist als Sicherheitsnetz notwendig, aber die Hauptszenarien sollten ihre eigenen spezifischen Zuordnungen haben.

Fehler Nr. 2: Geschäftslogik innerhalb von @ExceptionHandler zu schreiben.
Manchmal entsteht die Versuchung: „Da wir die Exception gefangen haben, lasst uns hier etwas korrigieren“. Zum Beispiel: „Wenn die Aufgabe nicht gefunden wurde, erstellen wir sie einfach“. Dies verwandelt den Fehlerbehandler in eine versteckte Stelle, die den Zustand ändert, und der Client erhält unvorhersehbares Verhalten. Der Handler sollte den Fehler in eine HTTP-Antwort übersetzen, nicht die Welt um sich herum verändern.

Fehler Nr. 3: Ein Teil der Regeln in den Controllern und ein Teil in @ControllerAdvice ohne Grund zu behalten.
Das passiert, wenn das Projekt stückweise wächst: Gestern haben Sie einen lokalen @ExceptionHandler erstellt, heute einen globalen hinzugefügt, aber den alten nicht entfernt. In der Folge wird ein Teil der Exceptions auf eine Weise behandelt, ein anderer Teil auf eine andere, und Sie erhalten wieder eine Ausbreitung des Vertrags. In einem Studienprojekt ist es besser, einen primären Weg zu wählen und ihn konsequent zu verfolgen.

Fehler Nr. 4: Rohe Exception-Messages für unerwartete Fehler zurückzugeben.
Bei Domain-Exceptions ist die Nachricht oft in Ordnung, um sie dem Client zu zeigen (wenn Sie sie „menschlich“ formulieren). Aber bei unerwarteten Fehlern (z. B. NullPointerException, IllegalStateException) dürfen Sie ex.getMessage() nicht nach außen geben: Dort könnten interne Informationen stehen, manchmal sogar Datenfragmente. Für unerwartete Fehler ist es besser, ein neutrales detail wie „Unexpected error“ zurückzugeben und die Ursache über Logs zu klären.

Fehler Nr. 5: @ControllerAdvice in eine Müllhalde mit Hunderten von Methoden ohne Struktur zu verwandeln.
Wenn es viele Exceptions gibt, kann der Advice wachsen. Das ist normal. Nicht normal ist, wenn er unlesbar wird. Auch wenn Sie noch keine „perfekte Architektur“ umsetzen, behalten Sie eine einfache Disziplin im Kopf: Die Handler sollten wie eine Zuordnungstabelle „Exception → Status → ProblemDetail“ lesbar sein, ohne unnötige Seiteneffekte und ohne Copy-Paste, das sich über drei Bildschirme erstreckt.

1
Aufgabe
Spring REST & MVC, Level 20, Lektion 1
Gesperrt
Lokaler @ExceptionHandler innerhalb des Controllers
Lokaler @ExceptionHandler innerhalb des Controllers
1
Aufgabe
Spring REST & MVC, Level 20, Lektion 1
Gesperrt
Globales `@RestControllerAdvice` für zwei Endpunkte
Globales `@RestControllerAdvice` für zwei Endpunkte
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION