Fichiers, chemins, I/O streams : vous avez travaillé avec beaucoup d’éléments dans ce chapitre ! Dans cette dernière section, nous couvrons quelques fonctionnalités avancées des I/O streams et de NIO.2 qui peuvent être très utiles en pratique.
Manipulation des Input Streams
Toutes les classes d’input stream incluent les méthodes suivantes pour manipuler l’ordre dans lequel les données sont lues depuis un I/O stream :
// InputStream et Reader
public boolean markSupported()
public void mark(int readLimit)
public void reset() throws IOException
public long skip(long n) throws IOException
Les méthodes mark()
et reset()
permettent de revenir à une position antérieure dans un I/O stream. Avant d’appeler l’une de ces méthodes, vous devriez appeler la méthode markSupported()
, qui renvoie true
uniquement si mark()
est supportée. La méthode skip()
est assez simple ; elle lit essentiellement des données depuis l’I/O stream et ignore le contenu.
Toutes les classes d’input stream ne supportent pas mark()
et reset()
. Assurez-vous d’appeler markSupported()
sur l’I/O stream avant d’appeler ces méthodes, sinon une exception sera lancée à l’exécution.
Marquer des Données
Supposons que nous ayons une instance InputStream
dont les prochaines valeurs sont LION. Considérons l’extrait de code suivant :
public void lireDonnees(InputStream is) throws IOException {
System.out.print((char) is.read()); // L
if (is.markSupported()) {
is.mark(100); // Marque jusqu'à 100 octets
System.out.print((char) is.read()); // I
System.out.print((char) is.read()); // O
is.reset(); // Réinitialise le stream à la position avant I
}
System.out.print((char) is.read()); // I
System.out.print((char) is.read()); // O
System.out.print((char) is.read()); // N
}
L’extrait de code affichera LIOION si mark()
est supporté et LION autrement. C’est une bonne pratique d’organiser vos opérations read()
de sorte que l’I/O stream se retrouve à la même position, que mark()
soit supporté ou non.
Qu’en est-il de la valeur de 100 que nous avons passée à la méthode mark()
? Cette valeur est appelée le readLimit
. Elle indique à l’I/O stream que nous prévoyons d’appeler reset()
après au plus 100 octets. Si notre programme appelle reset()
après avoir lu plus de 100 octets après l’appel à mark(100)
, cela peut lancer une exception, selon la classe d’I/O stream.
En réalité, mark()
et reset()
ne remettent pas les données dans l’I/O stream, mais stockent les données dans un tampon temporaire en mémoire pour être lues à nouveau. Par conséquent, vous ne devriez pas appeler l’opération mark()
avec une valeur trop grande, car cela pourrait occuper beaucoup de mémoire.
Ignorer des Données
Supposons que nous ayons une instance InputStream
dont les prochaines valeurs sont TIGERS. Considérons l’extrait de code suivant :
System.out.print((char)is.read()); // T
is.skip(2); // Ignore I et G
is.read(); // Lit E mais ne l'affiche pas
System.out.print((char)is.read()); // R
System.out.print((char)is.read()); // S
Ce code affiche TRS à l’exécution. Nous avons ignoré deux caractères, I et G. Nous avons également lu E mais ne l’avons utilisé nulle part, ce qui est similaire à l’appel de skip(1)
.
Le paramètre de retour de skip()
nous indique combien de valeurs ont été ignorées. Par exemple, si nous sommes près de la fin de l’I/O stream et que nous appelons skip(1000)
, la valeur de retour pourrait être 20, indiquant que la fin de l’I/O stream a été atteinte après que 20 valeurs ont été ignorées.
L’utilisation de la valeur de retour de skip()
est importante si vous devez garder une trace de votre position dans un I/O stream et du nombre d’octets qui ont été traités.
Révision des API de Manipulation
Nom de la méthode | Description |
---|---|
public boolean markSupported() | Renvoie true si la classe de stream supporte mark() |
public mark(int readLimit) | Marque la position actuelle dans le stream |
public void reset() | Tente de réinitialiser le stream à la position mark() |
public long skip(long n) | Lit et ignore le nombre spécifié de caractères |
Découverte des Attributs de Fichiers
Nous commençons notre discussion en présentant les méthodes de base pour lire les attributs de fichiers.
Vérification des Liens Symboliques
Plus tôt, nous avons vu que la classe Files
possède des méthodes appelées isDirectory()
et isRegularFile()
, qui sont similaires aux méthodes isDirectory()
et isFile()
sur File
. Alors que l’objet File
ne peut pas vous dire si une référence est un lien symbolique, la méthode isSymbolicLink()
sur Files
le peut.
Il est possible que isDirectory()
ou isRegularFile()
renvoie true
pour un lien symbolique, tant que le lien se résout respectivement en un répertoire ou un fichier régulier. Examinons un exemple de code :
System.out.print(Files.isDirectory(Paths.get("/canine/fur.jpg")));
System.out.print(Files.isSymbolicLink(Paths.get("/canine/coyote")));
System.out.print(Files.isRegularFile(Paths.get("/canine/types.txt")));
Le premier exemple affiche true
si fur.jpg
est un répertoire ou un lien symbolique vers un répertoire, et false
autrement. Le deuxième exemple affiche true
si /canine/coyote
est un lien symbolique, indépendamment du fait que le fichier ou le répertoire qu’il pointe existe. Le troisième exemple affiche true
si types.txt
pointe vers un fichier régulier ou un lien symbolique qui pointe vers un fichier régulier.
Vérification de l’Accessibilité des Fichiers
Dans de nombreux systèmes de fichiers, il est possible de définir un attribut booléen pour un fichier qui le marque comme caché, lisible ou exécutable. La classe Files
inclut des méthodes qui exposent cette information : isHidden()
, isReadable()
, isWritable()
, et isExecutable()
.
Un fichier caché ne peut normalement pas être vu lors de l’énumération du contenu d’un répertoire. Les indicateurs lisible, inscriptible et exécutable sont importants dans les systèmes de fichiers où le nom de fichier peut être vu, mais l’utilisateur peut ne pas avoir la permission d’ouvrir le contenu du fichier, de modifier le fichier, ou d’exécuter le fichier comme un programme, respectivement.
Voici un exemple de chaque méthode :
System.out.print(Files.isHidden(Paths.get("/morse.txt")));
System.out.print(Files.isReadable(Paths.get("/phoque/bebe.png")));
System.out.print(Files.isWritable(Paths.get("dauphin.txt")));
System.out.print(Files.isExecutable(Paths.get("baleine.png")));
Si le fichier morse.txt
existe et est caché dans le système de fichiers, le premier exemple affiche true
. Le deuxième exemple affiche true
si le fichier bebe.png
existe et que son contenu est lisible. Le troisième exemple affiche true
si le fichier dauphin.txt
peut être modifié. Enfin, le dernier exemple affiche true
si le fichier peut être exécuté dans le système d’exploitation. Notez que l’extension du fichier ne détermine pas nécessairement si un fichier est exécutable. Par exemple, un fichier image qui se termine par .png
pourrait être marqué comme exécutable dans certains systèmes de fichiers.
À l’exception de la méthode isHidden()
, ces méthodes ne déclarent aucune exception vérifiée et renvoient false
si le fichier n’existe pas.
Amélioration de l’Accès aux Attributs
Jusqu’à présent, nous avons accédé aux attributs individuels des fichiers avec plusieurs appels de méthodes. Bien que cela soit fonctionnellement correct, il y a souvent un coût chaque fois que l’une de ces méthodes est appelée. En termes simples, il est beaucoup plus efficace de demander au système de fichiers tous les attributs en une seule fois plutôt que d’effectuer plusieurs allers-retours vers le système de fichiers.
De plus, certains attributs sont spécifiques au système de fichiers et ne peuvent pas être facilement généralisés pour tous les systèmes de fichiers.
NIO.2 répond à ces deux préoccupations en vous permettant de construire des vues pour divers systèmes de fichiers avec un seul appel de méthode. Une vue est un groupe d’attributs connexes pour un type particulier de système de fichiers. Cela ne veut pas dire que les méthodes d’attributs précédemment discutées n’ont pas leur utilité. Si vous ne devez lire qu’un seul attribut d’un fichier ou d’un répertoire, demander une vue est inutile.
Comprendre les Types d’Attributs et de Vues
NIO.2 inclut deux méthodes pour travailler avec des attributs en un seul appel de méthode : une méthode d’attributs en lecture seule et une méthode de vue modifiable. Pour chaque méthode, vous devez fournir un objet de type de système de fichiers, qui indique à la méthode NIO.2 quel type de vue vous demandez. Par vue modifiable, nous entendons que nous pouvons à la fois lire et écrire des attributs avec le même objet.
Interface d’attributs | Interface de vue | Description |
---|---|---|
BasicFileAttributes | BasicFileAttributeView | Ensemble de base d’attributs supportés par tous les systèmes de fichiers |
DosFileAttributes | DosFileAttributeView | Ensemble de base d’attributs avec ceux supportés par les systèmes basés sur DOS/Windows |
PosixFileAttributes | PosixFileAttributeView | Ensemble de base d’attributs avec ceux supportés par les systèmes POSIX, tels qu’Unix, Linux, Mac, etc. |
Récupération des Attributs
La classe Files
inclut la méthode suivante pour lire les attributs d’une classe en lecture seule :
public static A readAttributes(
Path path,
Class type,
LinkOption… options) throws IOException
L’application nécessite de spécifier les paramètres Path
et BasicFileAttributes.class
.
var path = Paths.get("/tortues/mer.txt");
BasicFileAttributes data = Files.readAttributes(path,
BasicFileAttributes.class);
System.out.println("Est un répertoire ? " + data.isDirectory());
System.out.println("Est un fichier régulier ? " + data.isRegularFile());
System.out.println("Est un lien symbolique ? " + data.isSymbolicLink());
System.out.println("Taille (en octets) : " + data.size());
System.out.println("Dernière modification : " + data.lastModifiedTime());
La classe BasicFileAttributes
inclut de nombreuses valeurs avec le même nom que les méthodes d’attributs dans la classe Files
. L’avantage d’utiliser cette méthode, cependant, est que tous les attributs sont récupérés en une seule fois pour certains systèmes d’exploitation.
Modification des Attributs
La méthode Files
suivante renvoie une vue modifiable :
public static V getFileAttributeView(
Path path,
Class type,
LinkOption… options)
Nous pouvons utiliser la vue modifiable pour incrémenter la date/heure de dernière modification d’un fichier de 10 000 millisecondes, soit 10 secondes.
// Lire les attributs du fichier
var path = Paths.get("/tortues/mer.txt");
BasicFileAttributeView view = Files.getFileAttributeView(path,
BasicFileAttributeView.class);
BasicFileAttributes attributes = view.readAttributes();
// Modifier la date de dernière modification du fichier
FileTime lastModifiedTime = FileTime.fromMillis(
attributes.lastModifiedTime().toMillis() + 10_000);
view.setTimes(lastModifiedTime, null, null);
Après que la vue modifiable est récupérée, nous devons appeler readAttributes()
sur la vue pour obtenir les métadonnées du fichier. De là, nous créons une nouvelle valeur FileTime
et la définissons en utilisant la méthode setTimes()
:
// Méthode d'instance de BasicFileAttributeView
public void setTimes(FileTime lastModifiedTime,
FileTime lastAccessTime, FileTime createTime)
Cette méthode nous permet de passer null
pour toute valeur de date/heure que nous ne voulons pas modifier. Dans notre exemple de code, seule la date/heure de dernière modification est changée.
Tous les attributs de fichier ne peuvent pas être modifiés avec une vue. Par exemple, vous ne pouvez pas définir une propriété qui change un fichier en un répertoire. De même, vous ne pouvez pas changer la taille de l’objet sans modifier son contenu.
Parcourir un Arbre de Répertoires
Bien que la méthode Files.list()
soit utile, elle ne traverse que le contenu d’un seul répertoire. Que faire si nous voulons visiter tous les chemins dans un arbre de répertoires ? Avant de continuer, nous devons revoir quelques concepts de base sur les systèmes de fichiers. Rappelez-vous qu’un répertoire est organisé de manière hiérarchique. Par exemple, un répertoire peut contenir des fichiers et d’autres répertoires, qui peuvent à leur tour contenir d’autres fichiers et répertoires. Chaque enregistrement dans un système de fichiers a exactement un parent, à l’exception du répertoire racine, qui se trouve au sommet de tout.
Un système de fichiers est généralement visualisé comme un arbre avec un seul nœud racine et de nombreuses branches et feuilles. Dans ce modèle, un répertoire est une branche ou un nœud interne, et un fichier est un nœud feuille.
Une tâche courante dans un système de fichiers est d’itérer sur les descendants d’un chemin, soit en enregistrant des informations à leur sujet, soit, plus communément, en les filtrant pour un ensemble spécifique de fichiers. Par exemple, vous pouvez vouloir rechercher dans un dossier et imprimer une liste de tous les fichiers .java
. De plus, les systèmes de fichiers stockent les enregistrements de fichiers de manière hiérarchique. En général, si vous voulez rechercher un fichier, vous devez commencer par un répertoire parent, lire ses éléments enfants, puis lire leurs enfants, et ainsi de suite.
Parcourir un répertoire, également appelé marcher dans un arbre de répertoires, est le processus par lequel vous commencez avec un répertoire parent et itérez sur tous ses descendants jusqu’à ce qu’une condition soit remplie ou qu’il n’y ait plus d’éléments sur lesquels itérer. Par exemple, si nous recherchons un seul fichier, nous pouvons terminer la recherche lorsque le fichier est trouvé ou que nous avons vérifié tous les fichiers et n’avons rien trouvé. Le chemin de départ est généralement un répertoire spécifique ; après tout, il serait chronophage de rechercher dans tout le système de fichiers à chaque demande !
Sélection d’une Stratégie de Recherche
Deux stratégies courantes sont associées au parcours d’un arbre de répertoires : une recherche en profondeur d’abord et une recherche en largeur d’abord. Une recherche en profondeur d’abord traverse la structure de la racine à une feuille arbitraire, puis navigue en remontant vers la racine, traversant complètement tout chemin ignoré en cours de route. La profondeur de recherche est la distance de la racine au nœud actuel. Pour éviter une recherche sans fin, Java inclut une profondeur de recherche qui est utilisée pour limiter combien de niveaux (ou sauts) depuis la racine la recherche est autorisée à faire.
Alternativement, une recherche en largeur d’abord commence à la racine et traite tous les éléments d’une profondeur particulière avant de passer à la profondeur suivante. Les résultats sont ordonnés par profondeur, avec tous les nœuds à la profondeur 1 lus avant tous les nœuds à la profondeur 2, et ainsi de suite. Bien qu’une recherche en largeur d’abord tende à être équilibrée et prévisible, elle nécessite également plus de mémoire car une liste des nœuds visités doit être maintenue.
Pour ces explications, vous n’avez pas besoin de comprendre les détails de chaque stratégie de recherche que Java emploie ; vous devez juste être conscient que les méthodes API Stream de NIO.2 utilisent la recherche en profondeur d’abord avec une limite de profondeur, qui peut être optionnellement modifiée.
Parcourir un Répertoire
Passons maintenant aux méthodes de l’API Stream. La classe Files
inclut deux méthodes pour parcourir l’arbre de répertoires en utilisant une recherche en profondeur d’abord.
public static Stream walk(Path start,
FileVisitOption… options) throws IOException
public static Stream walk(Path start, int maxDepth,
FileVisitOption… options) throws IOException
Comme nos autres méthodes de stream, walk()
utilise l’évaluation paresseuse et évalue un Path
seulement lorsqu’il y arrive. Cela signifie que même si l’arbre de répertoires inclut des centaines ou des milliers de fichiers, la mémoire requise pour traiter un arbre de répertoires est faible. La première méthode walk()
s’appuie sur une profondeur maximale par défaut de Integer.MAX_VALUE
, tandis que la version surchargée permet à l’utilisateur de définir une profondeur maximale. Ceci est utile dans les cas où le système de fichiers pourrait être grand et où nous savons que l’information que nous recherchons est proche de la racine.
Plutôt que de simplement imprimer le contenu d’un arbre de répertoires, nous pouvons faire quelque chose de plus intéressant. La méthode getTailleChemin()
suivante parcourt un arbre de répertoires et renvoie la taille totale de tous les fichiers dans le répertoire :
private long getTaille(Path p) {
try {
return Files.size(p);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public long getTailleChemin(Path source) throws IOException {
try (var s = Files.walk(source)) {
return s.parallel()
.filter(p -> !Files.isDirectory(p))
.mapToLong(this::getTaille)
.sum();
}
}
La méthode d’aide getTaille()
est nécessaire car Files.size()
déclare IOException
, et nous préférerions ne pas mettre un bloc try/catch à l’intérieur d’une expression lambda. Au lieu de cela, nous l’enveloppons dans la classe d’exception non vérifiée UncheckedIOException
. Nous pouvons imprimer les données en utilisant la méthode format()
:
var taille = getTailleChemin(Path.of("/renard/donnees"));
System.out.format("Taille Totale : %.2f mégaoctets", (taille/1000000.0));
Selon le répertoire sur lequel vous exécutez ceci, il imprimera quelque chose comme ceci :
Taille Totale : 15.30 mégaoctets
Application d’une Limite de Profondeur
Disons que notre arbre de répertoires est assez profond, nous appliquons donc une limite de profondeur en changeant une ligne de code dans notre méthode getTailleChemin()
.
try (var s = Files.walk(source, 5)) {
Cette nouvelle version vérifie les fichiers uniquement dans les 5 étapes à partir du nœud de départ. Une valeur de profondeur de 0 indique le chemin actuel lui-même. Puisque la méthode calcule les valeurs uniquement sur les fichiers, vous devriez définir une limite de profondeur d’au moins 1 pour obtenir un résultat non nul lorsque cette méthode est appliquée à un arbre de répertoires.
Éviter les Chemins Circulaires
Beaucoup de nos méthodes NIO.2 précédentes traversent les liens symboliques par défaut, avec NOFOLLOW_LINKS
utilisé pour désactiver ce comportement. La méthode walk()
est différente en ce qu’elle ne suit pas les liens symboliques par défaut et nécessite l’option FOLLOW_LINKS
pour être activée. Nous pouvons modifier notre méthode getTailleChemin()
pour activer le suivi des liens symboliques en ajoutant le FileVisitOption
:
try (var s = Files.walk(source,
FileVisitOption.FOLLOW_LINKS)) {
Lors du parcours d’un arbre de répertoires, votre programme doit être prudent avec les liens symboliques, s’ils sont activés. Par exemple, si notre processus tombe sur un lien symbolique qui pointe vers le répertoire racine du système de fichiers, chaque fichier dans le système sera recherché !
Pire encore, un lien symbolique pourrait conduire à un cycle dans lequel un chemin est visité de manière répétée. Un cycle est une dépendance circulaire infinie dans laquelle une entrée dans un arbre de répertoires pointe vers l’un de ses répertoires ancêtres.
Sachez que lorsque l’option FOLLOW_LINKS
est utilisée, la méthode walk()
tracera tous les chemins qu’elle a visités, lançant une FileSystemLoopException
si un chemin est visité deux fois.
Recherche dans un Répertoire
Dans l’exemple précédent, nous avons appliqué un filtre à l’objet Stream
pour filtrer les résultats, bien qu’il existe une méthode plus pratique.
public static Stream find(Path start,
int maxDepth,
BiPredicate<Path, BasicFileAttributes> matcher,
FileVisitOption… options) throws IOException
La méthode find()
se comporte de manière similaire à la méthode walk()
, sauf qu’elle prend un BiPredicate
pour filtrer les données. Elle nécessite également qu’une limite de profondeur soit définie. Comme walk()
, find()
supporte également l’option FOLLOW_LINK
.
Les deux paramètres du BiPredicate
sont un objet Path
et un objet BasicFileAttributes
, que vous avez vu plus tôt dans le chapitre. De cette manière, Java récupère automatiquement les informations de base du fichier pour vous, vous permettant d’écrire des expressions lambda complexes qui ont un accès direct à cet objet. Nous illustrons cela avec l’exemple suivant :
Path path = Paths.get("/grandschats");
long tailleMin = 1_000;
try (var s = Files.find(path, 10,
(p, a) -> a.isRegularFile()
&& p.toString().endsWith(".java")
&& a.size() > tailleMin)) {
s.forEach(System.out::println);
}
Cet exemple recherche dans un arbre de répertoires et imprime tous les fichiers .java
avec une taille d’au moins 1 000 octets, en utilisant une limite de profondeur de 10. Bien que nous aurions pu accomplir cela en utilisant la méthode walk()
avec un appel à readAttributes()
, cette implémentation est beaucoup plus courte et plus pratique. Nous n’avons pas non plus à nous inquiéter des méthodes au sein de l’expression lambda déclarant une exception vérifiée, comme nous l’avons vu dans l’exemple getTailleChemin()
.