Vous aimez ce que vous lisez sur ce blog ?
Envie d'aller plus loin avec véritable formation d'expertise en Java ?
Venez suivre ma formation Masterclasse Expertise Java !

"Même un développeur experimenté a besoin de continuer à apprendre. Et dans cette formation... j'ai appris beaucoup !" - A.G., Java Champion

Sessions intra-entreprises sur demande : contact[at]mokatech.net.
Inscrivez-vous vite !

Au coeur du JDK : java.io expliqué simplement

Je suis surpris par le nombre de développeurs qui s'avouent intimidés par le package java.io. Il est vrai qu'il contient beaucoup de classes, dont la plupart portent des noms très similaires, mais nous allons voir que son architecture est en réalité très simple.

Il suffit de comprendre deux concepts pour y voir tout de suite plus clair : le schéma en 4 quadrants, et le pattern Decorator. Le premier permet de déterminer à quoi sert une classe ; le second indique son rôle dans la chaîne de lecture ou d'écriture des données.

Suivez le guide !

Les 4 quadrants

Le package java.io est composé de nombreuses classes, mais elles peuvent être réparties en 4 catégories, selon qu'elles réalisent des opérations :

  • De lecture ou d'écriture
  • Sur des données textuelles ou binaires

On peut ainsi les placer sur un graphe à 4 quadrants, chacun étant gouverné par une classe abstraite :

           |                   |                    |
           |      LECTURE      |      ECRITURE      |
           |                   |                    |  
-----------+-------------------+--------------------+
           |                   |                    |
  TEXTE    |      Reader       |      Writer        |
           |                   |                    |
-----------+-------------------+--------------------+
           |                   |                    |
  BINAIRE  |    InputStream    |    OutputStream    |
           |                   |                    |
-----------+-------------------+--------------------+

Ces classes abstraites sont ensuite implémentées par différentes classes concrètes spécialisées. Leur quadrant d'appartenance se déduit de leur suffixe (-Reader, -Writer, -InputStream ou -OutputStream) :

           |                           |                            |
           |          LECTURE          |          ECRITURE          |
           |                           |                            |  
-----------+---------------------------+----------------------------+
           |                           |                            |
           |  Reader                   |  Writer                    |
           |  +- BufferedReader        |  +- BufferedWriter         |
           |  +- StringReader          |  +- StringWriter           |
  TEXTE    |  +- CharArrayReader       |  +- CharArrayWriter        |
           |  +- ...                   |  +- FileWriter             |
           |                           |  +- PrintWriter            |
           |                           |  +- ...                    |
           |                           |                            |
-----------+---------------------------+----------------------------+
           |                           |                            |
           |  InputStream              |  OutputStream              |
           |  +- FileInputStream       |  +- FileOutputStream       |
           |  +- ByteArrayInputStream  |  +- ByteArrayOutputStream  |
  BINAIRE  |  +- ObjectInputStream     |  +- ObjectOutputStream     |
           |  +- PipedInputStream      |  +- PipedOutputStream      |
           |  +- ...                   |  +- ...                    |
           |                           |                            |
-----------+---------------------------+----------------------------+

Par exemple, pour écrire un flux binaire, on utilisera des classes appartenant au quadrant bas-droite : FileOutputStream, ObjectOutputStream, etc.

Conversion texte / binaire

Il existe également deux classes qui permettent de faire le pont entre l'univers des données binaires et celui des données textuelles : InputStreamReader et OutputStreamWriter.

           |                           |                            |
           |          LECTURE          |          ECRITURE          |
           |                           |                            |  
-----------+---------------------------+----------------------------+
           |                           |                            |
           |  Reader                   |  Writer                    |
           |  +- BufferedReader        |  +- BufferedWriter         |
           |  +- StringReader          |  +- StringWriter           |
  TEXTE    |  +- CharArrayReader       |  +- CharArrayWriter        |
           |  +- ...                   |  +- FileWriter             |
           |                           |  +- PrintWriter            |
           |                      .    |  +- ...                    |
           |                     /|\   |                       |    |
