Aller au contenu

TP guidé — Spring Batch : importer des cours de bourse depuis un fichier plat et Excel

1. Objectif

Vous allez construire une application Spring Boot permettant d’importer des données de marché depuis 2 sources :

  1. un fichier plat texte à positions fixes
  2. un fichier Excel .xlsx

L’application devra :

Le contexte est volontairement bancaire : vous importez des cours de bourse de titres cotés. Je ne sais pas si cela correspond à votre réalité.


2. Ce que vous allez décourvrir

À la fin du TP, vous saurez :


3. Sujet métier

Votre équipe reçoit chaque jour des fichiers de cours de bourse. Chaque ligne représente le cours d’un titre à une date donnée.

Exemple métier :

Donnée Exemple
ISIN FR0000120271
Ticker TTE
Marché XPAR
Date de cotation 2026-01-02
Ouverture 53.42
Plus haut 54.10
Plus bas 52.98
Clôture 53.90
Volume 1845000
Devise EUR

Règles de validation :


4. Architecture du projet

Le projet fourni contient bourse-batch-starter à compléter

Structure simplifiée :

bourse-batch
├── data
│   └── input
│       ├── cours-bourse-fixed.txt
│       └── cours-bourse.xlsx
├── src/main/java/fr/formation/boursebatch
│   ├── batch
│   │   ├── BatchConfig.java
│   │   ├── BatchScheduler.java
│   │   ├── CoursItemWriter.java
│   │   ├── CoursValidationProcessor.java
│   │   ├── ExcelCoursItemReader.java
│   │   ├── ImportSkipListener.java
│   │   └── TxtCoursLineMapper.java
│   ├── domain
│   │   ├── CoursBoursier.java
│   │   ├── RejetImport.java
│   │   └── TitreBoursier.java
│   ├── dto
│   │   ├── CoursBoursierDto.java
│   │   └── CoursBoursierViewDto.java
│   ├── repository
│   ├── service
│   └── web
└── src/main/resources
    ├── application.yml
    ├── application-postgres.yml
    └── templates/index.html

5. Dépendances importantes

Le fichier pom.xml contient notamment :

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-batch</artifactId>
</dependency>

<dependency>
    <groupId>org.apache.poi</groupId>
    <artifactId>poi-ooxml</artifactId>
    <version>5.3.0</version>
</dependency>

spring-boot-starter-batch apporte Spring Batch.

poi-ooxml permet de lire les fichiers Excel modernes .xlsx.


6. Configuration H2

Dans application.yml :

spring:
  datasource:
    url: jdbc:h2:mem:bourse_batch;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
    username: sa
    password:
  h2:
    console:
      enabled: true
      path: /h2-console
  batch:
    jdbc:
      initialize-schema: always
    job:
      enabled: false

Point important :

spring.batch.job.enabled: false

Cela évite que les jobs se lancent automatiquement au démarrage.

Nous préférons les déclencher depuis un bouton ou un scheduler pour que les apprenants voient clairement ce qui se passe.


7. Modèle de données

7.1 Entité TitreBoursier

Un titre représente l’instrument financier.

Exemples : TotalEnergies, BNP Paribas, Apple, Microsoft.

@Entity
@Table(name = "titre_boursier")
public class TitreBoursier {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String isin;
    private String ticker;
    private String marche;
    private String libelle;
}

7.2 Entité CoursBoursier

Un cours représente la valeur d’un titre pour une date donnée.

@Entity
@Table(name = "cours_boursier")
public class CoursBoursier {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(optional = false)
    private TitreBoursier titre;

    private LocalDate dateCotation;
    private BigDecimal ouverture;
    private BigDecimal plusHaut;
    private BigDecimal plusBas;
    private BigDecimal cloture;
    private Long volume;
    private String devise;
}

8. Pourquoi utiliser un DTO ?

Le DTO représente la ligne importée avant sauvegarde.

public record CoursBoursierDto(
        String isin,
        String ticker,
        String marche,
        String libelle,
        LocalDate dateCotation,
        BigDecimal ouverture,
        BigDecimal plusHaut,
        BigDecimal plusBas,
        BigDecimal cloture,
        Long volume,
        String devise
) {}

