Temario‎ > ‎Arquitectura Batch‎ > ‎

Spring Batch - Ejemplo Hola Mundo

Antes que nada si todavía no te armaste el entorno de trabajo, deberías ver acá.

Ahora sí, vamos a usar Spring Tool Suite, una distribución especial de eclipse que ya viene con todos los plugins que necesitamos. Y además trae templates de proyectos y ejemplos.

Creando el primer proyecto desde un template


Para crear nuestro primer ejemplo con Spring Batch hacemos:

  1. File -> New -> Other...  (ó CTRL+N)
  2. Seleccionamos "Spring -> Spring Template Project"
  3. Luego elegir el template "Batch -> Simple Spring Batch Project"
  4. Luego continuar con los pasos donde ingresamos el nombre del proyecto, packages, etc.

Analizando el ejemplo

Veamos la estructura de lo que se generó

Es un proyecto maven, así que la estructura ya es conocida para nosotros.

En resources vemos varios archivos:

  • log4j.properties: también debería sernos familiar, es para configurar el framework de logging.
  • launch-context.xml: es un archivo que describe un contexto de spring. Va a ser el punto central para configurar nuestra aplicación, y por ende para ejecutarla.
  • batch.properties: ya vamos a ver al meternos dentro de launch-context.xml, pero básicamente tiene propiedades que se usan en el xml, pero que preferimos que esten en un .properties a fín de que se puedan cambiar más fácilmente, quizás hasta en tiempo de ejecución, por parte de un usuario/administrador.

Por ahora vamos a obviar algunos detalles de configuración, así nos centramos en los elementos principales de nuestro proceso batch. Más adelante vamos a ver qué es todo eso que aparece en launch-context.xml

El Proceso Batch

Vemos que está definido en module-context.xml

    <batch:job id="job1">
        <batch:step id="step1"  >           
            <batch:tasklet transaction-manager="transactionManager" start-limit="100" >
                <batch:chunk reader="reader" writer="writer" commit-interval="1" />
            </batch:tasklet>
        </batch:step>
    </batch:job>

Es un job con un único step, que tiene reader y writer pero no tiene processor.

Si miramos las clases en src/main/java vemos que hay un reader:

@Component("reader")
public class ExampleItemReader implements ItemReader<String> {
    private String[] input = { "Hello world!", null };
    private int index = 0;
   
    public String read() throws Exception {
        return this.index < this.input.length ? this.input[this.index++] : null;
    }

}

Y un writer:

@Component("writer")
public class ExampleItemWriter implements ItemWriter<Object> {
    private static final Log log = LogFactory.getLog(ExampleItemWriter.class);
   
    public void write(List<? extends Object> data) throws Exception {
        log.info(data);
    }

}

A través de la annotation @Component, spring detecta esta clase y genera un bean con el id remarcado en azul. Así se relaciona la definición del job que vimos antes con nuestras clases.

Entonces qué hace este ejemplo ?

Simplemente el reader tiene un array de Strings hardcodeado, y va iterando sobre él, devolviéndo los Strings.

El writer simplemente loggea el string.

Ejecutando el proceso

Para esto, STS ya nos generó un testcase que si bien no tiene asserts sirve para ejecutar el proceso. ExampleJobConfigurationTests

Vamos a ver en consola algo como esto (en realidad va a haber más cosas, pero lo importante por ahora es esto. En todo caso se puede configurar en log4j.properties)

