Les fichiers CSV avec Java

Thierry Leriche-Dessirier

Le format CSV est, aujourd'hui encore, largement utilisé. Il fait le bonheur des équipes de développement, car il est simple à manipuler. La lecture d'un fichier au format CSV demande toutefois un certain nombre de compétences. Ce document aborde les points clés à maîtriser, pas à pas avec une difficulté progressive, pour savoir traiter le célèbre CSV. Commentez ♪

- Titre : Les fichiers CSV avec Java

- Auteur : Thierry Leriche-Dessirier

- Parution : 23 juillet 2011

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2011 Thierry Leriche-Dessirier. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- 1. Introduction

- 1-A. À propos de ce document

- 1-B. À propos du code

- 1-C. À propos des tests

- 1-D. Mises à jour

- 2. Format CSV

- 2-A. Pour la consultation

- 3. Première lecture

- 3-A. Helper

- 3-B. Lecture CSV de base

- 4. Utilisation via un DAO

- 4-A. Domaine Chien

- 4-B. DAO

- 5. Ligne de titre

- 6. Données mappées

- 7. Les autres séparateurs

- 7-A. Choix

- 7-B. Autoselection du bon séparateur

- 8. Données entourées de guillemets

- 8-A. Double guillemet

- 8-B. Valeurs sur plusieurs lignes

- 9. Optimisation

- 9-A. Indexation des id dans le DAO à l'aide d'une map

- 9-A-1. Indexation des prénoms à l'aide de multimap

- 10. Autoreload et multithread

- 11. Écriture

- 12. Les frameworks du marché

- 12-A. Open Csv

- 13. Pour aller plus loin

- 13-A. Trois ans plus tard

- 14. Conclusions

- 15. Remerciements

- 16. Annexes

- 16-A. Convertir des données CSV dans Excel

- 16-B. Exemple de DAO en version base de données

- 16-C. Helper readFile avec gestion des exceptions

- 16-D. DAO avec singleton

- 16-E. Extraits de Javadocs

- 16-F. Quelques liens

- 16-G. List ou Set ?

1 - Introduction

Zip avec les sources utilisées pour cet article : tuto-csv.zip.

Les fichiers CSV sont des fichiers (texte) dans lesquels on écrit des données organisées par ligne. Ce type de fichier est très utilisé pour envoyer des données (par FTP, par email, etc.). Par exemple les résultats des tirages du loto (ordre des boules, gains, etc.) sont disponibles au téléchargement sous forme de fichier CSV sur le site de la Française des Jeux.

Les fichiers CSV (pour « Comma Separated Values ») sont légers et réellement simples à manipuler. La lecture d'un fichier CSV en Java nécessite néanmoins l'utilisation d'un certain nombre de classes et de concepts qui, bien que basiques, ne sont pas si évidents à maîtriser, en particulier lorsqu'ils sont mis bout à bout.

Quand j'explique la lecture d'un CSV à mes élèves, je vois comme de la souffrance dans leurs regards ; le puzzle est difficile à assembler. Quand c'est avec des collègues que j'aborde le même sujet, il a d'abord l'air parfaitement connu, mais plus les minutes passent et plus les concepts mis en œuvre semblent resurgir d'un passé lointain. Dans les deux cas, les questions affluent rapidement.

Cet article traite donc des manipulations sur les fichiers CSV en Java. Dans une première partie, après une présentation rapide du format CSV, je montrerai comment lire un fichier CSV de manière simplifiée, pas à pas, puis je compléterai progressivement les exemples pour aborder des points/fonctionnalités de plus en plus complexes (titres, maps, séparateurs, optimisation, synchro, autoreload, écriture, etc.)

Pour des raisons didactiques évidentes, les algorithmes utilisés sont volontairement naïfs. Un chapitre est donc spécialement dédié aux optimisations.

Les codes proposés dans cet article peuvent être tronqués et/ou incomplets pour simplifier la lecture. C'est notamment le cas des try/catch et des tests unitaires. Les sources complètes sont disponibles dans le Zip en téléchargement.

1-A - À propos de ce document

En préparant cet article, j'ai fait un petit tour sur le Net, où j'ai trouvé de nombreuses librairies et encore plus de forums qui donnent des solutions aux questions classiques quand on travaille avec les CSV.

Or la lecture des fichiers CSV nécessite la maîtrise de compétences importantes. Il est vrai que l'existence de framework dispense d'avoir à traiter ces points, mais il me semble indispensable de les aborder au moins une fois, pour comprendre de quoi il retourne vraiment, même approximativement. Cela me semble d'ailleurs d'autant plus important que cet article a initialement été pensé pour mes élèves.

Ce document a été écrit de telle sorte que chaque nouveau chapitre complète le précédent. C'est pourquoi certains blocs de code ne sont clairement pas programmés de manière « propre » et qu'il y a notamment des copiés-collés un peu partout. Dans un vrai programme, il faudrait faire un peu de ménage. D'ailleurs, le code présent dans le Zip et en annexes n'est pas tout à fait le même.

1-B - À propos du code

J'ai fait le choix du franglais (mélange de français et d'anglais) pour écrire les codes proposés dans cet article. J'utilise le français quand je parle des chiens (cf. plus bas) et l'anglais quand je traite de points techniques et/ou conventionnels (ex. getters, singleton, etc.) Ce choix est parfois effectué dans des vrais projets d'entreprise.

Bien que ce ne soit pas la voie suivie par Sun (dans le JDK), de nombreuses équipes utilisent des conventions de nommage pour distinguer rapidement les interfaces et les classes. Par exemple, en préfixant les interfaces par « I » ou en suffixant les classes par « Impl » (pour implémentation). Dans le cadre des chiens (cf. plus bas), cela donnerait donc « IChien » pour l'interface décrivant un chien et « ChienImpl » pour son implémentation. Cette pratique a l'air triviale, mais elle apporte une vraie clarté dans les programmes. La société Sun, quant à elle, aurait nommé ces mêmes objets « Chien » et « DefaultChien » (ou « SimpleChien » selon les cas). Bien qu'un peu moins clair, c'est ce dernier type de nommage qui est utilisé dans cet article.

Enfin je souhaite insister sur le fait que les codes et/ou algorithmes présentés dans cet article ont été pensés et écrits dans un but pédagogique. Ils ne traitent pas de certaines problématiques, comme la volumétrie des données (il existe des applications qui utilisent des fichiers CSV pesant plusieurs Go) ou comme la charge mémoire. Ces algorithmes ne sont donc pas destinés à une utilisation professionnelle.

1-C - À propos des tests

Les morceaux de code proposés dans cet article ont été écrits et testés dans Eclipse. J'ai créé le projet Eclipse et ajouté les dépendances nécessaires à l'aide de Maven. Pour les tests, j'utilise le framework JUnit qui fait référence. On peut trouver une bonne présentation de JUnit sur developpez.com<point>

1-D - Mises à jour

1er mars 2014 : ajout du chapitre « Trois ans plus tard » dans lequel j'explique qu'il est efficace de traiter les lignes d'un fichier CSV par petits lots, lorsque c'est possible, et que c'est le sens de l'histoire.

2 - Format CSV

Le format des fichiers CSV (Comma Separated Values) est l'un des plus simples qu'on puisse imaginer. Les données sont organisées par ligne. Une ligne peut contenir plusieurs champs/colonnes séparés par des virgules. La première ligne donne (souvent) les titres des colonnes. Bien entendu, l'ordre des colonnes est le même dans toutes les lignes.

Exemple de fichier CSV contenant des données sur des chiens : chien-test-01.csv

Id,Prénom,Couleur,Age
1,Titi,Jaune,5
2,Médor,Noir,10
3,Pitié,Noir,5
4,Juju,Gris,5
5,Vanille,Blanc,7
6,Chocolat,Marron,12
7,Milou,Blanc,3
8,Idefix,Blanc,14
9,Pluto,Jaune,17
10,Dingo,Roux,1

Comme le montre cet exemple, les fichiers CSV sont très concis et très peu verbeux (contrairement au format XML par exemple).

Le format CSV est défini par la RFC 4180 : « Common Format and MIME Type for Comma-Separated Values (CSV) Files. »

Certains éléments de cet article ne font pas partie de la RFC 4180. C'est par exemple le cas des lignes vides ou des lignes de commentaire (cf. chapitre V.Ligne de titre) qui sont néanmoins très souvent utilisées dans les « vraies » applications. En plus de la RFC 4180, il existe donc tout un ensemble de règles plus ou moins admises et qui sont présentées ici.

2-A - Pour la consultation

On voit d'un seul coup d'œil la structure des données d'un fichier CSV. Si en plus, on utilise une tabulation comme séparateur de colonne à la place de la virgule, la structure devient encore plus claire. Le changement de séparateur est abordé au chapitre « VII. Les autres séparateurs »

Le même fichier utilisant la tabulation comme séparateur

On constate, sur cet exemple, que la tabulation apporte un gain de lisibilité au lecteur, même s'il n'y a aucune différence du point de vue de l'ordinateur. Globalement la structure est claire et une seule ligne (Chocolat) possède une mise en forme légèrement décalée.

Pour avoir une vision encore plus claire d'un fichier CSV, on peut l'ouvrir directement dans un logiciel de tableur comme Microsoft Excel. Ce type de logiciel permet de manipuler directement les données.

Fichier chien-test-01.csv dans Excel après conversion

Pour ouvrir un fichier CSV depuis Eclipse, il y a deux manières. Soit on double-clique sur le fichier, ce qui l'ouvrira dans l'éditeur du système, soit on fait un clic droit sur le fichier et on choisit l'éditeur de texte pour l'ouvrir dans Eclipse. Selon l'ordinateur utilisé, l'éditeur système sera sûrement MS Excel ou Open Office. Les étapes liées à l'ouverture d'un fichier CSV avec Excel sont présentées en annexes.

3 - Première lecture

La lecture d'un fichier CSV est réalisée en trois étapes majeures : la lecture bas niveau des données dans un fichier, la prise en compte du format des données et enfin l'utilisation (transformation) des données.

3-A - Helper

Dans le cadre de la lecture bas niveau du contenu d'un fichier CSV en Java, on doit manipuler un objet File. Le fichier CSV est une ressource du programme identifiée et accédée à l'aide de cet objet File.

La recherche/récupération d'une ressource sur le disque dur est une fonctionnalité récurrente pour un logiciel de lecture CSV. Je l'ai donc programmée de manière simplifiée et factorisée dans un « helper ».

Helper

public class CsvFileHelper {

public static String getResourcePath(String fileName) {
final File f = new File("");
final String dossierPath = f.getAbsolutePath() + File.separator + fileName;
return dossierPath;
}

public static File getResource(String fileName) {
final String completeFileName = getResourcePath(fileName);
File file = new File(completeFileName);
return file;
}
}

Test du Helper

private final static String FILE_NAME = "src/test/resources/chien-test-01.csv";

@Test
public void testGetResource() {
// Param
final String fileName = FILE_NAME;

// Result
// ...

// Appel
final File file = CsvFileHelper.getResource(fileName);

// Test
// On sait que le fichier existe bien puisque c'est avec lui qu'on travaille depuis le début.
assertTrue(file.exists());
}

L'annotation « @Test » sert à indiquer que la méthode ainsi annotée correspond à un test.

Maintenant qu'on sait récupérer un fichier sur le disque dur, il faut l'ouvrir et en lire les données. Dans la mesure où les données d'un fichier CSV sont organisées par ligne, il est possible d'utiliser des objets Java comme FileReader et BufferedReader qui fonctionnent en couple et, surtout, qui simplifient sensiblement les traitements.

La classe BufferedReader possède notamment la méthode readLine permettant de lire une ligne d'un seul coup, et non caractère par caractère comme ce serait le cas avec un « reader » classique. Java sait détecter les fins de lignes.

Helper (suite)

public class CsvFileHelper {

public static List<String> readFile(File file) {

List<String> result = new ArrayList<String>();

FileReader fr = new FileReader(file);
BufferedReader br = new BufferedReader(fr);

for (String line = br.readLine(); line != null; line = br.readLine()) {
result.add(line);
}

br.close();
fr.close();

return result;
}

...
}

