JavaSE 7 : NIO2 - File System

Un article de JTips.

Java 7 : découverte de NIO2 pour l'accès aux fichiers (en cours de rédaction)

JavaSE 7 : NIO2 - File System
Auteur : Alexis Hassler
Formation(s) sur le sujet :
Approfondissement java
Sewatech.png

Sous ce chapeau de New I/O se cachent plusieurs fonctionnalités bien distinctes. On peut principalement dissocier les classes d'accès au système de fichiers d'une part et les entrées / sorties asynchrones d'autre part. Seule la partie file system est traitée dans cette page.

java.io.File

Cette classe existe depuis Java 1.0 et est le moyen prévu pour parcourir le système de fichiers et de manipuler fichiers et répertoires. Cette classe a largement évolué depuis sa version initiale, en particulier pour Java 1.2, puis dans une moindre mesure pour Java 1.4 et Java 6.

Il est possible de créer un objet File, représentant un fichier ou répertoire à partir d'une chaîne de caractères ou d'un objet URI. Le format chemin peut être absolu ou relatif, mais est évidemment dépendant du système d'exploitation.

La classe java.io.File était jusqu'à maintenant le point central pour accéder au système de fichiers en Java. En Java SE 7, les fonctionnalités de cette classe sont reprises et enrichies dans d'autres classes du package java.nio.file. La classe java.io.File n'est pas dépréciée pour autant, mais son usage est déconseillé, au profit des nouvelles implémentations.

Manipulation de chemins

Un chemin d'accès peut être absolu ou relatif, son format dépend de l'OS. Les techniques pour définir le chemin d'un java.io.File était la chaîne de caractères ou l'URI. En JavaSE 7, on utilise l'interface java.nio.file.Path. Un objet Path est immuable, comme pour java.io.File. Un objet Path n'est pas directement associé à un fichier ou un répertoire, il peut exister sans fichier ou répertoire associé ; pour accéder aux fichiers associés il faut utiliser la classe java.nio.file.Files.

Comme Path est une interface, il faut utiliser la classe java.nio.file.Paths pour en créer des instances, à partir d'une chaîne de caractères ou d'une URI.

       Path myPath = Paths.get("mydir");

Les méthodes de Path permettent de décortiquer un chemin (nombre de niveaux, décomposition du chemin) et de naviguer dans les répertoires parents (accès au parent, accès à la racine), mais pas de naviguer dans le contenu.

Pour accéder à des sous-répertoires d'un chemin, on peut directement les passer à la création du Path, sans se préoccuper du séparateur.

       Path jarPath = Paths.get("dist", "Java7Examples.jar");

On peut aussi résoudre les sous-répertoires depuis l'objet Path, en donnant un sous-répertoire ou un sous-chemin.

       Path jarPath = projectPath.resolve("dist").resolve("Java7Examples.jar");

ou

       Path jarPath = projectPath.resolve(Paths.get("dist","Java7Examples.jar"));

Path est aussi capable d'extraire un chemin relatif par rapport à un autre chemin.

       Path jarPath = Paths.get("dist", "Java7Examples.jar")
                           .toAbsolutePath();;
       // => /home/alexis/NetBeansProjects/Java7Examples/dist/Java7Examples.jar
       Path homePath = Paths.get(System.getProperty("user.home"));
       // => /home/alexis
       System.out.println(homePath.relativize(jarPath));
       // => NetBeansProjects/Java7Examples/dist/Java7Examples.jar

Les méthodes equals, compareTo, startsWith et endsWith permettent de faire des comparaisons entre chemins.

La méthode normalize permet de faire le ménage dans les '.' et '..' qui auraient été utilisés pour la construction du chemin.

       Path myPath = Paths.get(".", "src");
       Path myPath = Paths.get("src", "..", ".", "src");
       System.out.println(myPath.toAbsolutePath());
       // => /home/alexis/NetBeansProjects/Java7Examples/src/.././src
       System.out.println(myPath.normalize().toAbsolutePath());
       // => /home/alexis/NetBeansProjects/Java7Examples/src

La méthode toRealPath permet de résoudre les liens symboliques.

       Path docPath = Paths.get("nio-doc");
       System.out.println(docPath.toAbsolutePath());
       // => /home/alexis/NetBeansProjects/Java7Examples/nio-doc
       System.out.println(docPath.toRealPath());
       // => /home/alexis/Téléchargements/nio

L’interopérabilité avec l'ancienne classe java.io.File a été conservée avec la possibilité de créer un Path depuis un File (myFile.toPath()) et réciproquement (myPath.toFile()).


Accès aux fichiers

