JavaRush /Курси /Go SELF /os.DirFS: робота з піддеревом як із віртуальною файловою ...

os.DirFS: робота з піддеревом як із віртуальною файловою системою

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

1. Як працює os.DirFS

Якщо ви вже вмієте робити os.Open("data/config.txt"), природне запитання таке: навіщо ще якась «віртуальна файлова система»? Причина проста: шлях до файлу і спосіб доступу до файлу — це дві різні задачі. os.Open жорстко прив’язує нас до диска й шляхів ОС, а os.DirFS дає змогу сказати: «ось тобі корінь — працюй лише всередині нього», а далі поводитися однаково і на диску, і в тестах, і, наприклад, із вбудованими ресурсами.

Уявіть, що ваш застосунок читає файли з теки data/: шаблони, довідку, приклади, «типовий конфіг». Поки ви пишете код самі на своєму комп’ютері — усе добре. Але щойно з’являються тести, CI, різні робочі директорії, різні ОС (Windows любить \, Unix любить /), починається класична трагікомедія: «у мене працює, у вас — ні».

os.DirFS — це як випустити застосунок на прогулянку, але у строго огородженому дворі: ви даєте йому кореневу директорію, а він ходить лише всередині, використовуючи FS‑шляхи. І так, це ще й помітно спрощує тестування: функцію можна писати так, щоб вона приймала fs.FS, а в тесті підставляти «фейкову» FS.

Що повертає os.DirFS(dir)

Коли ви вперше бачите os.DirFS("data"), мозок може спробувати інтерпретувати це як «ну, це просто рядок шляху, тільки у профіль». Але насправді це важливий архітектурний крок: os.DirFS повертає значення, яке реалізує інтерфейс fs.FS. Тобто ви отримуєте об’єкт, у якого є метод Open(name string) — і цей Open відкриває файли відносно кореня dir.

Важливо втримати думку: os.DirFS не «читає файл». Він робить інше: перетворює шматок диска на «віртуальну FS», усередині якої корінь — це dir, а всі операції використовують внутрішні імена.

Схематично це зручно уявляти так:

flowchart TD
    A["Диск (ОС)"] --> B["директорія dir = data/"]
    B --> C["os.DirFS(dir) => fs.FS"]
    C --> D["fs.ReadFile(fsys, 'cfg/app.txt')"]
    D --> E["Відкривається data/cfg/app.txt на диску"]

Тобто fsys := os.DirFS("data") створює для вас вікно в піддерево data/, тож далі в коді ви кажете не data/cfg/app.txt, а просто cfg/app.txt.

FS‑шляхи — не шляхи ОС

Найчастіша плутанина тут — не в коді, а в уявленні про нього. Ми вже вміємо збирати шляхи ОС через filepath.Join, очищати через filepath.Clean, перевіряти абсолютність і так далі. Але io/fs вводить інший тип домовленості: FS‑шлях — це ім’я всередині файлової системи fs.FS. Воно не зобовʼязане збігатися зі шляхом ОС і найчастіше не збігається.

FS‑шлях майже завжди дотримується простих правил: він відносний, використовує / як розділювач і не починається з /. Для ОС‑шляхів правила інші й залежать від платформи.

Зручно тримати «шпаргалку» перед очима:

Що ви будуєте Де використовується Чим збирати Розділювач
Шлях ОС (реальний шлях на диску) os.Open, os.ReadFile, os.Stat
filepath.Join
залежить від ОС (/ або \)
Шлях усередині fs.FS fs.ReadFile, fs.Stat, fsys.Open
path.Join
завжди /

Якщо коротко, то filepath — це «я розмовляю з операційною системою», а path — це «я працюю зі шляхами формату URL / FS‑шляхів (через /)».

Мініприклад, щоб відчути різницю:

package main

import (
	"fmt"
	"path"
	"path/filepath"
)

func main() {
	fmt.Println(filepath.Join("cfg", "app.txt")) // cfg/app.txt (на Unix) або cfg\app.txt (на Windows)
	fmt.Println(path.Join("cfg", "app.txt"))     // cfg/app.txt
}

У світі os.DirFS і io/fs нас цікавить саме path.Join, тому що FS‑шлях завжди використовує /, навіть якщо ви запускаєте програму на Windows.

Читаємо файл із віртуального кореня

Зробімо найпростіше: прочитаємо файл data/cfg/app.txt, але так, щоб data/ було коренем, а код працював із відносним іменем. Одразу зверніть увагу: ми не використовуємо os.ReadFile("data/..."), ми використовуємо fs.ReadFile(fsys, "..."), тому що fsys — це fs.FS.

