Zum Hauptinhalt springen

AB 02 – Die zweite Beziehung

Die Situation

Der Katalog braucht Artikel. Die Einkaufsabteilung hat dazu einen weiteren Satz beigesteuert:

„Jeder Artikel kommt von genau einem Lieferanten. Ein Lieferant kann viele Artikel haben — oder noch gar keinen, wenn wir ihn gerade erst aufgenommen haben."

Zum Nachschlagen und Vertiefen

Dieses Arbeitsblatt zeigt 1:n an einem Beispiel. Alle drei Kardinalitäten mit ihren Varianten — auch n:m, das hier nicht vorkommt, in Projekten aber sehr wohl — stehen im Infoblatt Beziehungen mit JPA abbilden.

Lernziele

Nach Bearbeitung dieses Arbeitsblatts kannst du:

  • eine 1:n-Beziehung mit @ManyToOne und @OneToMany abbilden und mappedBy erklären
  • den Unterschied zwischen FetchType.LAZY und FetchType.EAGER beschreiben
  • eine Fehlermeldung sichtbar machen, die die Konsole von sich aus nicht zeigt
  • die Meldung Cannot lazily initialize collection … lesen und die Ursache benennen
  • begründen, warum EAGER das Problem nicht löst, sondern ein zweites erzeugt
  • erklären, warum eine JPA-Entität kein gutes Antwortformat ist

Das Klassendiagramm wächst

Supplier- id: Long- name: StringContact- id: Long- street: String- postcode: String- city: String- phone: String11contactArticle- id: Long- designation: String- price: BigDecimal- createdAt: LocalDateTime- updatedAt: LocalDateTime1*articles

Sieh dir die beiden Linien nebeneinander an. Sie sagen Verschiedenes:

zu Contactzu Article
Notationgefüllte RauteLinie mit Pfeilspitzen an beiden Enden
NameKompositionAssoziation
Navigierbarkeitnur vom Supplier zum Contact — die Anschrift kennt ihren Lieferanten nichtin beide Richtungen — der Lieferant kennt seine Artikel, jeder Artikel seinen Lieferanten
Multiplizität111*
Was passiert beim Löschen des Lieferanten?Die Anschrift verschwindet mitoffen — die Artikel gehören ihm nicht

Die letzte Zeile ist keine Kleinigkeit. Was mit den Artikeln geschehen soll, wenn ein Lieferant aus dem Katalog fliegt, ist eine fachliche Frage — und das Diagramm beantwortet sie bewusst nicht. Du beantwortest sie in Arbeitsblatt 04.

Aufgaben

Aufgabe 1 – Die Artikel-Entität

src/main/java/de/szut/webshop/article/Article.java
package de.szut.webshop.article;

import java.math.BigDecimal;
import java.time.LocalDateTime;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;

import de.szut.webshop.supplier.Supplier;

@Entity
@Table(name = "article")
@Getter
@Setter
@NoArgsConstructor
public class Article {

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

private String designation;

@Column(precision = 10, scale = 2)
private BigDecimal price;

@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;

@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;

@ManyToOne
@JoinColumn(name = "supplier_id", nullable = false)
private Supplier supplier;
}
BigDecimal statt double

double rechnet im Binärsystem und kann 0,10 nicht genau darstellen. Drei mal zehn Cent ergeben damit 0.30000000000000004. Bei einem Warenkorb summiert sich so etwas — deshalb ist Geld in Java ein BigDecimal.

precision = 10, scale = 2 sagt der Datenbank: zehn Stellen insgesamt, davon zwei hinter dem Komma. In der Tabelle wird daraus numeric(10,2).

ergänzen in Supplier.java — als weiteres Feld, unter contact
@OneToMany(mappedBy = "supplier", fetch = FetchType.LAZY)
private List<Article> articles = new ArrayList<>();

Dazu kommen vier neue Importe in Supplier.java:

import java.util.ArrayList;
import java.util.List;
import jakarta.persistence.FetchType;
import jakarta.persistence.OneToMany;
Was fetch festlegt

Hibernate holt einen Lieferanten aus der Datenbank. Was ist dann mit seiner Anschrift — kommt die mit? Klingt vernünftig. Und die Artikel, die auch an ihm hängen? Und was an den Artikeln hängt? Wären Artikel noch Warengruppen zugeordnet, ginge es dort weiter. Wann ist Schluss mit dem Nachladen?