2012-10-14 15:05:37,964 INFO [org.springframework.batch.core.launch.support.SimpleJobLauncher] - <Job: [FlowJob: [name=job1]] launched with the following parameters: [{}]>
2012-10-14 15:05:37,976 INFO [org.springframework.batch.core.job.SimpleStepHandler] - <Executing step: [step1]>
2012-10-14 15:05:37,997 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Hello world!]>
2012-10-14 15:05:38,010 INFO [org.springframework.batch.core.launch.support.SimpleJobLauncher] - <Job: [FlowJob: [name=job1]] completed with the following parameters: [{}] and the following status: [COMPLETED]>
2012-10-14 15:05:38,022 INFO [org.springframework.context.support.GenericApplicationContext] - <Closing org.springframework.context.support.GenericApplicationContext@1a84da23: startup date [Sun Oct 14 15:05:37 ART 2012]; root of context hierarchy>
2012-10-14 15:05:38,023 INFO [org.springframework.beans.factory.support.DefaultListableBeanFactory] - <Destroying singletons in org.springframework.beans.factory.support.DefaultListableBeanFactory@6632060c: defining beans [org.springframework.beans.factory.config.PropertyPlaceholderConfigurer#0,exampleConfiguration,writer,reader,org.springframework.context.annotation.internalConfigurationAnnotationProcessor,org.springframework.context.annotation.internalAutowiredAnnotationProcessor,org.springframework.context.annotation.internalRequiredAnnotationProcessor,org.springframework.context.annotation.internalCommonAnnotationProcessor,org.springframework.jdbc.datasource.init.DataSourceInitializer#0,org.springframework.batch.core.scope.internalStepScope,org.springframework.beans.factory.config.CustomEditorConfigurer,org.springframework.batch.core.configuration.xml.CoreNamespacePostProcessor,jobRepository,step1,job1,dataSource,jobLauncher,transactionManager]; root of factory hierarchy>
La primer linea indica que comenzó la ejecución de nuestro Job. En la segunda se ve que se ejecuta el step1. La tercer linea es el print que hace nuestro ExampleItemWriter. Después vemos que terminó la ejecución del job. Y la siguiente linea, indica que se está cerrando el contexto de spring, destruyendo así todos los beans.

Iterando sobre el ejemplo

De a poquito vamos a ir modificando el ejemplo para que no sea tan simple (pavo), y así ir viendo como solucionar problemas más complejos y todo el poder de spring batch.

Leyendo y escribiendo desde/hacia archivos de texto

En una aplicación real obviamente que no tendríamos un array con String's hardcodeados, sino que seguramente se tomen de alguna fuente, por ejemplo un archivo de texto.

Ya dijimos que Spring Batch ya trae bastantes componentes para tareas comunes como esta.

Para esto vamos a definir el archivo entrada al proceso. Creamos un archivo src/test/resources/saludos.csv con el siguiente contenido:

Hola
Hello
Salut
Ciao
Guten Tag
Namaste
Priviet
Ni Hao
Shalom
Oi
Aloha

Y ahora deberíamos tener otro reader. Borramos el anterior y vamos a configurar una implementación de reader ya existente, no hace falta que escribamos código para esto.

    <bean id="reader" class="org.springframework.batch.item.file.FlatFileItemReader">
        <property name="resource" value="classpath:saludos.csv" />
        <property name="lineMapper">
            <bean class="org.springframework.batch.item.file.mapping.PassThroughLineMapper" />
        </property>
    </bean>

Lo interesante acá es:

  • utilizamos FlatFileItemReader que sirve para leer archivos de texto
  • configuramos el archivo
  • configuramos una implementación de LineMapper que sirve para procesar una linea en formato String y devolver un objeto. En este caso PassThroughLineMapper es una impl que simplemente devuelve el String así como viene.

Entonces al ejecutar deberíamos ver algo así:

2012-10-14 20:00:17,239 INFO [org.springframework.batch.core.job.SimpleStepHandler] - <Executing step: [step1]>
2012-10-14 20:00:17,263 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Hola]>
2012-10-14 20:00:17,267 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Hello]>
2012-10-14 20:00:17,271 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Salut]>
2012-10-14 20:00:17,274 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Ciao]>
2012-10-14 20:00:17,276 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Guten Tag]>
2012-10-14 20:00:17,278 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Namaste]>
2012-10-14 20:00:17,280 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Priviet]>
2012-10-14 20:00:17,282 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Ni Hao]>
2012-10-14 20:00:17,285 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Shalom]>
2012-10-14 20:00:17,287 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Oi]>
2012-10-14 20:00:17,289 INFO [uqbar.arqsw.batch.ExampleItemWriter] - <[Aloha

Ahora vamos a hacer que el writer escriba los saludos así como vienen a un archivo de salida. Parece usamos la contraparte, FlatFileItemWriter

    <bean id="writer" class="org.springframework.batch.item.file.FlatFileItemWriter">
        <property name="resource" value="file://tmp/output.csv" />
        <property name="lineAggregator">
            <bean class="org.springframework.batch.item.file.transform.PassThroughLineAggregator" />
        </property>
    </bean>


Que se configura con un LineAggregator que recibe un objeto como parámetro y retorno el string que se va a escribir en el archivo. Para nuestro ejemplo escribimos el String como viene, por eso usamos la impl PassThroughLineAggregator.

Luego, borramos la implementación de ItemWriter que teníamos, y ejecutamos.

Debería haber generado un archivo en /tmp/output.csv con el mismo contenido que el de origen.


Agregando Comportamiento: Generando saludos

Veamos cómo sería agregar algo de comportamiento. Para eso modificamos un poco nuestro dominio del proceso. Hagamos que el archivo de entrada tenga nombres de personas a saludar. En lugar de los saludos. Y, que el objetivo de nuestra aplicación sea generar los saludos en el archivo de salida.

Para esto tenemos dos opciones, o "lugares" donde agregar este comportamiento

  • Cuando se parsea cada linea, customizando el LineMapper: esto sería hacerlo cuanto antes, durante el parseo. Mantiene todo lo demás.
  • Luego de que se parseó, agregar un ItemProcessor: mantiene todo lo demás.

Vayamos por el primer caso y veamos sucede luego.

public class SaludoLineMapper implements LineMapper<String> {

    public String mapLine(String line, int lineNumber) throws Exception {
        return "Hola <" + line + "> !";
    }

}

Y ahora el xml quedaría:

    <bean id="reader" class="org.springframework.batch.item.file.FlatFileItemReader">
        <property name="resource" value="classpath:saludos.csv" />
        <property name="lineMapper">
            <bean class="uqbar.arqsw.batch.SaludoLineMapper" />
        </property>
    </bean>

Ejecutamos y...

Hola <Arturo> !
Hola <Laura> !
Hola <Juan> !
Hola <Mariela> !
Hola <Alvaro> !
Hola <Zoe> !
Hola <Azul> !
Hola <Guillermo> !
...

Previamente yo cambié el contenido del archivo saludos.csv para que contenga muchos nombres.

Con el próximo cambio vamos a ver qué implicancia tuvo esta decisión que tomamos.


Implementando saludos en varios idiomas

Supongamos ahora que las personas hablan diferentes idiomas, así que queremos saludarlos en su idioma correspondiente. Para eso vamos a necesitar:

  • que el archivo de input tenga, además del nombre, el idioma de la persona
  • que el batch interprete esa información y genere diferentes saludos. Para eso va a necesitar...
  • tener un mapeo entre "idioma" y "saludo"

Si bien podríamos implementar seguir metiendo este código en nuestro LineMapper, nos debería hacer ruido. Porque la responsabilidad de éste debería ser chiquita, simplemente parsear una linea y crear un objeto. En su lugar, nosotros estamos empezando a evaluar lógica de negocio.

Además, cómo sería ? debería:

  • tokenizar y parsear la linea del CSV, a nivel de texto
  • interpretar el idioma, y de alguna forma obtener el template de saludo
  • generar el saludo

Tendríamos juntas estas tres responsabilidades.

Entonces, modifiquemos un poco el ejemplo para implementarlo de una forma más prolija, y elegante.

Vamos a pensar un poco más en objetos de negocio y después vemos como adaptamos spring batch a eso.

Para esto el archivo de entrada lo queremos así:

Arthur,en
Laura,es
Ming,zh
Ilich,ru
Piotr,ru
Jean-Jacques,fr
Michelangelo,it
Joao,pr
David,en

Y, por otro lado, un archivo donde definamos los templates de saludos, src/main/resources/saludos-i18n.properties

en=Hello
es=Hola
ru=Priviet
fr=Salut
it=Ciao
ge=Guten Tag
zh=Ni Hao
pr=Oi

Ahora, pensamos que no queremos lidiar con Strings separados por comas, sino pensar en la entrada como un objeto, SaludoInput. Que tiene el nombre de la persona, y el idioma. Y tiene la responsabilidad de saber generar el saludo a partir de estos dos datos, más un conjunto de templates de saludos que recibe por parámetro:

public class SaludoInput {
    private String nombre;
    private String idioma;

    public String saludar(Properties saludos) {
        return saludos.getProperty(this.idioma) + " <" + this.nombre + ">";
    }

    // ... getters & setters

}


Entonces, ahora tenemos que hacer dos cosas:

  • Configurar el reader para que a partir de cada linea generé una instancia de esta clase
  • Agregar un ItemProcessor que invoque el método "saludar" para generar el saludo.
    <bean id="reader" class="org.springframework.batch.item.file.FlatFileItemReader">
        <property name="resource" value="classpath:saludos.csv" />
        <property name="lineMapper">
            <bean class="org.springframework.batch.item.file.mapping.DefaultLineMapper">
                <property name="lineTokenizer">
                    <bean class="org.springframework.batch.item.file.transform.DelimitedLineTokenizer">
                        <property name="names" value="nombre,idioma" />
                    </bean>
                </property>
                <property name="fieldSetMapper">
                    <bean class="org.springframework.batch.item.file.mapping.BeanWrapperFieldSetMapper">
                        <property name="prototypeBeanName" value="saludoInputPrototype" />
                    </bean>
                </property>
            </bean>
        </property>
    </bean>
   
    <bean id="saludoInputPrototype" scope="prototype" class="uqbar.arqsw.batch.model.SaludoInput"/>

Fíjense que

  • por un lado indicamos los nombres de las properties de cada columna del CSV ("nombre", "idioma") al DelimitedLineTokenizer.
  • por otro lado configuramos un FIeldSetMapper genérico que sabe crear y popular objetos en base a las valores parseados.
  • para configurar este último utilizamos el patrón prototype y el feature scope=prototype de Spring.

Luego el processor:

    <util:properties id="templateSaludos" location="classpath:saludos-i18n.properties" />

    <bean id="processor" class="uqbar.arqsw.batch.SaludoItemProcessor">
        <property name="templateSaludos" ref="templateSaludos" />
    </bean>

Modificando

<batch:chunk reader="reader" processor="processor" writer="writer" commit-interval="1" />

Y voilá !

Hello <Arthur>
Hola <Laura>
Ni Hao <Ming>
Priviet <Ilich>
Priviet <Piotr>
Salut <Jean-Jacques>
Ciao <Michelangelo>
Oi <Joao>
Hello <David>

Algo interesante a notar es que la cantidad de código que escribimos es minima !
No tuvimos que implementar código que abra y cierre archivos, ni que implemente el parseo del CSV, ni la creación y populado de nuestro objeto de dominio.
Tampoco implementamos nada para escribir el archivo de salida.
Si bien uno estaría tentando a decir "igual es fácil de implementar todo eso", todos sabemos que las cosas siempre parecen simples y fáciles al principio, pero luego vamos encontrando casos alternativos y nuevaos problemas que no estaba contemplados.
Al utiilzar componentes ya existentes, evitamos todas esas situaciones y utilizamos código ya testeado.
Además estas clases implementa más comportamiento del que estamos utiilizando, por ejemplo, permiten resumir ejecuciones que fueron pausadas.


Procesando Multiples Archivos de Entrada

Generalmente este tipo de aplicaciones no procesa un único archivo de entrada, sino múltiples. Generalmente, se tiene un directorio de "input" donde "alguien" ubica los archivos. Y el batch lo que hace es procesar todos los archivos del directorio, y luego borrarlos o moverlos hacia otro lado.

Ese alguien puede ser una persona física, o podría ser otro batch o programa, que de esta forma "encola" trabajo para este batch.

Para hacer esto, Spring Batch tiene una implementación de ItemReader que wrappea a nuestro reader, y lo va utilizando para cada archivo. Así que modificamos el bean que se llamaba "reader" para wrappearlo con otro de la clase MultiResourceItemReader

    <bean id="reader" class="org.springframework.batch.item.file.MultiResourceItemReader">
        <property name="resources" value="classpath:data/input/*.csv" />
        <property name="delegate" ref="fileReader" />
    </bean>

    <bean id="fileReader" class="org.springframework.batch.item.file.FlatFileItemReader">
        ...  <!-- Aca el reader queda como estaba -->
    </bean>

Lo que hicimos acá fue renombrar nuestro bean (el segundo que se ve) de reader a fileReader y definimos un nuevo bean (el de arriba) que tiene una referencia al otro.

Ahora creamos un par de archivos nuevos en src/main/resources/data/input/ con más nombres de personas y su idioma.

  • massaludos.csv
  • otrossaludos.csv
  • saludos.csv (el original)

Ejecutamos el test nuevamente y vamos a ver como resultado:

Salut <Elodi>
Hola <Mara>
Hello <Clark>
Ni Hao <Zao>
Salut <Francois>
Ciao <Leonardo>
Oi <Olavo>
Hola <Gisela>
Ciao <Pepe>
Hello <Mary>
Hello <Arthur>
Hola <Laura>
Ni Hao <Ming>
Priviet <Ilich>
Priviet <Piotr>
Salut <Jean-Jacques>
Ciao <Michelangelo>
Oi <Joao>
Hello <David>

Que contiene el resultado de evaluar todos los archivos.



Comments