Créer un service batch avec Spring Boot

Dans ce petit guide, je vais démontrer comment créer une solution basée sur spring boot batch.
Le batch réalisera un import à partir d’un fichier excel pour insérer les données dans une base après les avoir transformées.

Le projet

Téléchargez cette archive et importez là sur IntelliJ IDEA ou Spring Tool Suite (STS). Pour info cette archive contient :

  • le pom.xml du projet
  • les packages globaux du projet
  • le fichier csv contenant les données excel source
  • le ddl de la table cible (à exécuter sur une base en local avant de commencer l’exercice)

Business Class

Une fois le projet importé, la première étape sera de créer une classe représentative de notre donnée métier. Ici nous traiterons d’une personne. Je vais donc créer la classe suivante :
src/main/java/hello/Person.java

package hello;
public class Person {
    private String lastName;
    private String firstName;
    public Person() {
    }
    public Person(String firstName, String lastName) {
        this.firstName = firstName;
        this.lastName = lastName;
    }
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    public String getFirstName() {
        return firstName;
    }
    public String getLastName() {
        return lastName;
    }
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    @Override
    public String toString() {
        return "firstName: " + firstName + ", lastName: " + lastName;
    }
}

Intermediate processor

Le paradigme commun dans les processus batchs et de charger les données puis de les transformer et finalement de les charger dans une autre source de données. Ici la transformation sera simple, je vais transformer les attributs « firstname » et « lastname » en majuscule via ce simple processor.
src/main/java/hello/PersonItemProcessor.java

package hello;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.item.ItemProcessor;
public class PersonItemProcessor implements ItemProcessor<Person, Person> {
    private static final Logger log = LoggerFactory.getLogger(PersonItemProcessor.class);
    @Override
    public Person process(final Person person) throws Exception {
        final String firstName = person.getFirstName().toUpperCase();
        final String lastName = person.getLastName().toUpperCase();
        final Person transformedPerson = new Person(firstName, lastName);
        log.info("Converting (" + person + ") into (" + transformedPerson + ")");
        return transformedPerson;
    }
}

PersonItemProcessor implémente l’interface ItemProcessor de Spring Batch. Cela rend plus facile le déclenchement du code dans le batch que l’on définira plus bas. Selon cette interface, je reçois un objet Person que l’on transforme dans un même objet Person mais avec les attributs en majuscule.
NB : Il n’y a aucune obligation d’avoir le même objet en entrée et en sortie du processor.

Batch Configuration

Maintenant j’ai besoin d’assembler le batch en question. Spring batch fourni un bon nombre de classes utiles pour réduire le besoin d’écrire du code spécifique. Grâce à cela, on peut se focaliser  uniquement sur la logique métier du batch.
src/main/java/hello/BatchConfiguration.java

package hello;
import javax.sql.DataSource;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecutionListener;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepBuilderFactory;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.item.database.BeanPropertyItemSqlParameterSourceProvider;
import org.springframework.batch.item.database.JdbcBatchItemWriter;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.batch.item.file.mapping.BeanWrapperFieldSetMapper;
import org.springframework.batch.item.file.mapping.DefaultLineMapper;
import org.springframework.batch.item.file.transform.DelimitedLineTokenizer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
@Configuration
@EnableBatchProcessing
public class BatchConfiguration {
    @Autowired
    public JobBuilderFactory jobBuilderFactory;
    @Autowired
    public StepBuilderFactory stepBuilderFactory;
    @Autowired
    public DataSource dataSource;
    // tag::readerwriterprocessor[]
    @Bean
    public FlatFileItemReader reader() {
        FlatFileItemReader reader = new FlatFileItemReader();
        reader.setResource(new ClassPathResource("sample-data.csv"));
        reader.setLineMapper(new DefaultLineMapper() {{
            setLineTokenizer(new DelimitedLineTokenizer() {{
                setNames(new String[] { "firstName", "lastName" });
            }});
            setFieldSetMapper(new BeanWrapperFieldSetMapper() {{
                setTargetType(Person.class);
            }});
        }});
        return reader;
    }
    @Bean
    public PersonItemProcessor processor() {
        return new PersonItemProcessor();
    }
    @Bean
    public JdbcBatchItemWriter writer() {
        JdbcBatchItemWriter writer = new JdbcBatchItemWriter<Person>();
        writer.setItemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider());
        writer.setSql("INSERT INTO people (first_name, last_name) VALUES (:firstName, :lastName)");
        writer.setDataSource(dataSource);
        return writer;
    }
    // end::readerwriterprocessor[]
    // tag::jobstep[]
    @Bean
    public Job importUserJob(JobCompletionNotificationListener listener) {
        return jobBuilderFactory.get("importUserJob")
                .incrementer(new RunIdIncrementer())
                .listener(listener)
                .flow(step1())
                .end()
                .build();
    }
    @Bean
    public Step step1() {
        return stepBuilderFactory.get("step1")
                .<Person, Person> chunk(10)
                .reader(reader())
                .processor(processor())
                .writer(writer())
                .build();
    }
    // end::jobstep[]
}