Diese Frage muss jeder O/R-Mapper beantworten. Lädt er zu jedem Objekt sofort alles Verbundene mit, liegt schnell die halbe Datenbank im Speicher. Deshalb lässt Hibernate dich für jede Beziehung einzeln zwischen zwei Verhalten wählen:

FetchType.EAGERDie verbundenen Objekte werden sofort mitgeladen.
FetchType.LAZYSie werden erst geholt, wenn jemand sie tatsächlich anfasst.

Hier steht LAZY: Wer einen Lieferanten holt, bekommt seine Artikel nicht ungefragt dazu.

Vom Klassendiagramm zu den Tabellen

Was hier zu leisten ist, steht in einem Bild:

KlassendiagrammDatenbankSupplier- id: Long- name: String1*Article- id: Long- designation: String- price: BigDecimalabgebildet aufsupplieridnamecontact_id1Nordmetall GmbH7articleiddesignationpricesupplier_id41Wollpullover89.901Fremdschlüssel verweist auf Primärschlüssel

Bei einer 1:n-Beziehung wandert der Primärschlüssel der 1-Seite als Fremdschlüssel in die Tabelle der n-Seite. Das * im Klassendiagramm ist diese n-Seite: beliebig viele Artikel je Lieferant.

Was der Code davon bewirkt

@ManyToOne erzeugt in der korrespondierenden Tabelle die Spalte mit dem Fremdschlüssel. Sobald diese Annotation an einem Feld steht, weiß JPA: In meine Tabelle gehört ein Fremdschlüssel auf die andere — in diesem Fall gehört in die Tabelle article der Fremdschlüssel supplier_id. Der Name ist aus der Sicht der Klasse gelesen, in der die Annotation steht — viele Artikel zu einem Lieferanten. In Supplier steht für dieselbe Beziehung deshalb @OneToMany.

@JoinColumn konfiguriert diese Spalte. Ohne sie bekäme die Spalte einen Namen nach einer Standardregel; mit ihr legst du ihn ausdrücklich fest. nullable = false macht daraus ein NOT NULL — und das ist die 1 aus dem Klassendiagramm: Jeder Artikel muss einen Lieferanten haben.

Und was macht dann mappedBy?

In der Datenbank: nichts. Kein Feld, keine Spalte, keine Bedingung.

mappedBy ist eine Auskunft an Hibernate über den Java-Code, und sie lautet als Verneinung:

Dieses Feld hier speichert nichts. Es ist das Spiegelbild einer Beziehung, die schon woanders abgebildet ist — nämlich vom Feld supplier in Article.

Article.java@ManyToOne@JoinColumn(name = "supplier_id")private Supplier supplier;articleiddesignationsupplier_id41Wollpullover1nennt die SpalteSupplier.java@OneToMany(mappedBy = "supplier")private List<Article> articles;supplieridnamecontact_id1Nordmetall GmbH7keine Spalte für die Artikelnennt das Feld

Warum muss man das überhaupt sagen? Weil Java es nicht sehen kann. In Supplier steht List<Article> articles, in Article steht Supplier supplier. Für den Compiler sind das zwei völlig unabhängige Felder. Dass ein Mensch sofort erkennt, dass beide dieselbe Sache aus zwei Richtungen beschreiben, hilft Hibernate nicht. Es muss es gesagt bekommen.

Was passiert, wenn man es nicht sagt. Dann glaubt Hibernate an zwei getrennte Beziehungen und braucht für die zweite einen Speicherort. In der Lieferantentabelle kann sie nicht liegen — dort steht pro Zeile nur ein Wert, aber ein Lieferant hat viele Artikel. Also legt Hibernate eine dritte Tabelle an, supplier_articles, mit zwei Fremdschlüsseln. Dieselbe Beziehung wäre damit zweimal gespeichert, an zwei Orten, die auseinanderlaufen können. Genau deshalb ist mappedBy da.

Und wozu dann die Gegenrichtung überhaupt? Sie bringt den Weg zurück im Java-Code: supplier.getArticles() liefert die Artikel dieses Lieferanten.

Diese Liste ist nirgends gespeichert. Hibernate stellt sie erst her, wenn du sie anfasst — mit genau der Abfrage, die du sonst selbst schreiben müsstest:

select * from article where supplier_id = 1

Das ist dieselbe Spalte, in die die andere Seite schreibt. Beide Richtungen arbeiten mit article.supplier_id, nur von verschiedenen Enden her:

