Tutoriel pour apprendre à écrire du JSON natif et valide pour Spring Batch

Michelle Avomo

Il est étonnant de découvrir que le framework Spring n'offre pas la possibilité d'écrire nativement du JSON valide (RFC 7159).
À l'heure de publication de ce billet, une recherche, aussi bien sur le site de Spring que via Google, nous permet de nous rendre compte qu'il existe un ticket non résolu depuis 2012 allant partiellement dans ce sens (usecase 1 à 4).

En attendant, si on a besoin de produire un fichier ayant des données au format JSON avec Spring batch, il faut le faire soi-même en écrivant un writer custom (voir ici).

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum. Commentez

- Titre : Tutoriel pour apprendre à écrire du JSON natif et valide pour Spring Batch

- Auteur : Michelle Avomo

- Parution : 6 septembre 2016

- Licence :

Sommaire

- I. Le « challenge »

- II. Exploration de la boîte à outils de Spring Batch

- III. Des ItemWriters non thread-safe

- III-A. Spring batch et la notion de classe « thread-safe »

- IV. FlatItemWriter ne produit pas du JSON valide !

- IV-A. Cas de test

- IV-A-1. Configuration de FlatItemWriter  pour l'objectif 1

- IV-A-2. Configuration de FlatItemWriter pour objectif 2

- V. La solution custom

- VI. Récapitulatif

- VII. Conclusion

- VIII. Remerciements

I - Le « challenge »

Le « challenge » ici est de produire un fichier JSON valide à l'issue d'un traitement batch, tout en gardant la même flexibilité de configuration du job que celle offerte par les classes FlatItemWriter et StaxEventItemWriter.

Notre idée est d'explorer le framework Spring Batch, à la recherche de la logique au cœur de l'écriture dans des fichiers pour l'adapter à notre besoin.

Pour illustrer la problématique à résoudre, nous partons d'un cas simple :

1. Lire des noms et prénoms de personnes depuis un fichier texte ;

2. Les transformer en (lettres) capitales ;

3. Écrire le résultat dans un fichier JSON valide.

Nous commencerons par explorer la boîte à outils du framework pour voir ce qui nous sera utile dans notre démarche. Puis, nous présenterons rapidement la notion de thread-safety ou plutôt de non thread-safety des implémentations d'itemwriter par défaut.

Avant de présenter notre solution, JsonFlatFileItemWriter, nous commencerons par présenter les limites de l'implémentation FlatItemWriter (la classe StaxEventItemWriter étant mise de côté, car ne générant que du XML) par rapport à notre besoin.
Vous l'aurez deviné, ce billet ne reviendra pas sur les grands principes de Spring batch, il existe de bonnes documentations ici et là pour bien démarrer avec le framework.
Le code ainsi et les ressources de ce billet sont disponibles sur le Github de Soat.

II - Exploration de la boîte à outils de Spring Batch

Pour les opérations d'écritures, Spring Batch fournit les interfaces et la classe abstraite suivantes :

- ItemWriter : interface générique permettant de gérer les opérations d'écriture vers un support, quel qu'il soit (fichier, base de données…). Définit la méthode : write(List <? extends T>) qui est implémentée systématiquement pour les opérations d'écriture ;

- AbstractItemStreamItemWriter : classe abstraite, mère de toutes les classes implémentant l'interface ItemWriter. StaxEventItemWriter et FlatFileItemWriter l'étendent ;