Pourquoi ne pas lire directement dans l’entité JPA ?

Parce que le fichier externe n’est pas votre base de données.

Une ligne importée peut être :

Le DTO sert de zone tampon propre entre le monde extérieur et votre modèle métier.


9. Comprendre Spring Batch en une image mentale

Un batch Spring Batch, c’est une chaîne industrielle :

Fichier source
    ↓
ItemReader      lit une ligne
    ↓
ItemProcessor   vérifie et transforme
    ↓
ItemWriter      sauvegarde
    ↓
Base de données

Le tout est organisé ainsi :

Job
└── Step
    ├── Reader
    ├── Processor
    └── Writer

Dans notre TP :

Job importCoursTxtJob
└── Step importTxtStep
    ├── txtCoursReader
    ├── coursValidationProcessor
    └── coursItemWriter

Et pour Excel :

Job importCoursExcelJob
└── Step importExcelStep
    ├── excelCoursReader
    ├── coursValidationProcessor
    └── coursItemWriter

10. Format du fichier texte à positions fixes

Le fichier cours-bourse-fixed.txt contient des colonnes alignées.

Chaque champ possède une longueur fixe.

Champ Début Fin exclusive Longueur
ISIN 0 12 12
Ticker 12 20 8
Marché 20 30 10
Date 30 40 10
Ouverture 40 50 10
Plus haut 50 60 10
Plus bas 60 70 10
Clôture 70 80 10
Volume 80 92 12
Devise 92 95 3
Libellé 95 125 30

Exemple :

FR0000120271TTE     XPAR      2026-01-02     53.42     54.10     52.98     53.90     1845000EURTotalEnergies

Le séparateur visuel est l’espace, mais la vraie logique est la position des caractères.

C’est fréquent dans les systèmes bancaires anciens ou inter-applicatifs : ce n’est pas du CSV, c’est du fixe.


11. Étape 1 — Compléter le TxtCoursLineMapper

Dans le starter, ouvrez :

src/main/java/fr/formation/boursebatch/batch/TxtCoursLineMapper.java

Votre mission : transformer une ligne texte en CoursBoursierDto.

À faire

  1. Vérifier que la ligne n’est pas vide.
  2. Vérifier que la ligne a une longueur suffisante.
  3. Découper les champs avec substring.
  4. Nettoyer les espaces avec trim().
  5. Convertir la date avec LocalDate.parse(...).
  6. Convertir les prix avec BigDecimal.
  7. Convertir le volume avec Long.valueOf(...).
  8. Retourner un DTO.

Correction

public class TxtCoursLineMapper implements LineMapper<CoursBoursierDto> {
    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    @Override
    public CoursBoursierDto mapLine(String line, int lineNumber) {
        if (line == null || line.isBlank()) {
            throw new IllegalArgumentException("Ligne vide");
        }
        if (line.length() < 98) {
            throw new IllegalArgumentException("Ligne trop courte : " + line.length() + " caractères");
        }

        String isin = cut(line, 0, 12);
        String ticker = cut(line, 12, 20);
        String marche = cut(line, 20, 30);
        String date = cut(line, 30, 40);
        String ouverture = cut(line, 40, 50);
        String haut = cut(line, 50, 60);
        String bas = cut(line, 60, 70);
        String cloture = cut(line, 70, 80);
        String volume = cut(line, 80, 92);
        String devise = cut(line, 92, 95);
        String libelle = line.length() >= 125 ? cut(line, 95, 125) : ticker;

        return new CoursBoursierDto(
                isin,
                ticker,
                marche,
                libelle,
                LocalDate.parse(date, FORMATTER),
                new BigDecimal(ouverture),
                new BigDecimal(haut),
                new BigDecimal(bas),
                new BigDecimal(cloture),
                Long.valueOf(volume),
                devise
        );
    }

    private String cut(String line, int start, int end) {
        return line.substring(start, Math.min(end, line.length())).trim();
    }
}