Aufruf in JavaWas in der Spalte passiert
article.setSupplier(nordmetall)trägt den Wert 1 ein
supplier.getArticles()sucht alle Zeilen, in denen 1 steht

Deshalb braucht die Gegenrichtung keine eigene Spalte: Supplier.articles ist keine gespeicherte Liste, sondern ein Suchergebnis.

Ohne dieses Feld wäre die Beziehung einseitig. Vom Artikel käme man zum Lieferanten, umgekehrt nicht — dann müsste man die Artikel jedes Mal beim ArticleRepository erfragen, und das Objekt supplier selbst wüsste nichts von ihnen.

Aufgabe 2 – Erst schätzen

Du hast am SupplierController nichts geändert. Die drei Endpunkte aus Arbeitsblatt 01 stehen unverändert da.

Die naheliegende Erwartung: Es ändert sich nichts, denn es gibt ja keine Artikel.

Aufgabe 3 – Ausprobieren

Beide antworten mit derselben Meldung — nur das Feld instance unterscheidet sich:

{
"detail": "Failed to write request",
"instance": "/api/v1/suppliers",
"status": 500,
"title": "Internal Server Error"
}

Der POST funktioniert weiterhin. Nur die beiden GET-Endpunkte sind kaputt — ohne dass eine einzige Zeile in ihnen geändert wurde, und ohne dass ein Artikel existiert.

Und die Konsole schweigt

Sieh in der Konsole nach. Dort steht kein Stacktrace, keine Ausnahme, nichts. Nur die SQL-Abfrage, die sauber durchgelaufen ist.

Das ist der unangenehmste Fall beim Suchen: Die Anwendung weiß, was schiefgelaufen ist, sagt es aber niemandem.

Aufgabe 4 – Die Meldung sichtbar machen

Spring hat die Ursache — sie wird nur auf einer Stufe protokolliert, die standardmäßig nicht ausgegeben wird.

logging.level.org.springframework.web=DEBUG

Jetzt steht dort:

Resolved [org.springframework.http.converter.HttpMessageNotWritableException:
Could not write JSON: Cannot lazily initialize collection of role
'de.szut.webshop.supplier.Supplier.articles' with key '1' (no session)]
Die Ausnahme hat einen Namen — er steht nur nicht da

Was du siehst, ist die HttpMessageNotWritableException von Spring: „Ich konnte die Antwort nicht schreiben." Dahinter steckt aber Hibernates LazyInitializationException, und die Meldung nach dem Doppelpunkt ist ihre.

Der Name taucht in der Konsole nicht auf, weil Spring nur den Text der Ursache übernimmt. Zum Nachschlagen im Netz ist er trotzdem der richtige Suchbegriff — er ist einer der bekanntesten Fehler im Umgang mit JPA.

Was da passiert ist

FetchType.LAZY heißt: Hibernate lädt die Artikel nicht mit, wenn es den Lieferanten lädt. Statt der Liste setzt es einen Platzhalter ein, der so tut, als wäre er die Liste. Fragt jemand ihn nach seinem Inhalt, geht er zur Datenbank und holt ihn nach.

Das ist eine gute Idee — solange jemand da ist, der zur Datenbank gehen kann. Diese Verbindung heißt Session, und sie wird geschlossen, sobald deine Methode im Controller fertig ist.

Danach kommt Jackson und wandelt das Ergebnis in JSON um. Es geht Feld für Feld durch den Lieferanten, kommt bei articles an, fragt den Platzhalter nach seinem Inhalt — und der hat niemanden mehr, den er fragen könnte. Genau das steht in der Meldung: (no session).

Dass noch gar keine Artikel existieren, spielt keine Rolle. Der Platzhalter weiß es nicht; um es zu wissen, müsste er nachsehen.

Und warum geht es in die andere Richtung gut?

Article.supplier ist auch eine Beziehung — trotzdem macht sie keine Schwierigkeiten. Der Grund steht nicht in deinem Code, sondern in der Vorgabe von JPA:

AnnotationVorgabe, wenn nichts dabeisteht
@ManyToOne, @OneToOneEAGER — wird sofort mitgeladen
@OneToMany, @ManyToManyLAZY — wird erst bei Bedarf geholt

Merksatz: Eines ist schnell geholt, viele nicht.

Dein @ManyToOne bei Article.supplier steht ohne fetch-Angabe da — also EAGER. Der Lieferant ist deshalb immer schon geladen, sobald ein Artikel geladen wurde.

