Jetzt wird es konkret. Wir lesen CSV, validieren und schreiben nach H2. In diesem Teil geht es nicht um ein Beispiel, sondern um sauberes, restartbares I/O. Ein Reader liest die Rohdaten, ein Processor bereinigt oder transformiert sie, ein Writer schreibt das Ergebnis. Die Wahl von Reader und Writer entscheidet, ob dein Job in Produktion zuverlässig läuft oder ob du bei jedem Fehler Daten doppelt schreibst.
Zielbild
Nach diesem Kapitel hast du einen FlatFileItemReader mit stabilem Mapping, einen JdbcBatchItemWriter für performante Inserts und einen Blick auf Statefulness, Restartability und typische Fehlerbilder.
CSV-Format unseres Beispiels
Wir halten das Format bewusst simpel, aber realistisch. Es nutzt UTF-8,
Semikolon als Delimiter (Trennzeichen), eine Header-Zeile mit Spaltennamen
und Datumsfelder im ISO-Format (YYYY-MM-DD).
Header und Beispielzeile:
customer_id;first_name;last_name;email;birth_date;signup_date
123;Max;Mustermann;max@example.com;1990-01-15;2025-01-10
CSV in der Realität: Quotes, Trennzeichen, Sonderfälle
CSV ist selten sauber. Typische Abweichungen:
- Trennzeichen im Feld (z. B. Firmenname mit Semikolon)
- Quotes um Felder, teils gemischt
- Leere Zeilen oder zusätzliche Spalten
- BOM/Encoding-Probleme (Umlaute kaputt, Header falsch erkannt)
Für solche Fälle brauchst du einen Reader, der Quotes kennt und Spalten sauber tokenisiert:
return new FlatFileItemReaderBuilder<RawCustomerRow>()
.name("customerFileReader")
.resource(inputFile)
.linesToSkip(1)
.delimited()
.delimiter(";")
.quoteCharacter('"')
.names("customer_id", "first_name", "last_name",
"email", "birth_date", "signup_date")
.fieldSetMapper(new CustomerFieldSetMapper())
.build();
Wenn dein Input Quotes oder Sonderzeichen enthält, muss der Reader das Format kennen. Sonst entstehen stille Spaltenverschiebungen.
FlatFileItemReader: stabil lesen
Ein Reader ist dann professionell, wenn er restartable ist, Positionsdaten im ExecutionContext hält, robust auf Formatabweichungen reagiert und sein Mapping explizit beschreibt.
Minimaler Reader für unser CSV:
Ausschnitt: Bean-Methode in einer @Configuration-Klasse.
import org.springframework.batch.infrastructure.item.file.FlatFileItemReader;
import org.springframework.batch.infrastructure.item.file.builder.FlatFileItemReaderBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.core.io.Resource;
@Bean
public FlatFileItemReader<RawCustomerRow> customerFileReader(
@Value("${import.input-file}") Resource inputFile) {
return new FlatFileItemReaderBuilder<RawCustomerRow>()
.name("customerFileReader")
.resource(inputFile)
.linesToSkip(1) // Header
.delimited()
.delimiter(";")
.names("customer_id", "first_name", "last_name",
"email", "birth_date", "signup_date")
.fieldSetMapper(new CustomerFieldSetMapper())
.build();
}
Wichtig: linesToSkip(1) überspringt den Header sauber. names(...)
erzwingt eine feste Spaltenordnung. Das Parsing der Datumsfelder passiert im
FieldSetMapper.
Strikt vs. tolerant: Fehler sichtbar machen
Delimited-Tokenization ist standardmäßig streng: Wenn die Anzahl der Spalten nicht passt, wirft der Reader eine Exception (z. B. falscher Delimiter oder zusätzliche Spalten). Das ist gut für Datenqualität, aber kann bei instabilen Quellen zu häufigen Abbrüchen führen.
Entscheide bewusst:
- Strikt: Fehler früh sichtbar, weniger Datenrisiko.
- Tolerant: mehr Durchlauf, aber Risiko stiller Fehler.
In der Praxis ist striktes Parsing mit klaren Skip-Regeln oft die bessere Kombination.
FieldSetMapper: Parsing mit klaren Fehlern
Der Mapper ist der richtige Ort für Datums-Parsing im ISO-Format, Typ-Konvertierung und Input-Validierung mit klaren Fehlermeldungen.
import java.time.LocalDate;
import org.springframework.batch.infrastructure.item.file.mapping.FieldSetMapper;
import org.springframework.batch.infrastructure.item.file.transform.FieldSet;
public class CustomerFieldSetMapper implements FieldSetMapper<RawCustomerRow> {
@Override
public RawCustomerRow mapFieldSet(FieldSet fieldSet) {
return new RawCustomerRow(
fieldSet.readLong("customer_id"),
fieldSet.readString("first_name"),
fieldSet.readString("last_name"),
fieldSet.readString("email"),
LocalDate.parse(fieldSet.readString("birth_date")),
LocalDate.parse(fieldSet.readString("signup_date"))
);
}
}
Fehlerbehandlung: Wenn ein Feld nicht parsebar ist, wirft der Mapper eine
Exception. Beispiel: Aus 1990-01-15 wird ein LocalDate, aus foo
wird ein Fehler. In Teil 7 konfigurieren wir, ob das ein Skip oder ein Fail
ist.
JdbcBatchItemWriter: performantes Schreiben
Der Writer sammelt Items und schreibt sie in Batches:
Ausschnitt: Bean-Methode in einer @Configuration-Klasse.
import javax.sql.DataSource;
import org.springframework.batch.infrastructure.item.database.JdbcBatchItemWriter;
import org.springframework.batch.infrastructure.item.database.builder.JdbcBatchItemWriterBuilder;
import org.springframework.context.annotation.Bean;
@Bean
public JdbcBatchItemWriter<Customer> customerWriter(
DataSource businessDataSource) {
return new JdbcBatchItemWriterBuilder<Customer>()
.dataSource(businessDataSource)
.sql("""
MERGE INTO customers
(customer_id, first_name, last_name, email, birth_date, signup_date)
KEY (customer_id)
VALUES (:customerId, :firstName, :lastName, :email, :birthDate, :signupDate)
""")
.beanMapped()
.build();
}
Wichtig: beanMapped() nutzt Property-Namen als Parameter. MERGE
mit KEY (customer_id) macht den Writer idempotent: existiert der Datensatz
bereits, wird er aktualisiert statt doppelt eingefügt. Idempotent heißt: Du
kannst denselben Lauf wiederholen, ohne dass doppelte Daten entstehen. Bei
H2 funktioniert MERGE direkt; für PostgreSQL nutzt du
INSERT ... ON CONFLICT, für Oracle MERGE INTO ... USING.
Fehlerreporting: Zeilennummern nutzen
Wenn das Parsing scheitert, enthält die Exception oft die Zeilennummer und die Rohzeile. Das ist Gold für Debugging und Datenqualität. Im Logging solltest du diese Information immer mit ausgeben, damit du problematische Datensätze schnell findest.
Statefulness & Restartability
Reader und Writer müssen restartbar sein. Der Reader speichert Positionsdaten im ExecutionContext, der Writer sollte idempotent arbeiten oder Duplikate erkennen. Stateful heißt hier: Der Reader merkt sich seinen Fortschritt (z. B. bei Zeile 12.345) und setzt dort wieder an.
Wenn du im Fehlerfall den Chunk neu verarbeitest, darf kein doppelter Insert entstehen. Der Writer muss dazu entweder Upserts (Insert oder Update) machen oder eine Duplikat-Strategie nutzen, etwa Unique-Constraint plus Skip.
Wichtig bei DB-Readern: Wenn die Quelle zwischen Run und Restart mutiert, ist eine reine Positionsmarke nicht genug. Dann brauchst du Snapshot/Staging (Staging = Zwischentabelle) oder einen Cutoff-Parameter (feste Zeitgrenze), sonst ist der Restart nicht deterministisch.
Datenquelle bestimmt Restart-Semantik
Die Wahl der Datenquelle ist keine Nebenentscheidung. Sie bestimmt, ob ein Restart fachlich überhaupt stabil sein kann. Ein File-Import ist in der Regel immutable: gleiche Datei, gleiche Reihenfolge, gleiche Items. Ein ExecutionContext mit Positionsdaten reicht hier oft aus.
Eine Datenbank ist dagegen mutabel: neue Zeilen, Updates, Deletes, geänderte Sortierreihenfolgen. Ein DB-Reader, der beim Restart einfach an der Position weitermacht, sieht unter Umständen andere Daten als im ersten Run. Ergebnis: Lücken, Duplikate oder fachlich inkonsistente Reports.
Deshalb gilt: Bei DB-Quellen brauchst du eine stabile Input-Grenze, z. B. Snapshot/Staging, Cutoff mit deterministischer Sortierung oder ein Claim-Check-Pattern. Ohne diese Stabilisierung ist Restartability technisch möglich, aber fachlich nicht verlässlich. Konkrete Strategien und Parameter-Design dazu findest du in Teil 6: JobParameters & Restartability.
File-Lifecycle: Input, Processing, Done, Failed
Ein unterschätztes Thema ist der Datei-Lifecycle. Ein robustes Muster:
inbox/(neue Dateien)processing/(aktuell in Arbeit)done/(erfolgreich verarbeitet)failed/(nach Fehlern)
Dateien solltest du atomar verschieben, sobald der Job startet, um doppelte Runs zu vermeiden. So stellst du sicher, dass ein Re-Run nicht versehentlich die falsche Datei liest.
Typische Fehlerbilder
Typische Fehlerbilder sind ein falscher Delimiter mit leeren Feldern und falschem Mapping. Ein nicht übersprungener Header führt zu Parsing-Fehlern in Zeile 1. Falsch formatierte Datumsfelder werfen Mapper-Exceptions, Duplicate Keys brechen die Transaktion ab.
Anti-Patterns
Reader ohne explizite Spaltennamen erzeugen stilles Drift-Risiko. Writer ohne Idempotenz schreiben bei jedem Retry Duplikate. Parsing im Processor statt im Mapper macht Fehler zu spät sichtbar. CSV-Parsing ohne Encoding-Angabe zerstört Umlaute.
Kurzfazit
Reader und Writer sind die kritischen I/O-Grenzen deines Jobs. FlatFileItemReader und JdbcBatchItemWriter sind solide Defaults. Idempotenz beginnt im Writer, nicht erst im Monitoring.
Im nächsten Teil kümmern wir uns um JobParameters und Restartability: wie du Re-Runs sauber steuerst und warum Parameter-Design über Stabilität entscheidet.
Alle Teile der Serie: Serie: Spring Batch