12. Étape 2 — Comprendre le reader TXT

Dans BatchConfig.java, le reader TXT utilise FlatFileItemReader.

Correction :

@Bean
public FlatFileItemReader<CoursBoursierDto> txtCoursReader() {
    return new FlatFileItemReaderBuilder<CoursBoursierDto>()
            .name("txtCoursReader")
            .resource(new FileSystemResource(Path.of(inputDir, txtFile)))
            .linesToSkip(1)
            .lineMapper(new TxtCoursLineMapper())
            .build();
}

Explication :


13. Étape 3 — Compléter le processor de validation

Le fichier est déjà fourni :

CoursValidationProcessor.java

Il vérifie :

if (item.plusHaut().compareTo(item.plusBas()) < 0) {
    throw new IllegalArgumentException("Le plus haut ne peut pas être inférieur au plus bas");
}

Dans Spring Batch, si le processor lance une exception, la ligne peut être rejetée si le step est configuré avec :

.faultTolerant()
.skip(Exception.class)
.skipLimit(100)

Traduction bancaire : une ligne douteuse ne doit pas bloquer toute la chaîne d’import. On la met au rebut contrôlé, puis on continue. Le batch n’est pas fragile, il est civilisé.


14. Étape 4 — Comprendre le writer

Le writer reçoit des paquets de DTO valides.

@Component
@RequiredArgsConstructor
public class CoursItemWriter implements ItemWriter<CoursBoursierDto> {
    private final CoursBoursierService service;

    @Override
    public void write(Chunk<? extends CoursBoursierDto> chunk) {
        for (CoursBoursierDto dto : chunk) {
            service.enregistrerOuMettreAJour(dto);
        }
    }
}

Le service fait un upsert applicatif :


15. Étape 5 — Compléter le Step TXT

Un Step chunk-oriented fonctionne par lots.

Exemple :

@Bean
public Step importTxtStep(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
    return new StepBuilder("importTxtStep", jobRepository)
            .<CoursBoursierDto, CoursBoursierDto>chunk(50, transactionManager)
            .reader(txtCoursReader())
            .processor(processor)
            .writer(writer)
            .faultTolerant()
            .skip(Exception.class)
            .skipLimit(100)
            .listener(skipListener)
            .build();
}

Explication :

chunk(50, transactionManager)

Spring Batch traite 50 éléments dans une transaction.

Si tout va bien : commit.

Si une erreur survient sur une ligne autorisée au rejet : skip de la ligne, puis le batch continue.


16. Étape 6 — Créer le Job TXT

Correction :

@Bean
public Job importCoursTxtJob(JobRepository jobRepository, Step importTxtStep) {
    return new JobBuilder("importCoursTxtJob", jobRepository)
            .start(importTxtStep)
            .build();
}

Un job peut contenir plusieurs steps, mais ici nous restons volontairement simples.


17. Étape 7 — Lire le fichier Excel avec Apache POI

Le reader Excel est plus manuel qu’un FlatFileItemReader.

Il faut :

  1. ouvrir le classeur
  2. sélectionner la première feuille
  3. ignorer la ligne d’en-tête
  4. lire les cellules
  5. convertir les valeurs
  6. retourner un DTO
  7. retourner null quand le fichier est terminé.

Correction simplifiée :

public class ExcelCoursItemReader implements ItemReader<CoursBoursierDto>, AutoCloseable {
    private final Workbook workbook;
    private final Sheet sheet;
    private int rowIndex = 1;

    public ExcelCoursItemReader(Path path) throws Exception {
        this.workbook = WorkbookFactory.create(new FileInputStream(path.toFile()));
        this.sheet = workbook.getSheetAt(0);
    }

    @Override
    public CoursBoursierDto read() {
        while (rowIndex <= sheet.getLastRowNum()) {
            Row row = sheet.getRow(rowIndex++);
            if (row == null) {
                continue;
            }
            return new CoursBoursierDto(
                    str(row, 0),
                    str(row, 1),
                    str(row, 2),
                    str(row, 3),
                    date(row, 4),
                    decimal(row, 5),
                    decimal(row, 6),
                    decimal(row, 7),
                    decimal(row, 8),
                    Long.valueOf(str(row, 9)),
                    str(row, 10)
            );
        }
        return null;
    }
}