L'objectif de la méthode readFile est de lire un fichier CSV et d'en retourner les lignes sous forme de liste. Cette méthode permet de lire le fichier d'un coup. Le fichier n'est alors plus utile et peut donc être relâché. Les appels aux méthodes close() du FileReader et du BufferedReader servent à libérer les ressources.

La Javadoc de FileReader et de BufferedReader sont proposées en annexes.

Test du Helper (suite)

@Test
public void testReadFile() {
// Param
final String fileName = FILE_NAME;

// Result
final int nombreLigne = 11;

// Appel
final File file = CsvFileHelper.getResource(fileName);
List<String> lines = CsvFileHelper.readFile(file);

// Test
Assert.assertEquals(nombreLigne, lines.size());
}

Bien entendu, la lecture du contenu d'un fichier n'est pas si simple en vrai. Il y a des objets à initialiser, des flux à ouvrir et à fermer, et des exceptions à gérer. Un exemple de méthode readFile avec gestion des exceptions est proposé en annexes.

3-B - Lecture CSV de base

Tout au long de cet article, plusieurs versions d'un lecteur CSV sont proposées. Chaque nouvelle version complète la précédente. Pour uniformiser les versions, j'ai créé l'interface CsvFile qui définit en particulier la méthode getData(). Cette dernière renvoie une liste de tableaux où chaque élément (i.e. chaque tableau) de la liste représente une ligne du fichier CSV, et où chaque cellule du tableau correspond à une donnée (i.e. colonne) de la ligne.

Interface CsvFile

public interface CsvFile {

File getFile();

List<String[] > getData();
}

CsvFile01

public class CsvFile01 implements CsvFile {

public final static char SEPARATOR = ',';

private File file;
private List<String> lines;
private List<String[] > data;

private CsvFile01() {
}

public CsvFile01(File file) {
this.file = file;

// Init
init();
}

private void init() {
lines = CsvFileHelper.readFile(file);

data = new ArrayList<String[] >(lines.size());
String sep = new Character(SEPARATOR).toString();
for (String line : lines) {
String[] oneData = line.split(sep);
data.add(oneData);
}
}

// GETTERS ...
}

Dans cette première version, le programme lit toutes les lignes du fichier, sans distinguer les lignes vides, les commentaires, les titres, etc. Cette première version est donc clairement incomplète. Elle contient néanmoins la plupart des éléments importants.

L'ArrayList de la méthode init() est construit directement avec la bonne taille (i.e.lines.size) pour optimiser les traitements. En Java, les listes de type ArrayList sont créées avec une capacité initiale de 10 éléments. Bien entendu, cette capacité augmente au fur et à mesure qu'on ajoute de nouveaux éléments. En donnant la bonne taille dès l'initialisation, on gagne un peu en performances puisque Java n'a pas besoin d'augmenter la taille à chaque nouvel ajout dans la liste. La Javadoc de ArrayList est proposée en annexes.

Test de CsvFile01

public class CsvFile01Test {

private static final String FILE_NAME = "src/test/resources/chien-test-01.csv";
private static File file;

@BeforeClass
public static void beforeClass() {
file = CsvFileHelper.getResource(FILE_NAME);
}

@Test
public void testFile() {

// Appel
final CsvFile csvFile = new CsvFile01(file);
final File f = csvFile.getFile();

// Test
assertEquals(file, f);
}

@Test
public void testCsvFile() {

// Result
final int nombreLigne = 11;

// Appel
final CsvFile01 csvFile01 = new CsvFile01(file);
final List<String> lines = csvFile01.getLines();

// Test
assertEquals(nombreLigne, lines.size());
}

@Test
public void testData() {

// Result
final int nombreLigne = 11;
final int nombreColonnes = 4;

// Appel
final CsvFile csvFile = new CsvFile01(file);
final List<String[] > data = csvFile.getData();

// Test
assertEquals(nombreLigne, data.size());

for (String[] oneData : data) {
assertEquals(nombreColonnes, oneData.length);
}

}
}

L'annotation « @BeforeClass » sert à indiquer que la méthode ainsi annotée est exécutée juste après le chargement de la classe et avant le premier test de cette classe. L'annotation « @Before » sert à indiquer que la méthode ainsi annotée est exécutée juste avant chaque test. @BeforeClass est exécutée une seule fois tout au début des tests, mais @Before est exécutée à chaque test. @BeforeClass est lancée avant @Before.

4 - Utilisation via un DAO

Dans un programme, on évite d'accéder directement aux données brutes, notamment pour les lire en base ou depuis un fichier. On préfère travailler avec des objets du domaine/métier (des chiens dans le cadre des exemples de cet article) ou des collections d'objets. Le DAO, pour « Data Acces Object », est fait pour ça.

4-A - Domaine Chien

Sans grosse surprise, l'application qui sert de support à cet article traite de chiens. Il faut donc avoir des objets Chien. Ici j'ai créé une interface Chien et son implémentation SimpleChien comme illustré dans le diagramme de classe suivant.

Chien et SimpleChien

Ce diagramme de classe a été réalisé avec YUML : http://www.yuml.me

Comme indiqué en introduction, certaines équipes auraient plutôt nommé ces objets IChien et ChienImpl

Interface Chien

public interface Chien {
Integer getId();

String getPrenom();

String getCouleur();

Integer getAge();
}

Interface Chien

public class SimpleChien implements Chien {

private Integer id;
private String prenom;
private String couleur;
private Integer age;

@Override
public String toString() {
return prenom + "(" + id + ")";
}

// plus GETTERS / SETTERS
...
}

4-B - DAO

À ce stade, il ne reste donc plus qu'à écrire un DAO (Data Access Object) qui sera la partie du programme en charge de la lecture des données. L'interface ChienDao propose un contrat de service sans préciser où et/ou comment sont chargées/trouvées les données. Cette première version ne contient qu'une seule méthode, findAllChiens(), qui renvoie la liste complète des chiens (sous forme d'objet Chien) gérée par le programme.

Interface ChienDao

public interface ChienDao {

List<Chien> findAllChiens();
}

L'implémentation proposée, ci-dessous, est évidemment conçue autour des fichiers CSV et utilise le lecteur CsvFile présenté plus haut. Toutefois, rien n'empêcherait d'avoir une version utilisant une base de données (DatabaseChienDao) et répondant aux mêmes contrats de service. Un exemple simplifié d'un tel DAO est proposé en annexes.

L'écriture de la première implémentation de ChienDao peut se faire étape par étape, ce qui est proposé dans la suite. Tout d'abord, il faut créer la classe CsvChienDao1 implémentant ChienDao et s'assurer qu'elle compile, en ajoutant les méthodes définies dans l'interface, quitte à les laisser « vides ».

Dans cet article, j'ai clairement fait le choix d'utiliser des

DAO « avec état », c'est-à-dire des objets qui conservent des informations en mémoire.

1re implémentation (CsvChienDao1) de ChienDao

public class CsvChienDao1 implements ChienDao{

@Override
public List<Chien> findAllChiens() {

List<Chien> chiens = new ArrayList<Chien>();

// Du code ici...

return chiens;
}
}

Une fois que la classe compile, on peut écrire le (ou les) constructeurs. Ici, le constructeur sans argument a été marqué private. L'idée est d'avoir un constructeur public qui prend obligatoirement un fichier en paramètre. Ce constructeur en profite pour créer l'objet CsvFile en charge de lire le fichier.

CsvChienDao1 pas à pas : les constructeurs

public class CsvChienDao1 implements ChienDao {

private File file;
private CsvFile csvFile;

private CsvChienDao1() {
super();
}

public CsvChienDao1(File file) {
this();
this.file = file;
this.csvFile = new CsvFile01(file);
}

...

À partir de là, on peut commencer à écrire le code « intéressant » du DAO. L'algo est relativement cours. La lecture à proprement parler du fichier a été faite depuis le constructeur (par csvFile) et il ne reste qu'à parcourir la liste fournie par l'objet CsvFile et à en convertir les données.

CsvChienDao1 pas à pas : les données

@Override
public List<Chien> findAllChiens() {

List<Chien> chiens = new ArrayList<Chien>();

List<String[]> data = csvFile.getData();
for(String[] oneData : data) {
Chien chien = tabToChien(oneData);
chiens.add(chien);
}

return chiens;
}

CsvChienDao1 pas à pas : conversion

private Chien tabToChien(String[] tab) {
SimpleChien chien = new SimpleChien();

chien.setId(Integer.parseInt(tab[0]));
chien.setPrenom(tab[1]);
chien.setCouleur(tab[2]);
chien.setAge(Integer.parseInt(tab[3]));

return chien;
}

Sans grosse surprise, cette première version provoque des exceptions puisqu'on essaie de convertir aussi les titres en Integer… Il faut donc sauter la première ligne, contenant les titres, ce qui peut se faire de multiples façons.

CsvChienDao1 pas à pas : les données sans les titres

@Override
public List<Chien> findAllChiens() {

final List<Chien> chiens = new ArrayList<Chien>();

final List<String[]> data = csvFile.getData();
final List<String[]> dataSansTitre = data; // juste data
dataSansTitre.remove(0);

for(String[] oneData : dataSansTitre) {
final Chien chien = tabToChien(oneData);
chiens.add(chien);
}

return chiens;

}

Test de CsvChienDao1

public class CsvChienDao1Test {

private static final String FILE_NAME = "src/test/resources/chien-test-01.csv";
private static File file;
private static ChienDao chienDao;

@BeforeClass
public static void beforeClass() {
file = CsvFileHelper.getResource(FILE_NAME);
chienDao = new CsvChienDao1(file);
}

@Test
public void testFindAllChiens() {

// Param
// ...

// Result
final int nombreChien = 10;

// Appel
List<Chien> chiens = chienDao.findAllChiens();

// Test
assertEquals(nombreChien, chiens.size());
}

@Test
public void testIdefix() {
// Param
final String prenom = "Idefix";

// Result
final Integer id = 8;

// Appel
List<Chien> chiens = chienDao.findAllChiens();
Chien idefix = null;

for (Chien chien : chiens) {
if (chien.getPrenom().equals(prenom)) {
idefix = chien;
break;
}
}

// Test
assertNotNull(idefix);
assertEquals(id, idefix.getId());
}
}

Dans un vrai programme, la classe CsvChienDao1 aurait sans doute été conçue sous la forme d'un singleton. Le code ressemblerait alors à la classe CsvChienDao1WithSingleton, proposée en annexes. D'ailleurs, pour aller plus loin, dans un vrai programme, le DAO aurait été sans état (i.e. stateless) et les informations auraient été conservées dans un objet dédié.

5 - Ligne de titre

Comme entrevu dans les chapitres précédents, la première ligne des fichiers CSV est souvent une ligne de titre. Les éléments de cette ligne sont, grosso modo, les entêtes des colonnes. Il est donc intéressant pour notre lecteur CSV qu'il sache traiter ce type d'information. En outre, les fichiers CSV peuvent contenir des lignes vides et des commentaires dont il ne faudra donc pas tenir compte.

chien-test-02.csv : un fichier CSV avec des commentaires, des lignes vides et des titres

# Fichier avec la liste des chiens du magasin
# Propriété de Thierry

# Titres : id, Prénom, Couleur et Age.
Id,Prénom,Couleur,Age

1,Titi,Jaune,5
2,Médor,Noir,10
3,Pitié,Noir,5
4,Juju,Gris,5
5,Vanille,Blanc,7
6,Chocolat,Marron,12
7,Milou,Blanc,3
8,Idefix,Blanc,14
9,Pluto,Jaune,17
10,Dingo,Roux,1

On complète l'interface avec la méthode getTitles() qui renvoie la liste des titres/entêtes. De la même manière que la méthode getData() renvoie une liste de tableaux, la méthode getTitles() renvoie également un tableau dont la taille doit (en toute logique) correspondre à celle des tableaux de la liste.

Ajout de getTitles() dans l'interface CsvFile

public interface CsvFile {

File getFile();

List<String[] > getData();

String[] getTitles();
}

CsvFile02 sur le même modèle que CsvFile01 : avec nettoyage des lignes et sélection des titres

public class CsvFile02 implements CsvFile {

...
private String[] titles;

...
private void init() {
lines = CsvFileHelper.readFile(file);

data = new ArrayList<String[] >(lines.size());
String regex = new Character(SEPARATOR).toString();
boolean first = true;
for (String line : lines) {
// Suppression des espaces de fin de ligne
line = line.trim();

// On saute les lignes vides
if (line.length() == 0) {
continue;
}

// On saute les lignes de commentaire
if (line.startsWith("#")) {
continue;
}

String[] oneData = line.split(regex);

if (first) {
titles = oneData;
first = false;
} else {
data.add(oneData);
}
}
}

...
public String[] getTitles() {
return titles;
}
}

On profite de cette seconde version pour améliorer la lecture des lignes, en plus de distinguer les lignes de titre. On va aussi écarter les lignes vides et/ou de commentaire.

Test de CsvFile02 sur le même modèle que pour CsvFile01

private static final String FILE_NAME = "src/test/resources/chien-test-02.csv";
...

@Test
public void testData() {

// Result
final int nombreLigne = 10;
final int nombreColonnes = 4;

// Appel
final CsvFile csvFile = new CsvFile02(file);
final List<String[] > data = csvFile.getData();

// Test
assertEquals(nombreLigne, data.size());

for (String[] oneData : data) {
assertEquals(nombreColonnes, oneData.length);
}
}

@Test
public void testTitle() {

// Result
final int nombreColonnes = 4;

// Appel
final CsvFile csvFile = new CsvFile02(file);
final String[] title = csvFile.getTitles();

// Test
assertEquals(nombreColonnes, title.length);
}

Grâce à la séparation des titres et des données, le DAO peut se simplifier, puisqu'il n'y a plus besoin de filtrer la ligne de titre.

CsvChienDao2, plus simple que CsvChienDao1

public class CsvChienDao2 implements ChienDao {

...

public CsvChienDao2(File file) {
this.csvFile = new CsvFile02(file);
}

@Override
public List<Chien> findAllChiens() {

final List<Chien> chiens = new ArrayList<Chien>();

final List<String[] > data = csvFile.getData();

for (String[] oneData : data) {
final Chien chien = tabToChien(oneData);
chiens.add(chien);
}

return chiens;

}

...
}

6 - Données mappées

Le chapitre précédent montre comment séparer les lignes de données de la ligne de titre. Ce chapitre va un peu plus loin, en fournissant des colonnes non ordonnées à l'aide de Map. En effet, dans la lecture d'un fichier CSV, ce sont les données qui comptent et non l'ordre des colonnes. D'ailleurs l'habitude quand on utilise JDBC (l'API Java qui permet de se connecter aux bases de données) est de ne pas se soucier de l'ordre des colonnes puisque celles-ci sont repérées par leur nom. Ici, on veut justement reproduire ce comportement.