package main

import (
	"fmt"
	"io/fs"
	"os"
)

func main() {
	fsys := os.DirFS("data")

	b, err := fs.ReadFile(fsys, "cfg/app.txt")
	if err != nil {
		fmt.Println("не вдалося прочитати:", err)
		return
	}

	fmt.Println(string(b)) // наприклад: My App
}

Тут відбувається тиха, але важлива магія без спецефектів: fs.ReadFile працює поверх fs.FS, а конкретну реалізацію fs.FS дає os.DirFS. У підсумку код читання не знає, чи лежить це на диску, — він просто працює з абстракцією.

І саме це «не знає» — не недолік, а суперсила: такий код набагато простіше тестувати й переносити.

Чому не можна передавати «повний шлях» усередину fs.FS

Новачки часто роблять так: «раз файл на диску data/cfg/app.txt, то й у fs.ReadFile передам data/cfg/app.txt». Але це ламає саму ідею DirFS.

Після того як ви зробили fsys := os.DirFS("data"), корінь data уже враховано. Передавати його ще раз — усе одно що двічі надіти шапку: тепло не подвоїться, а виглядати ви почнете підозріло.

Ось приклад «як не треба»:

package main

import (
	"fmt"
	"io/fs"
	"os"
)

func main() {
	fsys := os.DirFS("data")

	_, err := fs.ReadFile(fsys, "data/cfg/app.txt")
	fmt.Println(err) // зазвичай: open data/cfg/app.txt: no such file or directory
}

Чому так? Тому що всередині цієї FS імʼя "cfg/app.txt" відповідає реальному файлу "data/cfg/app.txt". А "data/cfg/app.txt" усередині цієї FS означає «data/data/cfg/app.txt».

Це, до речі, одна з причин, чому DirFS дисциплінує: ви перестаєте розкидати «повні шляхи» по коду й починаєте мислити коренями та відносними іменами.

Помилки «немає файла»: як розпізнавати правильно

Коли ми працюємо через io/fs, ми хочемо перевіряти «не знайдено» однаково. У цьому світі канонічна причина — fs.ErrNotExist, а перевірку роблять через errors.Is. Ідея errors.Is і «ланцюжків причин» — це частина сучасної культури помилок у Go: помилка може бути обгорнута, але причина має розпізнаватися.

Покажімо правильний шаблон:

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func main() {
	fsys := os.DirFS("data")

	_, err := fs.Stat(fsys, "nope.txt")
	if errors.Is(err, fs.ErrNotExist) {
		fmt.Println("файл відсутній")
		return
	}
	if err != nil {
		fmt.Println("інша помилка:", err)
		return
	}

	fmt.Println("існує")
}

Тут два важливі моменти.

Перший: ми не порівнюємо помилки за рядком, тому що рядок — це «як написали», а не «що сталося».

Другий: ми не боїмося, що помилка була обгорнута вище. Поки всі обгортають правильно — через %w, errors.Is продовжить працювати. Це прямо випливає з підходу «errors are values»: помилки мають бути придатні для логіки, а не лише для друку.

3. Вбудовуємо os.DirFS у застосунок

Тепер зробімо фрагмент коду, який уже схожий на справжній застосунок, а не просто main. Уявімо, що в нашого навчального проєкту є текст довідки data/help/main.txt. Ми хочемо зробити функцію, яка читає цю довідку через fs.FS, а main нехай вирішує, звідки брати FS — з диска, з тесту чи ще звідкись.

Пакет resources: читання тексту за імʼям

Зробімо маленький пакет, який уміє читати текстові файли:

package resources

import (
	"fmt"
	"io/fs"
)

func ReadText(fsys fs.FS, name string) (string, error) {
	b, err := fs.ReadFile(fsys, name)
	if err != nil {
		return "", fmt.Errorf("не вдалося прочитати %q: %w", name, err)
	}
	return string(b), nil
}

Зверніть увагу на %w: ми додали контекст («що читали»), але зберегли причину, щоб errors.Is міг розпізнавати, наприклад, fs.ErrNotExist. Саме це й вважається правильним обгортанням.

main: створюємо FS через os.DirFS і читаємо довідку

Тепер main стає дуже охайним:

package main

import (
	"fmt"
	"os"

	"example.com/app/resources"
)

func main() {
	fsys := os.DirFS("data")

	s, err := resources.ReadText(fsys, "help/main.txt")
	if err != nil {
		fmt.Println("помилка:", err)
		return
	}
	fmt.Println(s)
}