-----------+---InputSreamReader---|----+---OutputStreamWriter--|----+
           |                      |    |                      \|/   |
           |  InputStream              |  OutputStream              |
           |  +- FileInputStream       |  +- FileOutputStream       |
           |  +- ByteArrayInputStream  |  +- ByteArrayOutputStream  |
  BINAIRE  |  +- ObjectInputStream     |  +- ObjectOutputStream     |
           |  +- PipedInputStream      |  +- PipedOutputStream      |
           |  +- ...                   |  +- ...                    |
           |                           |                            |
-----------+---------------------------+----------------------------+
  • La classe InputStreamReader propose, comme son nom l'indique, une interface de type Reader (texte) sur des données provenant d'un InputStream (binaire).
    Elle est particulièrement pratique lorsqu'une classe (ex: Socket) vous fournit un InputStream alors que vous savez que les données transmises seront de type texte.
  • Inversement, un OutputStreamWriter permettra d'utiliser les API de type Writer pour produire des données binaires propres à transiter par un OutputStream.

Mais la conversion texte / binaire pose toujours quelques problèmes.
Prenez la représentation binaire ci-dessous. Combien de caractères comporte-t-elle ? Où commence un caractère, où finit-il ?[1]

0100100001100101011011000110110001101111001000000101011101101111011100100110110001100100

Il est généralement impossible de le déterminer sans connaître le type d'encodage utilisé : UTF-8, UTF-16, ISO-8859-1, voire même -gasp!- Win-Cp1252... Ces algorithmes d'encodage sont encapsulés par des implémentations de la classe java.nio.Charset.

Pour que nos classes InputStreamReader et OutputStreamWriter puissent convertir efficacement des données textuelles en binaire (et réciproquement), il est donc nécessaire de leur fournir un Charset en paramètre de constructeur :

InputStream in = System.in;
Charset charset = Charset.forName("UTF-8");
InputStreamReader reader = new InputStreamReader(in, charset);

Petit test

Vous voyez qu'il est facile, une fois ce schéma mémorisé, de déterminer à quoi sert une classe du package java.io. Inversement, cela permet de trouver rapidement une classe réalisant l'opération souhaitée, sans apprendre l'API par coeur (ouf !).