On complète l'interface avec la méthode getMappedData() qui renvoie les données sous forme de Map, et non plus sous forme de tableau.

Ajout de getTitles() dans l'interface CsvFile

public interface CsvFile {

File getFile();

List<String[] > getData();

String[] getTitles();

List<Map<String,String>> getMappedData();
}

CsvFile03 sur le même modèle que CsvFile02

public class CsvFile03 implements CsvFile {

...
private List<Map<String, String>> mappedData;

private void init() {
lines = CsvFileHelper.readFile(file);

data = new ArrayList<String[] >(lines.size());
String regex = new Character(SEPARATOR).toString();
boolean first = true;
for (String line : lines) {
...
}

// On mappe les lignes trouvées
mapData();
}

private void mapData() {
mappedData = new ArrayList<Map<String, String>>(data.size());

final int titlesLength = titles.length;

for (String[] oneData : data) {
final Map<String, String> map = new HashMap<String, String>();
for (int i = 0; i < titlesLength; i++) {
final String key = titles[i];
final String value = oneData[i];
map.put(key, value);
}

mappedData.add(map);
}
}

...

public List<Map<String, String>> getMappedData() {
return mappedData;
}
}

Test de CsvFile03 sur le même modèle que pour CsvFile02

private static final String FILE_NAME = "src/test/resources/chien-test-02.csv";
...

@Test
public void testMappedData() {

// Result
final int nombreLigne = 10;
final int nombreColonnes = 4;

// Appel
final CsvFile csvFile = new CsvFile03(file);
final List<Map<String, String>> mappedData = csvFile.getMappedData();

// Test
assertEquals(nombreLigne, mappedData.size());

for (Map<String, String> oneMappedData : mappedData) {
assertEquals(nombreColonnes, oneMappedData.size());
}
}

Le DAO utilise donc des maps à la place des tableaux pour créer/stoker les objets Chien (cf. méthode mapToChien() qui remplace la méthode tabToChien()).

CsvChienDao3, avec des données mappées

public class CsvChienDao3 implements ChienDao {

private CsvFile csvFile;

public CsvChienDao3(File file) {
this.csvFile = new CsvFile03(file);
}

@Override
public List<Chien> findAllChiens() {

final List<Chien> chiens = new ArrayList<Chien>();

final List<Map<String, String>> mappedData = csvFile.getMappedData();

for (Map<String, String> map : mappedData) {
final Chien chien = mapToChien(map);
chiens.add(chien);
}

return chiens;
}

private Chien mapToChien(Map<String, String> map) {
final SimpleChien chien = new SimpleChien();

final String id = map.get("Id");
final String prenom = map.get("Prénom");
final String couleur = map.get("Couleur");
final String age = map.get("Age");

chien.setId(Integer.parseInt(id));
chien.setPrenom(prenom);
chien.setCouleur(couleur);
chien.setAge(Integer.parseInt(age));

return chien;
}
}

Ce qui est intéressant avec les données mappées, en remplacement des données sous forme de tableau, c'est que l'ordre des colonnes ne compte pas. Cela veut dire que si le format du fichier change, et plus particulièrement l'ordre des colonnes, il n'y aura pas d'impact sur le code du DAO.

On peut aller un peu plus loin en décidant que les clés des maps ne tiendront pas compte des majuscules et des accents par exemple, ce qui peut éliminer des petits problèmes indétectables. Dans la proposition de code suivante, la méthode cleanKey() retourne une telle clé. Elle sert pour insérer une donnée dans la Map mais aussi pour la rechercher.

CsvFile03 avec des clés en minuscules

public class CsvFile03 implements CsvFile {

...

private void mapData() {
...

for (String[] oneData : data) {
final Map<String, String> map = new HashMap<String, String>();
for (int i = 0; i < titlesLength; i++) {
final String key = CsvFileHelper.cleanKey(titles[i]);
final String value = oneData[i];
map.put(key, value);
}

mappedData.add(map);
}
}

...
}

Helper avec la méthode cleanKey()

public class CsvFileHelper {

public static String cleanKey(String key) {
String cleanKey = key.toLowerCase();
return cleanKey;
}

...
}

7 - Les autres séparateurs

Les fichiers CSV (pour « Coma Separated Values ») sont donc des fichiers dont les lignes contiennent des valeurs séparées par des virgules. Or le choix de la « virgule » comme séparateur de données n'est pas le seul choix possible. En France par exemple, on utilise plutôt le « point-virgule » comme séparateur. Certains éditeurs préfèrent, quant à eux, les « barres » ou les « tabulations ». Bien que ces quatre séparateurs soient les plus utilisés, on peut imaginer que n'importe quel caractère (ou groupe de caractères) peut être employé.

Pourquoi les éditeurs utilisent-ils des séparateurs différents ? Il existe plusieurs éléments de réponse à cette question. Une raison culturelle, et pas des moindres, qui explique l'utilisation de différents séparateurs est l'écriture des nombres à virgule, qui justement nécessitent l'emploi d'une virgule… Dans ce cas, on doit donc utiliser un autre séparateur (comme le point-virgule par exemple) pour éviter les conflits entre la virgule du nombre et la virgule du séparateur.

chien-test-03.csv avec le point-virgule comme séparateur à la place de la virgule simple

# Fichier avec la liste des chiens du magasin
# Propriété de Thierry

# Titres id; Prénom; Couleur et Age.
Id;Prénom;Couleur;Age

1;Titi;Jaune;5
2;Médor;Noir;10
3;Pitié;Noir;5
4;Juju;Gris;5
5;Vanille;Blanc;7
6;Chocolat;Marron;12
7;Milou;Blanc;3
8;Idefix;Blanc;14
9;Pluto;Jaune;17
10;Dingo;Roux;1

7-A - Choix

Le choix du séparateur à utiliser a donc un impact fort sur les résultats de l'algo. Dans la suite, on distinguera notamment trois cas : 1) le choix du bon séparateur, tout se passe bien ; 2) le choix du mauvais séparateur, ce qui fait royalement planter le programme ; 3) le choix du mauvais séparateur, mais qui, par chance/hasard, ne fait pas planter le programme, mais donne néanmoins des résultats faux.

CsvChienDao4

public class CsvFile04 implements CsvFile {

public final static char DEFAULT_SEPARATOR = ',';

private char separator = DEFAULT_SEPARATOR;

private File file;
private List<String> lines;
private List<String[] > data;
private String[] titles;
private List<Map<String, String>> mappedData;

private CsvFile04() {
}

public CsvFile04(File file) {
this(file, DEFAULT_SEPARATOR);
}

public CsvFile04(File file, char separator) {

this.file = file;
this.separator = separator;

// Init
init();
}

private void init() {
lines = CsvFileHelper.readFile(file);

data = new ArrayList<String[] >(lines.size());
String regex = new Character(separator).toString();
boolean first = true;
for (String line : lines) {
...
}

mapData();
}

...
}

Avec cette nouvelle version, il est donc possible de spécifier le séparateur à utiliser. Bien que n'importe quel caractère, ou chaîne de caractères, puisse faire office de séparateur, j'ai décidé (pour la suite) de restreindre les séparateurs autorisés à une liste prédéfinie.

Séparateurs autorisés

public final static List<Character> AVAILABLE_SEPARATORS = Collections.unmodifiableList(new ArrayList<Character>(
Arrays.asList(',', ';', '\t', '|')));

private boolean isValidSeparator(char separator) {
return AVAILABLE_SEPARATORS.contains(separator);
}

public CsvFile04(File file, char separator) {

if (file == null) {
throw new IllegalArgumentException("Le fichier file ne peut pas être null");
}
this.file = file;

if(!isValidSeparator(separator)) {
throw new IllegalArgumentException("Le séparateur spécifié n'est pas pris en charge.");
}
this.separator = separator;

// Init
init();
}

Les tests de cette version ne diffèrent pas tellement des tests précédents.

Test de CsvFile04

public class CsvFile04Test {

private static final String FILE_NAME = "src/test/resources/chien-test-04.csv";
private static File file;

@BeforeClass
public static void beforeClass() {
file = CsvFileHelper.getResource(FILE_NAME);
}

...

@Test(expected = IllegalArgumentException.class)
public void testIllegalSeparator() {
// Param
final char separator = '-';

// Result
// ...

// Appel
final CsvFile csvFile = new CsvFile04(file, separator);
}

@Test
public void testMappedData() {

// Param
final char separator = ';';

// Result
final int nombreLigne = 10;
final int nombreColonnes = 4;

// Appel
final CsvFile csvFile = new CsvFile04(file, separator);
final List<Map<String, String>> mappedData = csvFile.getMappedData();

// Test
assertEquals(nombreLigne, mappedData.size());

for (Map<String, String> oneMappedData : mappedData) {
assertEquals(nombreColonnes, oneMappedData.size());
}
}

}

CsvChienDao4

public class CsvChienDao4 implements ChienDao {

public final static char FRENCH_SEPARATOR = ';';

private CsvFile csvFile;

public CsvChienDao4(File file) {

if (file == null) {
throw new IllegalArgumentException("Le fichier file ne peut pas être null");
}

this.csvFile = new CsvFile04(file, FRENCH_SEPARATOR);
}

...

}