Manipuler des chemins, c'est bien, mais l'objectif est généralement d'accéder aux fichiers, soit de façon externe pour les déplacer, copier, renommer ou pour en manipuler les métadonnées, soit pour lire ou modifier le contenu. Jusqu'à présent, l'accès externe était assuré par la classe File alors que le contenu était accédé par des flux (stream), éventuellement encapsulés dans des readers ou writers.

L'accès externe est aujourd'hui assuré par la classe utilitaire java.nio.file.Files, qui ne contient que des méthodes statiques.

On trouve les méthodes de création (createFile, createDirectory, createLink), suppression (delete), déplacement (move) et de copie (copie) qui travaillent avec des objets Path. Files fournit la nature du chemin : fichier (isRegularFile), répertoire (isDirectory), lien symbolique (isSymbolicLink). Files fournit aussi des informations sur le fichier : taille (size), propriétaire (getOwner), lisible (isReadable), modifiable (isWritable), caché (isHidden), exécutable (isExecutable), type MIME de son contenu (probeContentType).

On notera avec grand plaisir le support des liens symboliques : créer, parcourir,...

L'accès aux méta-données a été amélioré. Certaines sont directement fournies par Files (heure de modification, propriétaire), d'autres sont accessibles via les attributs de fichier. Ces attributs peuvent être vus sous différentes formes : basique qui est compatible avec tous les systèmes, dos qui est valable uniquement sous Windows ou posix qui peut être utilisé sur les systèmes d'exploitation sérieux. La vue basique n'a que peu d'intérêt puisque la plupart de ses informations sont directement disponibles via la classe Files. Son seul intérêt réside dans les performances car il peut économiser plusieurs accès successifs à un fichier

       BasicFileAttributes jarBasicAttrs = Files.readAttributes(jarPath, BasicFileAttributes.class);
       System.out.println("creationTime is " + jarBasicAttrs.creationTime());
       System.out.println("lastAccessTime is " + jarBasicAttrs.lastAccessTime());

Pour Posix, la plus-value se situe dans les permissions.


Parcourir les répertoires

Parcourir un répertoire est un peu moins intuitif mais plus puissant qu'avec java.io.File. En effet, on doit utiliser une ressource de type java.nio.file.DirectoryStream ; l'utilisation du try-with-resources est bienvenue.

       Path homePath = Paths.get(System.getProperty("user.home"));
       try (DirectoryStream<Path> stream = Files.newDirectoryStream(homePath)) {
           for (Path entry : stream) {
               System.out.println(entry);
           }
       }
       // => /home/alexis/Bureau
       // => /home/alexis/NetBeansProjects
       // => /home/alexis/Images
       // => /home/alexis/Téléchargements
       // => /home/alexis/.m2
       // => /home/alexis/.gnome2
       // => ...

La méthode peut aussi recevoir un paramètre optionnel qui permet de filtrer le résultat avec la syntaxe glob ou avec des expressions régulières.

       try (DirectoryStream<Path> stream = Files.newDirectoryStream(homePath, "*.{java,xml}")) {
           for (Path entry : stream) {
               System.out.println(entry);
           }
       }

En fait, la vrai puissance de cette méthode est dans sa capacité à parcourir de gros répertoires, contrairement à java.io.File qui construit d'abord un tableau avant de le mettre à disposition.

Pour aller plus loin dans la puissance, on peut utiliser la méthode walkFileTree. Celle-ci permet de parcourir toute une structure hiérarchique de répertoire à partir d'une racine, en utilisant un visiteur. Par exemple, pour rechercher des fichiers source en java, nous pouvons parcourir l'arbre de répertoires à partir de la racine du projet, et détecter les fichiers *.java.

Nous commençons par écrire un visiteur. Celui-ci doit implémenter l'interface java.nio.file.FileVisitor, éventuellement en héritant de java.nio.file.SimpleFileVisitor. L'évaluation du nom du fichier pourrait se faire par comparaison de chaînes de caractère mais gagne en élégance si on utilise un PathMatcher, avec une syntaxe glob ou regexp, comme pour le DirectoryStream.

   private static class SourceFileVisitor extends SimpleFileVisitor<Path> {
       private static final Path ROOT = Paths.get("");
       private static final PathMatcher SOURCE_MATCHER = ROOT.getFileSystem().getPathMatcher("glob:**/*.java");
       
       @Override
       public FileVisitResult visitFile(Path path, BasicFileAttributes attr) {
           if (SOURCE_MATCHER.matches(path)) {
               System.out.println(path);
           }
           return FileVisitResult.CONTINUE;
       }
   }

