TS
Thomas Schmitz

Freiberuflicher IT-Berater, Softwareentwickler & DevOps. Praxisnahe Artikel zu API-Design, Architektur und Cloud-Workflows.

Serie: Spring Batch · Teil 5 von 12

Spring Batch Teil 5: Reader/Writer-Strategien (CSV → DB)

FlatFileItemReader und JdbcBatchItemWriter sauber konfigurieren, inklusive Mapping, Fehlerbildern und State.

Praxisnah Checkliste

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

Mehr Beiträge aus dem Blog.