І ось тут з’являється приємний ефект: якщо завтра ви вирішите, що «ресурси» мають лежати не на диску, а в пам’яті, ваш resources.ReadText змінювати не доведеться. Ви зміните лише зв’язування залежності, тобто те, що передається як fs.FS.

Збирання шляхів усередині FS: обережно через path.Join

Рано чи пізно вам захочеться читати не один фіксований файл, а різні, наприклад help/<topic>.txt. У цей момент багато хто з’їжджає до склеювання рядків виду "help/" + topic + ".txt" — і так, спочатку здається, що воно «працює»… доки раптом не перестає.

Краще збирати FS‑шлях явно через path.Join, тому що це гарантує / і коректну роботу зі слешами:

package resources

import (
	"path"
)

func HelpPath(topic string) string {
	return path.Join("help", topic+".txt")
}

І використовувати так:

package main

import (
	"fmt"
	"os"

	"example.com/app/resources"
)

func main() {
	fsys := os.DirFS("data")

	name := resources.HelpPath("main")
	fmt.Println(name) // help/main.txt

	_ = fsys
}

Так, приклад трохи іграшковий, але він формує правильну звичку: FS‑шлях будуємо як FS‑шлях, а не як OS‑шлях.

4. Обмеження DirFS і питання безпеки

Дуже легко почати думати, що os.DirFS("data") — це залізобетонна пісочниця: мовляв, «усе, ми в безпеці, ніхто не вийде назовні». На практиці DirFS справді дає важливе обмеження: ви не зможете відкрити імʼя, яке містить .., або імʼя з провідним / як нормальний FS‑шлях. Тобто тривіальний path traversal через ../.. він відсікає.

Але важливо розуміти межу відповідальності: DirFS — це насамперед зручна реалізація fs.FS для піддерева на диску, а не універсальна система безпеки. Наприклад, якщо всередині кореня лежать символічні посилання, вони можуть вести куди завгодно — і це окрема тема. Сьогодні ми глибоко в неї не занурюватимемося, а лише зафіксуємо: безпека шляхів — це не одна галочка в коді, а набір заходів.

Ще один практичний нюанс: якщо ви пишете os.DirFS("data"), то data — це відносно поточної робочої директорії процесу. В IDE, у тестах і в CI поточна директорія може відрізнятися, тому важливо або чітко контролювати, де ви запускаєте програму, або передавати абсолютний шлях кореня. Але це вже шлях ОС, і його зазвичай збирають через filepath.Abs/filepath.Join на межі застосунку.

5. Типові помилки під час роботи з os.DirFS

Помилка № 1: змішування FS‑шляхів і шляхів ОС в одному місці.
Зазвичай це виглядає так: ви створили fsys := os.DirFS("data"), а потім раптом робите fs.ReadFile(fsys, filepath.Join("cfg", "app.txt")). У Unix це може «випадково» спрацювати, а у Windows ви отримаєте шлях із \, який для FS‑шляхів не є коректним розділювачем. Вирішується проста дисципліна: всередині fs.FS використовуємо / і за потреби path.Join, а filepath залишаємо для спілкування з ОС.

Помилка № 2: передавати в fs.ReadFile імʼя з провідним /.
FS‑шлях — відносний. Рядок "/cfg/app.txt" для fs.FS — не «абсолютний шлях», а просто «погане імʼя». Це часта помилка після довгого досвіду роботи з os.Open. Допомагає звичка: спочатку задаємо корінь через DirFS, потім використовуємо імена без провідного /.

Помилка № 3: двічі враховувати корінь (data/... усередині FS, яка вже DirFS("data")).
Це призводить до спроби відкрити data/data/... і закономірного «не знайдено». Найпростіший прийом захисту — тримати поруч із кодом коментар-інваріант: «усередині цієї FS всі імена відносні до data/».

Помилка № 4: обгортати помилки без %w і потім дивуватися, що errors.Is не працює.
Якщо ви пишете fmt.Errorf("read %q: %v", name, err), ви перетворюєте причину помилки на текст. Потім errors.Is(err, fs.ErrNotExist) перестане працювати, і ви почнете городити порівняння рядків, а це вже майже «археологія багів». Правильний варіант — fmt.Errorf("read %q: %w", name, err).

Помилка № 5: очікувати, що os.DirFS розвʼяже всі питання безпеки.
DirFS корисний і навіть відсікає частину невдалих імен, але безпека шляхів — це не лише заборона ... Навіть у навчальних проєктах важливо тримати в голові: зовнішні дані, тобто те, що прийшло від користувача, не можна бездумно підставляти в операції з файлами. Потрібно перевіряти імена та проєктувати API так, щоб «небезпечні рядки» не потрапляли всередину файлового шару.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