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 | |
залежить від ОС (/ або \) |
| Шлях усередині fs.FS | fs.ReadFile, fs.Stat, fsys.Open | |
завжди / |
Якщо коротко, то 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 так, щоб «небезпечні рядки» не потрапляли всередину файлового шару.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