Ensuite, l'utilisation de ce visiteur se fait simplement par l'appel de la méthode walkFileTree.

       Files.walkFileTree(Paths.get(""), new SourceFileVisitor());
       // => src/org/sewatech/java7/nio2/UsingFileStore.java
       // => src/org/sewatech/java7/nio2/SymLinkSupport.java
       // => src/org/sewatech/java7/nio2/Directories.java
       // => src/org/sewatech/java7/nio2/MetadataAttributes.java


Lire un fichier, écrire dans un fichier

Pour lire séquentiellement le contenu d'un fichier, il fallait jusqu'à maintenant instancier un flux de type FileReader ou FileInputStream et l'associer avec des flux de filtre, comme un BufferedReader. Avec NIO2, ça n'a pas changé... Seule différence, la classe java.nio.file.Files fournit des méthodes utilitaires pour nous simplifier le travail.

Ainsi pour parcourir le contenu d'un fichier texte, on peut utiliser Files.newBufferedReader.

       Charset UTF8 = Charset.forName("UTF-8");
       
       Path sourcePath = Paths.get("src/org/sewatech/java7/nio2/UsingFileStore.java");
       try (BufferedReader reader = Files.newBufferedReader(sourcePath, UTF8)) {
           String line = null;
           while ((line = reader.readLine()) != null) {
               System.out.println(line);
           }
       }

Évidemment, il existe l'équivalent pour écrire dans un fichier.

       Path sourcePath = Paths.get("nothing.txt");
       String content = "line 1\nline 2\nline 3\n...";
       try (BufferedWriter writer = Files.newBufferedWriter(sourcePath, UTF8)) {
           writer.write(content);
       }

La classe Files offre aussi des méthodes pour obtenir un flux sans buffer (newInputStream et newOutputStream) ainsi que des canaux NIO pour des accès directs (newByteChannel).

Enfin, pour les petits fichiers Files nous simplifie la vie en nous fournissant directement le contenu binaire (readAllBytes) ou textuel (readAllLines).

       Path sourcePath = Paths.get("src/org/sewatech/java7/nio2/UsingFileStore.java");
       List<String> lines = Files.readAllLines(sourcePath, UTF8);
       for (String line : lines) {
           System.out.println(line);
       }


Liens symboliques

Comme nous l'avons déjà vu, la méthode toRealPath de Path permet de résoudre les liens symboliques dans les chemins.

       Path docRealPath = docPath.toRealPath();
       // => /home/alexis/Documents/nio

Le support des liens symboliques est aussi assuré dans la classe Files. Pour la suppression d'un lien, c'est la même méthode delete que pour les fichiers qui est utilisé.

       Files.delete(docPath);

On peut vérifier que le répertoire réel existe toujours.

       System.out.println("docRealPath exists : " + Files.exists(docRealPath));
       // => false

Pour la création du lien, on utilise la méthode createSymbolicLink.

       Files.createSymbolicLink(docPath, docRealPath);
       System.out.println("docRealPath exists : " + Files.exists(docRealPath));
       // => false

La méthode isSymbolicLink permet de vérifier si le chemin point sur un lien symbolique.

       System.out.println("docPath is a sym link : " + Files.isSymbolicLink(docPath));

Enfin, la méthode readSymbolicLink donne le même résultat que path.toRealPath dans le cas des liens symboliques et renvoie une NotLinkException sinon.

       System.out.println("Real path : " + Files.readSymbolicLink(docPath));
       // => /home/alexis/Documents/nio


Permissions Posix

L'information de sécurité la plus simple à obtenir est l'utilisateur propriétaire du fichier. Cette information est accessible directement par la classe Files.

       UserPrincipal owner = Files.getOwner(manifestPath);
       System.out.println(owner);
       // => alexis

Pour connaître le groupe d'appartenance du fichier, il faut passer par les attributs de méta-données. Ceux-ci fournissent un autre moyen de récupérer l'utilisateur propriétaire.

       PosixFileAttributes attributes = Files.readAttributes(manifestPath, PosixFileAttributes.class);
       UserPrincipal owner = attributes.owner();
       GroupPrincipal group = attributes.group();
       System.out.println("Le fichier appartient à " + owner + ":" + group);

L'information détaillée des permission peut être récupérée par le même objet d'attributs (méthode permissions) ou directement via la classe Files (getPosixFilePermissions)

       Set<PosixFilePermission> permissions = attributes.permissions();
       System.out.println(PosixFilePermissions.toString(permissions));
       // => rw-r--r--

Ces permissions peuvent aussi être modifiées, à condition que notre process en ait la permission, bien sûr.

       Set<PosixFilePermission> permissions = PosixFilePermissions.fromString("rw-rw-r--");
       Files.setPosixFilePermissions(manifestPath, permissions);