Allez, un petit quiz rapide pour voir si vous avez compris. Je relève les copies dans 5 minutes[2].
Quelle classe utiliser pour :

  • Lire du texte depuis une String ?
  • Lire un flux binaire depuis un fichier ?
  • Ecrire un objet dans un flux binaire (c'est-à-dire le sérialiser) ?
  • Lire du texte de façon optimisée (en utilisant un buffer) ?

Vous voyez, ce n'est pas difficile !
Voyons maintenant le second principe fondateur du package java.io : le pattern Decorator, qui explique la façon dont toutes ces classes peuvent être assemblées.

Le pattern Decorator

Principe théorique

Le design pattern Decorator ("Décorateur" en français) fait partie des patterns structurels.
Il permet d'ajouter des comportements (méthodes) à un objet de base, par composition plutôt que par héritage, favorisant ainsi la cohésion et la réutilisabilité. Et comme nous sommes sur un blog sérieux, hop hop, vite un diagramme UML tiré de Wikipedia :

Decorator_UML_class_diagram.png

Le principe est simple : sur une brique de base exposant une certaine interface, on vient brancher des briques additionnelles proposant la même interface mais fournissant des services supplémentaires.

Vous serez sans doute surpris d'apprendre que vous utilisez le pattern Decorator tous les jours : lorsque vous branchez un casque à votre lecteur MP3 ; lorsque vous jouez aux Lego ; lorsque vous utilisez un hub USB ou un switch réseau.

Le Décorateur au quotidien

Prenons l'exemple du lecteur MP3. Cet appareil produit de la musique, et l'expose suivant une interface (physique en l'occurrence) prédéfinie : la forme de la prise jack. Tout appareil (casque, enceintes...) se conformant à cette interface peut alors consommer ladite musique.

Mais il est également possible de brancher, entre le lecteur et le casque, différents accessoires comme une rallonge ou un contrôleur de volume. Chacun fournit une fonctionnalité différente, mais tous peuvent être combinés pour former une chaîne arbitraire s'intercalant entre le producteur de données et le consommateur. Cette "composabilité", très pratique, n'est possible que parce que tous les accessoires exposent la même interface que la brique initiale qu'ils "décorent".

Application au package java.io

Le package java.io est entièrement construit sur ce principe de composition, permis par le pattern Décorateur.

Certaines classes lisent/écrivent réellement les données sur/depuis un certain medium (fichier, réseau, buffer mémoire...) : elles sont donc toujours en bout de chaîne. D'autres en revanche ne font que manipuler ou observer les données qui transitent sur la chaîne de lecture/écriture : ce sont les décorateurs.

Par exemple, FileWriter, ByteArrayInputStream, ou StringReader sont des classes de bout de chaîne ; en revanche, BufferedReader, LineNumberReader ou ObjectOutputStream sont des décorateurs.

Pour composer une chaîne de lecture ou d'écriture, il vous suffit de sélectionner une classe de bout de chaîne permettant de lire ou d'écrire sur le medium cible, puis de brancher dessus autant de décorateurs que nécessaire pour obtenir les fonctionnalités souhaitées. Le branchement d'un élément sur le suivant s'effectue habituellement en le passant le second comme paramètre du constructeur du premier.

Exemple :

FileReader fr = new FileReader("/path/to/file"); // classe terminale, pour lire un fichier texte
BufferedReader reader = new BufferedReader(fr); // Décorateur, pour utiliser un buffer de lecture

Quelques exemples

Voyons quelques exemples classiques de chaînes (la gestion des exceptions est omise ici).

  • Lire les lignes d'un fichier texte, en comptant les lignes (LineNumberReaderBufferedReaderFileReader) :
FileReader fr = new FileReader("/path/to/file");
BufferedReader reader = new BufferedReader(fr);
LineNumberReader counter = new LineNumberReader(reader);
String line = null;
while ((line = counter.readLine()) != null) {
    int lineNum = counter.getLineNumber();
    System.out.println(lineNum + " : " + line);
}
counter.close();
  • Sérialiser un objet vers un tableau de bytes (ObjectOutputStreamByteArrayOutputStream) :
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject("Hello World");
oos.close();
  • Lire une ligne de texte saisie dans la console ; attention, System.in est de type InputStream, il faut donc le convertir (BufferedReaderInputStreamReaderInputStream fourni par System.in) :
InputStream in = System.in;
InputStreamReader isr = new InputStreamReader(in, Charset.forName("UTF-8"));
BufferedReader reader = new BufferedReader(isr);
String line = reader.readLine();
System.out.println(line);
reader.close();

Nous nous arrêterons là, mais vous voyez que les combinaisons sont infinies.

Conclusion

Le package java.io est conçu selon deux concepts très simples : un double découpage lecture/écriture et texte/binaire d'une part, et le design pattern Decorator d'autre part. Le premier indique la fonction des classes, et le second la façon dont elles peuvent être assemblées en une chaîne de lecture ou d'écriture.

La prochaine fois que vous parcourerez la javadoc à la recherche d'une classe correspondant à vos besoins, rappelez-vous le schéma en 4 quadrants !



Articles connexes

Dans la série "Au coeur du JDK", vous serez sans doute également intéressés par :

Notes

[1] C'est "Hello World" en binaire (8bits par caractère) !

[2] 1-StringReader, 2-FileInputStream, 3-ObjectOutputStream, 4-BufferedReader


Commentaires

1. Le lundi 16 janvier 2012, 01:06 par fcamblor

Très sympa cette notion de quadrants, ça permet de bien représenter visuellement les choses ! Merci :-)

Moi ce qui m'agace dans l'API IO, c'est les appels à la méthode close().
Est-on bien sur qu'en appelant le close() de plus haut niveau, le close() de tous les objets décorés seront appelés ? (ça serait logique, et dans l'idée du pattern decorator).
Lorsqu'on utilise le try-with-resources de java7 sur 3 streams décorés, peut-on utiliser 1 seul try-with-resources sur le Closeable le plus "haut" ?
Est-on tout le temps _obligé_ de fermer un Stream ? Je veux dire... lorsqu'il s'agit d'un stream in memory par exemple (sans lien avec le filesystem), est-ce que le GC ne serait pas capable de fermer tout seul ces streams lorsque l'objet est garbage collecté ?