Quelques explications :
L’annotation @EnableBatchProcessing ajoute pas mal de beans critique aux batchs qui permettent de gagner pas mal de lignes de code.
Le premier morceau de code défini les parties « input », « processor » et « output » du batch :

  • reader() crée un ItemReader. Il va regarder après un fichier nommé sample-data.csv et parser chaque ligne pour les transformer en Person
  • processor() crée une instance du PersonItemProcessor que l’on a défini un peu plus en amont.
  • write(DataSource) crée un ItemWriter. Celui-ci a pour cible une connection JDBC et va automatiquement recevoir une copie de la datasource créer par @EnableBatchProcessing. Cela inclue le code SQL pour insérer une Person.

Le deuxième morceau de code se concentre sur la configuration du batch :

  • importUserJob(…) et step1() définissent une seule « step ». Les jobs sont construits à partir de ces steps, où chaque « step » peut inclure un reader, un processor et un writer.
  • Dans cette définition de batch, j’ai besoin d’un RunIdIncrementer car le job utilise une base de donnée pour maintenir l’état de l’exécution. Je liste ensuite chaque étape, ici il n’y en a qu’une, l’étape step1().
  • Le job se termine ensuite.
  • Dans cette configuration, je définis également combien de donnée sont à écrire en même temps. Dans notre cas, il écrit 10 données à la fois. Après, je configure le reader, processor et writer utilisant les données d’auparavant.

Listener de fin de job

src/main/java/hello/JobCompletionNotificationListener.java

package hello;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.listener.JobExecutionListenerSupport;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Component;
@Component
public class JobCompletionNotificationListener extends JobExecutionListenerSupport {
	private static final Logger log = LoggerFactory.getLogger(JobCompletionNotificationListener.class);
	private final JdbcTemplate jdbcTemplate;
	@Autowired
	public JobCompletionNotificationListener(JdbcTemplate jdbcTemplate) {
		this.jdbcTemplate = jdbcTemplate;
	}
	@Override
	public void afterJob(JobExecution jobExecution) {
		if(jobExecution.getStatus() == BatchStatus.COMPLETED) {
			log.info("!!! JOB FINISHED! Time to verify the results");
			List results = jdbcTemplate.query("SELECT first_name, last_name FROM people", new RowMapper() {
				@Override
				public Person mapRow(ResultSet rs, int row) throws SQLException {
					return new Person(rs.getString(1), rs.getString(2));
				}
			});
			for (Person person : results) {
				log.info("Found <" + person + "> in the database.");
			}
		}
	}
}

Cette classe écoute la fin du job avec comme déclencheur un BatchStatus.Completed et ensuite utilise un JdbcTemplate pour inspecter les résultats.

Rendre l’application exécutable

src/main/java/hello/Application.java

package hello;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class Application {
    public static void main(String[] args) throws Exception {
        SpringApplication.run(Application.class, args);
    }
}

@SpringBootApplication est une annotation pratique qui ajoute les suivantes :

  • @Configuration qui tag la classe comme une source de définitions de bean pour le contexte de l’application.
  • @EnableAutoConfiguration qui dit à Spring Boot de commencer à ajouter les beans basés sur les paramètres du classpath, d’autres beans et de plusieurs autres propriétés.
  • @ComposentScan qui dit à Spring de regarder après d’autres composants, configurations ou services dans le package hello.

La méthode main() utilise la méthode de Spring boot SpringApplication.run() pour lancer l’application. Et voilà, l’application est prête à être lancer et tout ça sans une seule ligne de XML !
La magie de Spring Boot a encore une fois opérée.