Le parti pris (afin de simplifier l'exemple) de cette version du DAO est d'utiliser le point-virgule comme séparateur.

Test de CsvChienDao4

public class CsvChienDao4Test {

private static final String FILE_NAME = "src/test/resources/chien-test-04.csv";
private static File file;
private static ChienDao chienDao;

@BeforeClass
public static void beforeClass() {
file = CsvFileHelper.getResource(FILE_NAME);
chienDao = new CsvChienDao4(file);
}

...

@Test
public void testFindAllChiens() {

// Param
// ...

// Result
final int nombreChien = 10;

// Appel
List<Chien> chiens = chienDao.findAllChiens();
for (Chien chien : chiens) {
System.out.println(chien);
}
// --> Titi(1)
// --> Médor(2)
// --> Pitié(3)
// --> Juju(4)
// --> Vanille(5)
// --> Chocolat(6)
// --> Milou(7)
// --> Idefix(8)
// --> Pluto(9)
// --> Dingo(10)

// Test
assertEquals(nombreChien, chiens.size());
}

...
}

7-B - Autoselection du bon séparateur

Il arrive très souvent qu'on ne connaisse pas à l'avance le bon séparateur à utiliser pour lire un fichier CSV. Les Anglais utilisent généralement la virgule, les Français préfèrent le point-virgule qui ne pose pas de problème avec le format des nombres, tandis que certains projets ont fait le choix du pipe ou de la tabulation, etc.

Il existe plusieurs manières de « deviner » le bon séparateur à utiliser. Voici quelques propositions. Dans tous les cas, il faut lire les lignes et tenter d'analyser les contenus. On peut ainsi parcourir toutes les lignes ou se contenter des N premières. Dans la mesure où un « vrai » fichier CSV peut être très volumineux, je préfère me concentrer sur les premières lignes seulement. Ce choix n'engage que moi et les codes proposés le sont purement à titre d'exemple.

Le principe de la première version est de lire les cinq (ce nombre peut être paramétré) premières lignes et de compter le nombre d'occurrences de chacun des séparateurs possibles. Le meilleur candidat est alors celui qui donne les mêmes résultats sur toutes les lignes. Il faut bien entendu exclure les zéros.

CsvFile05

public class CsvFile05 implements CsvFile {

public final static char DEFAULT_SEPARATOR = ',';

public final static List<Character> AVAILABLE_SEPARATORS = Collections.unmodifiableList(new ArrayList<Character>(
Arrays.asList(',', ';', '\t', '|')));

private boolean autoDetectSeparatorMode = false;
private int numberOfLinesForAutoDetectSeparator = 5;
private char separator = DEFAULT_SEPARATOR;

private List<String> lines;
private List<String> cleanedLines;
...

public CsvFile05(File file) {
this(file, DEFAULT_SEPARATOR);
}

public CsvFile05(File file, char separator) {
this(file, separator, false);
}

public CsvFile05(File file, char separator, boolean autoDetectSeparatorMode) {

if (file == null) {
throw new IllegalArgumentException("Le fichier file ne peut pas être null");
}

this.file = file;

if (!autoDetectSeparatorMode) {
if (!isValidSeparator(separator)) {
throw new IllegalArgumentException("Le séparateur spécifié n'est pas pris en charge.");
}
this.separator = separator;
}

this.autoDetectSeparatorMode = autoDetectSeparatorMode;

// Init
init();
}

public Character selectBestSeparator() {

if (lines.size() == 0) {
// Exception ?...
}

// Ajustement du nombre de lignes dispo
if (cleanedLines.size() < numberOfLinesForAutoDetectSeparator) {
numberOfLinesForAutoDetectSeparator = cleanedLines.size();
}

List<Character> reste = new ArrayList<Character>();

for (Character separator : AVAILABLE_SEPARATORS) {
int previous = 0;
boolean isGoodCandidate = false;
for (int i = 0; i < numberOfLinesForAutoDetectSeparator; i++) {
int compte = compterSeperateurs(cleanedLines.get(i), separator);
if (compte == 0) {
// pas de séparateur dans cette ligne
isGoodCandidate = false;
break;
}
if (compte != previous && previous != 0) {
// pas le même nombre de séparateurs que la ligne précédente
isGoodCandidate = false;
break;
}

previous = compte;
isGoodCandidate = true;
}
if (isGoodCandidate) {
reste.add(separator);
}
}

if (reste.isEmpty()) {
// Exception ? aucun candidat
}

if (1 < reste.size()) {
// Exception ? trop de candidats
}

return reste.get(0);

}

public int compterSeperateurs(String line, char separator) {
int number = 0;

int pos = line.indexOf(separator);
while (pos != -1) {
number++;
line = line.substring(pos + 1);
pos = line.indexOf(separator);
}
return number;
}

private boolean isValidSeparator(char separator) {
return AVAILABLE_SEPARATORS.contains(separator);
}

private void init() {
lines = CsvFileHelper.readFile(file);

cleanedLines = new ArrayList<String>();
for (String line : lines) {
// Suppression des espaces de fin de ligne
line = line.trim();

// On saute les lignes vides
if (line.length() == 0) {
continue;
}

// On saute les lignes de commentaire
if (line.startsWith("#")) {
continue;
}

cleanedLines.add(line);
}

if (autoDetectSeparatorMode) {
this.separator = selectBestSeparator();
}

data = new ArrayList<String[] >(cleanedLines.size());
String regex = new Character(separator).toString();
boolean first = true;
for (String line : cleanedLines) {
...
}

mapData();
}
...
}

C'est ici que l'on voit pourquoi j'utilise une List et non un Set pour définir la liste des séparateurs valides AVAILABLE_SEPARATORS. En effet, l'ordre de cette liste donne une sorte de priorité utilisée par l'algo. Le premier séparateur qui semble bon sera choisi. Or il n'est pas impossible que deux séparateurs aient l'air valides en même temps, d'où le besoin d'un ordonnancement.

Test de CsvFile05

private static final String FILE_NAME = "src/test/resources/chien-test-04.csv";

@BeforeClass
public static void beforeClass() {
file = CsvFileHelper.getResource(FILE_NAME);
}

@Test
public void testCompterSeperateurs() {

// Param
final char separator = ',';
final String line = "1,Titi,Jaune,5";

// Result
final int nombre = 3;

// Appel
final CsvFile05 csvFile = new CsvFile05(file, separator, true);
final int compte = csvFile.compterSeperateurs(line, separator);

// Test
assertEquals(nombre, compte);

}

@Test
public void testSelectBestSeparator() {
// Param
// ..

// Result
final Character separator = ';';

// Appel
final CsvFile05 csvFile = new CsvFile05(file, separator, true);
final Character separatorCalcule = csvFile.selectBestSeparator();

// Test
assertEquals(separator, separatorCalcule);

}

@Test
public void testMappedData() {

// Param
final char separator = ';';

// Result
final int nombreLigne = 10;
final int nombreColonnes = 4;

// Appel
final CsvFile csvFile = new CsvFile05(file, separator, true);
final List<Map<String, String>> mappedData = csvFile.getMappedData();

// Test
assertEquals(nombreLigne, mappedData.size());

for (Map<String, String> oneMappedData : mappedData) {
assertEquals(nombreColonnes, oneMappedData.size());
}
}

Le DAO (CsvChienDao5) et son test (CsvChienDao5Test) correspondant à l'utilisation de cette version du CsvFile (CsvFile05) ne sont pas reproduits dans ce document, mais sont disponibles dans le Zip.

Le principe de la seconde version est de ne compter les occurrences que pour la ligne de titre (i.e. la première) et pour la deuxième ligne. On fait le compte dans l'ordre des séparateurs (d'où la List et non un Set) et on s'arrête dès que cela correspond. L'idée conductrice est que les titres ne contiennent normalement pas de données bizarres.

L'algorithme utilisé pour déterminer le bon séparateur dans CsvFile05 peut être réutilisé à moindre coût pour cette nouvelle version, puisqu'elle fait déjà ce qu'il faut (ce qui d'ailleurs semble valider que cette stratégie ait du sens).

CsvFile06

