Vous allez construire une application Spring Boot permettant d’importer des données de marché depuis 2 sources :
.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é.
À la fin du TP, vous saurez :
Job
Step
ItemReader
ItemProcessor
ItemWriter
chunk(...)
@Scheduled
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 :
FR0000120271
TTE
XPAR
2026-01-02
53.42
54.10
52.98
53.90
1845000
EUR
Règles de validation :
Le projet fourni contient bourse-batch-starter à compléter
bourse-batch-starter
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
Le fichier pom.xml contient notamment :
pom.xml
<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.
spring-boot-starter-batch
poi-ooxml permet de lire les fichiers Excel modernes .xlsx.
poi-ooxml
Dans application.yml :
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.
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; }
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; }
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.
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
Le fichier cours-bourse-fixed.txt contient des colonnes alignées.
cours-bourse-fixed.txt
Chaque champ possède une longueur fixe.
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.
TxtCoursLineMapper
Dans le starter, ouvrez :
src/main/java/fr/formation/boursebatch/batch/TxtCoursLineMapper.java
Votre mission : transformer une ligne texte en CoursBoursierDto.
CoursBoursierDto
substring
trim()
LocalDate.parse(...)
BigDecimal
Long.valueOf(...)
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(); } }
Dans BatchConfig.java, le reader TXT utilise FlatFileItemReader.
BatchConfig.java
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 :
name(...)
resource(...)
linesToSkip(1)
lineMapper(...)
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é.
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 :
upsert
Un Step chunk-oriented fonctionne par lots.
@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(); }
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.
@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.
Le reader Excel est plus manuel qu’un FlatFileItemReader.
Il faut :
null
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.
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.
Dans BatchScheduler.java :
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()); }
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.
Lancez l’application :
mvn spring-boot:run
Ouvrez :
http://localhost:8080
Cliquez sur :
Lancer le job TXT
Lancer le job Excel
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.
Créer une base :
CREATE DATABASE bourse_batch;
Configurer application-postgres.yml :
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
chunk(50)
spring.batch.job.enabled=false
timestamp
Pour aller plus loin :
ImportHistorique
(isin, dateCotation)
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