Apache Camel – Transformation des données d’un fichier texte

Une erreur d'appréciation fréquente concernant Apache Camel est de croire que son utilisation est réservée à des contextes client/serveur du style mise en oeuvre de Webservice, ou d'une messagerie JMS... Il est vrai que c'est pour ce genre de besoins que bien souvent on l'utilise la première fois. A l'usage, on découvre rapidement qu'il permet de faire beaucoup d'autres choses sympathiques.

Combien de fois ai-je écrit un bout de code qui lit un fichier de données et transforme celles-ci pour utilisation dans une application? En fait, je ne sais pas exactement, mais trop souvent, c'est certain, je l'avoue, C'est le genre de code que j'ai en de multiples exemplaires, quelque part, "juste quelques adaptations" (comprendre un bon copié/collé) et hop ! Voilà, c'est fait...

Pour commencer

Grâce au composant file et aux formats de données existants, l'utilisation de Camel fournit une solution plus concise.
Par exemple, la lecture ligne par ligne d'un fichier texte et l'affichage du contenu dans les logs s'écrit avec la route:

from("file:/tmp/csv?noop=true&fileName=test.csv")
.split(body().tokenize("
"))
.log("ligne: ${body}").end();

Autrement dit : lire le fichier /tmp/csv/test.csv, éclater son contenu en morceaux en cherchant les fins de lignes, écrire le tout sur la sortie de log et c'est fini. Bon d'accord, il n'y a pas vraiment de traitement des données, mais cela reste une bonne base.

Utilisation du format Bindy

Apache Camel est plutôt bien outillé pour réaliser des transformations de données. Après un petit tour dans la documentation, je peux faire en sorte que Camel convertisse les lignes. Dans mon exemple, le fichier test.csv contient des valeurs séparées par des virgules :

nexistepas,com,2020-01-03,OWN-3121-4959
nomdomaine,fr,2013-11-14,OWN-4242-4431

Je modifie ma route pour insérer la conversion de chaque ligne en une liste de valeurs correspondant à chacune des colonnes :

from("file:/tmp/csv?noop=true&fileName=test.csv")
.split(body().tokenize("
"))
.log("ligne: ${body}")
.unmarshal().csv()
.log("colonnes: ${body}")
.end();

L'exécution de cette route avec le fichier de test donne comme résultat dans les logs :

21,737  INFO route1(212): ligne: nexistepas,com,2020-01-01,OWN-3121-4999
21,745  INFO route1(212): colonnes: [nexistepas, com, 2020-01-01, OWN-3121-4999]
21,746  INFO route1(212): ligne: nomdomaine,fr,2013-10-11,OWN-4413-4431
21,747  INFO route1(212): colonnes: [nomdomaine, fr, 2013-10-11, OWN-4413-4431]

On obtient bien une liste de String contenant les valeurs des colonnes. Il ne reste plus qu'à ajouter un bean de traitement qui utilise les valeurs. Par exemple :

public class InportSimple
{
    Logger log = LoggerFactory.getLogger(InportSimple.class);

    public void importer(final List<String> colonnes)
    {
        for( final String colonne: colonnes) {
            this.log.info("c:" + colonne);
        }
    }
}

Comme le bean ne contient qu'une seule méthode et n'a besoin de rien d'autre qu'un Logger, il est possible de le créer directement. Dans des cas plus complexes, Camel sait injecter le bean depuis Spring par exemple. De même, il pourrait implémenter Processor, ce qui a l'avantage de donner l'accès à l'objet Exchange courant. Pour la mise en oeuvre du bean, il faut modifier la route, qui devient :

from("file:/tmp/csv?noop=true&fileName=test.csv")
.split(body().tokenize("
"))
.log("ligne: ${body}")
.unmarshal().csv()
.log("colonnes: ${body}")
.bean(InportSimple.class)
.end();

La trace montre que l'on obtient bien le comportement attendu :

45,061  INFO route1(212): ligne: nexistepas,com,2020-01-01,OWN-3121-4999
45,067  INFO route1(212): colonnes: [nexistepas, com, 2020-01-01, OWN-3121-4999]
45,069  INFO InportSimple(27): c:nexistepas
45,069  INFO InportSimple(27): c:com
45,069  INFO InportSimple(27): c:2020-01-01
45,069  INFO InportSimple(27): c:OWN-3121-4999
45,071  INFO route1(212): ligne: nomdomaine,fr,2013-10-11,OWN-4413-4431
45,072  INFO route1(212): colonnes: [nomdomaine, fr, 2013-10-11, OWN-4413-4431]
45,073  INFO InportSimple(27): c:nomdomaine
45,073  INFO InportSimple(27): c:fr
45,073  INFO InportSimple(27): c:2013-10-11
45,073  INFO InportSimple(27): c:OWN-4413-4431

Il est possible d'améliorer cette solution pour obtenir directement la conversion d'une ligne en un objet. Dans le cas d'un fichier CSV, cela passe par l'utilisation du format de données Bindy. Après l'inclusion de l'artefact camel-bindy dans le classpath, il faut écrire un bean POJO correspondant aux colonnes. La liaison entre les données et les objets est spécifiée avec des annotations :

package org.netapsys.csv.descripteur;

import org.apache.camel.dataformat.bindy.annotation.CsvRecord;
import org.apache.camel.dataformat.bindy.annotation.DataField;

@CsvRecord(separator = ",", crlf = "UNIX")
public class Descripteur
{
    @DataField(pos = 1)
    private String nom = "";

    @DataField(pos = 2)
    private String zone = "";

    @DataField(pos = 3, pattern = "yyyy-MM-dd")
    private Date date = null;

    @DataField(pos = 4,required = false)
    private String titulaire = "";

    // Omission des accesseurs .../...

    // Juste pour faciliter la lecture des traces.
    @Override
    public String toString()
    {
        return "Descripteur [nom=" + getNom() + ", zone=" + getZone() + ", date=" + getDate()
                + ", titulaire=" + getTitulaire() + "]";
    }
}

Reste ensuite à modifier la route, pour demander la conversion :

from("file:/tmp/csv?noop=true&fileName=test.csv")
.split(body().tokenize("
"))
.log("ligne: ${body}")
.unmarshal().bindy(BindyType.Csv, "org.netapsys.csv.descripteur")
.bean(ImportBindy.class).end();

On utilise un autre bean pour traiter chaque objet car le format Bindy retourne une liste de dictionnaires d'objets :

public class ImportBindy
{
    Logger log = LoggerFactory.getLogger(ImportBindy.class);

    public void importer(final List<HashMap<String, Descripteur>> donnees)
    {
        for (final HashMap<String,Descripteur> dictionnaire : donnees) {
            for (final String cle : dictionnaire.keySet()) {
                    this.log.info("toString: " + dictionnaire.get(cle));
            }
        }
    }
}

La trace d'exécution permet de vérifier que c'est bien la méthode toString() de la classe Descripteur qui est finalement appelée :

55,250  INFO route1(212): ligne: nomdomaine,fr,2013-10-11,OWN-4413-4431
55,251 DEBUG BindyCsvDataFormat(178): Size of the record splitted : 4
55,251 DEBUG BindyCsvFactory(177): Pos : 1, Data : nomdomaine, Field type : class java.lang.String
55,251 DEBUG BindyCsvFactory(177): Pos : 2, Data : fr, Field type : class java.lang.String
55,252 DEBUG BindyCsvFactory(177): Pos : 3, Data : 2013-10-11, Field type : class java.util.Date
55,252 DEBUG BindyCsvFactory(177): Pos : 4, Data : OWN-4413-4431, Field type : class java.lang.String
55,252 DEBUG BindyCsvFactory(213): Counter mandatory fields : 0
55,252 DEBUG BindyCsvDataFormat(191): Graph of objects created : {org.netapsys.csv.descripteur.Descripteur=Descripteur [nom=nomdomaine, zone=fr, date=Fri Oct 11 00:00:00 CEST 2013, titulaire=OWN-4413-4431]}
55,253  INFO ImportBindy(30): toString: Descripteur [nom=nomdomaine, zone=fr, date=Fri Oct 11 00:00:00 CEST 2013, titulaire=OWN-4413-4431]

Le code présenté n'est qu'un exemple de ce qu'il est possible de réaliser avec Apache-Camel. Le même type d'approche est utilisable avec d'autre formats de données, et il existe d'autre approches envisageables pour la transformation, notamment l'utilisation du pattern Message Translator.

Le cas des gros fichiers.

A l'utilisation, le code précédent présente un défaut, qui n'apparaît que lors du traitement de "gros" fichiers. Dans ce cas, il est probable que l'on rencontre des erreurs de saturations mémoires. Par exemple, avec un fichier de 4 Mo environ, on obtient avec Java VisualVM le graphe mémoire suivant :
Evolution mémoire sans streaming

La consommation mémoire est beaucoup trop importante par rapport au traitement. C'est une conséquence de la façon dont le splitter fonctionne. Heureusement, il est possible de lire le fichier d'entrée par morceaux en mode "streaming". En ajoutant streaming() à la route :

from("file:/tmp/csv?noop=true&fileName=test.csv")
.split(body().tokenize("
"))((/public/memoire_camel/memory-csv-nostream.png|Evolution mémoire sans streaming|C))
.streaming()
.log("ligne: ${body}")
.unmarshal().bindy(BindyType.Csv, "org.netapsys.csv.descripteur")
.bean(ImportBindy.class).end();

Le graphe montre une diminution très significative de la mémoire :
Evolution mémoire avec streaming

Conclusion : l'utilisation du streaming est toujours préférable dès que le volume des données à traiter est un peu important. De plus, en mode streaming, la charge CPU est moindre car le gestionnaire mémoire est moins sollicité.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.