- ItemStream : interface permettant de sauvegarder périodiquement l'état d'un traitement et reprendre un traitement stoppé (en cas d'erreur, par exemple) au niveau d'arrêt du traitement précédent ;

- ResourceAwareItemWriterItemStream : interface commune des Itemwriters qui implémentent ItemStream et écrivent dans une ressource de sortie (au sens Spring) ;

- LineAggregator : interface permettant de définir la logique de sérialisation d'un objet du modèle sous la forme d'une chaîne de caractères ;

- FlatFileHeaderCallback : interface de callback permettant d'écrire un en-tête (header) dans un fichier ;

- FlatFileFooterCallback : interface de callback permettant d'écrire un pied de page (footer) dans un fichier.

Pour les opérations d'écriture dans les fichiers, Spring Batch fournit des implémentations d'Itemwriter dédiées à la production de fichiers texte (plus ou moins complexes) et de fichiers XML.

Pour rappel, ces classes sont définies telles que :

1. public class StaxEventItemWriter extends AbstractItemStreamItemWriter implements

2. ResourceAwareItemWriterItemStream,InitializingBean

1. public class FlatFileItemWriter extends AbstractItemStreamItemWriter implements

2. ResourceAwareItemWriterItemStream,InitializingBean

III - Des ItemWriters non thread-safe

Dans la documentation des implémentations de l'ItemWriter mentionnées plus haut, il est commun de trouver la précision suivant laquelle la classe n'est pas thread-safe :

1. /**

2. * This class is an item writer that writes data to a file or stream. The writer

3. * also provides restart. The location of the output file is defined by a

4. * {@link Resource} and must represent a writable file.<br>

5. *

6. * Uses buffered writer to improve performance.<br>

7. *

8. * The implementation is <b>not</b> thread-safe.

9. *

10. * @author Waseem Malik

11. * @author Tomas Slanina

12. * @author Robert Kasanicky

13. * @author Dave Syer

14. * @author Michael Minella

15. */

16. public class FlatFileItemWriter<T> extends AbstractItemStreamItemWriter<T> implements ResourceAwareItemWriterItemStream<T>,

17. InitializingBean {

Étant donné que notre writer s'inspire du fonctionnement de ces deux classes (StaxEventItemWriter et FlatFileItemWriter), il convient de revenir sur la raison pour laquelle ce dernier aura la même limitation.

III-A - Spring batch et la notion de classe « thread-safe »

Par défaut, les jobs et steps spring sont monothreadés :

Many batch processing problems can be solved with single threaded, single process jobs, so it is always a good idea to properly check if that meets your needs before thinking about more complex implementations. Measure the performance of a realistic job and see if the simplest implementation meets your needs first: you can read and write a file of several hundred megabytes in well under a minute, even with standard hardware.

Les steps d'un job donné sont exécutés de façon séquentielle au sein d'un même thread. Vu depuis le job, un traitement classique se déroule suivant le modèle de la figure ci-dessous.

Dans le cadre d'un environnement multithreadé, il n'est pas garanti que le fichier (puisque c'est ce que nous voulons avoir finalement) soit dans un état cohérent du fait de l'accès concurrent activé.
Nous avons voulu savoir pourquoi ils n'ont pas juste optimisé ces classes par défaut pour des environnements multithreadés. La réponse tient en deux volets :

1. Dans le code, aucune portion de code de ces classes ne gère les accès concurrents ;

2. Les beans Spring sont « Stateful » : elles ont besoin de gérer la reprise de l'exécution d'une tâche suite à une interruption, là où la précédente exécution s'est arrêtée (restart). Et, dans le cas des fichiers, elles ont également besoin de maintenir leur état de traitement afin de savoir quand écrire l'en-tête (FlatFileHeaderCallback) et/ou le pied de page (FlatFileFooterCallback) notamment.

Cette interrogation levée, nous pouvons fermer cette parenthèse et revenir au writer custom produisant du JSON.

IV - FlatItemWriter ne produit pas du JSON valide !

Quand on sait qu'il est possible de produire des fichiers texte plus ou moins complexes avec FlatFileItemWriter, il est tentant de l'utiliser pour la génération du JSON.

Avec de l'imagination (débordante, pour le coup), il est peut-être possible d'arriver au résultat attendu. Ayant essayé cette solution dans un premier temps, nous n'avons obtenu qu'un JSON invalide. Et c'est logique.

En effet, pour traiter correctement un fichier JSON dans le cadre d'un batch, il faut tenir compte des contraintes liées au format.

Un fichier JSON valide ; contenant une liste d'objets peut se décomposer comme suit :

IV-A - Cas de test

Soit le fichier texte contenant les noms et prénoms ci-dessous :

1. Toto,Titi

2. Titi,Toto

3. Tata,Toto

4. Titi,Tata

5. Tata,Toto

Objectif 1 : lire les noms et prénoms de ce fichier, les transformer en (lettres capitales), puis écrire le résultat dans un fichier texte basique.

Objectif 2 : répéter la même action, mais en écrivant le résultat dans un fichier JSON. FlatItemWriter pour écriture dans un fichier texte (objectif 1).

Dans les configurations présentées plus bas, le processus de lecture est occulté, car hors scope.

Nous nous concentrons uniquement sur la partie de l'écriture dans le fichier.

IV-A-1 - Configuration de FlatItemWriter  pour l'objectif 1

Étape 1 : implémentation de LineAggregator : format chaîne de caractères de l'objet à sérialiser dans le fichier suivi d'un retour à la ligne :

1. public class PersonLineAggregator<Person> implements LineAggregator<Person> {

3. @Override

4. public String aggregate(Person person) {

5. //LINE_SEPARATOR = System.getProperty("line.separator");

6. return person.toString() + AppUtils.LINE_SEPARATOR;

7. }

8. }

Étape 2 : définition optionnelle d'un en-tête et d'un pied de page pour le fichier attendu :

1. public class PersonHeaderFooterCallBack implements FlatFileHeaderCallback, FlatFileFooterCallback{

3. private static final String OUTPUT_HEADER = "#Persons";

4. private static final String OUTPUT_FOOTER = "#eof";

6. @Override

7. public void writeHeader(Writer writer) throws IOException {

8. writer.write(OUTPUT_HEADER + AppUtils.LINE_SEPARATOR);

9. }

11. @Override

12. public void writeFooter(Writer writer) throws IOException {

13. writer.write(OUTPUT_FOOTER);

14. }

15. }

Étape 3 : configuration de FlatItemWriter pour l'écriture :

1. @Bean

2. public ItemWriter<Person> writer() {

3. FlatFileItemWriter<Person> writer = new FlatFileItemWriter<Person>();

5. writer.setLineSeparator(AppUtils.COMMA_SEPARATOR);

6. writer.setLineAggregator(new PersonLineAggregator<Person>());

8. //Setting header and footer.

9. PersonHeaderFooterCallBack headerFooterCallback = new PersonHeaderFooterCallBack();

10. writer.setHeaderCallback(headerFooterCallback);

11. writer.setFooterCallback(headerFooterCallback);

13. writer.setEncoding(AppUtils.UTF_8.name());

14. writer.setShouldDeleteIfExists(true);

15. writer.setResource(new FileSystemResource(System.getProperty("user.dir") + File.separator  + TARGET_SAMPLE_OUTPUT_DATA_TXT));

17. return writer;

18. }

Résultat de l'objectif 1 : fichier exploitable en l'état :

1. #Persons,

2. [lastname=TITI, firstname=TOTO],

3. [lastname=TOTO, firstname=TITI],

4. [lastname=TOTO, firstname=TATA],

5. [lastname=TATA, firstname=TITI],

6. [lastname=TOTO, firstname=TATA],

7. #eof

Ce format de texte peut convenir en l'état ou faire l'objet d'autres modifications, mais n'est pas inexploitable comme le deuxième cas ci-dessous.

IV-A-2 - Configuration de FlatItemWriter pour objectif 2

Pour cette partie, nous considérons passer par Jackson pour sérialiser le modèle métier en chaîne de caractères.

Étape 1 : nouvelle implémentation de LineAggregator : format de l'objet JSON à sérialiser dans le fichier :

1. ...

2. public class PersonJsonItemAggregator<Person> implements LineAggregator<Person> {

3. ...

5. @Override

6. public String aggregate(Person person) {

7. String result = null;

8. try {

9. result = JsonUtils.convertObjectToJsonString(person);

10. } catch (JsonProcessingException jpe) {

11. logger.error("An error has occured. Error message {} ", jpe.getMessage() );

12. }

13. return result;

14. }

15. }

Étape 2 : définition obligatoire d'un en-tête et d'un pied de page pour le fichier attendu :

1. public class PersonHeaderFooterCallBack implements FlatFileHeaderCallback, FlatFileFooterCallback{

3. private static final String JSON_ROOT_NODE = "Persons";

4. private JsonWriter jsonWriter;

6. @Override

7. public void writeHeader(Writer writer) throws IOException {

8. this.jsonWriter = new JsonWriter(writer);

9. jsonWriter.beginObject().name(JSON_ROOT_NODE).beginArray();

10. }

12. @Override

13. public void writeFooter(Writer writer) throws IOException {

14. jsonWriter.endArray().endObject();

15. jsonWriter.close();

16. }

17. }

Étape 3 : configuration de FlatItemWriter pour l'écriture du JSON :

1. @Bean

2. public ItemWriter<Person> writer() {

3. FlatFileItemWriter<Person> writer = new FlatFileItemWriter<Person>();

4. writer.setLineSeparator(AppUtils.COMMA_SEPARATOR);

6. //Setting header and footer.

7. PersonHeaderFooterCallBack headerFooterCallback = new PersonHeaderFooterCallBack();

8. writer.setHeaderCallback(headerFooterCallback);

9. writer.setFooterCallback(headerFooterCallback);

11. writer.setLineAggregator(new PersonJsonItemAggregator<Person>());

13. writer.setResource(new FileSystemResource(System.getProperty("user.dir") + File.separator  + TARGET_SAMPLE_OUTPUT_DATA_JSON));

14. writer.setEncoding(AppUtils.UTF_8.name());

15. writer.setShouldDeleteIfExists(true);

17. return writer;

18. }

Résultat de l'objectif 2 : fichier inexploitable, car ne respectant pas le format JSON.

C'est exactement ce problème de validation de JSON que nous avons voulu résoudre en implémentant une solution custom, réutilisable et flexible au niveau de la configuration.

V - La solution custom

Bibliothèques nécessaires à l'écriture de l'API :

1. Jackson-databind (version 2.4.6) :  ObjectMapper nous permet de sérialiser un objet en JSON sans effort dans la classe JsonUtils ;

2. Gson  (version 2.31) :  nous permet d'écrire le callback du header et footer sans effort avec la classe JsonWriter ;

3. Java 5 ou plus récent.

Cette classe suit la définition des FlatFileItemWriter et de StaxEventItemWriter et se présente telle que :

1. /**

2. * Custom writer implementation made to output json format.

3. *

4. * @author Michelle AVOMO

5. *

6. */

7. public class JsonFlatFileItemWriter<T> extends AbstractItemStreamItemWriter<T>

8. implements ResourceAwareItemWriterItemStream<T>, InitializingBean

Pour reprendre le deuxième objectif présenté plus haut, la configuration globale se définit comme sur l'exemple ci-après :

1. @Bean

2. public ItemWriter<Person> writer() {

3. //JSON_ROOT_NODE = persons but can be null and in that case just call the default constructor

4. JsonFlatFileItemWriter<Person> writer = new JsonFlatFileItemWriter<Person>(JSON_ROOT_NODE);

5. writer.setJsonItemAggregator(new JsonItemAggregator<Person>());

6. writer.setResource(new FileSystemResource(System.getProperty("user.dir") + File.separator  + TARGET_SAMPLE_OUTPUT_DATA_JSON));

7. writer.setEncoding(AppUtils.UTF_8.name());

8. writer.setShouldDeleteIfExists(true);

10. return writer;

11. }

VI - Récapitulatif

Étape 1 : instanciation de notre JsonFlatFileItemWriter : le paramètre passé en entrée du constructeur est facultatif

JsonFlatFileItemWriter<Person> writer = new JsonFlatFileItemWriter<Person>(JSON_ROOT_NODE);

Étape 2 : définition de l'instance de LineAggregator renommé au vu contexte en JsonItemAggregator

writer.setJsonItemAggregator(new JsonItemAggregator<Person>());

Ici, nous avons repris la même logique de LineAggregator de l'objectif 2 (jackson-databind définit la logique de sérialisation du Pojo dans le fichier de sortie) et rendu le tout générique.

Étape 3 : Définition du chemin du fichier de sortie attendu

writer.setResource(new FileSystemResource(System.getProperty("user.dir") + File.separator + TARGET_SAMPLE_OUTPUT_DATA_JSON));

Les autres étapes de configuration de l'encodage du texte et de suppression ou non de fichiers déjà existants sont facultatives.

Étape 4 : il n'y a pas d'étape 4. Vous pouvez lancer le traitement.

Résultat suite à la solution custom

JSON valide en sortie.

VII - Conclusion

Dans la solution proposée, nous avons gardé à l'esprit la flexibilité de configuration de Spring Batch. La solution finale requiert même moins de lignes de configuration que les versions existantes, tout simplement parce que nous avons identifié l'écriture du header et du footer, ainsi que celle des délimiteurs (virgule et espace), comme toujours vraie dans le cadre d'un fichier JSON.

VIII - Remerciements

Cet article a été publié avec l'aimable autorisation de la société Soat.

Nous tenons à remercier Claude Leloup pour la relecture orthographique, et Malick SECK pour la mise au gabarit.