Dommage de ne pas avoir évoqué les Piped*Stream qui : à chaque fois que je les utilise il me faut 10 bonnes minutes avant de me rappeler la manière de faire...

2. Le lundi 16 janvier 2012, 01:46 par Olivier Croisier

Normalement, l'appel de close() sur l'élément de plus haut niveau est répercuté sur les éléments inférieurs. Ce principe devrait s'appliquer également en Java7.
Et pour ce qui est de fermer les streams, le fait d'appeler close() force également le flush. Si tu ne flushes pas et que la JVM s'arrête, tu peux perdre les données encore en buffer. Il est donc recommandé de bien fermer ses flux.

3. Le lundi 16 janvier 2012, 07:26 par Eric Reboisson

Merci pour cet article et bonne journée

4. Le lundi 16 janvier 2012, 08:38 par Laurent Vaills

J'encourage tout le monde à bien spécifier explicitement un Charset lors de l'instanciation d'un InputStreamReader ou OutputStreamWriter afin de ne pas être dépendant du charset par défaut de la JVM (qui lui-même varie en fonction de l'OS sur lequel il est utilisé). En faisant cela, vous n'aurez pas de mauvaise surprise sur vos lectures / écritures de données lorsque vous exécuterez votre application sur une autre machine.

5. Le lundi 16 janvier 2012, 13:39 par HollyDays

@ fcamblor

En théorie, le garbage collector pourrait être capable de fermer les flux. Le problème — et c'est là une des grandes différences entre Java et les langages à désallocation explicite, comme C++ avec son opérateur delete, par exemple — c'est qu'il est impossible de prévoir quand le garbage collector détruira l'objet flux. Il est même tout à fait possible que l'objet reste dans l'état "à détruire" jusqu'à ce que la JVM s'arrête (pour cause de fin du programme).

Dans un tel cas, le programme Java atteindrait assez rapidement la limite maximum du nombre de flux ouvrables à un instant donné, et le risque qu'il plante pour cause de "limite de l'OS : nombre de fichiers maximum ouverts atteint" deviendrait assez élevée. (Dans les faits, on atteint cette limite assez vite, car au niveau de l'OS, un fichier ouvert consomme des ressources non négligeables.)

Par ailleurs, comme le dit Olivier, si la JVM s'arrête de manière inopinée et non contrôlée, alors en plus, le flux non fermé n'est même pas flushé, et il y a risque de perte de données du fait d'un non-envoi par le flux.

Moralité : il faut toujours fermer les flux à la main, parce que c'est le seul moyen, d'une part, d'avoir la garantie que les flux sont bien fermés, et d'autre part, de maîtriser le moment où ces flux seront effectivement fermés.

6. Le lundi 16 janvier 2012, 21:34 par reda

Excellent article. Je considère que le mystère des java.io est désormais résolu pour moi.

Merci mille fois.

7. Le mardi 17 janvier 2012, 05:48 par PhiLho

Excellent article, il résume fort bien de façon synthétique et conceptuelle une librairie qui apparait effectivement comme touffue et confuse, en général.
Le premier commentaire (et les autres !) est bienvenu aussi, complétant bien l'article (même si l'info était implicite), parce que "où faire le close" est rarement abordé dans les articles de façon explicite (et encore moins dans la JavaDoc). Fut un temps où je fermais consciencieusement chaque niveau du décorateur...
Un exemple de gestion propre des exceptions (try/catch/finally) serait le bienvenu, on trouve trop souvent des articles se contentant d'un close() dans le try, sans finally... Comme cet article est appelé à devenir une référence des débutants (il le mérite !), autant qu'il soit parfait... :-)
Il y a d'autres choses à dire sur les exceptions, comme le PrintWriter qui n'en jette pas, contrairement à ses petits camarades.

Merci pour cet article, en tout cas.

8. Le vendredi 20 janvier 2012, 20:02 par Tom

Excellent !
Merci pour ce récapitulatif.
J'aurai la réponse quand quand on me posera une question sur IO, "Utilise le quadrant, Man !"

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.