1. Базові інтерфейси io/fs
Коли ви тільки вчитеся програмувати, здається природним: «Потрібен файл — просто читаю його». Це як чайник: захотіли чаю — налили води — увімкнули. Але коли програма стає трохи більшою за «Привіт, світе!», раптом з’ясовується, що «просто прочитати файл» тягне за собою чимало запитань: де цей файл лежить, які права має процес, який поточний каталог, як це тестувати, а не лише «на моєму ноутбуці працює».
І ось тут починається доросле життя: файлова система — це залежність, майже як база даних або мережа. А залежності зручно підміняти, обмежувати, тестувати й контролювати. Пакет io/fs якраз дає стандартну «мову» — інтерфейси, якими можна описати доступ до файлів, не прив’язуючись до os.*.
Уявіть, що ваш навчальний CLI-проєкт (нехай це буде наш маленький трекер завдань tasker) зберігає файли у каталозі data/. Раніше ми могли будь-де написати os.ReadFile("data/tasks.txt"). Це зручно… аж доки вам не потрібно написати тест, який запускається в CI, де немає вашого каталогу data/, і де «випадково» немає прав на читання. Якщо ж ваша логіка приймає fs.FS, тест підсовує їй «файли в пам’яті» — і всім добре.
fs.FS: мінімальний контракт «дерева файлів»
Якщо ви колись дивилися на стандартну бібліотеку Go і думали: «Чому там стільки інтерфейсів?», то вітаю: ви вже на порозі розуміння, чому Go — це Go. У io/fs усе починається з дуже маленького контракту.
Інтерфейс fs.FS — це буквально вміння відкрити файл за ім’ям:
type FS interface {
Open(name string) (File, error)
}
У цьому місці варто пригальмувати. Метод один. Це не «файлова система взагалі», не «операційка», не «диск». Це просто: «дай мені файл із назвою name у межах твого світу».
І ось тут з’являється важливе слово дня: FS‑шлях — шлях усередині fs.FS. Це не шлях ОС. Це відносне ім’я всередині конкретної файлової системи.
Нижче — мінімальна, але вже корисна функція для нашого tasker: читаємо текстовий файл із переданої FS (без жодного os.* всередині):
package files
import (
"io/fs"
)
func ReadText(fsys fs.FS, name string) (string, error) {
b, err := fs.ReadFile(fsys, name)
if err != nil {
return "", err
}
return string(b), nil
}
Тут ми використали fs.ReadFile — хелпер із io/fs. Він усередині виконає Open, прочитає файл повністю й закриє його. Це зручно, коли файл невеликий (конфіг, шаблон, текст довідки). Якщо файл гігантський, читати його повністю — не найкраща ідея, але сьогодні наша мета саме зрозуміти контракт, а не змагатися в оптимізації.
fs.File: файл як ресурс і чому Close() важливий
Коли fs.FS.Open спрацьовує успішно, ви отримуєте fs.File. І тут Go знову вдає, що «все просто», але насправді підсовує вам правильну модель світу: файл — це ресурс, і його потрібно закривати.
У fs.File є три базові можливості: читати (Read), закривати (Close) і отримувати метадані (Stat). Це не обов’язково «справжній файл на диску»: це може бути файл у пам’яті, файл усередині архіву, файл усередині вбудованого ресурсу — неважливо. Закривати його все одно потрібно, бо реалізація може тримати дескриптори, блокування, внутрішні буфери тощо.
Давайте напишемо маленьку функцію для нашого tasker: дізнатися розмір файла із задачами. Тут ми спеціально не використовуємо fs.Stat (хелпер), а показуємо «ручний» шлях через Open → defer Close → Stat:
package files
import (
"io/fs"
)
func FileSize(fsys fs.FS, name string) (int64, error) {
f, err := fsys.Open(name)
if err != nil {
return 0, err
}
defer f.Close() // Закриваємо в будь-якому разі.
info, err := f.Stat()
if err != nil {
return 0, err
}
return info.Size(), nil
}
Зверніть увагу на психологічний момент: defer f.Close() — це не магія, а спосіб не забути закрити ресурс, особливо якщо нижче буде ще п’ять return у різних гілках. Якщо вам здається, що закривати не потрібно, бо він же маленький, то це приблизно як «пристібатися не треба — я ж лише до магазину». Зазвичай саме «по дорозі до магазину» і трапляється те, що потім лагодять увесь вечір.
fs.ReadFileFS: вузький інтерфейс «мені достатньо ReadFile»
Іноді ви заздалегідь знаєте, що потоковий доступ через Open вам не потрібен, і що вам достатньо лише ReadFile(name) — тобто прочитати файл повністю. У io/fs для цього є вузький інтерфейс fs.ReadFileFS.
Чому це взагалі важливо? Тому що вузькі інтерфейси — одна з ключових звичок Go-розробника. Замість «дай мені величезну залежність на все» ми кажемо: «мені потрібне ось це конкретне вміння». Тоді код простіше тестувати і складніше використати неправильно.
Ось приклад: наш tasker хоче прочитати довідковий текст команди з файла help/main.txt. Ми можемо оголосити функцію, яка приймає саме fs.ReadFileFS:
package helptext
import (
"fmt"
"io/fs"
)
func LoadHelp(rfs fs.ReadFileFS, name string) (string, error) {
b, err := rfs.ReadFile(name)
if err != nil {
return "", fmt.Errorf("завантаження довідки %q: %w", name, err)
}
return string(b), nil
}
Ми додали wrapping через "%w", щоб зберегти причину помилки (це стане в пригоді, коли вам треба буде відрізняти «файл не знайдено» від «доступ заборонено» або «зламалася реалізація FS»). Підхід «помилки як значення» та ідеї wrapping/перевірок причин — це безпосереднє продовження того, що ми вже закріплювали раніше.
Тепер важливе питання: а що, якщо в нас є лише fs.FS, а ми хочемо оптимально використати ReadFile там, де його підтримують? Тоді можна застосувати знайому безпечну перевірку через type assertion (v, ok := x.(T)). І так, це той самий випадок, коли type assertion — не хитрість, а нормальний інструмент: «якщо вмієш більше — використовую, якщо ні — працюю за базовим контрактом».
package helptext
import (
"io/fs"
)
func LoadHelpSmart(fsys fs.FS, name string) (string, error) {
if rfs, ok := fsys.(fs.ReadFileFS); ok {
b, err := rfs.ReadFile(name)
if err != nil {
return "", err
}
return string(b), nil
}
// запасний варіант: загальний шлях через fs.ReadFile
b, err := fs.ReadFile(fsys, name)
if err != nil {
return "", err
}
return string(b), nil
}
Якщо ви раптом забули, що таке type assertion, то нагадаю сенс людською мовою: «спробуй подивитися на значення інтерфейсного типу як на більш конкретний інтерфейс або тип; якщо вийде — чудово, якщо ні — просто не панікуй». І це напряму пов’язано з тим, що в Go інтерфейси — це контракти, а не «ієрархія класів».
3. Розмова про шляхи: filepath і path
Ось тут багато новачків спотикаються — і це нормально. Ми з вами звикли до шляхів ОС: C:\Projects\... на Windows, /home/user/... на Linux/macOS. Там є роздільники, диски, абсолютні й відносні шляхи, поточний каталог, і все це обробляє пакет path/filepath.
Але у світі io/fs шлях — це не шлях ОС, а ім’я всередині FS. І в цього імені є важливі властивості:
- Зазвичай роздільник — /, незалежно від ОС.
- Зазвичай шлях відносний — без «абсолютного сенсу».
- Правила безпеки та валідності шляху — окрема тема, але вже зараз корисно не змішувати «ім’я всередині FS» і «шлях ОС».
Щоб не плутатися, тримайте просту шпаргалку:
| Що ми описуємо | Який пакет для шляхів | Який роздільник очікуємо |
|---|---|---|
| Шлях в операційній системі (диск/каталоги) | |
залежить від ОС (\ або /) |
| Шлях/ім’я всередині fs.FS (віртуальне дерево) | |
завжди / |
Давайте подивимося на різницю в коді. Ось складання шляху ОС — коли ви справді працюєте з диском і хочете з’єднати каталог та ім’я файла:
package main
import (
"fmt"
"path/filepath"
)
func main() {
p := filepath.Join("data", "tasks.txt")
fmt.Println(p) // на Windows: "data\\tasks.txt", на Linux/macOS: "data/tasks.txt"
}
А ось складання FS‑шляху — імені файла всередині fs.FS, де «мова шляхів» зазвичай /:
package main
import (
"fmt"
"path"
)
func main() {
name := path.Join("help", "main.txt")
fmt.Println(name) // help/main.txt
}
Чому нам узагалі важлива ця різниця? Тому що якщо ви почнете «згодовувати» в fs.FS.Open шлях, зібраний через filepath.Join на Windows, то отримаєте ім’я з \, а всередині віртуальної FS (і багатьох реалізацій) очікується /. І тоді ваша програма буде «магічно працювати на одному комп’ютері й дивним чином не працювати на іншому». Це класичний баг, який виглядає як прокляття, а насправді — просто різні домовленості про шляхи.
Для закріплення — маленька схема. Вона показує, що в нас ніби два «шари шляхів», і їх не можна змішувати в одну каструлю:
flowchart TD
A["Ваш код"] --> B["Шлях ОС: filepath.* (абсолютний/відносний, залежно від ОС)"]
A --> C["FS-шлях: path.* (усередині fs.FS, роздільник завжди /)"]
B --> D["os.Open/os.ReadFile (робота напряму з диском)"]
C --> E["fsys.Open / fs.ReadFile (робота через fs.FS)"]
Якщо коротко й чесно: filepath — це «як на диску», path — це «як у віртуальному дереві».
4. Міні-рефакторинг tasker: читання файлів через fs.FS
Зараз ми зробимо дуже типовий крок, який у реальних проєктах часто виглядає як: «Ой, а чому ми не зробили так одразу?». Ми візьмемо читання текстів із файлів і приберемо пряму залежність від os.ReadFile із логіки.
Припустімо, у tasker є файл із підказкою щодо формату задач docs/format.txt, а ще файл із привітанням docs/banner.txt. Раніше ми могли написати так:
// було: жорстка прив’язка до диска:
// b, err := os.ReadFile("docs/banner.txt")
Тепер робимо по-дорослому: пакет uihelp читає файли через fs.FS, і йому байдуже, звідки вони беруться.
package uihelp
import (
"fmt"
"io/fs"
)
func Banner(fsys fs.FS) (string, error) {
b, err := fs.ReadFile(fsys, "docs/banner.txt")
if err != nil {
return "", fmt.Errorf("читання банера: %w", err)
}
return string(b), nil
}
Важлива деталь: усередині Banner немає ні слова про поточний каталог, абсолютні шляхи, диски тощо. Є лише контракт: «у FS має існувати файл docs/banner.txt». Це різко спрощує життя, тому що:
- Логіку простіше тестувати — ми підставимо FS із потрібними файлами.
- Логіку простіше перевикористовувати — неважливо, звідки беремо файли.
- Помилки простіше діагностувати — ми додали контекст «читання банера».
Якщо вам зараз хочеться запитати: «Окей, а як створити fsys на диску?», то це чудове запитання — і правильний момент для нього настане після того, як ви впевнено тримаєте в голові контракт fs.FS і різницю між шляхами. Сьогодні фіксуємо: логіка приймає fs.FS, а те, як саме влаштований fsys, — це деталь реалізації на межі застосунку.
5. Типові помилки під час роботи з fs.FS і шляхами
Помилка № 1: плутати FS‑шлях і шлях ОС, бо «це ж усе рядки».
Рядки справді всюди однакові, але значення — різне. Якщо ви складаєте ім’я для fsys.Open, орієнтуйтеся на домовленість io/fs: зазвичай / і відносність. Для шляхів ОС використовуйте filepath. Щойно ви почнете змішувати filepath.Join і fsys.Open, баги стануть міжплатформними, тобто найнеприємнішими.
Помилка № 2: забувати закривати fs.File після Open.
У прикладах із fs.ReadFile закриття відбувається «під капотом», і це розслабляє. Але щойно ви викликаєте fsys.Open, одразу ставте defer f.Close(). Це не бюрократія: різні реалізації fs.FS можуть тримати реальні ресурси, і ви не хочете на практиці з’ясовувати, що таке витік дескрипторів.
Помилка № 3: приймати в API занадто широкий контракт, а потім використовувати лише частину.
Якщо вашій функції достатньо ReadFile(name), приймайте fs.ReadFileFS. Це робить очікування зрозумілішими. Якщо потрібен лише Open, приймайте fs.FS. У Go хороший стиль — починати з мінімально достатнього інтерфейсу.
Помилка № 4: робити wrapping помилок, але втрачати причину.
Якщо ви пишете fmt.Errorf("...: %v", err), причина втрачається для errors.Is. Якщо ви пишете fmt.Errorf("...: %w", err), причина зберігається, і код вище може коректно розпізнати тип або клас помилки. Це частина базової філософії «помилки як значення» й одна з причин, чому в Go так люблять errors.Is/errors.As.
Помилка № 5: очікувати, що fs.ReadFile підходить «для всього».
fs.ReadFile читає файл повністю. Для невеликих текстів це чудово, але для великих файлів може бути недоречно. Навіть якщо сьогодні ми читаємо «банер» і довідку, пам’ятайте: повне читання — це свідомий вибір, а не варіант за замовчуванням на всі випадки життя.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