Percorrendo Árvores de Ficheiros em Java
Percorra diretórios recursivamente em Java com Files.walk, Files.find e a interface FileVisitor.
O capítulo anterior terminou com Files.walk(dir) — a forma Stream<Path> de "dê-me todos os ficheiros sob este diretório." Essa é a ferramenta rápida para o caso comum. Este capítulo aborda a alternativa de nível mais baixo, Files.walkFileTree, que permite controlar a travessia de formas que a forma stream não consegue: tratar erros de I/O por ficheiro, ignorar subárvores inteiras durante a caminhada, executar código na saída de um diretório além da entrada, e curto-circuitar numa correspondência.
Use Files.walk para "listar tudo." Use Files.walkFileTree para "fazer algo em cada passo, com controlo sobre o passo."
Três APIs de caminhada
O catálogo, por ordem de frequência de uso:
| API | Retorna | Quando |
|---|---|---|
Files.walk(dir) | Stream<Path> | Mais comum — filter/map/foreach sobre cada entrada |
Files.find(dir, depth, biPredicate) | Stream<Path> | Igual, com um predicado ciente de atributos (isDirectory, mtime) |
Files.walkFileTree(dir, visitor) | Path (o início) | Necessita de hooks pré/pós-visita, tratamento de erros por ficheiro, ou para abortar a caminhada |
Os dois primeiros são suficientes para 90% do código "encontre todos os ficheiros .log". walkFileTree é o que se usa quando a resposta é "e depois apagar o diretório" ou "parar de caminhar assim que encontrar o que procuro."
FileVisitor e SimpleFileVisitor
Files.walkFileTree recebe um FileVisitor<Path> — uma interface com quatro métodos que o walker chama em momentos específicos:
FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs); // entering a directory
FileVisitResult visitFile(Path file, BasicFileAttributes attrs); // each non-directory entry
FileVisitResult visitFileFailed(Path file, IOException exc); // I/O failure on a specific file
FileVisitResult postVisitDirectory(Path dir, IOException exc); // leaving the directory (after all children)A ordem importa: para um diretório d com filhos [a, b/, c], as chamadas são preVisitDirectory(d), visitFile(a), preVisitDirectory(b), ... postVisitDirectory(b), visitFile(c), postVisitDirectory(d). O hook post* é o que torna possível a eliminação recursiva — não é possível apagar um diretório antes de apagar o seu conteúdo.
SimpleFileVisitor<Path> é a classe auxiliar que implementa os quatro métodos com valores padrão sensatos (continuar em caso de sucesso, lançar excepção em caso de falha). Faça uma subclasse e substitua apenas os métodos de que necessita:
class LogPrinter extends SimpleFileVisitor<Path> {
@Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
System.out.println(f);
return FileVisitResult.CONTINUE;
}
}
Files.walkFileTree(root, new LogPrinter());Este é o visitor mínimo viável.
FileVisitResult: quatro sinais
Cada método do visitor retorna um FileVisitResult a informar o walker sobre o que fazer a seguir:
| Valor | Efeito |
|---|---|
CONTINUE | Normal — ir para a próxima entrada |
SKIP_SUBTREE | (de preVisitDirectory apenas) Ignorar este diretório e os seus filhos completamente |
SKIP_SIBLINGS | Parar de visitar o restante do diretório atual; retomar no próximo irmão do pai |
TERMINATE | Parar a caminhada completamente |
SKIP_SUBTREE é o que se usa com mais frequência: "não descer para .git/ ou node_modules/." Retorne-o de preVisitDirectory quando o nome do diretório corresponder e o walker ignorará tanto o diretório como os seus filhos:
@Override public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes a) {
String name = dir.getFileName() == null ? "" : dir.getFileName().toString();
if (name.equals(".git") || name.equals("node_modules")) {
return FileVisitResult.SKIP_SUBTREE;
}
return FileVisitResult.CONTINUE;
}TERMINATE é o sinal "encontrado, parar" — útil quando se procura o primeiro ficheiro correspondente e não se quer caminhar o restante:
@Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) {
if (f.getFileName().toString().equals("target.txt")) {
found = f;
return FileVisitResult.TERMINATE;
}
return FileVisitResult.CONTINUE;
}A forma Stream não consegue fazer isto — Files.walk(...).filter(...).findFirst() curto-circuita, mas apenas depois de o walker já ter enumerado cada entrada do diretório no stream. Para uma árvore profunda onde a correspondência é superficial, walkFileTree é significativamente mais rápido.
Tratamento de erros por ficheiro
visitFile e preVisitDirectory só são chamados quando o JDK conseguiu ler a entrada. Se um único ficheiro for ilegível (permissão negada, symlink pendente, condição de corrida onde foi apagado durante a caminhada), visitFileFailed é chamado em vez disso com a excepção. Por padrão SimpleFileVisitor relança — isso aborta a caminhada:
@Override public FileVisitResult visitFileFailed(Path f, IOException e) throws IOException {
throw e; // default behaviour
}Para um walker tolerante (registar e continuar), substitua-o:
@Override public FileVisitResult visitFileFailed(Path f, IOException e) {
System.err.println("skipping " + f + ": " + e.getMessage());
return FileVisitResult.CONTINUE;
}Files.walk(...) não tem este hook — lança uma UncheckedIOException de dentro do stream no momento em que encontra uma entrada inválida, e o stream fica inutilizável após isso. Para scanners de longa duração em sistemas de ficheiros que não controla totalmente, é mais uma razão para usar walkFileTree.
O caso de uso canónico: eliminação recursiva
Files.delete só funciona em diretórios vazios. Para remover uma árvore é necessário apagar primeiro as folhas, depois os diretórios que as continham. walkFileTree tem a forma certa para isto — visitFile apaga o ficheiro, postVisitDirectory apaga o diretório depois de todos os seus filhos terem sido removidos:
Files.walkFileTree(root, new SimpleFileVisitor<Path>() {
@Override public FileVisitResult visitFile(Path f, BasicFileAttributes a) throws IOException {
Files.delete(f);
return FileVisitResult.CONTINUE;
}
@Override public FileVisitResult postVisitDirectory(Path d, IOException e) throws IOException {
if (e != null) throw e; // propagate I/O failures from descent
Files.delete(d);
return FileVisitResult.CONTINUE;
}
});Esta é a receita do JDK para "apagar uma árvore de diretórios." Toda a base de código que precisa disto acaba com alguma versão deste bloco de 10 linhas. Guarde uma cópia numa classe utilitária e reutilize.
Links simbólicos
Por padrão, Files.walkFileTree e Files.walk não seguem links simbólicos. Esse é o padrão seguro: evita loops infinitos num symlink que aponta para o seu próprio ancestral. Para segui-los, passe FileVisitOption.FOLLOW_LINKS:
Files.walkFileTree(root, EnumSet.of(FileVisitOption.FOLLOW_LINKS),
Integer.MAX_VALUE, visitor);Ao optar por isso, o walker deteta ciclos automaticamente — rastreia as chaves de diretório visitadas e para se a mesma aparecer novamente com FileSystemLoopException. Essa é a única forma de percorrer uma árvore com links sem escrever a deteção de ciclos manualmente.
Um exemplo prático: impressão de árvore, skip-subtree, eliminação recursiva
O programa abaixo constrói uma pequena árvore de diretórios com alguns subdiretórios (um dos quais queremos ignorar), ficheiros a vários níveis, e depois percorre-a de três formas. Primeiro, um impressor de árvore com SimpleFileVisitor que ignora .git. Segundo, um "encontrar a primeira correspondência" com TERMINATE. Terceiro, o padrão canónico de eliminação recursiva que remove toda a árvore no final.
O que retirar da execução:
- O hook
preVisitDirectoryretornouSKIP_SUBTREEno momento em que viu.git. O walker nunca desceu para o diretório; o ficheiroconfigsob ele nunca foi visitado. Essa é a ferramenta certa para "ignorar estes diretórios convencionais" —.git,node_modules,target,dist, ou qualquer outro que o projeto não queira percorrer. A formaStream<Path>não consegue fazer isto sem produzir as entradas e filtrá-las, o que ainda implica o custo da leitura do diretório. - A ordem das chamadas para
sub/foipreVisitDirectory(sub)→visitFile(b.txt)→preVisitDirectory(nested)→visitFile(c.txt)→postVisitDirectory(nested)→postVisitDirectory(sub). Os hookspost*disparam depois de todos os descendentes terem sido processados — esse é o contrato depth-first, e é o que torna possível o padrão de eliminação recursiva. - A caminhada "encontrar o primeiro" retornou
TERMINATEdevisitFileno momento em quec.txtapareceu. Tudo depois disso — as entradas restantes emnested/, o restante desub/, o restante deroot/— nunca foi visitado. Numa árvore pequena a economia é invisível; numa árvore profunda onde a correspondência é superficial, é a diferença entre O(n) e O(profundidade-da-correspondência). - A eliminação recursiva teve duas partes.
visitFileapagou as folhas;postVisitDirectoryapagou os diretórios (agora vazios). A ordem depth-first do walker garantiu que cada filho foi visitado antes dopostVisitDirectorydo seu pai, portantoFiles.delete(d)sempre viu um diretório vazio. Tentar apagar o diretório empreVisitDirectoryfalharia porque os filhos ainda estão lá; tentar apagá-lo comFiles.delete(root)no final falharia pela mesma razão. O hookpost*é o ponto central da API visitor. - Ao longo de tudo,
SimpleFileVisitorfoi a classe base e substituímos apenas os métodos de que precisávamos.visitFileFailedficou no seu valor padrão (lançar excepção), o que para estas demonstrações com ficheiros temporários é adequado. Para um scanner num sistema de ficheiros real que não controla totalmente — digamos, um scanner antivírus a percorrer/, onde os ficheiros podem ser apagados durante a caminhada — substituavisitFileFailedpara registar eCONTINUE.
O que vem a seguir
A Parte 13 termina aqui. Ficheiros foram escritos, lidos, abertos, copiados, movidos, apagados, percorridos, serializados. Streams foram tamponados, decorados, formatados, mapeados, canalizados. A próxima parte, Date and Time, passa para um problema completamente diferente: representar instantes, durações, datas de calendário, fusos horários, e a formatação e análise destes — java.time, a API moderna que substituiu java.util.Date e Calendar.