Das ist eine stille Abhängigkeit, und du solltest sie kennen: Schreibst du dort fetch = FetchType.LAZY — was viele Anleitungen im Netz aus guten Gründen empfehlen —, dann bricht in Arbeitsblatt 03 die Umwandlung eines Artikels in ein DTO mit derselben Ausnahme wie hier. Die Ursache ist dieselbe: Dort geschieht die Umwandlung im Controller, ohne offene Session.

Ab Arbeitsblatt 04 ist das kein Thema mehr — dann liegt die Umwandlung im Service, und der arbeitet in einer Transaktion. Alles Weitere im Infoblatt Beziehungen mit JPA abbilden.

Warum ist die Verbindung schon zu?

Wegen einer Zeile, die du in Arbeitsblatt 01 selbst eingetragen hast (und die schon im Gästebuch stand):

spring.jpa.open-in-view=false

Mit true bliebe die Session offen, bis die Antwort vollständig geschrieben ist — der Fehler träte nicht auf. Genau das ist der Grund, warum diese Einstellung so verbreitet ist, und zugleich der Grund, warum sie hier auf false steht: Sie verdeckt einen Entwurfsfehler, statt ihn zu beheben. Datenbankzugriffe während des Schreibens der Antwort sind nicht das, was man will — man sieht sie nur nicht mehr.

Aufgabe 5 – Die naheliegende Reparatur

Wenn das Problem ist, dass die Artikel nicht geladen wurden — dann lädt man sie eben mit.

@OneToMany(mappedBy = "supplier", fetch = FetchType.EAGER)
private List<Article> articles = new ArrayList<>();

Status 200. Die Antwort sieht sauber aus:

[
{
"articles": [],
"contact": { "city": "Bremen", "id": 1, "phone": "0421 123456",
"postcode": "28217", "street": "Hafenstr. 12" },
"id": 1,
"name": "Nordmetall GmbH"
}
]

Sieht nach einer Lösung aus.

SpalteWert
idleer lassen — die Datenbank vergibt sie
designationSchraube M6
price0.45
created_atden aktuellen Zeitpunkt
updated_atdenselben Zeitpunkt
supplier_iddie id deines Lieferanten, also 1

Die Änderung wird erst mit dem Knopf Submit (Pfeil nach oben, oder Strg+Enter) in die Datenbank geschrieben.

Warum von Hand und nicht über einen Endpunkt?

Weil es für Artikel noch keinen gibt. Den baust du im nächsten Arbeitsblatt — und bis dahin ist das Datenbankfenster der kürzeste Weg, um an Daten zu kommen. Genau dafür ist es da.

Was zurückkommt

Der Status ist 200 OK. Und trotzdem ist alles falsch:

Gemessen an dieser AntwortWert
Statuscode200 OK
Länge der Antwort19 438 Zeichen
Größte Verschachtelungstiefe500
Wie oft "articles" vorkommt167 mal
Wie oft der Name Nordmetall GmbH vorkommt0 mal
Ist es gültiges JSON?nein — mitten im Satz abgeschnitten

Die letzten beiden Zeilen sind die entscheidenden. Die Antwort ist fast zwanzigtausend Zeichen lang und enthält die eine Angabe nicht, um die es ging. Und sie ist kein gültiges JSON: Kein Client kann sie einlesen.

In der Konsole steht:

Could not write JSON: Document nesting depth (501) exceeds the maximum allowed (500)

Jackson hat bei Tiefe 500 abgebrochen — mitten im Schreiben. Da der Statuscode 200 zu diesem Zeitpunkt längst gesendet war, konnte niemand ihn noch zurücknehmen.

SupplierArticlearticlessupplierzwei Felder, ein KreisSupplierarticles[0]supplierarticles[0]supplierAbbruch bei Tiefe 500

Zwei Felder zeigen aufeinander. In Java ist das harmlos: supplier.getArticles().get(0).getSupplier() ist wieder derselbe Lieferant, und der Speicher enthält ihn nur einmal. JSON kann das nicht — es kennt keine Verweise, nur Verschachtelung. Also schreibt Jackson den Lieferanten erneut hin, und darin wieder die Artikel, und darin wieder den Lieferanten.

Die Erkenntnis

LAZY und EAGER sind nicht die Frage. Beide sind Antworten auf wann wird geladen — und keine von beiden ändert etwas daran, was in der Antwort steht.