Cette façon de procéder est parfaite pour les développeurs habitués à Linux ou Unix. Les autres préféreront probablement manipuler les permissions une à une. Ils auront l'avantage d'utiliser un type énuméré, plus rigoureux qu'une simple chaîne de caractères.

       PosixFilePermission[] permissionsArray = {PosixFilePermission.OWNER_READ, 
                                                 PosixFilePermission.OWNER_WRITE,
                                                 PosixFilePermission.GROUP_READ,
                                                 PosixFilePermission.OTHERS_READ};
       Set<PosixFilePermission> newPermissions = new HashSet<>(Arrays.asList(permissionsArray));
       Files.setPosixFilePermissions(manifestPath, newPermissions);

Cette façon de procéder peut être pratique pour ajouter ou supprimer une permission.

       Set<PosixFilePermission> permissions = attributes.permissions();
       permissions.add(PosixFilePermission.OTHERS_EXECUTE);
       permissions.remove(PosixFilePermission.OTHERS_READ);
       Files.setPosixFilePermissions(path, permissions);


Partitions et disques

Une machine peut avoir accès à plusieurs stockages, comme des volumes ou des partitions. Ces stockages sont représentés dans NIO2 par des java.nio.file.FileStore.

On peut retrouver le stockage pour un chemin.

       Path homePath = Paths.get(System.getProperty("user.home"));
       FileStore homeStore = Files.getFileStore(homePath);
       System.out.println(homeStore.name());
       // => rootfs

A partir de là, on peut savoir si celui-ci supporte les attributs Posix ou Dos. On peut aussi retrouver les informations de taille, d'occupation et de place disponible qu'on avait déjà sur java.io.File.

       System.out.println("Support Posix : " + homeStore.supportsFileAttributeView(PosixFileAttributeView.class));
       // => true
       System.out.println("Taille (Go) : " + formatter.format(homeStore.getTotalSpace()/1048576));
       // => Taille (Go) : 11 536
       System.out.println("Espace disponible (Go) : " + homeStore.getUsableSpace()/1048576);
       // => Espace disponible (Go) : 880
       System.out.println("Espace inoccupé (Go) : " + homeStore.getUnallocatedSpace()/1048576);
       // => Espace inoccupé (Go) : 1466

Il est possible de récupérer l'ensemble des stockages d'un système.

       Iterable<FileStore> fileStores = FileSystems.getDefault().getFileStores();
       for (FileStore fileStore : fileStores) {
           System.out.print(fileStore);
           System.out.println(", type : " + fileStore.type());
       }
       // => / (/dev/sda1), type : ext4
       // => /proc (proc), type : proc
       // => ...
       // => /media/sf_stockage (stockage), type : vboxsf

En revanche, je n'ai pas trouvé de moyen de récupérer le point de montage du store. Cette information est affichée dans le toString, mais n'est pas disponible dans l'interface publique.

Notification de changement

L'objet central du dispositif de surveillance et de notification est de type java.nio.file.WatchService et s'obtient à partir du FileSystem.

       WatchService watcher = FileSystems.getDefault().newWatchService();

un WatchService est capable de surveiller n'importe quel objet Watchable, comme un Path. Il suffit d'effectuer l'enregistrement en précisant quels événements doivent être surveillés (constantes de StandardWatchEventKinds).

       Path sourcePath = Paths.get("nothing.txt");
       WatchKey key = sourcePath.register(watcher, ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);

L'annulation de la surveillance d'un fichier se fait en annulant l'objet WatchKey et l'annulation de toutes les surveillances set fait en clôturant le WatchService.

       key.cancel();
       // ou
       watcher.close();

Entre-temps, l'attente d'événements peut se faire avec la méthode take qui attend indéfiniment ou avec la méthode poll qui attend pendant une durée limitée, voire pas du tout. Les événements sont consignés sur la clé, qui doit être réinitialisée après traitement des événements.

       watcher.take();
       for (WatchEvent<?> event : key.pollEvents()) {
           System.out.println(event.context() + " - " + event.kind());
       }
       key.reset();

Conclusion

Tout d'abord, je dois avouer que lorsque j'ai commencer la visite de NIO2, je ne m'attendais pas à tant de nouveautés, d'autant que ce sujet n'a pas fait beaucoup de bruit à la sortie de JavaSE 7.

Pour résumer, on peut lister les nouveautés comme ceci :

  • Les fonctionnalités de classe java.io.File sont reproduites et réparties dans les classes Path, Files, FileStore et FileSystem du package java.nio.file. Les responsabilités sont ainsi mieux organisées.
  • Le support des spécificités des systèmes de fichiers Posix est apporté, en particulier les permissions et les liens symboliques.
  • Le support des notifications a été ajouté.

Quelques références pour approfondir le sujet :


Quelques autres articles sur JavaSE7