Point clé : dans un ItemReader, retourner null signifie : fin de lecture.


18. Étape 8 — Déclencher les jobs depuis une page web

Le contrôleur contient deux routes POST :

@PostMapping("/batch/txt")
public String lancerTxt(RedirectAttributes redirectAttributes) throws Exception {
    JobExecution execution = jobLauncher.run(importCoursTxtJob, new JobParametersBuilder()
            .addLong("timestamp", System.currentTimeMillis())
            .toJobParameters());
    redirectAttributes.addFlashAttribute("message", "Job TXT lancé : " + execution.getStatus());
    return "redirect:/";
}

Pourquoi ajouter un timestamp ?

Spring Batch identifie une exécution avec :

Job + JobParameters

Si vous relancez exactement le même job avec les mêmes paramètres, Spring Batch peut refuser de le rejouer.

Le timestamp rend chaque lancement unique.


19. Étape 9 — Planifier un batch

Dans BatchScheduler.java :

@Scheduled(cron = "${app.batch.cron-every-30-minutes}")
public void importerToutesLes30Minutes() throws Exception {
    jobLauncher.run(importCoursTxtJob, new JobParametersBuilder()
            .addLong("timestamp", System.currentTimeMillis())
            .toJobParameters());
}

Dans application.yml :

app:
  batch:
    schedule-enabled: false
    cron-every-30-minutes: "0 */30 * * * *"
    cron-market-close: "0 30 18 * * MON-FRI"

Pour activer la planification :

app:
  batch:
    schedule-enabled: true

Deux exemples :

cron-every-30-minutes: "0 */30 * * * *"

Toutes les 30 minutes.

cron-market-close: "0 30 18 * * MON-FRI"

Du lundi au vendredi à 18h30.


20. Étape 10 — Tester avec H2

Lancez l’application :

mvn spring-boot:run

Ouvrez :

http://localhost:8080

Cliquez sur :

Ouvrez ensuite la console H2 :

http://localhost:8080/h2-console

Paramètres :

JDBC URL : jdbc:h2:mem:bourse_batch
User     : sa
Password :

Requêtes utiles :

SELECT COUNT(*) FROM TITRE_BOURSIER;
SELECT COUNT(*) FROM COURS_BOURSIER;
SELECT * FROM REJET_IMPORT;

Vous devriez trouver quelques lignes rejetées volontairement, car les fichiers contiennent des données incohérentes.


21. Passage à PostgreSQL

Créer une base :

CREATE DATABASE bourse_batch;

Configurer application-postgres.yml :

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/bourse_batch
    username: postgres
    password: postgres

Lancer avec le profil PostgreSQL :

mvn spring-boot:run -Dspring-boot.run.profiles=postgres

22. Questions

  1. Quelle est la différence entre un Job et un Step ?
  2. Pourquoi utilise-t-on un DTO entre le fichier et l’entité JPA ?
  3. Que signifie chunk(50) ?
  4. Pourquoi spring.batch.job.enabled=false est utile dans ce TP ?
  5. Pourquoi ajoute-t-on un paramètre timestamp au lancement du job ?
  6. Que se passe-t-il si une ligne est invalide dans le processor ?
  7. Quelle différence entre une erreur de lecture et une erreur de validation ?
  8. Pourquoi stocker les rejets dans une table ?
  9. Pourquoi l’import doit-il être idempotent dans un contexte bancaire ?
  10. Quelle différence entre un fichier CSV et un fichier à positions fixes ?

23. Extensions possibles

Pour aller plus loin :


24. Correction complète

La correction complète est fournie dans :

bourse-batch-correction.zip

Le projet à compléter est fourni dans :

bourse-batch-starter.zip

Les fichiers de données sont déjà inclus dans chaque projet :

data/input/cours-bourse-fixed.txt
data/input/cours-bourse.xlsx