Der Fehler steckt in den Verweisen selbst. Der Lieferant enthält seine Artikel, jeder Artikel enthält wieder seinen Lieferanten. Solange beide Felder in der Antwort landen, entsteht daraus der Kreis: Mit LAZY bricht die Umwandlung davor ab, mit EAGER läuft sie hinein.

Zu lösen ist das nicht am Ladezeitpunkt, sondern an der Antwort: Was der Client bekommt, darf die Rückverweise nicht enthalten. Die Entität enthält sie zwangsläufig — sie ist das Abbild einer Tabellenzeile und braucht jeden Fremdschlüssel und jede Gegenrichtung. Was ein Client sehen soll, ist eine andere Frage, und die stellt das nächste Arbeitsblatt.

Ließe sich der Zirkel nicht einfach abschalten?

Doch. Mit @JsonManagedReference auf Supplier.articles und @JsonBackReference auf Article.supplier — oder schlichter mit @JsonIgnore auf einer der beiden Seiten — bricht Jackson den Zirkel beim Schreiben ab, ohne dass ein einziges Java-Feld verschwindet. Der Absturz aus Aufgabe 5 wäre damit weg. Das ist keine Krücke, sondern der Standardtreffer, wenn man genau diesen Fehler sucht.

Repariert ist damit nur der Absturz. Die Antwort bleibt trotzdem die Tabellenzeile: Jede Spalte, die article später bekommt, taucht ungefragt im JSON auf — niemand muss sie freigeben, sie ist einfach da.

Das ist dieselbe Art Problem wie Kopplung aus der Lernsituation zur Welt von Zuul im ersten Lehrjahr — nur zwischen Schichten statt zwischen Klassen: Datenbanktabelle und Client-Antwort sind ein und dasselbe Objekt. Ändert sich die Tabelle, ändert sich die Antwort mit, ob gewollt oder nicht.

Aufgabe 6 – Aufräumen

Die beiden GET-Endpunkte liefern damit wieder 500. Das ist in Ordnung — sie werden im nächsten Arbeitsblatt ersetzt.

Die Zeile ist es wert, dass du sie dir merkst

logging.level.org.springframework.web=DEBUG ist der Griff, mit dem du eine 500 ohne Stacktrace zum Sprechen bringst. Er kostet nichts und dauert eine Minute. Im Betrieb bleibt er aus — dort erzeugt er zu viel Ausgabe.

Aufgabe 7 – Testfälle

IDBeschreibungVorbedingungTestschritteErwartetes ErgebnisErgebnis
TF-01Die dritte Tabelle entstehtArticle wurde angelegt, Anwendung neu gestartet
  1. Im Datenbankfenster den Baum aktualisieren
Es gibt eine Tabelle article mit einer Spalte supplier_id.
TF-02mappedBy verhindert eine vierte TabelleDie Beziehung wurde beidseitig eingetragen
  1. Alle Tabellen im Schema public auflisten
Es gibt genau drei Tabellen. Keine supplier_articles.
TF-03Der Fremdschlüssel liegt beim ArtikelTabellen wurden angelegt
  1. Spalten von article ansehen
  2. Spalten von supplier ansehen
article.supplier_id ist Fremdschlüssel und nicht eindeutig — anders als supplier.contact_id. Das ist der Unterschied zwischen 1:n und 1:1.
TF-04LAZY: der Endpunkt bricht ohne einen einzigen Artikelfetch = LAZY, Tabelle article ist leer
  1. GET auf /api/v1/suppliers
Status 500 mit Failed to write request.
TF-05Die Ursache wird erst mit DEBUG sichtbarTF-04 wurde ausgeführt
  1. Konsole ohne DEBUG durchsehen
  2. DEBUG-Zeile ergänzen
  3. Aufruf wiederholen
Ohne die Zeile steht nichts in der Konsole. Mit ihr steht dort Cannot lazily initialize collection of role '…Supplier.articles'.
TF-06EAGER ohne Artikel sieht wie eine Lösung ausfetch = EAGER, Tabelle article ist leer
  1. GET auf /api/v1/suppliers
Status 200, und "articles" ist eine leere Liste.
TF-07EAGER mit einem Artikel liefert 200 und trotzdem Unsinnfetch = EAGER, genau ein Artikel in der Tabelle
  1. GET auf /api/v1/suppliers
  2. Antwortlänge ansehen
  3. nach dem Lieferantennamen suchen
Status 200, rund 19 000 Zeichen, und der Name des Lieferanten kommt kein einziges Mal vor.