Il existe plusieurs façons de lire et d’écrire à partir d’un fichier. Nous les montrons dans cette section en copiant un fichier vers un autre.
Utilisation des I/O Streams
Les I/O streams concernent la lecture/écriture de données, il ne devrait donc pas être surprenant que les méthodes les plus importantes soient read() et write(). Les classes InputStream et Reader déclarent une méthode read() pour lire des données d’octets à partir d’un I/O stream. De même, OutputStream et Writer définissent tous deux une méthode write() pour écrire un octet dans le stream :
Les méthodes copyStream() suivantes montrent un exemple de lecture de toutes les valeurs d’un InputStream et d’un Reader, et de leur écriture dans un OutputStream et un Writer, respectivement. Dans les deux exemples, -1 est utilisé pour indiquer la fin du stream.
void copyStream(InputStream in, OutputStream out) throws IOException {
int b;
while ((b = in.read()) != -1) {
out.write(b);
}
}
void copyStream(Reader in, Writer out) throws IOException {
int b;
while ((b = in.read()) != -1) {
out.write(b);
}
}
Attendez. Nous avons dit que nous lisions et écrivions des octets, alors pourquoi les méthodes utilisent-elles int au lieu de byte ? Rappelez-vous que le type de données byte a une plage de 256 caractères. Ils avaient besoin d’une valeur supplémentaire pour indiquer la fin d’un I/O stream. Les auteurs de Java ont décidé d’utiliser un type de données plus large, int, afin que des valeurs spéciales comme -1 puissent indiquer la fin d’un I/O stream. Les classes de flux de sortie utilisent également int, pour être cohérentes avec les classes de flux d’entrée.
La lecture et l’écriture d’un octet à la fois n’est pas une méthode particulièrement efficace. Heureusement, il existe des méthodes surchargées pour lire et écrire plusieurs octets à la fois.
Les valeurs offset et length s’appliquent au tableau lui-même. Par exemple, un offset de 3 et une length de 5 indique que le stream doit lire jusqu’à cinq octets/caractères de données et les placer dans le tableau à partir de la position 3. Examinons un exemple :
void copyStream(InputStream in, OutputStream out) throws IOException {
int tailleLot = 1024;
var tampon = new byte[tailleLot];
int longueurLue;
while ((longueurLue = in.read(tampon, 0, tailleLot)) > 0) {
out.write(tampon, 0, longueurLue);
out.flush();
}
}
Au lieu de lire les données un octet à la fois, nous lisons et écrivons jusqu’à 1024 octets à la fois. La valeur de retour longueurLue est essentielle pour déterminer si nous sommes à la fin du stream et combien d’octets ont été lus.
À moins que notre fichier ne soit un multiple de 1024 octets, la dernière itération de la boucle while écrira une valeur inférieure à 1024 octets. Par exemple, si la taille du tampon est de 1 024 octets et que la taille du fichier est de 1 054 octets, la dernière lecture ne sera que de 30 octets. Si nous ignorions cette valeur de retour et écrivions plutôt 1 024 octets, 994 octets de la boucle précédente seraient écrits à la fin du fichier.
Nous avons également ajouté une méthode flush() pour réduire la quantité de données perdues si l’application se termine de manière inattendue. Lorsque des données sont écrites dans un flux de sortie, le système d’exploitation sous-jacent ne garantit pas que les données arriveront immédiatement au système de fichiers. La méthode flush() demande que toutes les données accumulées soient écrites immédiatement sur le disque. Ce n’est pas sans coût, cependant. Chaque fois qu’elle est utilisée, elle peut provoquer un retard notable dans l’application, surtout pour les gros fichiers. À moins que les données que vous écrivez ne soient extrêmement critiques, la méthode flush() ne devrait être utilisée qu’occasionnellement. Par exemple, elle ne devrait pas nécessairement être appelée après chaque écriture, comme c’est le cas dans cet exemple.
Des méthodes équivalentes existent sur Reader et Writer, mais elles utilisent char plutôt que byte, ce qui rend la méthode copyStream() équivalente très similaire.
L’exemple précédent fait apparaître la lecture et l’écriture d’un fichier comme quelque chose de complexe. C’est parce qu’il n’utilise que des I/O streams de bas niveau. Essayons à nouveau en utilisant des streams de haut niveau.
void copierFichierTexte(File src, File dest) throws IOException {
try (var lecteur = new BufferedReader(new FileReader(src));
var ecrivain = new BufferedWriter(new FileWriter(dest))) {
String ligne = null;
while ((ligne = lecteur.readLine()) != null) {
ecrivain.write(ligne);
ecrivain.newLine();
}
}
}
La clé est de choisir les classes de haut niveau les plus utiles. Dans ce cas, nous traitons avec File, nous voulons donc utiliser un FileReader et un FileWriter. Les deux classes ont des constructeurs qui peuvent prendre soit une String représentant l’emplacement, soit un File directement.
Si le fichier source n’existe pas, une FileNotFoundException, qui hérite de IOException, sera lancée. Si le fichier de destination existe déjà, cette implémentation l’écrasera. Nous pouvons passer un second paramètre booléen optionnel à FileWriter pour un drapeau d’ajout si nous voulons modifier ce comportement.
Nous avons également choisi d’utiliser un BufferedReader et un BufferedWriter pour pouvoir lire une ligne entière à la fois. Cela nous donne les avantages de la lecture de lots de caractères à la ligne 30 sans avoir à écrire une logique personnalisée. La ligne 31 écrit toute la ligne de données à la fois. Comme la lecture d’une ligne supprime les sauts de ligne, nous les ajoutons à la ligne 32. Les lignes 27 et 28 démontrent le chaînage des constructeurs. Le constructeur try-with-resources se charge de fermer tous les objets de la chaîne.
Maintenant, imaginez que nous voulions des données binaires au lieu de caractères. Nous devrions choisir des classes de haut niveau différentes : BufferedInputStream, BufferedOutputStream, FileInputStream et FileOutputStream. Nous appellerions readAllBytes() au lieu de readLine() et stockerions le résultat dans un byte[] au lieu d’une String. Enfin, nous n’aurions pas besoin de gérer les nouvelles lignes car les données sont binaires.
Nous pouvons faire un peu mieux que BufferedOutputStream et BufferedWriter en utilisant PrintStream et PrintWriter. Ces classes contiennent quatre méthodes clés. Les méthodes print() et println() impriment des données avec et sans nouvelle ligne, respectivement. Il y a aussi les méthodes format() et printf(), que nous décrivons dans la section sur les interactions utilisateur.
void copierFichierTexte(File src, File dest) throws IOException {
try (var lecteur = new BufferedReader(new FileReader(src));
var ecrivain = new PrintWriter(new FileWriter(dest))) {
String ligne = null;
while ((ligne = lecteur.readLine()) != null)
ecrivain.println(ligne);
}
}
Bien que nous ayons utilisé une String, il existe de nombreuses versions surchargées de println(), qui prennent tout, des primitives et des valeurs String aux objets. Sous le capot, ces méthodes effectuent souvent simplement String.valueOf().
Les classes de print stream ont la particularité d’être les seules classes de I/O stream que nous couvrons qui n’ont pas de classes de stream d’entrée correspondantes. Et contrairement aux autres classes OutputStream, PrintStream n’a pas Output dans son nom.
Il pourrait vous surprendre d’apprendre que vous utilisez régulièrement un objet PrintStream tout au long de ce livre. System.out et System.err sont tous deux des objets PrintStream. De même, System.in, souvent utile pour lire les entrées utilisateur, est un InputStream.
Contrairement à la majorité des autres I/O streams que nous avons couverts, les méthodes des classes de print stream ne lancent aucune exception vérifiée. Si elles le faisaient, vous seriez obligé d’attraper une exception vérifiée chaque fois que vous appelleriez System.out.print() !
Le séparateur de ligne est \n ou \r\n, selon votre système d’exploitation. La méthode println() s’en occupe pour vous. Si vous avez besoin d’obtenir le caractère directement, l’une ou l’autre des méthodes suivantes vous le retournera :
System.getProperty("line.separator");
System.lineSeparator();
Amélioration avec Files
Les API NIO.2 fournissent des moyens encore plus faciles de lire et d’écrire un fichier en utilisant la classe Files. Commençons par examiner trois façons de copier un fichier en lisant les données et en les réécrivant :
private void copierPathCommeString(Path entree, Path sortie) throws IOException {
String chaine = Files.readString(entree);
Files.writeString(sortie, chaine);
}
private void copierPathCommeOctets(Path entree, Path sortie) throws IOException {
byte[] octets = Files.readAllBytes(entree);
Files.write(sortie, octets);
}
private void copierPathCommeLignes(Path entree, Path sortie) throws IOException {
List lignes = Files.readAllLines(entree);
Files.write(sortie, lignes);
}
C’est assez concis ! Vous pouvez lire un Path comme un String, un tableau d’octets ou une List. Sachez que tout le fichier est lu en une seule fois pour ces trois méthodes, stockant ainsi tout le contenu du fichier en mémoire en même temps. Si le fichier est significativement volumineux, vous risquez de déclencher une OutOfMemoryError en essayant de le charger entièrement en mémoire. Heureusement, il existe une alternative. Cette fois, nous affichons le fichier au fur et à mesure de sa lecture.
private void lireParesseusement(Path chemin) throws IOException {
try (Stream s = Files.lines(chemin)) {
s.forEach(System.out::println);
}
}
Maintenant, le contenu du fichier est lu et traité de manière paresseuse, ce qui signifie que seule une petite partie du fichier est stockée en mémoire à un moment donné. En poussant les choses un peu plus loin, nous pouvons exploiter d’autres méthodes de stream pour un exemple plus puissant.
try (var s = Files.lines(chemin)) {
s.filter(f -> f.startsWith("WARN:"))
.map(f -> f.substring(5))
.forEach(System.out::println);
}
Cet exemple de code recherche dans un journal les lignes qui commencent par WARN:, en affichant le texte qui suit. En supposant que le fichier d’entrée requins.log soit comme suit :
INFO:Démarrage du serveur
DEBUG:Processus disponibles = 10
WARN:Aucune base de données n'a pu être détectée
DEBUG:Processus disponibles réinitialisés à 0
WARN:Effectuer une récupération manuelle
INFO:Serveur démarré avec succès
La sortie de l’exemple serait alors la suivante :
Aucune base de données n'a pu être détectée
Effectuer une récupération manuelle
Comme vous pouvez le voir, nous avons la capacité de manipuler des fichiers de manière complexe, souvent avec seulement quelques expressions courtes.
Files.readAllLines() vs. Files.lines()
Ces deux exemples compilent et s’exécutent :
Files.readAllLines(Paths.get("oiseaux.txt")).forEach(System.out::println);
Files.lines(Paths.get("oiseaux.txt")).forEach(System.out::println);
La première ligne lit tout le fichier en mémoire et effectue une opération d’impression sur le résultat, tandis que la seconde ligne traite paresseusement chaque ligne et l’imprime au fur et à mesure qu’elle est lue. L’avantage du second extrait de code est qu’il ne nécessite pas que l’intégralité du fichier soit stockée en mémoire à un moment donné.
Vous devriez également être conscient des cas où des types incompatibles sont mélangés. Voyez-vous pourquoi ce qui suit ne compile pas ?
Files.readAllLines(Paths.get("oiseaux.txt"))
.filter(s -> s.length() > 2)
.forEach(System.out::println);
La méthode readAllLines() renvoie une List, pas un Stream, donc la méthode filter() n’est pas disponible.
Combinaison avec newBufferedReader() et newBufferedWriter()
Parfois, vous devez mélanger les I/O streams et NIO.2. Heureusement, Files inclut deux méthodes de commodité pour obtenir des I/O streams.
private void copierPath(Path entree, Path sortie) throws IOException {
try (var lecteur = Files.newBufferedReader(entree);
var ecrivain = Files.newBufferedWriter(sortie)) {
String ligne = null;
while ((ligne = lecteur.readLine()) != null) {
ecrivain.write(ligne);
ecrivain.newLine();
}
}
}
Vous pouvez envelopper les constructeurs de I/O stream pour produire le même effet, bien qu’il soit beaucoup plus facile d’utiliser la méthode factory. La première méthode, newBufferedReader(), lit le fichier spécifié à l’emplacement Path en utilisant un objet BufferedReader.
Révision des méthodes communes de lecture et d’écriture
Le tableau suivant passe en revue les méthodes publiques communes des I/O streams que vous devriez connaître pour la lecture et l’écriture. Nous incluons également close() et flush() car elles sont utilisées lors de l’exécution de ces actions.
Classe | Nom de la méthode | Description |
---|---|---|
Tous les streams d’entrée | public int read() | Lit un seul octet ou renvoie -1 si aucun octet n’est disponible. |
InputStream | public int read(byte[] b) | Lit les valeurs dans le tampon. Renvoie le nombre d’octets ou de caractères lus. |
Reader | public int read(char[] c) | Lit les valeurs dans le tampon. Renvoie le nombre d’octets ou de caractères lus. |
InputStream | public int read(byte[] b, int offset, int length) | Lit jusqu’à length valeurs dans le tampon à partir de la position offset. Renvoie le nombre d’octets ou de caractères lus. |
Reader | public int read(char[] c, int offset, int length) | Lit jusqu’à length valeurs dans le tampon à partir de la position offset. Renvoie le nombre d’octets ou de caractères lus. |
Tous les streams de sortie | public void write(int b) | Écrit un seul octet. |
OutputStream | public void write(byte[] b) | Écrit un tableau de valeurs dans le stream. |
Writer | public void write(char[] c) | Écrit un tableau de valeurs dans le stream. |
OutputStream | public void write(byte[] b, int offset, int length) | Écrit length valeurs du tableau dans le stream, en commençant par l’index offset. |
Writer | public void write(char[] c, int offset, int length) | Écrit length valeurs du tableau dans le stream, en commençant par l’index offset. |
BufferedInputStream | public byte[] readAllBytes() | Lit les données en octets. |
BufferedReader | public String readLine() | Lit une ligne de données. |
BufferedWriter | public void write(String line) | Écrit une ligne de données. |
BufferedWriter | public void newLine() | Écrit une nouvelle ligne. |
Tous les streams de sortie | public void flush() | Vide les données tamponnées à travers le stream. |
Tous les streams | public void close() | Ferme le stream et libère les ressources. |
Le tableau suivant présente les méthodes communes de lecture et d’écriture de Files NIO.2 :
Nom de la méthode | Description |
---|---|
public static byte[] readAllBytes() | Lit toutes les données en octets |
public static String readString() | Lit toutes les données dans une String |
public static List readAllLines() | Lit toutes les données dans une List |
public static Stream lines() | Lit les données de manière paresseuse |
public static void write(Path path, byte[] bytes) | Écrit un tableau d’octets |
public static void writeString(Path path, String string) | Écrit une String |
public static void write(Path path, List list) | Écrit une liste de lignes (techniquement, tout Iterable de CharSequence) |