JavaRush /Курси /Go SELF /Стратегія помилок під час обходу файлової системи

Стратегія помилок під час обходу файлової системи

Go SELF
Рівень 41 , Лекція 4
Відкрита

1. Помилки під час обходу: чому це норма

Коли ви починаєте працювати з файловою системою, хочеться вірити в казку: «дам шлях — отримаю список файлів». Реальність же більше нагадує серіал: сьогодні каталог є, завтра його видалили; у файлу були права, а потім їх забрали; посеред обходу висмикнули флешку. Так, таке буває. Тому помилки — не рідкість, а частина контракту: у Go помилка — це значення, з яким ми працюємо програмно, а не «виняток із реальності».

Особливо важливо зрозуміти це саме під час обходу: WalkDir може пройти тисячі шляхів, і «ідеальна» стратегія обробки помилок залежить від того, навіщо ви взагалі обходите дерево.

Де з’являються помилки в WalkDir і що означає err

filepath.WalkDir(root, fn) працює так: бібліотека викликає вашу функцію fn багато разів — по одному разу для кожного шляху, який вона зустрічає. Це і є callback: ви не керуєте циклом напряму, але керуєте реакцією на кожну «зустріч» із файлом або каталогом.

Ключовий момент: обробник WalkDir отримує три параметри, і один із них — err. І цей err — не «щось страшне», а сигнал: «під час обходу цього шляху виникла проблема». Вам не потрібно вгадувати — потрібно обрати поведінку: зупинити весь обхід, пропустити піддерево або зібрати помилку й рухатися далі.

Нагадаю сигнатуру:

func(path string, d os.DirEntry, err error) error

І ось тут з’являється важіль керування: значення, яке повертається, визначає, як обхід триватиме далі.

Міні-модель: три стратегії

Якщо сильно спростити, у нас є лише три зрозумілі стратегії:

  1. Зупинитися на першій помилці: «без повного результату жити не можна».
  2. Пропустити проблемне місце: «частковий результат — це нормально».
  3. Зібрати всі помилки й продовжити: «я хочу максимум даних + звіт про проблеми».

У 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. Як вибрати стратегію і як зібрати все в одну функцію

Таблиця вибору стратегії

Вибір стратегії — це не справа смаку, а відповідь на запитання: що важливіше — повнота, швидкість реакції чи максимальна корисність результату. Щоб це не перетворювалося на філософію на кухні, ось компактна таблиця.

Стратегія Коли підходить Плюси Мінуси
StopOnError
Якщо без повного результату немає сенсу продовжувати Простий код, швидкий фейл, легко тестувати Можна не дійти до корисних даних через одну проблему
SkipPermissionErrors
Якщо частина дерева не критична, і ви очікуєте проблеми доступу Частковий результат, менше шуму, швидше дістатися до цілі Можна непомітно пропустити важливе, якщо політика надто м’яка
CollectErrors
Якщо потрібен максимум даних + звіт про всі проблеми Найінформативніший режим, зручно для звітів і діагностики Складніший 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...), або щонайменше друком чи логуванням на межі застосунку.

1
Опитування
Файлова система, рівень 41, лекція 4
Недоступний
Файлова система
Файлова система
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