1. Когда нужен рекурсивный обход
Если вы только начинаете, то кажется логичным: «Ну я же уже умею os.ReadDir, значит сделаю цикл, а если там папка — ещё раз сделаю ReadDir…». И да, так можно. Ровно один раз. Потом ваш код начнёт обрастать проверками, глубиной, сбором путей, обработкой ошибок, а ещё вы случайно забудете return в одном месте — и получите очень творческую бесконечность.
Рекурсивный обход нужен в реальных задачах постоянно. Например, вы хотите найти все файлы с расширением .log внутри ./logs, или посчитать, сколько «мусорных» временных файлов осталось после аварийного завершения программы, или собрать список файлов проекта (но аккуратно, не залезая в .git и vendor). Это всё один и тот же паттерн: «есть корень, есть дерево, нужно пройтись по всем узлам».
filepath.WalkDir как раз и нужен, чтобы не превращать ваш main.go в роман на 800 страниц с печальным финалом.
2. filepath.WalkDir: что делает и как устроен
filepath.WalkDir(root, fn) обходит дерево файловой системы, начиная с root, и вызывает вашу функцию fn для каждого найденного объекта: и для директорий, и для файлов, включая сам root. Сигнатура выглядит так: func WalkDir(root string, fn fs.WalkDirFunc) error.
Полезная гарантия: обход идёт в лексикографическом порядке (по именам). Это делает вывод детерминированным (воспроизводимым), но означает, что для каждой директории нужно прочитать её содержимое целиком перед тем, как углубляться дальше. В большинстве учебных и прикладных сценариев это поведение — прям подарок.
И очень важный момент про безопасность и предсказуемость: WalkDir не следует символическим ссылкам. Это снижает риск бесконечных циклов обхода (когда ссылка указывает «куда‑то вверх» или образует петлю), и часто делает поведение понятнее.
Колбэк: «вам звонят, когда нашли файл»
Когда вы впервые видите WalkDir(".", func(...) error { ... }), мозг иногда говорит: «Так… погодите… кто кого вызывает?». Это нормальная реакция. Здесь работает модель «инверсия управления»: не вы ходите по дереву и дергаете обработчик, а стандартная библиотека ходит по дереву и вызывает вашу функцию на каждом шаге.
Представьте, что вы наняли курьера обходить все квартиры в подъезде. Вы не ходите сами, вы только оставили ему инструкцию: «Когда найдёшь дверь — позвони, скажи номер и что там написано». WalkDir — это курьер, а fn — ваш телефонный разговор.
Схематично это можно представить так:
flowchart TD
A["filepath.WalkDir(root, fn)"] --> B["находит путь path"]
B --> C["вызывает fn(path, d, err)"]
C --> D["fn возвращает nil/SkipDir/SkipAll/ошибку"]
D --> E["WalkDir решает: продолжать, пропустить, остановиться"]
Главная мысль: ваша функция fn — это правила поведения на каждом найденном объекте.
Контракт fs.WalkDirFunc: path, d, err
Функция‑обработчик имеет тип fs.WalkDirFunc. В документации описано, что её вызывают для каждого файла/директории, и именно возвращаемое значение управляет тем, как продолжится обход.
Ключевая тройка параметров такая:
| Параметр | Что это | Практический смысл |
|---|---|---|
|
текущий путь | то, что вы обычно печатаете или анализируете |
|
«лёгкое описание» объекта | можно спросить d.IsDir(), d.Name() и т.д. |
|
ошибка, связанная с этим path | сигнал: «по этому пути что-то пошло не так» |
Самое важное правило на практике звучит скучно, но спасает нервы: в начале обработчика проверьте err. В документации прямо сказано, что err сообщает об ошибке доступа к path, и функция решает, что делать: вернуть ошибку (остановить обход) или продолжать другим способом.
И вот тут вспоминаем философию Go: «ошибки — это значения, с ними работают как с данными, а не как с катастрофой». В WalkDir это ощущается особенно явно: ошибка не обязательно означает «всё пропало», иногда это просто «эту папку не открыли».
3. Базовые примеры: печатаем и фильтруем
Самый маленький пример: печатаем всё, что нашли
Начинать лучше с кода, который делает одну понятную вещь: выводит все пути. Да, это похоже на «всем известный ls -R», но это идеальная тренировка мышц.
package main
import (
"fmt"
"io/fs"
"path/filepath"
)
func main() {
err := filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
fmt.Println(path)
return nil
})
if err != nil {
fmt.Println("walk:", err)
}
}
Здесь вы видите базовую форму: один вызов WalkDir и одна функция‑обработчик. И обратите внимание: WalkDir возвращает error, то есть если вы внутри обработчика вернёте «настоящую» ошибку — обход остановится и ошибка «выйдет наружу». Это прямое следствие контракта: не магия, а обычный возврат значения.
«Только файлы» и «только директории»: простая фильтрация
Когда вы уже умеете печатать всё, следующий естественный шаг — печатать только то, что вам нужно. Например, только файлы (не директории). Это особенно удобно, когда вы делаете анализ: «найти все .tmp», «найти все .log», «найти все файлы больше N» (размер — позже, сегодня без фанатизма).
package main
import (
"fmt"
"io/fs"
"path/filepath"
)
func main() {
_ = filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // пока просто пропускаем проблемные места
}
if d.IsDir() {
return nil
}
fmt.Println("file:", path) // file: ./main.go
return nil
})
}
Здесь важно почувствовать идею: d.IsDir() — это быстрый «переключатель ветки». Нам не нужно os.Stat на каждый объект, потому что WalkDir даёт DirEntry. В этом и смысл «лёгких метаданных».
4. Управление обходом и обработка проблем
SkipDir и SkipAll: не ошибки, а управляющие сигналы
В обычной жизни слово «ошибка» звучит как «ой». В WalkDir есть два специальных значения, которые формально имеют тип error, но по смыслу — это команды управления.
filepath и io/fs описывают SkipDir и SkipAll как значения, которые используются как возвращаемое значение из обработчика, чтобы пропустить директорию или остановить обход.
Удобно держать это в голове в виде маленькой таблицы:
| Что вернуть из fn | Что произойдёт |
|---|---|
|
идём дальше |
|
не заходим внутрь текущей директории |
|
прекращаем обход полностью |
|
прекращаем обход и возвращаем ошибку наружу |
Сделаем практический пример: пропускаем .git, потому что в учебном проекте она обычно не нужна для анализа, а файлов там много.
package main
import (
"io/fs"
"path/filepath"
)
func main() {
_ = filepath.WalkDir(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && d.Name() == ".git" {
return filepath.SkipDir
}
return nil
})
}
Заметьте важную деталь: фильтровать лучше по d.Name(), а не «магической подстроке» в path. Так меньше шансов случайно пропустить что-то не то.
Ошибки в err: часть «безопасного» поведения
Слово «безопасность» в файловой системе часто означает не только «защита от злоумышленника», но и «защита от хаоса». У файловой системы хаос — это нормальное состояние: файл мог исчезнуть, права могли измениться, директория могла стать недоступной, а антивирус мог решить, что ваш .tmp выглядит подозрительно (да, такое бывает, и он не извинится).
WalkDirFunc описывает, что WalkDir вызывает обработчик с ненулевым err как минимум в случаях проблем со Stat корня или проблем чтения директории. Причём там есть очень «педагогичная» деталь: обработчик может быть вызван до чтения директории, чтобы вы могли вернуть SkipDir и вообще избежать попытки чтения, и ещё раз — если чтение провалилось, чтобы сообщить ошибку.
Практический вывод: если вы игнорируете err, вы игнорируете реальность. А реальность потом игнорирует ваш код и устраивает вам panic… ну ладно, чаще просто неправильный результат.
Самый безопасный для новичка шаблон внутри обработчика обычно такой:
if err != nil {
// либо return err (строго)
// либо return nil (мягко, но результат может быть неполным)
}
И вы всегда явно выбираете стратегию.
5. Безопасность путей: symlink и «приземление» пользовательского ввода
С рекурсивным обходом связана ещё одна категория «безопасности»: что будет, если путь пришёл от пользователя? Например, ваш CLI‑инструмент принимает папку для анализа: todo scan <dir>.
Тут есть две разных проблемы.
Первая — петли и неожиданные прыжки по дереву через символические ссылки. Частично это решено самой библиотекой: filepath.WalkDir не следует символическим ссылкам. Это не абсолютная «защита от всего», но уже снижает шанс, что вы «уйдёте гулять» в неожиданное место.
Вторая — path traversal на уровне строк: когда пользователь пытается подсунуть что-то вроде ../../.. или абсолютный путь. Один из полезных «лексических фильтров» — filepath.IsLocal. Он проверяет, что путь не абсолютный, не пустой, и лексически остаётся внутри базовой директории при Join(base, path). При этом подчёркивается, что проверка чисто лексическая и не учитывает символические ссылки в файловой системе.
Вот минимальная проверка для аргумента, который вы хотите трактовать как «путь внутри рабочей директории»:
package main
import (
"fmt"
"os"
"path/filepath"
)
func main() {
p := os.Args[1]
if !filepath.IsLocal(p) {
fmt.Println("path must be local:", p)
return
}
fmt.Println("ok:", p) // ok: data
}
Это не превращает ваш инструмент в «неуязвимую крепость», но уже не даёт пользователю случайно (или намеренно) отправить вас сканировать C:\Windows или /etc, если вы этого не планировали.
6. Мини-проект: команда scan для папки данных
Чтобы примеры были не только теорией, давайте представим, что у нас уже есть учебное CLI‑приложение для задач (условно назовём его todo). Оно хранит данные в директории ./data, а иногда (после экспериментов или падений) там могут оставаться временные файлы .tmp или резервные .bak. Команда scan будет выводить подозрительные файлы, не трогая содержимое — только имена.
Сделаем основу диспетчера команд максимально просто (да, flag будет позже — сейчас руками, как в старые добрые времена):
package main
import (
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Println("usage: todo <cmd>")
return
}
switch os.Args[1] {
case "scan":
scanDataDir()
default:
fmt.Println("unknown command:", os.Args[1])
}
}
Теперь сама функция обхода. Тут мы используем WalkDir, пропускаем директории .git (на случай, если кто-то запустит скан из корня репозитория), и печатаем файлы с расширением .tmp или .bak.
package main
import (
"fmt"
"io/fs"
"path/filepath"
"strings"
)
func scanDataDir() {
root := "data"
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // мягко: пропустим проблемное место
}
if d.IsDir() && d.Name() == ".git" {
return filepath.SkipDir
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(d.Name(), ".tmp") || strings.HasSuffix(d.Name(), ".bak") {
fmt.Println("suspicious:", path) // suspicious: data/tasks.bak
}
return nil
})
}
Здесь вы видите «скелет» реального обхода: ранние возвраты, проверка err, проверка директории, затем простая фильтрация по имени. Трюк в том, что этот код легко расширяется: позже вы сможете не только печатать, но и собирать результаты в срез, сортировать, красиво выводить таблицей. Но сегодня мы не прыгаем в следующую лекцию — наша цель именно понять механику WalkDir.
7. Типичные ошибки при работе с filepath.WalkDir
Ошибка №1: игнорировать err в обработчике и использовать d как будто он всегда валидный.
Параметр err приходит не «для галочки». Он означает, что с этим path что-то пошло не так: нет прав, объект исчез, директория не читается. Если вы продолжаете работать так, будто ошибки нет, вы получаете либо некорректный результат, либо странные падения уже в вашем коде, а не в стандартной библиотеке.
Ошибка №2: возвращать обычную ошибку там, где хотели просто «не заходить в папку».
Новички иногда пишут return fmt.Errorf("skip") в надежде, что обход просто пропустит директорию. Но по контракту это означает «остановить обход и вернуть ошибку наружу». Если вы хотите именно пропустить директорию, нужно вернуть filepath.SkipDir (или fs.SkipDir), потому что это специальный управляющий сигнал.
Ошибка №3: проверять, что директория «не нужна», по подстроке в path, а не по имени d.Name().
Проверки вида strings.Contains(path, "git") почти всегда приводят к сюрпризам: вы случайно пропустите my-git-notes.txt, или не пропустите .git из-за особенностей путей на Windows. Проверка по d.Name() обычно проще и точнее, потому что имя — это именно имя текущего элемента, без «шумных» частей пути.
Ошибка №4: путать «OS‑пути» и «пути внутри абстрактной FS», а потом удивляться слешам.
filepath.WalkDir работает с путями в формате операционной системы (разделитель зависит от платформы). Это поведение прямо отмечено в документации: в отличие от io/fs.WalkDir, где пути всегда со слешами. Если вы начнёте склеивать пути «вручную» через /, на Windows вас ждёт культурный шок.
Ошибка №5: считать, что WalkDir — это «безопасная песочница» для пользовательского пути.
То, что WalkDir не следует symlink’ам, уже полезно, но это не означает, что можно принимать любой путь от пользователя и бездумно обходить весь диск. Если путь должен быть «локальным» относительно вашей рабочей директории, используйте лексическую проверку вроде filepath.IsLocal, и помните, что она не учитывает символические ссылки и работает именно как лексический фильтр.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