1. Помилки під час обходу: чому це норма
Коли ви починаєте працювати з файловою системою, хочеться вірити в казку: «дам шлях — отримаю список файлів». Реальність же більше нагадує серіал: сьогодні каталог є, завтра його видалили; у файлу були права, а потім їх забрали; посеред обходу висмикнули флешку. Так, таке буває. Тому помилки — не рідкість, а частина контракту: у Go помилка — це значення, з яким ми працюємо програмно, а не «виняток із реальності».
Особливо важливо зрозуміти це саме під час обходу: WalkDir може пройти тисячі шляхів, і «ідеальна» стратегія обробки помилок залежить від того, навіщо ви взагалі обходите дерево.
Де з’являються помилки в WalkDir і що означає err
filepath.WalkDir(root, fn) працює так: бібліотека викликає вашу функцію fn багато разів — по одному разу для кожного шляху, який вона зустрічає. Це і є callback: ви не керуєте циклом напряму, але керуєте реакцією на кожну «зустріч» із файлом або каталогом.
Ключовий момент: обробник WalkDir отримує три параметри, і один із них — err. І цей err — не «щось страшне», а сигнал: «під час обходу цього шляху виникла проблема». Вам не потрібно вгадувати — потрібно обрати поведінку: зупинити весь обхід, пропустити піддерево або зібрати помилку й рухатися далі.
Нагадаю сигнатуру:
func(path string, d os.DirEntry, err error) error
І ось тут з’являється важіль керування: значення, яке повертається, визначає, як обхід триватиме далі.
Міні-модель: три стратегії
Якщо сильно спростити, у нас є лише три зрозумілі стратегії:
- Зупинитися на першій помилці: «без повного результату жити не можна».
- Пропустити проблемне місце: «частковий результат — це нормально».
- Зібрати всі помилки й продовжити: «я хочу максимум даних + звіт про проблеми».
У Go це особливо природно, тому що помилки — це значення. Їх можна зберігати, обгортати, класифікувати й на цій основі вирішувати, що робити далі.
2. Навчальний застосунок fsreport: база для прикладів
Щоб приклади не були відірваними від життя, уявімо, що ми пишемо утиліту fsreport: вона рекурсивно обходить каталог і збирає список файлів, наприклад лише .log, а потім друкує короткий звіт.
Сьогодні ми додамо до fsreport саме те, заради чого й потрібна ця лекція: керовану стратегію помилок під час обходу.
Міні-модель даних (основа; далі будемо розширювати):
package main
type FileItem struct {
Path string
Size int64
}
Тепер зробімо перелік для стратегії помилок:
package main
type ErrorStrategy int
const (
StopOnError ErrorStrategy = iota
SkipPermissionErrors
CollectErrors
)
3. Три стратегії обробки помилок на практиці
Стратегія №1: зупинитися на першій помилці
Ця стратегія здається найсуворішою, але насправді вона найпростіша й часто найчесніша. Якщо ваш результат має бути повним, наприклад ви будуєте індекс і він обов’язково має включати все, то будь-яка помилка робить результат сумнівним. У такій ситуації логічно одразу завершити обхід і передати помилку вище.
Головний прийом тут — додати контекст: не просто повернути помилку, а обгорнути її так, щоб було зрозуміло, на якому шляху вона сталася. У Go для цього зазвичай використовують fmt.Errorf(... %w ...), щоб зберегти причину помилки й потім перевіряти її через errors.Is.
Приклад функції ScanStopOnError (коротко й по суті):
package main
import (
"fmt"
"os"
"path/filepath"
)
func ScanStopOnError(root string) ([]FileItem, error) {
var items []FileItem
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return fmt.Errorf("walk %q: %w", path, err)
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
return fmt.Errorf("info %q: %w", path, err)
}
items = append(items, FileItem{Path: path, Size: info.Size()})
return nil
})
if err != nil {
return nil, err
}
return items, nil
}
Зверніть увагу на два джерела проблем: err із WalkDir і окрема помилка з d.Info(). Це нормальна ситуація: дерево живе, а метадані можна не встигнути прочитати, навіть якщо елемент щойно був у списку.
Стратегія №2: пропускати проблемні місця
Стратегія «пропуску» — це коли ви кажете: «мені важливіше пройти якнайдалі, ніж зупинитися на першому недоступному каталозі». Типовий приклад: ви скануєте домашній каталог і не хочете падати лише тому, що System Volume Information або аналог недоступний.
У WalkDir є спеціальні «сигнальні значення», які виглядають як помилки, але за змістом ними не є. Найважливіше для нас — filepath.SkipDir: «не заходь у цей каталог усередину, але продовжуй обхід решти дерева».
Дуже частий і корисний варіант — пропускати помилки доступу (permission errors). Для цього ми можемо перевіряти os.IsPermission(err).
package main
import (
"fmt"
"os"
"path/filepath"
)
func ScanSkipPermissions(root string) ([]FileItem, error) {
var items []FileItem
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
if os.IsPermission(err) {
return filepath.SkipDir
}
return fmt.Errorf("walk %q: %w", path, err)
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
if os.IsPermission(err) {
return nil // для файла: просто пропускаємо його
}
return fmt.Errorf("info %q: %w", path, err)
}
items = append(items, FileItem{Path: path, Size: info.Size()})
return nil
})
if err != nil {
return nil, err
}
return items, nil
}
Тонкий момент: SkipDir логічно повертати саме тоді, коли проблема в каталозі, бо його зміст — «не заходити всередину». Для файла «не заходити всередину» не має сенсу, тому простіше повернути nil і просто не додавати його до результату.
Стратегія №3: агрегувати помилки й продовжувати
Іноді бізнес-завдання звучить так: «зібрати все, що вдасться, але не приховувати проблеми». Наприклад, ви будуєте звіт про логи, і вам корисно знати, які файли не вдалося прочитати за метаданими. У такій стратегії ви не падаєте одразу, але й не вдаєте, що все чудово.
Практичний патерн такий: всередині обходу ми складаємо помилки в []error, а наприкінці повертаємо одну помилку через errors.Join. Це зручно, тому що викличний код отримує одну помилку, але вона містить усі причини.
Зробімо функцію:
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
)
func ScanCollectErrors(root string) ([]FileItem, error) {
var items []FileItem
var errs []error
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
errs = append(errs, fmt.Errorf("walk %q: %w", path, err))
return nil
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if err != nil {
errs = append(errs, fmt.Errorf("info %q: %w", path, err))
return nil
}
items = append(items, FileItem{Path: path, Size: info.Size()})
return nil
})
if len(errs) > 0 {
return items, errors.Join(errs...)
}
return items, nil
}
Чому тут важливо використовувати %w під час обгортання? Тому що так зберігається початкова причина, і в майбутньому у викличному коді можна буде зробити errors.Is(err, os.ErrPermission) або іншу перевірку за ланцюжком. У Go 1.13+ це прямо підтримано стандартною бібліотекою через ідею ланцюжка причин (wrapping/unwrapping) і функції errors.Is / errors.As.
4. Як вибрати стратегію і як зібрати все в одну функцію
Таблиця вибору стратегії
Вибір стратегії — це не справа смаку, а відповідь на запитання: що важливіше — повнота, швидкість реакції чи максимальна корисність результату. Щоб це не перетворювалося на філософію на кухні, ось компактна таблиця.
| Стратегія | Коли підходить | Плюси | Мінуси |
|---|---|---|---|
|
Якщо без повного результату немає сенсу продовжувати | Простий код, швидкий фейл, легко тестувати | Можна не дійти до корисних даних через одну проблему |
|
Якщо частина дерева не критична, і ви очікуєте проблеми доступу | Частковий результат, менше шуму, швидше дістатися до цілі | Можна непомітно пропустити важливе, якщо політика надто м’яка |
|
Якщо потрібен максимум даних + звіт про всі проблеми | Найінформативніший режим, зручно для звітів і діагностики | Складніший UX: треба вміти показувати або логувати агреговану помилку |
Універсальна функція Scan
Коли ви починаєте писати реальний код, хочеться не три різні функції, а одну — з параметром. Зараз ми це й зробимо: Scan(root, strategy).
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
)
func Scan(root string, strategy ErrorStrategy) ([]FileItem, error) {
var items []FileItem
var errs []error
walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
switch strategy {
case StopOnError:
return fmt.Errorf("walk %q: %w", path, err)
case SkipPermissionErrors:
if os.IsPermission(err) {
return filepath.SkipDir
}
return fmt.Errorf("walk %q: %w", path, err)
case CollectErrors:
errs = append(errs, fmt.Errorf("walk %q: %w", path, err))
return nil
}
}
if d.IsDir() {
return nil
}
info, infoErr := d.Info()
if infoErr != nil {
if strategy == SkipPermissionErrors && os.IsPermission(infoErr) {
return nil
}
if strategy == CollectErrors {
errs = append(errs, fmt.Errorf("info %q: %w", path, infoErr))
return nil
}
return fmt.Errorf("info %q: %w", path, infoErr)
}
items = append(items, FileItem{Path: path, Size: info.Size()})
return nil
})
if walkErr != nil {
return nil, walkErr
}
if len(errs) > 0 {
return items, errors.Join(errs...)
}
return items, nil
}
Так, це трохи довше, ніж «одна стратегія», зате поведінка стала явною та керованою. А ще — тестованою: ви можете прогнати Scan у різних режимах і порівняти результат.
Блок-схема: як думати всередині callback WalkDir
Іноді допомагає буквально «побачити», що ми робимо, щоб перестати плутатися в return nil, return err, SkipDir і в тому, куди я взагалі повертаю.
flowchart TD
A["WalkDir викликає обробник (path, d, err)"] --> B{err != nil?}
B -- "так" --> C{Стратегія?}
C -- "StopOnError" --> D["Повернути обгорнуту помилку"]
C -- "SkipPermissionErrors" --> E{"os.IsPermission(err)?"}
E -- "так" --> F["return filepath.SkipDir"]
E -- "ні" --> D
C -- "CollectErrors" --> G["Додати до errs, обгорнути помилку; return nil"]
B -- "ні" --> H{"d.IsDir()?"}
H -- "так" --> I["return nil"]
H -- "ні" --> J["info, infoErr := d.Info()"]
J --> K{infoErr != nil?}
K -- "так" --> L["Реакція за стратегією"]
K -- "ні" --> M["Додати до items; return nil"]
5. Помилки з контекстом: чому не варто повертати «просто err»
Новачку дуже хочеться написати: return err — і все. Але майже одразу ви ловите ситуацію: «помилка є, а де саме вона сталася — незрозуміло». Тому ми додаємо контекст: операцію та шлях.
Водночас важливо не втратити початкову причину помилки, якщо ви хочете потім розрізняти «немає прав» і «немає файла». Для збереження причини використовують %w у fmt.Errorf. Це стандартний механізм Go: помилка може «містити» іншу помилку, утворюючи ланцюжок причин, а errors.Is і errors.As вміють цей ланцюжок переглядати.
Якщо запам’ятати одну практичну формулу: «помилка без шляху — це майже завжди напівпомилка».
6. Міні-приклад main: запускаємо та порівнюємо режими
Щоб відчути, що стратегія справді впливає на поведінку програми, зробімо найпростіший main. Тут без прапорців і аргументів: ми їх ще не розглядали, тож просто скануємо поточний каталог ".".
package main
import (
"fmt"
)
func main() {
items, err := Scan(".", CollectErrors)
fmt.Println("файлів:", len(items)) // файлів: 123 (приклад)
if err != nil {
fmt.Println("помилки сканування:", err)
}
}
У режимі
StopOnError ви побачите, що програма впала б одразу на першому проблемному місці. У режимі
SkipPermissionErrors вона, найімовірніше, дійде далі, але мовчки пропустить недоступні ділянки. У режимі
CollectErrors вона покаже і кількість файлів, і довгий список проблем — зате чесно.
7. Типові помилки
Помилка №1: ігнорувати err параметр в обробнику WalkDir.
Це найпідступніша помилка: ви починаєте використовувати d.Name() або d.IsDir(), хоча за контрактом вам уже сказали: «на цьому шляху сталася помилка». У такій ситуації d може виявитися не тим, чому варто довіряти. Звичка має бути залізною: перша перевірка всередині callback — це if err != nil { ... }.
Помилка №2: повертати звичайну помилку, коли ви хотіли просто «пропустити».
Коли ви повертаєте fmt.Errorf(...), ви кажете WalkDir: «зупиняйся». Коли ви повертаєте filepath.SkipDir, ви кажете: «у цей каталог не заходь, але йди далі». Якщо переплутати ці сенси, виникають дивні ефекти: то обхід раптово зупиняється, то ви не розумієте, чому «зникли» цілі гілки дерева.
Помилка №3: використовувати filepath.SkipDir для файла й чекати дива.
SkipDir логічний лише для каталогу: він буквально про «не заходити всередину». Якщо ви повернете його для файла, отримаєте поведінку, яку важко пояснити собі за тиждень. Для файла «пропуск» зазвичай означає return nil і «не додавати в результат».
Помилка №4: друкувати помилку без контексту шляху.
Повідомлення на кшталт «permission denied» без шляху — це як «у вас зламалося» без уточнення, що саме. Завжди додавайте хоча б шлях і операцію: walk, info, stat. А щоб причина помилки зберігалася й її можна було класифікувати, обгортайте через %w.
Помилка №5: агрегувати помилки, але забути повернути підсумкову помилку назовні.
Іноді код акуратно складає помилки в errs, а потім повертає (items, nil) — і виходить «ми все зробили ідеально», хоча половина дерева не прочиталася. Якщо ви обрали стратегію агрегації, вона має завершуватися явним результатом: або errors.Join(errs...), або щонайменше друком чи логуванням на межі застосунку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