From 76e584145299989ccee64009a0ad2abdfb068b4d Mon Sep 17 00:00:00 2001 From: igorcampos-dev Date: Wed, 7 Jan 2026 23:34:43 -0300 Subject: [PATCH] feat(spring-batch-file-example): implement Spring Batch file processing with custom readers for small and large Excel files; refactor services and tests --- spring-batch-file-examples/README.md | 48 ++++++-- spring-batch-file-examples/pom.xml | 35 +++--- .../src/main/java/com/io/example/README.md | 38 ------- .../io/example/config/ExcelBatchConfig.java | 91 --------------- .../config/LargeExcelReadBatchConfig.java | 69 ++++++++++++ .../config/SmallExcelReadBatchConfig.java | 63 +++++++++++ ...Controller.java => StudentController.java} | 14 +-- .../java/com/io/example/dto/ExecutionDto.java | 12 ++ .../java/com/io/example/enums/JobsType.java | 14 +++ .../job/{FileJob.java => FilesJob.java} | 15 ++- .../reader/StreamingExcelItemReader.java | 104 +++++++++++++++++ .../io/example/service/FileBatchService.java | 3 +- .../{TestService.java => StudentService.java} | 2 +- .../service/impl/FileBatchServiceImpl.java | 14 ++- ...rviceImpl.java => StudentServiceImpl.java} | 4 +- ...rTest.java => TestControllerStudents.java} | 31 +++-- .../example/job/BatchJobIntegrationTest.java | 69 +++++++++--- .../service/FileBatchServiceImplTest.java | 106 ++++++++++++------ ...st.java => StudentServiceImplStudent.java} | 6 +- 19 files changed, 503 insertions(+), 235 deletions(-) delete mode 100644 spring-batch-file-examples/src/main/java/com/io/example/README.md delete mode 100644 spring-batch-file-examples/src/main/java/com/io/example/config/ExcelBatchConfig.java create mode 100644 spring-batch-file-examples/src/main/java/com/io/example/config/LargeExcelReadBatchConfig.java create mode 100644 spring-batch-file-examples/src/main/java/com/io/example/config/SmallExcelReadBatchConfig.java rename spring-batch-file-examples/src/main/java/com/io/example/controller/{TestController.java => StudentController.java} (61%) create mode 100644 spring-batch-file-examples/src/main/java/com/io/example/dto/ExecutionDto.java create mode 100644 spring-batch-file-examples/src/main/java/com/io/example/enums/JobsType.java rename spring-batch-file-examples/src/main/java/com/io/example/job/{FileJob.java => FilesJob.java} (51%) create mode 100644 spring-batch-file-examples/src/main/java/com/io/example/reader/StreamingExcelItemReader.java rename spring-batch-file-examples/src/main/java/com/io/example/service/{TestService.java => StudentService.java} (76%) rename spring-batch-file-examples/src/main/java/com/io/example/service/impl/{TestServiceImpl.java => StudentServiceImpl.java} (73%) rename spring-batch-file-examples/src/test/java/com/io/example/controller/{TestControllerTest.java => TestControllerStudents.java} (72%) rename spring-batch-file-examples/src/test/java/com/io/example/service/{TestServiceImplTest.java => StudentServiceImplStudent.java} (64%) diff --git a/spring-batch-file-examples/README.md b/spring-batch-file-examples/README.md index 444578a..b185017 100644 --- a/spring-batch-file-examples/README.md +++ b/spring-batch-file-examples/README.md @@ -1,22 +1,41 @@ -# Spring Batch Examples | DB And Async +# Spring Batch File Readers -This project is a **Spring Boot** application demonstrating a **fully asynchronous Spring Batch job**, designed with a focus on **performance** and **scalability**. +This project is a **Spring Boot** application that demonstrates how to build **custom file readers using Spring Batch**, with a strong focus on **performance**, **scalability**, and **clean design**. + +The main goal of this repository is to showcase **different strategies for reading files** depending on their size and characteristics, following **real-world batch processing patterns**. --- ## πŸš€ Overview -The example showcases how to configure and run an **asynchronous Spring Batch job** that processes a large dataset efficiently. -The job reads **10,000 records** from a database table, simulating item processing by printing -`"item processed"` for each entry. +The project currently provides **custom Spring Batch `ItemReader` implementations** for reading Excel files, using **different approaches for small and large files**: + +- **Small Excel files**: loaded and processed entirely in memory +- **Large Excel files**: streamed row by row to minimize memory usage + +The architecture is intentionally extensible, allowing additional file formats (such as **CSV**) to be added in the future without changing the core batch flow. --- -## βš™οΈ How It Works +## πŸ“‚ Supported File Types + +### βœ… Currently Implemented + +- **Small Excel files (`.xlsx`)** + - Suitable for files that fit comfortably in memory + - Simple and fast processing + +- **Large Excel files (`.xlsx`)** + - Streaming-based reader + - Designed for large datasets + - Low memory footprint + - Handles empty rows gracefully + +### πŸ•’ Planned + +- **CSV files** +- Other structured file formats (as needed) -- The job leverages Spring Batch’s asynchronous capabilities to read and process data concurrently. -- An **H2 in-memory database** is used to store the sample data. -- The asynchronous behavior is enabled through a specific Spring profile. --- @@ -25,6 +44,13 @@ The job reads **10,000 records** from a database table, simulating item processi - **Java 21** - **Spring Batch** - **Spring Boot** -- **H2 Database** +- **Apache POI (Streaming API)** +- **pjfanning** + +--- + +## 🎯 Project Goals ---- \ No newline at end of file +- Demonstrate **production-ready Spring Batch readers** +- Show how to handle **large files efficiently** +- Provide clean, extensible examples without framework overengineering \ No newline at end of file diff --git a/spring-batch-file-examples/pom.xml b/spring-batch-file-examples/pom.xml index fb7fe3a..25fe5be 100644 --- a/spring-batch-file-examples/pom.xml +++ b/spring-batch-file-examples/pom.xml @@ -28,6 +28,7 @@ 0.2.0 2.4.240 0.8.14 + 5.2.0 @@ -69,20 +70,6 @@ ${instancio.version} - - org.springframework.boot - spring-boot-starter-test - ${spring.boot.version} - test - - - - org.springframework.batch - spring-batch-test - test - ${spring.batch.version} - - org.springframework.batch.extensions spring-batch-excel @@ -102,6 +89,26 @@ ${poi.ooxml.version} + + com.github.pjfanning + excel-streaming-reader + ${excel.streaming.reader.version} + + + + org.springframework.boot + spring-boot-starter-test + ${spring.boot.version} + test + + + + org.springframework.batch + spring-batch-test + test + ${spring.batch.version} + + diff --git a/spring-batch-file-examples/src/main/java/com/io/example/README.md b/spring-batch-file-examples/src/main/java/com/io/example/README.md deleted file mode 100644 index b73f712..0000000 --- a/spring-batch-file-examples/src/main/java/com/io/example/README.md +++ /dev/null @@ -1,38 +0,0 @@ -# File Reading Examples \| Multi-format Processing - -This project is a Spring Boot module that demonstrates basic, configurable setups for reading and processing different file formats using Spring batch. The examples are intentionally generic and meant to illustrate common configuration patterns, extension points, and how to plug different file readers into a batch processing pipeline. - ---- - -## Overview - -- Purpose: provide foundational configurations and examples for reading files of various formats. -- Goal: show how to configure readers, processors, and batch steps in a reusable way that can be adapted to different file types and processing requirements. - ---- - -## Scope - -This module focuses on demonstrating basic reader configurations and integration points. Use this section to list which file types are supported by this module: - -- Excel \(.xlsx\) β€” reading implemented -- CSV \(.csv\) β€” next/planned - ---- - -## How It Works (Generic) - -- Uses Spring Batch for job and step orchestration. -- Provides example reader beans and mapping strategies for different file formats. -- Demonstrates how to enable/disable features via Spring profiles and configuration properties. -- Designed for extensibility so additional file readers can be added with minimal changes. - ---- - -## Technologies Used - -- Java 21 -- Spring Boot -- Spring Batch - ---- \ No newline at end of file diff --git a/spring-batch-file-examples/src/main/java/com/io/example/config/ExcelBatchConfig.java b/spring-batch-file-examples/src/main/java/com/io/example/config/ExcelBatchConfig.java deleted file mode 100644 index 42e582e..0000000 --- a/spring-batch-file-examples/src/main/java/com/io/example/config/ExcelBatchConfig.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.io.example.config; - -import com.io.example.dto.StudentDto; -import com.io.example.mapper.StudentMapper; -import com.io.example.service.TestService; -import lombok.RequiredArgsConstructor; -import org.springframework.batch.core.Step; -import org.springframework.batch.core.configuration.annotation.StepScope; -import org.springframework.batch.core.repository.JobRepository; -import org.springframework.batch.core.step.builder.StepBuilder; -import org.springframework.batch.extensions.excel.poi.PoiItemReader; -import org.springframework.batch.integration.async.AsyncItemProcessor; -import org.springframework.batch.integration.async.AsyncItemWriter; -import org.springframework.batch.item.ItemProcessor; -import org.springframework.batch.item.ItemWriter; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.io.ClassPathResource; -import org.springframework.core.io.InputStreamResource; -import org.springframework.core.task.SimpleAsyncTaskExecutor; -import org.springframework.transaction.PlatformTransactionManager; - -import java.io.IOException; -import java.io.InputStream; -import java.util.concurrent.Future; - -@Configuration -@RequiredArgsConstructor -public class ExcelBatchConfig { - - private final TestService testService; - - @Bean - @StepScope - public PoiItemReader excelReader( - @Value("#{jobParameters['filePath']}") String filePath - ) throws IOException { - InputStream inputStream = new ClassPathResource(filePath).getInputStream(); - PoiItemReader reader = new PoiItemReader<>(); - reader.setResource(new InputStreamResource(inputStream)); - reader.setLinesToSkip(1); - reader.setRowMapper(new StudentMapper()); - return reader; - } - - @Bean - public AsyncItemProcessor asyncProcessor( - ItemProcessor processor - ) { - AsyncItemProcessor asyncProcessor = new AsyncItemProcessor<>(); - asyncProcessor.setDelegate(processor); - asyncProcessor.setTaskExecutor(new SimpleAsyncTaskExecutor()); - return asyncProcessor; - } - - @Bean - public AsyncItemWriter asyncWriter( - ItemWriter writer - ) { - AsyncItemWriter asyncWriter = new AsyncItemWriter<>(); - asyncWriter.setDelegate(writer); - return asyncWriter; - } - - @Bean - public ItemWriter writer() { - return studentsDto -> studentsDto.forEach(testService::print); - } - - @Bean - public ItemProcessor processor() { - return studentDto -> studentDto; - } - - @Bean - public Step step(JobRepository jobRepository, - PlatformTransactionManager transactionManager, - AsyncItemProcessor processor, - AsyncItemWriter writer, - PoiItemReader reader, - @Value("${spring.batch.chunk-size}") int chunkSize) { - return new StepBuilder("step", jobRepository) - .>chunk(chunkSize, transactionManager) - .processor(processor) - .writer(writer) - .reader(reader) - .build(); - } - -} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/config/LargeExcelReadBatchConfig.java b/spring-batch-file-examples/src/main/java/com/io/example/config/LargeExcelReadBatchConfig.java new file mode 100644 index 0000000..3d37717 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/config/LargeExcelReadBatchConfig.java @@ -0,0 +1,69 @@ +package com.io.example.config; + +import com.io.example.dto.StudentDto; +import com.io.example.reader.StreamingExcelItemReader; +import com.io.example.service.StudentService; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.transaction.PlatformTransactionManager; + +import java.time.LocalDate; + +@Configuration +@RequiredArgsConstructor +public class LargeExcelReadBatchConfig { + + private final StudentService studentService; + + @Bean + @StepScope + public StreamingExcelItemReader largeExcelReader( + @Value("#{jobParameters['filePath']}") String filePath + ) { + return new StreamingExcelItemReader<>( + new ClassPathResource(filePath), + row -> new StudentDto( + row.getCell(0).getStringCellValue(), + row.getCell(1).getStringCellValue(), + LocalDate.parse(row.getCell(2).getStringCellValue()) + ) + ); + } + + @Bean + public ItemProcessor largeExcelProcessor() { + return student -> student; + } + + @Bean + public ItemWriter largeExcelWriter() { + return items -> items.forEach(studentService::print); + } + + @Bean + public Step largeExcelStep( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + StreamingExcelItemReader largeExcelReader, + ItemProcessor largeExcelProcessor, + ItemWriter largeExcelWriter, + @Value("${spring.batch.chunk-size}") int chunkSize + ) { + return new StepBuilder("largeExcelStep", jobRepository) + .chunk(chunkSize, transactionManager) + .reader(largeExcelReader) + .processor(largeExcelProcessor) + .writer(largeExcelWriter) + .build(); + } + +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/config/SmallExcelReadBatchConfig.java b/spring-batch-file-examples/src/main/java/com/io/example/config/SmallExcelReadBatchConfig.java new file mode 100644 index 0000000..2416fe9 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/config/SmallExcelReadBatchConfig.java @@ -0,0 +1,63 @@ +package com.io.example.config; + +import com.io.example.dto.StudentDto; +import com.io.example.mapper.StudentMapper; +import com.io.example.service.StudentService; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.extensions.excel.poi.PoiItemReader; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.transaction.PlatformTransactionManager; + +@Configuration +@RequiredArgsConstructor +public class SmallExcelReadBatchConfig { + + private final StudentService studentService; + + @Bean + @StepScope + public PoiItemReader smallExcelReader( + @Value("#{jobParameters['filePath']}") String filePath + ) { + PoiItemReader reader = new PoiItemReader<>(); + reader.setResource(new ClassPathResource(filePath)); + reader.setLinesToSkip(1); + reader.setRowMapper(new StudentMapper()); + return reader; + } + + @Bean + public ItemProcessor smallExcelProcessor() { + return student -> student; + } + + @Bean + public ItemWriter smallExcelWriter() { + return items -> items.forEach(studentService::print); + } + + @Bean + public Step smallExcelStep(JobRepository jobRepository, + PlatformTransactionManager transactionManager, + PoiItemReader smallExcelReader, + ItemProcessor smallExcelProcessor, + ItemWriter smallExcelWriter, + @Value("${spring.batch.chunk-size}") int chunkSize) { + + return new StepBuilder("smallExcelStep", jobRepository) + .chunk(chunkSize, transactionManager) + .reader(smallExcelReader) + .processor(smallExcelProcessor) + .writer(smallExcelWriter) + .build(); + } +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/controller/TestController.java b/spring-batch-file-examples/src/main/java/com/io/example/controller/StudentController.java similarity index 61% rename from spring-batch-file-examples/src/main/java/com/io/example/controller/TestController.java rename to spring-batch-file-examples/src/main/java/com/io/example/controller/StudentController.java index bdd8db8..5705e90 100644 --- a/spring-batch-file-examples/src/main/java/com/io/example/controller/TestController.java +++ b/spring-batch-file-examples/src/main/java/com/io/example/controller/StudentController.java @@ -1,24 +1,22 @@ package com.io.example.controller; +import com.io.example.dto.ExecutionDto; import com.io.example.service.FileBatchService; import lombok.RequiredArgsConstructor; import org.springframework.batch.core.BatchStatus; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController @RequestMapping("/job") @RequiredArgsConstructor -public class TestController { +public class StudentController { private final FileBatchService fileBatchService; - @GetMapping("/process") - public ResponseEntity processJob(){ - var response = fileBatchService.runJob(); + @PostMapping("/process") + public ResponseEntity processJob(@RequestBody ExecutionDto dto){ + var response = fileBatchService.runJob(dto); return ResponseEntity.ok(response); } diff --git a/spring-batch-file-examples/src/main/java/com/io/example/dto/ExecutionDto.java b/spring-batch-file-examples/src/main/java/com/io/example/dto/ExecutionDto.java new file mode 100644 index 0000000..807ba17 --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/dto/ExecutionDto.java @@ -0,0 +1,12 @@ +package com.io.example.dto; + +import com.io.example.enums.JobsType; +import lombok.*; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExecutionDto { + private JobsType type; +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/enums/JobsType.java b/spring-batch-file-examples/src/main/java/com/io/example/enums/JobsType.java new file mode 100644 index 0000000..e13adff --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/enums/JobsType.java @@ -0,0 +1,14 @@ +package com.io.example.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum JobsType { + + SMALL_EXCEL("smallExcelJob"), + LARGE_EXCEL("largeExcelJob"); + + private final String jobName; +} \ No newline at end of file diff --git a/spring-batch-file-examples/src/main/java/com/io/example/job/FileJob.java b/spring-batch-file-examples/src/main/java/com/io/example/job/FilesJob.java similarity index 51% rename from spring-batch-file-examples/src/main/java/com/io/example/job/FileJob.java rename to spring-batch-file-examples/src/main/java/com/io/example/job/FilesJob.java index bdf6e93..0ddfee5 100644 --- a/spring-batch-file-examples/src/main/java/com/io/example/job/FileJob.java +++ b/spring-batch-file-examples/src/main/java/com/io/example/job/FilesJob.java @@ -10,12 +10,19 @@ @Component @RequiredArgsConstructor -public class FileJob { +public class FilesJob { @Bean - public Job excelJob(JobRepository jobRepository, Step step) { - return new JobBuilder("excelJob", jobRepository) - .start(step) + public Job smallExcelJob(JobRepository jobRepository, Step smallExcelStep) { + return new JobBuilder("smallExcelJob", jobRepository) + .start(smallExcelStep) + .build(); + } + + @Bean + public Job largeExcelJob(JobRepository jobRepository, Step largeExcelStep) { + return new JobBuilder("largeExcelJob", jobRepository) + .start(largeExcelStep) .build(); } diff --git a/spring-batch-file-examples/src/main/java/com/io/example/reader/StreamingExcelItemReader.java b/spring-batch-file-examples/src/main/java/com/io/example/reader/StreamingExcelItemReader.java new file mode 100644 index 0000000..16cd6af --- /dev/null +++ b/spring-batch-file-examples/src/main/java/com/io/example/reader/StreamingExcelItemReader.java @@ -0,0 +1,104 @@ +package com.io.example.reader; + +import com.github.pjfanning.xlsx.StreamingReader; +import lombok.SneakyThrows; +import org.apache.poi.ss.usermodel.*; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemStreamReader; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Iterator; +import java.util.function.Function; + +@Component +@StepScope +public class StreamingExcelItemReader + implements ItemStreamReader { + + private final Resource resource; + private final Function rowMapper; + + private Workbook workbook; + private Iterator rowIterator; + private int currentRow = 0; + + public StreamingExcelItemReader( + Resource resource, + Function rowMapper + ) { + this.resource = resource; + this.rowMapper = rowMapper; + } + + @Override + @SneakyThrows(IOException.class) + public void open(ExecutionContext context) { + this.currentRow = context.getInt("current.row", 0); + + InputStream is = resource.getInputStream(); + this.workbook = StreamingReader.builder() + .rowCacheSize(100) + .bufferSize(4096) + .open(is); + + Sheet sheet = workbook.getSheetAt(0); + this.rowIterator = sheet.iterator(); + + int linesToSkip = 1; + int skip = Math.max(linesToSkip, currentRow); + for (int i = 0; i < skip && rowIterator.hasNext(); i++) { + rowIterator.next(); + } + } + + @Override + public T read() { + while (rowIterator.hasNext()) { + + Row row = rowIterator.next(); + currentRow++; + + if (isRowEmpty(row)) { + continue; + } + + return rowMapper.apply(row); + } + + return null; + } + + @Override + public void update(ExecutionContext context) { + context.putInt("current.row", currentRow); + } + + @Override + @SneakyThrows(IOException.class) + public void close() { + if (workbook != null) { + workbook.close(); + } + } + + private boolean isRowEmpty(Row row) { + if (row == null) return true; + + for (int i = row.getFirstCellNum(); i < row.getLastCellNum(); i++) { + Cell cell = row.getCell(i); + if (cell != null && cell.getCellType() != CellType.BLANK) { + return false; + } + } + + return true; + } + + +} diff --git a/spring-batch-file-examples/src/main/java/com/io/example/service/FileBatchService.java b/spring-batch-file-examples/src/main/java/com/io/example/service/FileBatchService.java index c8118be..7a77767 100644 --- a/spring-batch-file-examples/src/main/java/com/io/example/service/FileBatchService.java +++ b/spring-batch-file-examples/src/main/java/com/io/example/service/FileBatchService.java @@ -1,8 +1,9 @@ package com.io.example.service; +import com.io.example.dto.ExecutionDto; import org.springframework.batch.core.BatchStatus; public interface FileBatchService { - Long runJob(); + Long runJob(ExecutionDto dto); BatchStatus getJobStatus(Long jobId); } diff --git a/spring-batch-file-examples/src/main/java/com/io/example/service/TestService.java b/spring-batch-file-examples/src/main/java/com/io/example/service/StudentService.java similarity index 76% rename from spring-batch-file-examples/src/main/java/com/io/example/service/TestService.java rename to spring-batch-file-examples/src/main/java/com/io/example/service/StudentService.java index 7033c45..e5122da 100644 --- a/spring-batch-file-examples/src/main/java/com/io/example/service/TestService.java +++ b/spring-batch-file-examples/src/main/java/com/io/example/service/StudentService.java @@ -2,6 +2,6 @@ import com.io.example.dto.StudentDto; -public interface TestService { +public interface StudentService { void print(StudentDto studentDto); } diff --git a/spring-batch-file-examples/src/main/java/com/io/example/service/impl/FileBatchServiceImpl.java b/spring-batch-file-examples/src/main/java/com/io/example/service/impl/FileBatchServiceImpl.java index ddd1d13..146d070 100644 --- a/spring-batch-file-examples/src/main/java/com/io/example/service/impl/FileBatchServiceImpl.java +++ b/spring-batch-file-examples/src/main/java/com/io/example/service/impl/FileBatchServiceImpl.java @@ -1,5 +1,6 @@ package com.io.example.service.impl; +import com.io.example.dto.ExecutionDto; import com.io.example.exception.BusinessException; import com.io.example.service.FileBatchService; import lombok.RequiredArgsConstructor; @@ -9,22 +10,27 @@ import org.springframework.batch.core.launch.JobLauncher; import org.springframework.stereotype.Service; +import java.util.Map; + @Slf4j @Service @RequiredArgsConstructor public class FileBatchServiceImpl implements FileBatchService { + private final Map jobs; private final JobLauncher asyncJobLauncher; - private final Job excelJob; private final JobExplorer jobExplorer; @Override - public Long runJob() { - String jobName = excelJob.getName(); + public Long runJob(ExecutionDto dto) { + Job job = jobs.get(dto.getType().getJobName()); + + String jobName = job.getName(); var parameters = getParameters(); + try { log.info("Starting async execution of job: {}", jobName); - JobExecution jobExecution = asyncJobLauncher.run(excelJob, parameters); + JobExecution jobExecution = asyncJobLauncher.run(job, parameters); log.info("Job {} started with status: {}", jobName, jobExecution.getStatus()); return jobExecution.getId(); } catch (Exception e) { diff --git a/spring-batch-file-examples/src/main/java/com/io/example/service/impl/TestServiceImpl.java b/spring-batch-file-examples/src/main/java/com/io/example/service/impl/StudentServiceImpl.java similarity index 73% rename from spring-batch-file-examples/src/main/java/com/io/example/service/impl/TestServiceImpl.java rename to spring-batch-file-examples/src/main/java/com/io/example/service/impl/StudentServiceImpl.java index 7e5b676..82ab8c5 100644 --- a/spring-batch-file-examples/src/main/java/com/io/example/service/impl/TestServiceImpl.java +++ b/spring-batch-file-examples/src/main/java/com/io/example/service/impl/StudentServiceImpl.java @@ -1,13 +1,13 @@ package com.io.example.service.impl; import com.io.example.dto.StudentDto; -import com.io.example.service.TestService; +import com.io.example.service.StudentService; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @Slf4j @Service -public class TestServiceImpl implements TestService { +public class StudentServiceImpl implements StudentService { @Override public void print(StudentDto studentDto) { diff --git a/spring-batch-file-examples/src/test/java/com/io/example/controller/TestControllerTest.java b/spring-batch-file-examples/src/test/java/com/io/example/controller/TestControllerStudents.java similarity index 72% rename from spring-batch-file-examples/src/test/java/com/io/example/controller/TestControllerTest.java rename to spring-batch-file-examples/src/test/java/com/io/example/controller/TestControllerStudents.java index 5e37e47..921a0bf 100644 --- a/spring-batch-file-examples/src/test/java/com/io/example/controller/TestControllerTest.java +++ b/spring-batch-file-examples/src/test/java/com/io/example/controller/TestControllerStudents.java @@ -1,5 +1,8 @@ package com.io.example.controller; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.io.example.dto.ExecutionDto; +import com.io.example.enums.JobsType; import com.io.example.exception.BusinessException; import com.io.example.exception.GlobalHandlerException; import com.io.example.service.FileBatchService; @@ -8,6 +11,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.batch.core.BatchStatus; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; @@ -17,16 +21,18 @@ import org.springframework.test.web.servlet.MockMvc; import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -@WebMvcTest(TestController.class) +@WebMvcTest(StudentController.class) @DisplayName("TestController - Unit tests with MockMvc") @Import(GlobalHandlerException.class) -class TestControllerTest { +class TestControllerStudents { private static final Long jobId = Instancio.create(Long.class); + private static final ObjectMapper MAPPER = new ObjectMapper(); @Autowired private MockMvc mockMvc; @@ -34,13 +40,20 @@ class TestControllerTest { @MockitoBean private FileBatchService fileBatchService; - @Test - @DisplayName("GET /job/process β†’ should return job ID when service runs successfully") - void shouldReturnJobIdWhenProcessJobIsCalled() throws Exception { - - when(fileBatchService.runJob()).thenReturn(jobId); - - mockMvc.perform(get("/job/process") + @ParameterizedTest + @ValueSource(strings = { + "SMALL_EXCEL", + "LARGE_EXCEL" + }) + @DisplayName("POST /job/process β†’ should return job ID when service runs successfully") + void shouldReturnJobIdWhenProcessJobIsCalled(String jobName) throws Exception { + var dto = new ExecutionDto(JobsType.valueOf(jobName)); + + when(fileBatchService.runJob(dto)).thenReturn(jobId); + + mockMvc.perform(post("/job/process") + .contentType(MediaType.APPLICATION_JSON) + .content(MAPPER.writeValueAsString(dto)) .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(content().string(String.valueOf(jobId))); diff --git a/spring-batch-file-examples/src/test/java/com/io/example/job/BatchJobIntegrationTest.java b/spring-batch-file-examples/src/test/java/com/io/example/job/BatchJobIntegrationTest.java index 2289946..19e5dc3 100644 --- a/spring-batch-file-examples/src/test/java/com/io/example/job/BatchJobIntegrationTest.java +++ b/spring-batch-file-examples/src/test/java/com/io/example/job/BatchJobIntegrationTest.java @@ -1,19 +1,24 @@ package com.io.example.job; import com.io.example.dto.StudentDto; -import com.io.example.service.TestService; +import com.io.example.service.StudentService; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.batch.core.BatchStatus; +import org.springframework.batch.core.Job; import org.springframework.batch.core.JobExecution; -import org.springframework.batch.extensions.excel.poi.PoiItemReader; import org.springframework.batch.test.JobLauncherTestUtils; import org.springframework.batch.test.context.SpringBatchTest; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import java.util.Map; + import static com.io.example.util.DataUtils.getParameters; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -29,39 +34,69 @@ class BatchJobIntegrationTest { private JobLauncherTestUtils jobLauncherTestUtils; @Autowired - private PoiItemReader excelReader; + @Qualifier("smallExcelJob") + private Job smallExcelJob; + + @Autowired + @Qualifier("largeExcelJob") + private Job largeExcelJob; + + private Map jobs; @MockitoBean - private TestService testService; + private StudentService studentService; + + @BeforeEach + void setUp() { + jobs = Map.of( + "SMALL_JOB", smallExcelJob, + "LARGE_JOB", largeExcelJob + ); + } - @Test + @ParameterizedTest + @ValueSource(strings = { + "SMALL_JOB", + "LARGE_JOB" + }) @DisplayName("Should execute ExcelJob and complete successfully reading real file") - void shouldExecuteJobSuccessfully() throws Exception { - JobExecution execution = jobLauncherTestUtils.launchJob(getParameters("files/students.xlsx")); + void shouldExecuteJobSuccessfully(String jobType) throws Exception { + jobLauncherTestUtils.setJob(jobs.get(jobType)); + + JobExecution execution = + jobLauncherTestUtils.launchJob(getParameters("files/students.xlsx")); assertThat(execution.getStatus()) - .as("Job should complete successfully") .isEqualTo(BatchStatus.COMPLETED); - verify(testService, atLeastOnce()).print(any(StudentDto.class)); - + verify(studentService, atLeastOnce()).print(any(StudentDto.class)); } - @Test + @ParameterizedTest + @ValueSource(strings = { + "SMALL_JOB", + "LARGE_JOB" + }) @DisplayName("Should fail executing ExcelJob when reader throws exception") - void shouldFailJobWhenReaderThrowsException() throws Exception { + void shouldFailJobWhenReaderThrowsException(String jobType) throws Exception { + jobLauncherTestUtils.setJob(jobs.get(jobType)); JobExecution execution = jobLauncherTestUtils.launchJob(getParameters("files/students-error.xlsx")); assertThat(execution.getStatus()).isEqualTo(BatchStatus.FAILED); - verify(testService, never()).print(any(StudentDto.class)); + verify(studentService, never()).print(any(StudentDto.class)); } - @Test + @ParameterizedTest + @ValueSource(strings = { + "SMALL_JOB", + "LARGE_JOB" + }) @DisplayName("Should execute ExcelJob and complete successfully when reader has no data") - void shouldCompleteJobWithNoData() throws Exception { + void shouldCompleteJobWithNoData(String jobType) throws Exception { + jobLauncherTestUtils.setJob(jobs.get(jobType)); JobExecution execution = jobLauncherTestUtils.launchJob(getParameters("files/students-empty.xlsx")); @@ -69,7 +104,7 @@ void shouldCompleteJobWithNoData() throws Exception { .as("Job should complete successfully") .isEqualTo(BatchStatus.COMPLETED); - verify(testService, never()).print(any(StudentDto.class)); + verify(studentService, never()).print(any(StudentDto.class)); } diff --git a/spring-batch-file-examples/src/test/java/com/io/example/service/FileBatchServiceImplTest.java b/spring-batch-file-examples/src/test/java/com/io/example/service/FileBatchServiceImplTest.java index 6fd56b3..f004f9e 100644 --- a/spring-batch-file-examples/src/test/java/com/io/example/service/FileBatchServiceImplTest.java +++ b/spring-batch-file-examples/src/test/java/com/io/example/service/FileBatchServiceImplTest.java @@ -1,11 +1,18 @@ package com.io.example.service; +import com.io.example.dto.ExecutionDto; +import com.io.example.enums.JobsType; import com.io.example.exception.BusinessException; import com.io.example.service.impl.FileBatchServiceImpl; import org.instancio.Instancio; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -16,52 +23,80 @@ import org.springframework.batch.core.explore.JobExplorer; import org.springframework.batch.core.launch.JobLauncher; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Stream; + import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -@DisplayName("Unit tests for AsyncBatchServiceImpl") class FileBatchServiceImplTest { - private static final Long jobId = Instancio.create(Long.class); + private static final Long JOB_ID = Instancio.create(Long.class); @Mock private JobLauncher asyncJobLauncher; @Mock - private Job asyncBatchJob; + private JobExplorer jobExplorer; @Mock - private JobExplorer jobExplorer; + private Job job; - @InjectMocks - private FileBatchServiceImpl fileBatchService; + @Mock + private ExecutionDto executionDto; - @Test - @DisplayName("Should run job successfully and return job execution id") - void shouldRunJobSuccessfully() throws Exception { + @Mock + private JobExecution jobExecution; - JobExecution jobExecutionMock = mock(JobExecution.class); - when(jobExecutionMock.getId()).thenReturn(123L); - when(asyncJobLauncher.run(eq(asyncBatchJob), any(JobParameters.class))) - .thenReturn(jobExecutionMock); + private FileBatchServiceImpl service; - Long jobId = fileBatchService.runJob(); + @BeforeEach + void setup() { + Map jobs = new HashMap<>(); + jobs.put(JobsType.SMALL_EXCEL.getJobName(), job); + jobs.put(JobsType.LARGE_EXCEL.getJobName(), job); - assertThat(jobId).isEqualTo(123L); - verify(asyncJobLauncher).run(eq(asyncBatchJob), any(JobParameters.class)); + service = new FileBatchServiceImpl(jobs, asyncJobLauncher, jobExplorer); } - @Test + @ParameterizedTest + @MethodSource("scenarios") + void shouldRunJobSuccessfully(String jobType, String jobName) throws Exception { + + when(executionDto.getType()).thenReturn(JobsType.valueOf(jobType)); + when(job.getName()).thenReturn(jobName); + + when(jobExecution.getId()).thenReturn(1L); + when(jobExecution.getStatus()).thenReturn(BatchStatus.STARTED); + + when(asyncJobLauncher.run(any(Job.class), any(JobParameters.class))).thenReturn(jobExecution); + + Long executionId = service.runJob(executionDto); + + assertEquals(1L, executionId); + + verify(asyncJobLauncher).run(eq(job), any(JobParameters.class)); + } + + @ParameterizedTest + @MethodSource("scenarios") @DisplayName("Should throw BusinessException when job execution fails") - void shouldThrowBusinessExceptionWhenJobFails() throws Exception { + void shouldThrowBusinessExceptionWhenJobFails(String jobType, String jobName) throws Exception { - when(asyncJobLauncher.run(any(Job.class), any(JobParameters.class))) - .thenThrow(new RuntimeException("Simulated error")); + when(job.getName()).thenReturn(jobName); + when(asyncJobLauncher.run(any(Job.class), any(JobParameters.class))).thenThrow(new RuntimeException("Simulated error")); - BusinessException exception = assertThrows(BusinessException.class, - () -> fileBatchService.runJob()); + BusinessException exception = assertThrows( + BusinessException.class, + () -> service.runJob(new ExecutionDto(JobsType.valueOf(jobType))) + ); assertThat(exception.getMessage()).isEqualTo("Simulated error"); } @@ -69,25 +104,32 @@ void shouldThrowBusinessExceptionWhenJobFails() throws Exception { @Test @DisplayName("Should return job status when job execution exists") void shouldReturnJobStatus() { - JobExecution jobExecutionMock = mock(JobExecution.class); - when(jobExecutionMock.getStatus()).thenReturn(BatchStatus.COMPLETED); - when(jobExplorer.getJobExecution(jobId)).thenReturn(jobExecutionMock); + when(jobExecution.getStatus()).thenReturn(BatchStatus.COMPLETED); + when(jobExplorer.getJobExecution(JOB_ID)).thenReturn(jobExecution); - BatchStatus status = fileBatchService.getJobStatus(jobId); + BatchStatus status = service.getJobStatus(JOB_ID); assertThat(status).isEqualTo(BatchStatus.COMPLETED); - verify(jobExplorer).getJobExecution(jobId); + verify(jobExplorer).getJobExecution(JOB_ID); } @Test @DisplayName("Should throw BusinessException when job execution not found") void shouldThrowBusinessExceptionWhenJobNotFound() { - when(jobExplorer.getJobExecution(jobId)).thenReturn(null); + when(jobExplorer.getJobExecution(JOB_ID)).thenReturn(null); BusinessException exception = assertThrows(BusinessException.class, - () -> fileBatchService.getJobStatus(jobId)); + () -> service.getJobStatus(JOB_ID)); assertThat(exception.getMessage()) - .isEqualTo("JobExecution not found for this id: " + jobId); + .isEqualTo("JobExecution not found for this id: " + JOB_ID); + } + + static Stream scenarios() { + return Stream.of( + Arguments.of("SMALL_EXCEL", JobsType.SMALL_EXCEL.getJobName()), + Arguments.of("LARGE_EXCEL", JobsType.LARGE_EXCEL.getJobName()) + ); } -} + +} \ No newline at end of file diff --git a/spring-batch-file-examples/src/test/java/com/io/example/service/TestServiceImplTest.java b/spring-batch-file-examples/src/test/java/com/io/example/service/StudentServiceImplStudent.java similarity index 64% rename from spring-batch-file-examples/src/test/java/com/io/example/service/TestServiceImplTest.java rename to spring-batch-file-examples/src/test/java/com/io/example/service/StudentServiceImplStudent.java index bb5a715..5831546 100644 --- a/spring-batch-file-examples/src/test/java/com/io/example/service/TestServiceImplTest.java +++ b/spring-batch-file-examples/src/test/java/com/io/example/service/StudentServiceImplStudent.java @@ -1,13 +1,13 @@ package com.io.example.service; import com.io.example.dto.StudentDto; -import com.io.example.service.impl.TestServiceImpl; +import com.io.example.service.impl.StudentServiceImpl; import org.instancio.Instancio; import org.junit.jupiter.api.Test; -class TestServiceImplTest { +class StudentServiceImplStudent { - private final TestServiceImpl service = new TestServiceImpl(); + private final StudentServiceImpl service = new StudentServiceImpl(); @Test void print_shouldLogStudent() {