public class CsvFile06 implements CsvFile {
...

public Character selectBestSeparator() {

if (cleanedLines.size() < 2) {
// Exception ?...
}

for (Character separator : AVAILABLE_SEPARATORS) {
final String ligneTitre = cleanedLines.get(0);
final String ligne1 = cleanedLines.get(1);

final int compteLigneTitre = compterSeperateurs(ligneTitre, separator);
final int compteLigne1 = compterSeperateurs(ligne1, separator);

if (compteLigneTitre == 0 || compteLigne1 == 0) {
continue;
}

if (compteLigneTitre == compteLigne1) {
return separator;
}
}

return null;
}

...

Le test de CsvFile06 (i.e. CsvFile06Test) ainsi que son DAO est disponible dans le Zip.

Les stratégies proposées ici sont loin d'être parfaites. Il faut les considérer comme de simples illustrations.

8 - Données entourées de guillemets

Il peut arriver (souvent) qu'un des champs du fichier CSV contienne le « séparateur ». Par exemple, un nombre à virgule (3,14) contient le caractère virgule. Quand un champ contient le séparateur (virgule, point-virgule, etc.), il est obligatoire d'ajouter des guillemets autour du champ pour que le séparateur soit échappé (c'est-à-dire ne soit pas pris en compte).

chien-test-07b.csv : un fichier CSV avec des guillemets

# Fichier avec la liste des chiens du magasin
# Propriété de Thierry

# Titres id; Prénom; Couleur et Age.
"Id";"Prénom";"Couleur";"Age"

"1";"Titi";"Jaune";"5"
"2";"Médor";"Noir";"10"
"3";"Pitié";"Noir";"5"
"4";"Juju";"Gris";"5"
"5";"Vanille";"Blanc";"7"
"6";"Chocolat";"Marron";"12"
"7";"Milou";"Blanc";"3"

# La ligne suivante (Idefix) a trois couleurs avec un point-virgule dedans
"8";"Idefix";"Blanc; noir et beige";"14"

"9";"Pluto";"Jaune";"17"
10;Dingo;Roux;1

Dans le fichier « chien-test-07b.csv », la ligne « Idéfix » possède un champ qui contient le séparateur (i.e. le point-virgule). Du coup tous les champs du fichier sont entourés de guillemets. On pourrait penser que cette stratégie est bien pratique, mais elle alourdit inutilement le fichier CSV et le rend illisible. En outre ce n'est pas l'affaire de notre « reader CSV » puisqu'il doit lire les fichiers qu'on lui donne et n'a pas la main sur leur création.

Des logiciels comme Open Office entourent systématiquement tous les champs avec des guillemets, même lorsque cela n'est pas nécessaire.

En réalité, seule la ligne « Idéfix » a besoin de guillemets.

chien-test-07c.csv : un fichier CSV avec des guillemets uniquement pour Idéfix

# Fichier avec la liste des chiens du magasin
# Propriété de Thierry

# Titres id; Prénom; Couleur et Age.
Id;Prénom;Couleur;Age

1;Titi;Jaune;5
2;Médor;Noir;10
3;Pitié;Noir;5
4;Juju;Gris;5
5;Vanille;Blanc;7
6;Chocolat;Marron;12
7;Milou;Blanc;3

# La ligne suivante (Idefix) a trois couleurs avec un point-virgule dedans
8;Idefix;"Blanc; noir et beige";14

9;Pluto;Jaune;17
10;Dingo;Roux;1

Cette nouvelle contrainte nous conduit à utiliser des expressions régulières (regex) plus complexes qu'un simple séparateur.

CsvFile07

public class CsvFile07 extends AbstractAdvanceCsvFile {

public CsvFile07(File file) {

if (file == null) {
throw new IllegalArgumentException("Le fichier file ne peut pas être null");
}

this.file = file;

if (!isValidSeparator(separator)) {
throw new IllegalArgumentException("Le séparateur spécifié n'est pas pris en charge.");
}

// Init
init();
}

private void init() {
lines = CsvFileHelper.readFile(file);

cleanLines();

data = new ArrayList<String[] >(cleanedLines.size());

final String regex = "(^|(?<=;))([^\";])*((?=;)|$)|((?<=^\")|(?<=;\"))([^\"]|\"\")*((?=\";)|(?=\"$))";
Pattern p = Pattern.compile(regex);
boolean first = true;
for (String line : cleanedLines) {
Matcher m = p.matcher(line);

List<String> temp = new ArrayList<String>();
while (m.find()) {
temp.add(m.group());
}

String[] oneData = listToArray(temp);
if (first) {
titles = oneData;
first = false;
} else {
data.add(oneData);
}
}

mapData();
}

private String[] listToArray(List<String> liste) {
String[] oneData = new String[liste.size()];
for (int i = 0; i < oneData.length; i++) {
oneData[i] = liste.get(i);
}
return oneData;
}

// GETTERS

}

Ce bout de code apporte des nouveautés avec l'utilisation des classes Pattern et Matcher du JDK. Ces classes permettent de travailler avec une « vraie » regex et non pas simplement un char comme dans les versions précédentes.

CsvFile07 avec juste la gestion des regex

public class CsvFile07 extends AbstractAdvanceCsvFile {

...

private void init() {
...

final String regex = "(^|(?<=;))([^\";])*((?=;)|$)|((?<=^\")|(?<=;\"))([^\"]|\"\")*((?=\";)|(?=\"$))";
Pattern p = Pattern.compile(regex);

for (String line : cleanedLines) {
Matcher m = p.matcher(line);
...

while (m.find()) {
temp.add(m.group());
}

...
}

mapData();
}

...
}

Il y a un article d'introduction aux regex sur developpez.com, écrit par « Cyberzoide », que je vous invite à consulter.

Test de CsvFile07

public class CsvFile07cTest {

private static final String FILE_NAME = "src/test/resources/chien-test-07c.csv";

@Test
public void testMappedData() {

// Param

// Result
final int nombreLigne = 10;
final int nombreColonnes = 4;

// Appel
final CsvFile csvFile = new CsvFile07(file);
final List<Map<String, String>> mappedData = csvFile.getMappedData();
List<String[] > data = csvFile.getData();
for(String[] oneData:data) {
for(String s:oneData) {
System.out.println(s);
}
System.out.println("--------");
}

// Test
assertEquals(nombreLigne, mappedData.size());

for (Map<String, String> oneMappedData : mappedData) {
assertEquals(nombreColonnes, oneMappedData.size());
}
}

}

Dans cette version, la classe n'offre pas le choix du séparateur. Elle impose le point-virgule. Toutefois, on peut améliorer le programme pour que la regex prenne en compte un séparateur spécifique.

CsvFile07 avec un séparateur spécifique

private void init() {
...

// "(^|(?<=;))([^\";])*((?=;)|$)|((?<=^\")|(?<=;\"))([^\"]|\"\")*((?=\";)|(?=\"$))";

final String regex = "(^|(?<=" + separator + "))([^\"" + separator + "])*((?=" + separator
+ ")|$)|((?<=^\")|(?<=" + separator + "\"))([^\"]|\"\")*((?=\"" + separator + ")|(?=\"$))";
Pattern p = Pattern.compile(regex);

...
}

On peut également laisser le programme deviner seul le bon séparateur, en s'inspirant des algos proposés plus haut.

8-A - Double guillemet

Les champs peuvent donc également être délimités par des guillemets. Lorsqu'un champ contient lui-même des guillemets, ils sont doublés afin de ne pas être considérés comme début ou fin du champ.

chien-test-08.csv : un fichier CSV avec des doubles guillemets

# Titres id; Prénom; Couleur et Age.
Id;Prénom;Couleur;Age

# Titi a une couleur avec doubles guillemets
1;Titi;"Jaune ""bizarre"" et noir";5

2;Médor;Noir;10
3;Pitié;Noir;5
4;Juju;Gris;5
5;Vanille;Blanc;7
6;Chocolat;Marron;12
7;Milou;Blanc;3

# La ligne suivante (Idefix) a trois couleurs avec un point-virgule dedans
8;Idefix;"Blanc; noir et beige";14

9;Pluto;Jaune;17
10;Dingo;Roux;1

Pour traiter le cas du « double guillemet », on peut au choix complexifier l'expression régulière proposée dans CsvFile07 ou faire une mini retouche sur le résultat de l'appel à la fonction « group() ». Pour des raisons de simplicité, c'est cette seconde proposition que j'ai choisie. Attention néanmoins, cette « retouche » fait un traitement sur des String, ce qui est déconseillé dans un programme traitant une grosse quantité de données et ayant besoin de performances avancées.

CsvFile08

public class CsvFile08 extends AbstractAdvanceCsvFile {

...

private void init() {
lines = CsvFileHelper.readFile(file);

cleanLines();

data = new ArrayList<String[] >(cleanedLines.size());

final String regex = "(^|(?<=" + separator + "))([^\"" + separator + "])*((?=" + separator
+ ")|$)|((?<=^\")|(?<=" + separator + "\"))([^\"]|\"\")*((?=\"" + separator + ")|(?=\"$))";
Pattern p = Pattern.compile(regex);
boolean first = true;
for (String line : cleanedLines) {
Matcher m = p.matcher(line);

List<String> temp = new ArrayList<String>();
while (m.find()) {
String value = m.group();
value = value.replaceAll("\"\"", "\"");
temp.add(value);
}

String[] oneData = listToArray(temp);
if (first) {
titles = oneData;
first = false;
} else {
data.add(oneData);
}
}

mapData();
}

...
}

Le test associé n'est pas présenté ici, car il n'apporte rien de nouveau par rapport à CsvFile07Test. Il est néanmoins fourni dans le Zip.

8-B - Valeurs sur plusieurs lignes

Les champs peuvent contenir des retours à la ligne, par exemple si le champ correspond à un article de journal composé de plusieurs paragraphes. Dans ce cas, le champ doit être entouré de guillemets.

chien-test-09.csv : un fichier CSV avec des retours à la ligne dans un champ

# Titres id; Prénom; Couleur et Age.
Id;Prénom;Couleur;Age

# Titi a une couleur avec doubles guillemets
1;Titi;"Jaune ""bizarre""";5

2;Médor;Noir;10
3;Pitié;Noir;5
4;Juju;Gris;5
5;Vanille;Blanc;7
6;Chocolat;Marron;12

# Le prénom de Milou est sur plusieurs lignes
7;"Milou
Chien de Tintin
Ami du Capitaine Addock";Blanc;3

# La ligne suivante (Idefix) a trois couleurs avec un point-virgule dedans
8;Idefix;"Blanc; noir et beige";14

9;Pluto;Jaune;17
10;Dingo;Roux;1

La prise en compte des champs sur plusieurs lignes est incompatible avec le plus gros des codes proposés plus haut puisque ceux-ci traitent les lignes une par une. Si on veut continuer d'utiliser les regex, on doit donc lire tout le fichier d'un coup, ce qui peut se faire en concaténant les lignes lues à partir de la liste fournie par readFile() ou en écrivant une méthode dédiée.

Il est possible aussi de traiter les champs en même temps qu'on lit le fichier, en écrivant des structures de contrôle (if, switch) pour gérer chaque caractère, notamment les guillemets ouvrant ou fermant, les slash, les guillemets précédés de slash, etc. Il y a grosso modo deux manières de traiter cette piste. Soit on lit tout le fichier, dont on place le contenu dans un String, puis on traite chaque caractère de ce String, soit on lit le fichier caractère par caractère et on en profite pour traiter ce caractère à la volée.

Pour lire un fichier entier, on utilise un (mauvais) code ressemblant au suivant.

Lecture dédiée (sans le try)

public static String readFullFile(File file) {
StringBuilder sb = new StringBuilder();

FileReader reader = new FileReader(file);

char[] buffer = new char[2048];
int nb = 0;
while ((nb = reader.read(buffer)) > 0) {
sb.append(buffer, 0, nb);
}

reader.close();

return sb.toString();
}

Ici j'utilise un « buffer » qui fait globalement le même travail que le BufferedReader utilisé plus haut, pour des raisons évidentes de performances. Du coup, autant reprendre le code de readFile() et concaténer les lignes.

Avec concaténation des lignes

public static String readFullFile(File file) {
final List<String> lines = CsvFileHelper.readFile(file);

StringBuilder sb = new StringBuilder();

for(String line : lines) {
sb.append(line);
sb.append("\n");     // pour recréer le retour à la ligne
}

return sb.toString();
}

Il ne reste plus qu'à traiter le String résultat, caractère par caractère. On notera que le fait de n'utiliser que le caractère «

» comme fin de ligne va un peu simplifier les traitements…

Avec concaténation des lignes

public List<String[] > readFileAsStringThenTab(File file) {
final String str = readFullFile(file);

for(int i = 0; i < str.length(); i++) {
char c = str.charAt(i);

// ici traiter le char c...
processChar(c);
}
}

Ici, je ne donne pas d'exemple de méthode « processChar(c) », car la seconde approche (traiter les caractères en même temps que la lecture) me parait plus appropriée, notamment avec les gros fichiers.

Pour lire le fichier et traiter les caractères à la volée, il faut revenir à la première solution.

Traitement à la volée

...

reader = new FileReader(file);
for(char c = (char) reader.read(); c != '\0'; c = (char) reader.read() ) {
// ici traiter le char c...
processChar2(c);
}

...

Le code de « processChar2(c) » est relativement simple et constitué, comme indiqué plus haut, de structures de contrôle.

9 - Optimisation

Les codes proposés plus haut ne sont clairement pas performants. Ils font des recherches dans du texte, ce qui est coûteux, qui plus est en utilisant des regex (expressions régulières) complexes.

9-A - Indexation des id dans le DAO à l'aide d'une map

De la même manière qu'une base de données indexe ses enregistrements, rien n'interdit au CsvDao d'indexer ses données. Le cas le plus simple est celui de l'id qui est unique. Une simple Map suffit pour traiter ce cas…

Mapper les id

public interface ChienDao {

List<Chien> findAllChiens();

Chien findChienById(Integer id);
}

Comme il est nécessaire de lire le fichier au moins une fois, on en profite pour mapper les id des chiens au passage. Ceci oblige à réécrire une partie des classes proposées ci-dessus, notamment en créant la classe abstraite AbstractChienDao.

Classe abstraite AbstractChienDao

public abstract class AbstractChienDao implements ChienDao {

protected CsvFile csvFile;

private Map<Integer, Chien> chiensMap;
private List<Chien> chiens;

protected Chien tabToChien(String[] tab) {

if (tab == null) {
throw new IllegalArgumentException("Le tableau ne peut pas être null");
}

final SimpleChien chien = new SimpleChien();

chien.setId(Integer.parseInt(tab[0]));
chien.setPrenom(tab[1]);
chien.setCouleur(tab[2]);
chien.setAge(Integer.parseInt(tab[3]));

return chien;
}

protected Chien mapToChien(Map<String, String> map) {
if (map == null) {
throw new IllegalArgumentException("La Map ne peut pas être null");
}

final SimpleChien chien = new SimpleChien();

final String id = map.get("Id");
final String prenom = map.get("Prénom");
final String couleur = map.get("Couleur");
final String age = map.get("Age");

chien.setId(Integer.parseInt(id));
chien.setPrenom(prenom);
chien.setCouleur(couleur);
chien.setAge(Integer.parseInt(age));

return chien;
}

protected List<Chien> findAllChiensByMap() {
if (chiens == null) {
init();
}

return chiens;
}

public Chien findChienById(Integer id) {
if (chiensMap == null) {
init();
}

return chiensMap.get(id);
}

protected void init() {
final List<Map<String, String>> mappedData = csvFile.getMappedData();

// On init avec la bonne taille
chiens = new ArrayList<Chien>(mappedData.size());
chiensMap = new HashMap<Integer, Chien>();

for (Map<String, String> map : mappedData) {
final Chien chien = mapToChien(map);
chiens.add(chien);
chiensMap.put(chien.getId(), chien);
}
}
}

CsvChienDao10

public class CsvChienDao10 extends AbstractChienDao implements ChienDao {

private CsvChienDao10() {
}

public CsvChienDao10(File file) {
if (file == null) {
throw new IllegalArgumentException("Le fichier file ne peut pas être null");
}

this.csvFile = new CsvFile06(file, ';', true);
}

@Override
public List<Chien> findAllChiens() {
return findAllChiensByMap();
}
}

et le test

public class CsvChienDao10Test {
private static final String FILE_NAME = "src/test/resources/chien-test-04.csv";
private static File file;
private static ChienDao chienDao;

@BeforeClass
public static void beforeClass() {
file = CsvFileHelper.getResource(FILE_NAME);
chienDao = new CsvChienDao10(file);
}

@Test
public void testFindAllChiens() {

// Param
// ...

// Result
final int nombreChien = 10;

// Appel
List<Chien> chiens = chienDao.findAllChiens();
for (Chien chien : chiens) {
System.out.println(chien);
}
// --> Titi(1)
// --> Médor(2)
// --> Pitié(3)
// --> Juju(4)
// --> Vanille(5)
// --> Chocolat(6)
// --> Milou(7)
// --> Idefix(8)
// --> Pluto(9)
// --> Dingo(10)

// Test
assertEquals(nombreChien, chiens.size());
}

@Test
public void testFindById() {
// Param
final Integer id = 8;

// Result
final String prenom = "Idefix";

// Appel
Chien chien = chienDao.findChienById(id);
System.out.println(chien);
// --> Idefix(8)

// Tests
assertEquals(prenom, chien.getPrenom());
}
}

Dans ce code, la méthode init() est marquée protected et non private pour que le DAO qui hérite de AbstractChienDao puisse lancer l'initialisation des listes sans attendre les éventuels appels aux méthodes « find », par exemple depuis le constructeur. Un développeur peut en effet décider d'effectuer l'initialisation dès le lancement du programme. Le démarrage prend donc plus de temps, mais la fonctionnalité « find » est d'autant plus rapide lorsqu'on l'utilise. En général les développeurs font ce choix au cas par cas.

CsvChienDao10 avec pré initialisation depuis le constructeur

public class CsvChienDao10AvecPreInit extends AbstractChienDao implements ChienDao {

public CsvChienDao10AvecPreInit(File file) {

...
this.csvFile = new CsvFile06(file, ';', true);

init(); // Initialisation dès le constructeur
}

...
}

9-A-1 - Indexation des prénoms à l'aide de multimap

On peut également indexer les prénoms. Puisqu'ils ne sont pas supposés être uniques, il faut utiliser un mécanisme de MultiMap.

Une multimap, c'est une map de liste ou une map de map. Des frameworks comme Google-Collections offrent des fonctionnalités MultiMap avancées. Toutefois, dans le cadre du ChienDao, une Map spécifique, contenant une liste comme valeur, suffit amplement. J'invite le lecteur à découvrir les fonctionnalités offertes par Google en lisant mon article d'introduction aux Google-Collections sur developpez.com.

Find By Prenom

public interface ChienDao {

List<Chien> findAllChiens();

Chien findChienById(Integer id);

List<Chien> findChiensByPrenom(String prenom);
}

Avec une multimap

public abstract class AbstractChienDao implements ChienDao {

private Map<String, List<Chien>> chiensMultimapByPrenom;

...

public List<Chien> findChiensByPrenom(String prenom) {
if (chiensMap == null) {
init();
}

return chiensMultimapByPrenom.get(prenom);
}

protected void init() {
...

// On ne connait pas la taille de la multimap à l'avance
chiensMultimapByPrenom = new HashMap<String, List<Chien>>();

for (Map<String, String> map : mappedData) {
...

addChienToMultimap(chien, chiensMultimapByPrenom, chien.getPrenom());
}
}

private void addChienToMultimap(Chien chien, Map<String, List<Chien>> multimap, String key) {
List<Chien> sublist = multimap.get(key);
if (sublist == null) {
sublist = new ArrayList<Chien>();
multimap.put(key, sublist);
}
sublist.add(chien);
}
}

chien-test-11.csv : un fichier CSV avec 2 chiens nommés Vanille

# Titres id; Prénom; Couleur et Age.
Id;Prénom;Couleur;Age

1;Titi;Jaune;5
2;Médor;Noir;10
3;Pitié;Noir;5
4;Juju;Gris;5
5;Vanille;Blanc;7
6;Chocolat;Marron;12
7;Milou;Blanc;3
8;Idefix;Blanc;14
9;Pluto;Jaune;17
10;Dingo;Roux;1

# 2nd chien Vanille
11;Vanille;Beige;13

et le test

public class CsvChienDao11Test {
private static final String FILE_NAME = "src/test/resources/chien-test-11.csv";
private static File file;
private static ChienDao chienDao;

@BeforeClass
public static void beforeClass() {
file = CsvFileHelper.getResource(FILE_NAME);
chienDao = new CsvChienDao10(file);
}

@Test
public void testFindByPrenomChocolat() {
// Param
final String prenom = "Chocolat";

// Result
final int nb = 1;

// Appel
List<Chien> chiens = chienDao.findChiensByPrenom(prenom);
System.out.println(chiens);
// --> [Chocolat(6)]

// Tests
assertEquals(nb, chiens.size());
assertEquals(prenom, chiens.get(0).getPrenom());
}

@Test
public void testFindByPrenomVanille() {
// Param
final String prenom = "Vanille";

// Result
final int nb = 2;

// Appel
List<Chien> chiens = chienDao.findChiensByPrenom(prenom);
System.out.println(chiens);
// --> [Vanille(5), Vanille(11)]

// Tests
assertEquals(nb, chiens.size());
assertEquals(prenom, chiens.get(0).getPrenom());
assertEquals(prenom, chiens.get(1).getPrenom());
}
}

Bien entendu, dans un vrai programme, on chercherait plutôt les chiens dont le nom ressemble à celui passé en paramètre, sans forcément tenir compte des majuscules et/ou des accents. Un tel programme aurait même autorisé des mauvais caractères dans le prénom, mais ce n'est pas l'objet de cet article.

De manière générale, on réservera cette technique de « cache » à l'aide de Map aux champs les plus sollicités.

10 - Autoreload et multithread

Même si ce n'est pas l'objet des fichiers CSV (qui doivent être utilisés pour transmettre des infos et non pour les stocker) on peut imaginer que plusieurs programmes lisent ou modifient un fichier CSV en même temps. Cela pose des questions quant à la gestion des ressources et à la détection des éventuelles modifications (par un autre programme) durant la lecture. Les nouvelles versions de Java-IO (java 7 ou 8) devraient apporter des réponses simples.

11 - Écriture

L'écriture d'un fichier CSV est simplement l'inverse de la lecture. On peut même dire que c'est plus simple puisqu'on à la main sur tous les paramètres, notamment les formats utilisés. Je vais donc suivre une démarche similaire à celle utilisée pour la lecture, mais en sautant les étapes.

On commence par créer l'interface CsvFileWriter, porteuse des contrats de service qui nous intéressent, puis on peut se concentrer sur une première implémentation.

Interface CsvFileWriter

public interface CsvFileWriter {

void write(List<Map<String, String>> mappedData);

void write(List<Map<String, String>> mappedData, String[] titles);
}

CsvFileWriter01

public class CsvFileWriter01 implements CsvFileWriter {

private File file;
private char separator;

public CsvFileWriter01(File file) {
this(file, ';');
}

public CsvFileWriter01(File file, char separator) {
this();

if (file == null) {
throw new IllegalArgumentException("Le fichier ne peut pas etre nul");
}

this.file = file;
this.separator = separator;
}

private void writeEmptyFile() {
...
}

@Override
public void write(List<Map<String, String>> mappedData) {

if (mappedData == null) {
throw new IllegalArgumentException("la liste ne peut pas être nulle");
}

if (mappedData.isEmpty()) {
writeEmptyFile();
}
final Map<String, String> oneData = mappedData.get(0);

final String[] titles = new String[oneData.size()];

int i = 0;
for (String key : oneData.keySet()) {
titles[i++] = key;
}
write(mappedData, titles);
}

@Override
public void write(List<Map<String, String>> mappedData, String[] titles) {

if (mappedData == null) {
throw new IllegalArgumentException("la liste ne peut pas être nulle");
}

if (titles == null) {
throw new IllegalArgumentException("les titres ne peuvent pas être nuls");
}

if (mappedData.isEmpty()) {
writeEmptyFile();
}

FileWriter fw = new FileWriter(file);
BufferedWriter bw = new BufferedWriter(fw);

// Les titres
boolean first = true;
for (String title : titles) {
if (first) {
first = false;
} else {
bw.write(separator);
}
write(title, bw);
}
bw.write("\n");

// Les données
for (Map<String, String> oneData : mappedData) {
first = true;
for (String title : titles) {
if (first) {
first = false;
} else {
bw.write(separator);
}
final String value = oneData.get(title);
write(value, bw);

}
bw.write("\n");

bw.close();
fw.close();
}
}

private void write(String value, BufferedWriter bw) throws IOException {

if (value == null) {
value = "";
}

boolean needQuote = false;

if (value.indexOf("\n") != -1) {
needQuote = true;
}

if (value.indexOf(separator) != -1) {
needQuote = true;
}

if (value.indexOf("\"") != -1) {
needQuote = true;
value = value.replaceAll("\"", "\"\"");
}

if(needQuote) {
value = "\"" + value + "\"";
}

bw.write(value);
}
}

Ici, c'est bien entendu la méthode write(String, BufferedWriter) qui réalise la partie importante du travail lié au format CSV. Les autres méthodes write(..) se concentrent sur les séparateurs et les éléments techniques.

Les classes FileWriter et BufferedWriter fonctionnent selon les mêmes principes que FileWriter et BufferedWriter, déjà abordées plus haut.

Les Javadocs de FileWriter et de BufferedWriter sont proposées en annexes.

Test de CsvFileWriter01

private static final String FILE_NAME = "out/chien-test-out01.csv";

private static File file;
private static CsvFileWriter csvFileWriter;

@BeforeClass
public static void beforeClass() {
file = CsvFileHelper.getResource(FILE_NAME);
csvFileWriter = new CsvFileWriter01(file);
}

private List<Map<String, String>> createMap() {

List<Map<String, String>> data = new ArrayList<Map<String, String>>();

Map<String, String> oneData1 = new HashMap<String, String>();
oneData1.put("Id", "1");
oneData1.put("Prénom", "Idéfix");
oneData1.put("Couleur", "Blanc");
oneData1.put("Age", "15");
data.add(oneData1);

Map<String, String> oneData2 = new HashMap<String, String>();
oneData2.put("Id", "2");
oneData2.put("Prénom", "Milou \"de Tintin\"");
oneData2.put("Couleur", "Blanc");
oneData2.put("Age", "7");
data.add(oneData2);

return data;
}

@Test
public void testWrite() {
// Param
final List<Map<String, String>> data = createMap();

// Resultat attendu
final String[] wantedTitles = { "Age", "Couleur", "Prénom", "Id" };

// Appel
csvFileWriter.write(data);

final CsvFile csvFile = new CsvFile07(file, ';');
final String[] titlesFromFile = csvFile.getTitles();

// Test
// pas d'ordre dans les titres
for (String title : titlesFromFile) {
assertTrue(isInTab(title, wantedTitles));
}

}

@Test
public void testWriteAvecOrdre() {
// Param
final List<Map<String, String>> data = createMap();
final String[] titles = { "Id", "Prénom", "Age", "Couleur" };

// Resultat attendu
final String[] wantedTitles = { "Id", "Prénom", "Age", "Couleur" };

// Appel
csvFileWriter.write(data, titles);
final CsvFile csvFile = new CsvFile07(file, ';');
final String[] titlesFromFile = csvFile.getTitles();

// Test
// L'ordre compte
for (int i = 0; i < wantedTitles.length; i++) {
Assert.assertEquals(wantedTitles[i], titlesFromFile[i]);
}
}

Pour réaliser ce test, je pars du principe que le lecteur CsvFile07 a déjà été testé dans les chapitres précédents et qu'on peut s'appuyer dessus pour vérifier le fonctionnement du « writer ».

Pour le DAO, c'est un peu le même principe sauf qu'on travaille directement avec une liste de chiens.

Interface ChienDao avec la méthode write(..) en plus

public interface ChienDao {

List<Chien> findAllChiens();

Chien findChienById(Integer id);

List<Chien> findChiensByPrenom(String prenom);

void writeChiens(List<Chien> chiens, File file);
}

CsvChienDao12

public class CsvChienDao12 extends AbstractChienDao implements ChienDao {

private CsvFileWriter fileWriter;

...

@Override
public void writeChiens(List<Chien> chiens, File file) {
if (chiens == null) {
throw new IllegalArgumentException("La liste de chien ne peut pas être nulle");
}

if (file == null) {
throw new IllegalArgumentException("Le fichier ne peut pas être nul");
}

fileWriter = new CsvFileWriter01(file);

List<Map<String, String>> mappedData = new ArrayList<Map<String, String>>();
for (Chien chien : chiens) {
Map<String, String> oneData = chienToMap(chien);
mappedData.add(oneData);
}
fileWriter.write(mappedData);

}

private Map<String, String> chienToMap(Chien chien) {

Map<String, String> oneData = new HashMap<String, String>();

oneData.put("Id", chien.getId().toString());
oneData.put("Prénom", chien.getPrenom());
oneData.put("Couleur", chien.getCouleur());
oneData.put("Age", chien.getAge().toString());

return oneData;
}

...
}

et le test de CsvChienDao12

private static final String FILE_NAME = "out/chien-test-out02.csv";
private static File file;
private static ChienDao chienDao;

@BeforeClass
public static void beforeClass() {
file = CsvFileHelper.getResource(FILE_NAME);
chienDao = new CsvChienDao12();
}

private List<Chien> createChiens() {
List<Chien> chiens = new ArrayList<Chien>();

SimpleChien chien1 = new SimpleChien();
chien1.setId(1);
chien1.setPrenom("Pollux");
chien1.setCouleur("Blanc et vert avec un motif \"anglais\" et des poils gris sur la queue");
chien1.setAge(12);
chiens.add(chien1);

SimpleChien chien2 = new SimpleChien();
chien2.setId(2);
chien2.setPrenom("Lulu");
chien2.setCouleur("Noir;Blanc et jaune");
chien2.setAge(12);
chiens.add(chien2);

return chiens;
}

@Test
public void testWrite() {
// Param
final List<Chien> chiens = createChiens();

// Result
final String[] wantedTitles = { "Age", "Couleur", "Prénom", "Id" };
final int nombreChiens = 2;

// Appel
chienDao.writeChiens(chiens, file);
final CsvFile csvFile = new CsvFile07(file, ';');
final String[] titlesFromFile = csvFile.getTitles();

// Tests
// pas d'ordre dans les titres
for (String title : titlesFromFile) {
assertTrue(isInTab(title, wantedTitles));
}

Assert.assertEquals(csvFile.getData().size(), nombreChiens);
}

12 - Les frameworks du marché

Le web regorge de frameworks dédiés aux fichiers CSV. Certains, comme Open CSV, sont vraiment bons et répondent à des besoins génériques. D'autres sont plus spécialisés, par exemple sur des optimisations particulières.

Les chapitres précédents expliquent les notions de base du CSV, mais le plus simple et le plus efficace reste d'utiliser une librairie toute prête.

12-A - Open Csv

Open Csv est une librairie Java sous licence Open source. On la trouve sur le Web à l'adresse http://opencsv.sourceforge.net

Pour utiliser Open CSV dans un projet Maven, il suffit d'ajouter une dépendance dans le Pom.

Dépendance OpenCsv à ajouter dans le pom.xml

<dependency>
<groupId>net.sf.opencsv</groupId>
<artifactId>opencsv</artifactId>
<version>2.0</version>
</dependency>

Puis comme d'habitude lancer Maven

Installation avec Maven

mvn clean install eclipse:eclipse

Pour utiliser Open CSV dans le programme sans trop modifier le DAO, il suffit d'utiliser le CSVReader dans notre CsvFile.

CsvFile12 avec Open CSV

public class CsvFile12 extends AbstractAdvanceCsvFile {

private CSVReader reader;

private CsvFile12() {
}

public CsvFile12(File file) {
this(file, DEFAULT_SEPARATOR);
}

public CsvFile12(File file, char separator) {

this.file = file;
this.separator = separator;

// Init
init();
}

private void init() {
reader = new CSVReader(new FileReader(file), separator);
data = new ArrayList<String[] >();

String[] nextLine;
while ((nextLine = reader.readNext()) != null) {
final int size = nextLine.length;
if(size == 0) {
continue;
}

String debut = nextLine[0].trim();
if(debut.length() == 0 && size == 1 ) {
continue;
}
if(debut.startsWith("#")) {
continue;
}
data.add(nextLine);
}

titles = data.get(0);
data.remove(0);

mapData();
}

...

}

et le test

private static final String FILE_NAME = "src/test/resources/chien-test-11.csv";

@Test
public void testMappedData() {

// Param
final char separator = ';';

// Result
final int nombreLigne = 11;
final int nombreColonnes = 4;

// Appel
final CsvFile csvFile = new CsvFile12(file, separator);
final List<Map<String, String>> mappedData = csvFile.getMappedData();
// List<String[] > data = csvFile.getData();

// Test
assertEquals(nombreLigne, mappedData.size());

for (Map<String, String> oneMappedData : mappedData) {
assertEquals(nombreColonnes, oneMappedData.size());
}
}

@Test
public void testTitles() {

// Param
final char separator = ';';

// Result
String[] wantedTitles = { "Id", "Prénom", "Couleur", "Age" };

// Appel
final CsvFile csvFile = new CsvFile12(file, separator);
final String[] titles = csvFile.getTitles();
for (String title : titles) {
System.out.println(title);
}

// Test
for (int i = 0; i < wantedTitles.length; i++) {
assertEquals(wantedTitles[i], titles[i]);
}
}

13 - Pour aller plus loin

Bien que cet article présente quelques optimisations, il fait l'impasse sur les optimisations importantes et, encore plus, sur l'utilisation des fichiers CSV en fonction de la volumétrie.

En effet, les codes proposés fonctionnent très bien sur des projets modestes, dans lesquels les fichiers CSV ne contiennent que quelques centaines/milliers de lignes. On pensera par exemple aux fichiers CSV disponibles sur le site de la Française des Jeux et donnant les résultats des tirages du Loto, Euro Millions, etc. À raison d'un ou deux tirages (i.e. lignes) par semaine, et pour quelques dizaines d'années d'historique, on est sur des tailles raisonnables, chaque ligne ne comportant qu'une vingtaine de champs. En outre, les applications qui gèrent ce type de données ne nécessitent pas de performances (vitesse, charge mémoire, etc.) élevées.

Au contraire, certaines applications, par exemple dans le domaine de la bourse, utilisent des fichiers CSV multiformats (très) volumineux. J'ai ainsi travaillé sur une application de mise à jour des valeurs de l'ensemble des actions du marché. Les fichiers CSV alors utilisés pesaient des dizaines (voire des centaines dans des cas spéciaux extrêmes) de Mo et devaient être intégrés toutes les cinq minutes, sans ralentir ni surcharger le reste du serveur.

Avec de telles contraintes, on doit monter d'un cran. Mais avant tout il convient de noter que, et c'est valable dans tous les cas, les fichiers CSV doivent servir exclusivement à transférer de l'information, et non au stockage des données. Pourquoi cela ? D'abord parce que, qui dit stockage dit aussi mise à jour. Quand on modifie une ligne d'un fichier CSV, par exemple en modifiant la valeur « 123 » d'un champ par « 123456 » (la taille du champ change), on doit réécrire l'intégralité des lignes suivantes. Si le fichier comporte 25 lignes et qu'on modifie la 3e ligne, on doit réécrire 22 lignes seulement, ce qui est quasi instantané. Si le fichier contient la liste des transactions boursières de Paris, ce sont des centaines et des centaines de milliers de lignes qu'il faudra de nouveau écrire. Dans ce cas, on est très loin des performances qu'on obtiendrait avec une base de données.

Ensuite, il est évident que, dépassé une certaine taille, il n'est plus possible de charger en mémoire l'intégralité d'un fichier CSV et de le traiter plus tard. On est obligé de le traiter en même temps qu'on lit les lignes. Les patterns de lecture des flux à utiliser sont donc relativement différents.

13-A - Trois ans plus tard

Edit : Je reviens sur cet article trois ans après l'avoir écrit. Entre temps, la technologie a évolué. La mémoire vive (RAM) des serveurs a été multipliée par quatre, voire plus. Même les postes de développeurs sont de plus en plus équipés de 16 Go de RAM alors que 4 était jusque là le standard. Les capacités des disques durs, et plus généralement des unités de stockage, ont été revues à la hausse. Alors qu'il y a trois ans, l'unité de mesure était encore la centaine de Go (Giga), on parle désormais en To (Tera). En outre, les unités de stockage sans partie mobile, comme les SSD, sont enfin accessibles à des prix compétitifs et offrent des performances incroyables.

Tout cela pourrait encourager les développeurs à ne plus faire d'effort dans leurs programmes. Mais c'est un piège. Au contraire, on manipule de plus en plus de données. On a donc des fichiers CSV de plus en plus gros. Et oui, trois ans plus tard, les fichiers CSV sont toujours présents. Le format JSON a révolutionné le secteur informatique, mais ne répond pas à tous les besoins, notamment dans le cadre d'une forte volumétrie.

L'idéal est de travailler avec des fichiers CSV dont chaque ligne est indépendante des autres, ce qui est souvent le cas. Par exemple, dans mes fichiers des cours des actions, chaque ligne correspond à la valeur d'une action et ne dépend donc pas des autres lignes. Le sens de l'histoire tend à traiter chaque ligne au moment où elle est lue et non plus de lire l'ensemble du fichier d'un coup pour en traiter les lignes plus tard. Des frameworks comme Spring-Batch facilitent ce fonctionnement. Ils recommandent d'ailleurs, pour des raisons évidentes, de traiter les lignes par lots (plus ou moins gros). Ainsi au lieu de traiter un fichier de 10 000 lignes en une seule fois, on préfère le découper en plus petits lots de seulement 1000 lignes chacun. On traitera alors 10 lots. La taille d'un lot dépend bien entendu du sujet d'étude. Mais cela n'est possible que si les lignes sont indépendantes.

En fait, cette méthodologie existait déjà il y a trois ans. Elle était d'ailleurs déjà efficace. Or, l'évolution des processeurs ne tend plus à augmenter la fréquence (exprimée en MHz), mais à multiplier le nombre de cœurs, c'est-à-dire d'unités de calcul/traitement. Les derniers processeurs destinés aux particuliers ont souvent 4 cœurs ou plus, leur permettant donc d'effectuer autant d'opérations en même temps. Pouvoir diviser un fichier CSV en petits lots permet d'utiliser ce nouveau potentiel. Et, disons-le, c'est extrêmement performant.

L'arrivée de Java 7 a permis de vrais gains dans le cadre de la programmation concurrente (i.e. parallèle) et celle de Java 8 (prévue dans quelques semaines) va simplifier l'écriture des programmes dans ce but. Un mot/notion a bien connaitre sera le « Stream ». Je vous conseille de vous y intéresser.

14 - Conclusions

L'objectif de cet article était de montrer ce qui se cache derrière un sujet aussi simple que les fichiers CSV. Ce sont des notions de base que tout développeur se doit de connaître. Je voulais aller un peu plus loin que la version minimale en proposant des chapitres d'algo et d'optimisation. Or comme on le voit dans ce document, le sujet est réellement complexe.

Les fichiers CSV ne doivent être utilisés que pour la transmission de données. Cet article n'en parle pas beaucoup, mais c'est pourtant un point essentiel. Les techniques présentées plus haut sont à réserver aux fichiers dont la taille reste raisonnable (disons quelques centaines de lignes/données). On pourrait en discuter encore pendant des heures.

Dans tous les cas, il existe de très bonnes librairies (open source) sur le Web et il est recommandé de les utiliser, plutôt que de tout réécrire.

15 - Remerciements

Je tiens à remercier, en tant qu'auteur de cet article sur les CSV, toutes les personnes qui m'ont aidé et soutenu. Je pense tout d'abord à mes collègues qui subissent mes questions au quotidien, mais aussi à mes contacts et amis du web, dans le domaine de l'informatique ou non, qui m'ont fait part de leurs remarques et critiques. Bien entendu, je n'oublie pas l'équipe de developpez.com qui m'a guidé dans la rédaction de cet article et m'a aidé à le corriger et le faire évoluer, principalement sur le forum.

Plus particulièrement j'adresse mes remerciements, par ordre alphabétique, à ClaudeLELOUP, deltree, Djug, Estelle, Karyne, Hing, hwoarang, Loceka, Nemek, Patriarch24, Romain, sinok, SucreGlace, tchize et william44290.

16 - Annexes

16-A - Convertir des données CSV dans Excel

Fichier chien-test-01.csv dans Excel

Bouton Convertir

Étape 1 de la conversion : choix du type de colonne

Étape 2 de la conversion : choix du séparateur

Étape 3 de la conversion : choix du format des données

Fichier chien-test-01.csv dans Excel après conversion

Quand on ouvre un fichier CSV avec Open Office, il faut éviter de sauver les modifications. En effet, Open Office a tendance à modifier le format interne des fichiers CSV, les rendant alors inutilisables. Ce bogue sera sûrement corrigé prochainement. Sans surprise, MS Excel n'a pas ce genre de problème.

16-B - Exemple de DAO en version base de données

DatabaseChienDao simplifié

public class DatabaseChienDao implements ChienDao {

@Override
public List<Chien> findAllChiens() {

final List<Chien> chiens = new ArrayList<Chien>();

Connection con = null;
Statement statement = null;

try {
Class.forName("oracle.jdbc.driver.OracleDriver");

// (2)
con = DriverManager.getConnection("monUrl", "monLogin", "monPassword");
// (3)
String sql = "SELECT * FROM chien ";

// (4)
statement = con.createStatement();

// (5)
ResultSet rs = statement.executeQuery(sql);

while (rs.next()) {
final Chien chien = rsetToChien(rs);
chiens.add(chien);
}

} catch (Exception e) {
// ...
} finally {
try {
if (statement != null) {
statement.close();
}
if (con != null) {
con.close();
}
} catch (Exception e) {
// ...
}
}

return chiens;
}

private Chien rsetToChien(ResultSet rs) {
if (rs == null) {
throw new IllegalArgumentException("Le resultset ne peut pas être null");
}

final SimpleChien chien = new SimpleChien();

try {
final Integer id = rs.getInt("id");
final String prenom = rs.getString("prenom");
final String couleur = rs.getString("color_poil");
final Integer age = rs.getInt("age");

chien.setId(id);
chien.setPrenom(prenom);
chien.setCouleur(couleur);
chien.setAge(age);

} catch (Exception e) {
// ...
}

return chien;
}

}

16-C - Helper readFile avec gestion des exceptions

Helper avec la gestion des exceptions

public class CsvFileHelper {

public static List<String> readFile(File file) {

if (file == null) {
throw new IllegalArgumentException("Le fichier ne peut pas être null");
}

if (!file.exists()) {
throw new IllegalArgumentException("Le fichier " + file.getName() + " n'existe pas.");
}

final List<String> result = new ArrayList<String>();

FileReader fr = null;
BufferedReader br = null;

try {
fr = new FileReader(file);
br = new BufferedReader(fr);

for (String line = br.readLine(); line != null; line = br.readLine()) {
result.add(line);
}

} catch (Exception e) {
e.printStackTrace();

} finally {
if (br != null) {
try {
br.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (fr != null) {
try {
fr.close();
} catch (IOException e) {
e.printStackTrace();
}
}

}

return result;
}

...
}

16-D - DAO avec singleton

Exemple de classe CsvChienDao1WithSingleton

public class CsvChienDao1WithSingleton implements ChienDao {

private File file;

private CsvFile csvFile;

private static CsvChienDao1WithSingleton instance;

private CsvChienDao1WithSingleton() {
}

private CsvChienDao1WithSingleton(File file) {
this.file = file;
this.csvFile = new CsvFile01(file);
}

private static void init(File file) {
instance = new CsvChienDao1WithSingleton(file);
}

public static synchronized CsvChienDao1WithSingleton getInstance(File file) {

if (instance == null) {
init(file);
} else {
final String oldFileName = instance.getFile().getAbsolutePath();
final String newFileName = file.getAbsolutePath();
if (!oldFileName.equals(newFileName)) {
// Si on demande un nouveau fichier
init(file);
}
}

return instance;
}

@Override
public List<Chien> findAllChiens() {
...
}

private Chien tabToChien(String[] tab) {
...
}

public File getFile() {
return file;
}

public void setFile(File file) {
this.file = file;
}

}

Comme tout bon singleton, les éléments clés sont, ici, un constructeur privé, une variable d'instance statique et une méthode d'obtention d'instance également statique. Dans cet exemple j'ai aussi fait le choix de marquer la méthode getInstance comme synchronisée. On notera que la littérature propose plusieurs solutions pour synchroniser un singleton, mais que le mot clé synchronized est en définitive le plus simple et, sans doute, le plus efficace.

Ce code utilise l'instruction file.getAbsolutePath(), car la méthode getAbsolutePath() renvoie le nom complet du fichier, à partir de la racine, avec ses différents dossiers. La méthode getName() quant à elle ne renvoie que le nom du fichier, sans préciser dans quel dossier il se trouve. L'utilisation de getAbsolutePath(), et non de getName(), évite de confondre deux fichiers différents dans des dossiers différents, mais portant le même nom. Une illustration (une classe de test) de cette différence est fournie dans le Zip sous le nom « FileNameTest.java »

Pour tester ce singleton, il suffit de reprendre le test CsvChienDao1Test et de changer une seule ligne.

Test de CsvChienDao1WithSingleton copié sur CsvChienDao1Test

public class CsvChienDao1WithSingletonTest {

...

@BeforeClass
public static void beforeClass() {
file = CsvFileHelper.getResource(FILE_NAME);
chienDao = CsvChienDao1WithSingleton.getInstance(file);
}

...
}

Le problème, avec le code proposé, ci-dessus, est que chaque nouveau fichier passé à getInstance écrase les valeurs chargées à partir du fichier précédent. Or il est légitime de supposer que le chargement des données contenues dans le fichier précédent a consommé une grosse quantité de ressources (CPU, time, etc.) On peut alors complexifier le singleton pour qu'il gère plusieurs fichiers, par exemple à l'aide d'une Map. Je donne ici un exemple avec la classe CsvChienDao1WithSingletonMultiFile, mais je conseille toutefois de ne pas abuser de cette pratique.

Exemple de classe CsvChienDao1WithSingletonMultiFile

public class CsvChienDao1WithSingletonMultiFile implements ChienDao {

private CsvFile csvFile;

private static Map<String, CsvChienDao1WithSingletonMultiFile> instanceMap
= new HashMap<String, CsvChienDao1WithSingletonMultiFile>();

private CsvChienDao1WithSingletonMultiFile() {
}

private CsvChienDao1WithSingletonMultiFile(File file) {
this.csvFile = new CsvFile01(file);
}

private static void init(File file) {
CsvChienDao1WithSingletonMultiFile instance = new CsvChienDao1WithSingletonMultiFile(file);
instanceMap.put(file.getgetAbsolutePath(), instance);
}

public static synchronized CsvChienDao1WithSingletonMultiFile getInstance(File file) {

CsvChienDao1WithSingletonMultiFile instance = instanceMap.get(file.getName());
if (instance == null) {
init(file);
}

return instance;
}

...
}

Cette Map est intéressante, mais il faut se demander comment on va accéder aux données de chaque fichier. En effet, le contrat de service défini dans l'interface ChienDao, et en particulier dans le cadre de la méthode findAllChiens(), n'utilise pas de fichier (ni de nom de fichier) pour sélectionner une des entrées de la Map. Il faut donc trouver un truc, mais ce n'est pas l'objet de ce document…

16-E - Extraits de Javadocs

Voici ce que dit la Javadoc au sujet de FileReader : « Convenience class for reading character files. The constructors of this class assume that the default character encoding and the default byte-buffer size are appropriate. To specify these values yourself, construct an InputStreamReader on a FileInputStream. FileReader is meant for reading streams of characters. For reading streams of raw bytes, consider using a FileInputStream. »

Voici ce que dit la Javadoc au sujet de BufferedReader : « Reads text from a character-input stream, buffering characters so as to provide for the efficient reading of characters, arrays, and lines. The buffer size may be specified, or the default size may be used. The default is large enough for most purposes. In general, each read request made of a Reader causes a corresponding read request to be made of the underlying character or byte stream. It is therefore advisable to wrap a BufferedReader around any Reader whose read() operations may be costly, such as FileReaders and InputStreamReaders. [..] Without buffering, each invocation of read() or readLine() could cause bytes to be read from the file, converted into characters, and then returned, which can be very inefficient. Programs that use DataInputStreams for textual input can be localized by replacing each DataInputStream with an appropriate BufferedReader. »

Voici ce que dit la Javadoc au sujet de ArrayList : « public ArrayList() constructs an empty list with an initial capacity of ten. Each ArrayList instance has a capacity. The capacity is the size of the array used to store the elements in the list. It is always at least as large as the list size. As elements are added to an ArrayList, its capacity grows automatically. The details of the growth policy are not specified beyond the fact that adding an element has constant amortized time cost. An application can increase the capacity of an ArrayList instance before adding a large number of elements using the ensureCapacity operation. This may reduce the amount of incremental reallocation. »

Voici ce que dit la Javadoc au sujet de FileWriter : « Convenience class for writing character files. The constructors of this class assume that the default character encoding and the default byte-buffer size are acceptable. To specify these values yourself, construct an OutputStreamWriter on a FileOutputStream. Whether or not a file is available or may be created depends upon the underlying platform. Some platforms, in particular, allow a file to be opened for writing by only one FileWriter (or other file-writing object) at a time. In such situations the constructors in this class will fail if the file involved is already open. FileWriter is meant for writing streams of characters. For writing streams of raw bytes, consider using a FileOutputStream. »

Voici ce que dit la Javadoc au sujet de BufferedWriter : « Writes text to a character-output stream, buffering characters so as to provide for the efficient writing of single characters, arrays, and strings. The buffer size may be specified, or the default size may be accepted. The default is large enough for most purposes. A newLine() method is provided, which uses the platform's own notion of line separator as defined by the system property line.separator. Not all platforms use the newline character ('

') to terminate lines. Calling this method to terminate each output line is therefore preferred to writing a newline character directly. In general, a Writer sends its output immediately to the underlying character or byte stream. Unless prompt output is required, it is advisable to wrap a BufferedWriter around any Writer whose write() operations may be costly, such as FileWriters and OutputStreamWriters. [..] Without buffering, each invocation of a print() method would cause characters to be converted into bytes that would then be written immediately to the file, which can be very inefficient. »

16-F - Quelques liens

Une bonne présentation de JUnit 4 : https://rpouiller.developpez.com/tutoriels/java/tests-unitaires-junit4

La RFC 4180 présentant le format CSV : http://tools.ietf.org/html/rfc4180

Éditeur online YUML pour faire des diagrammes UML sympas : http://www.yuml.me

Une bonne présentation des Regex : https://cyberzoide.developpez.com/java/regex/

Une bonne présentation des Google-Collections : https://thierry-leriche-dessirier.developpez.com/tutoriels/java/tuto-google-collections/

16-G - List ou Set ?

Dans le programme, « chapitre VI-A - Choix », je déclare la liste des séparateurs acceptables en tant que List et non en tant que Set. Ce choix peut sembler étrange dans la mesure où il est évident qu'il ne doit pas y avoir de doublon dans cette liste. Par contre, l'ordre des items de la liste aura son importance dans le cadre de la recherche du bon séparateur, d'où le choix d'une List et non d'un Set.

Séparateurs autorisés sous forme de Set : solution non retenue

public final static Set<Character> AVAILABLE_SEPARATORS_AS_SET = Collections.unmodifiableSet(new LinkedHashSet<Character>(
Arrays.asList(',', ';', '\t', '|')));

Les interfaces List et Set font partie de l'API Java-Collections. Une List (ex. ArrayList) est tout simplement une liste dans laquelle les éléments sont ordonnés. Une Set est également une sorte de liste, mais dans laquelle les items ne sont pas ordonnés. En outre un élément ne peut pas être présent deux fois dans un Set.