Статьи

Превращение рекурсивного обхода файловой системы в поток

Когда я изучал программирование, еще во времена Turbo Pascal мне удавалось перечислять файлы в каталоге с помощью функций FindFirst , FindNext и FindClose . Сначала я придумал процедуру печати содержимого данного каталога. Вы можете себе представить, как я был горд, обнаружив, что на самом деле могу вызывать эту процедуру из самой себя, чтобы рекурсивно обходить файловую систему. Ну, тогда я не знал термин рекурсия , но это сработало. Подобный код в Java будет выглядеть примерно так:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
public void printFilesRecursively(final File folder) {
    for (final File entry : listFilesIn(folder)) {
        if (entry.isDirectory()) {
            printFilesRecursively(entry);
        } else {
            System.out.println(entry.getAbsolutePath());
        }
    }
}
 
private File[] listFilesIn(File folder) {
    final File[] files = folder.listFiles();
    return files != null ? files : new File[]{};
}

Не знал, File.listFiles() может вернуть File.listFiles() , не так ли? Вот как он сигнализирует об ошибках ввода / вывода, например, если IOException никогда не существовало. Но дело не в этом. System.out.println() — это редко то, что нам нужно, поэтому этот метод нельзя использовать повторно или компоновать. Вероятно, это лучший контрпример принципа Open / Closed . Я могу представить несколько вариантов использования для рекурсивного обхода файловой системы:

  1. Получение полного списка всех файлов для отображения
  2. Поиск всех файлов, соответствующих заданному шаблону / свойству (также проверьте File.list(FilenameFilter) )
  3. Поиск одного конкретного файла
  4. Обработка каждого отдельного файла, например, отправка его по сети

Каждый приведенный выше вариант использования имеет уникальный набор проблем. Например, мы не хотим создавать список всех файлов, потому что это займет значительное количество времени и памяти, прежде чем мы сможем начать его обработку. Мы хотели бы обрабатывать файлы по мере их обнаружения и лениво — путем вычисления конвейерной линии (но без неуклюжего шаблона посетителей). Также мы хотим выполнить поиск с коротким замыканием, чтобы избежать ненужных операций ввода-вывода. К счастью, в Java 8 некоторые из этих проблем можно решить с помощью потоков:

1
2
3
final File home = new File(FileUtils.getUserDirectoryPath());
final Stream<Path> files = Files.list(home.toPath());
files.forEach(System.out::println);

Помните, что Files.list(Path) (новый в Java 8) не просматривает подкаталоги — мы исправим это позже. Самый важный урок здесь: Files.list() возвращает Stream<Path> — значение, которое мы можем передавать, составлять, отображать, фильтровать и т. Д. Это очень гибкий инструмент, например, довольно просто подсчитать, сколько у меня файлов в каталоге для каждого расширения:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
import org.apache.commons.io.FilenameUtils;
 
//...
 
final File home = new File(FileUtils.getUserDirectoryPath());
final Stream<Path> files = Files.list(home.toPath());
final Map<String, List<Path>> byExtension = files
        .filter(path -> !path.toFile().isDirectory())
        .collect(groupingBy(path -> getExt(path)));
 
byExtension.
        forEach((extension, matchingFiles) ->
                System.out.println(
                        extension + "\t" + matchingFiles.size()));
 
//...
 
private String getExt(Path path) {
    return FilenameUtils.getExtension(path.toString()).toLowerCase();
}

Хорошо, просто другой API, вы могли бы сказать. Но это становится действительно интересным, когда нам нужно пойти глубже , рекурсивно обходя подкаталоги. Одной из удивительных особенностей потоков является то, что вы можете комбинировать их друг с другом различными способами. Старый Scala, говорящий «flatMap that shit» также применим здесь, посмотрите этот рекурсивный код Java 8:

1
2
3
4
5
6
7
8
9
//WARNING: doesn't compile, yet:
 
private static Stream<Path> filesInDir(Path dir) {
    return Files.list(dir)
            .flatMap(path ->
                    path.toFile().isDirectory() ?
                            filesInDir(path) :
                            singletonList(path).stream());
}

Stream<Path> лениво созданный filesInDir() содержит все файлы в каталоге, включая подкаталоги. Вы можете использовать его как любой другой поток, вызывая map() , filter() , anyMatch() , findFirst() и т. Д. Но как это действительно работает? flatMap() похожа на map() но в то время как map() является простым преобразованием 1: 1, flatMap() позволяет заменить одну запись во входном Stream несколькими записями. Если бы мы использовали map() , мы бы получили Stream<Stream<Path>> (или, возможно, Stream<List<Path>> ). Но flatMap() выравнивает эту структуру, взорвав внутренние записи. Давайте посмотрим на простой пример. Представьте, что Files.list() вернул два файла и один каталог. Для файлов flatMap() получает одноэлементный поток с этим файлом. Мы не можем просто вернуть этот файл, нам нужно обернуть его, но, по сути, это не операция. Это становится намного интереснее для каталога. В этом случае мы вызываем filesInDir() рекурсивно. В результате мы получаем поток содержимого этого каталога, который мы внедряем в наш внешний поток.

Код выше короткий, приятный и … не компилируется. Эти надоедливые проверенные исключения снова. Вот исправленный код, обернутый проверенными исключениями для здравомыслия:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
public static Stream<Path> filesInDir(Path dir) {
    return listFiles(dir)
            .flatMap(path ->
                    path.toFile().isDirectory() ?
                            filesInDir(path) :
                            singletonList(path).stream());
}
 
private static Stream<Path> listFiles(Path dir) {
    try {
        return Files.list(dir);
    } catch (IOException e) {
        throw Throwables.propagate(e);
    }
}

К сожалению, этот довольно элегантный код недостаточно ленив. flatMap() оценивает с нетерпением, таким образом, он всегда пересекает все подкаталоги, даже если мы едва запрашиваем первый файл. Вы можете попробовать мою крошечную библиотеку LazySeq которая пытается обеспечить даже более lazy-seq абстракцию, похожую на потоки в Scala или lazy-seq в Clojure. Но даже стандартное решение JDK 8 может быть очень полезным и значительно упростить ваш код.