1. Навіщо приймати fs.FS, а не викликати os.* безпосередньо
Коли ви вперше чуєте пораду «приймай fs.FS», це легко сприйняти як чергову модну мантру зі світу Go. Але на практиці йдеться про дуже конкретний біль: код, який напряму викликає os.ReadFile, швидко перетворюється на код, який важко нормально тестувати й незручно використовувати в різних сценаріях. А ще він починає залежати від поточної директорії, прав доступу на диску та особливостей операційної системи.
Дизайн API — це завчасне рішення про те, що є входом вашої функції, що входить до її відповідальності, а що ви свідомо залишаєте «за дверима». І fs.FS — чудовий спосіб сказати: «мені потрібна файлова система як джерело даних, але я не хочу знати, яка саме».
Межа застосунку: os зовні, io/fs всередині
Уявіть наш навчальний застосунок, умовно назвімо його tasker: він зберігає завдання у файлі й уміє читати довідкові тексти, шаблони та налаштування. До появи io/fs новачок зазвичай робить так: усередині будь-якої функції пише os.ReadFile("data/help.txt"), а потім дивується, чому тести неочікувано падають на CI або на комп’ютері сусіда.
Краще мислити так: main — це межа застосунку. На цій межі ми можемо працювати з os, шляхами ОС, прапорцями командного рядка та всім «брудним» зовнішнім світом. А от бізнес-логіка й прикладні функції повинні отримувати залежності параметрами.
Ось як це зручно уявити:
flowchart TD
A[точка входу / CLI] -->|"створює os.DirFS('data')"| B[логіка застосунку]
B -->|приймає fs.FS| C[функції читання]
C -->|fs.ReadFile / fs.Stat| D[дані]
У цій схемі найважливіше ось що: main залежить від os, а прикладний код — лише від io/fs.
3. Сигнатури й імена файлів в API
Базова форма: func X(fsys fs.FS, name string) (...)
На цьому етапі у вас уже могло виникнути запитання: «Гаразд, а як виглядає “правильна” сигнатура?». Хороша новина: вона виглядає нудно. А нудно в Go — часто означає надійно.
Давайте додамо в tasker читання текстового «банера» — наприклад, привітання або довідкової шпаргалки — щоб його можна було показувати в CLI. Ми хочемо читати файл з будь-якої FS, а не лише з диска.
package ui
import (
"fmt"
"io/fs"
)
func LoadBanner(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
}
Зверніть увагу на дві речі. По-перше, ми не викликаємо os.ReadFile. По-друге, ми додаємо контекст до помилки й загортаємо її через %w, щоб причина не загубилася.
Зрештою, рішення загортати помилку чи ні — це теж частина дизайну API: якщо ви загортаєте через %w, ви фактично дозволяєте коду, що викликає, розпізнавати першопричину через errors.Is/errors.As, а отже робите цю причину частиною контракту. Іноді це корисно, а іноді розкриває зайві деталі реалізації.
FS‑шлях і шлях ОС — це різні «світи»
Якщо в цей момент ви думаєте: «Та яка різниця, шлях і шлях», — вітаю: ви на порозі класичної помилки, яка ловитиметься три години, а виправлятиметься однією заміною пакета.
Шлях ОС живе у світі filepath. Там роздільники залежать від платформи (на Windows це часто "\\"), там бувають абсолютні шляхи, диски, UNC та інші радощі життя.
FS‑шлях живе у світі io/fs. Це відносне ім’я всередині конкретної FS, і за домовленістю в ньому використовується /.
Міні-таблиця, яку корисно буквально тримати поруч:
| Що ви збираєте | Чим збирати | Приклад |
|---|---|---|
| Шлях на диску (OS path) | |
|
| Шлях усередині fs.FS (FS path) | |
|
І ось важливий нюанс дизайну: якщо ваша функція приймає fs.FS, то параметр name має бути FS‑шляхом, а не «чимось, що лише схоже на шлях».
Валідація імені: це частина контракту, якщо шлях приходить ззовні
Зараз буде момент, коли інженерна параноя виглядає як здоровий глузд. Якщо name приходить від користувача (CLI-аргумент, параметр HTTP, ім’я файла з конфігурації), то ви зобов’язані думати про те, що туди може прийти "../../secret.txt".
Так, ми вже обговорювали fs.ValidPath раніше, але в цій лекції важливе саме API-мислення: валідація — це не «десь потім», а частина контракту.
Ось приклад безпечного читання для нашого tasker, який читає користувацький шаблон нотатки:
package ui
import (
"fmt"
"io/fs"
)
func LoadUserTemplate(fsys fs.FS, name string) (string, error) {
if !fs.ValidPath(name) {
return "", fmt.Errorf("недійсний шлях до шаблону: %q", name)
}
b, err := fs.ReadFile(fsys, name)
if err != nil {
return "", fmt.Errorf("читання шаблону %q: %w", name, err)
}
return string(b), nil
}
Зверніть увагу: fs.ValidPath перевіряє форму імені, а не існування файла. Це ідеально підходить як запобіжник на вході: ми не намагаємося «причесати» шлях, а чесно кажемо: «не можна».
4. Вузькі та «опціональні» інтерфейси
Вузькі інтерфейси: коли fs.FS — не єдиний варіант
Багато новачків спершу бояться інтерфейсів, а потім — ще більше: починають створювати «універсальний інтерфейс на всі випадки життя». У Go люблять протилежний підхід: чим менший інтерфейс, тим краще, якщо він справді відповідає вашій потребі.
fs.FS дає вам лише Open. Але інколи ви пишете функцію, якій потрібно лише прочитати файл цілком. Тоді приймати fs.FS можна, але не обов’язково: в io/fs є вузьке розширення fs.ReadFileFS — інтерфейс для файлових систем, які вміють ReadFile.
Зробімо в tasker функцію читання довідки. Нам не потрібні Open/Close/Stat, нам потрібно просто «дай рядок».
package ui
import (
"fmt"
"io/fs"
)
func LoadHelpText(fsys fs.ReadFileFS, name string) (string, error) {
b, err := fsys.ReadFile(name)
if err != nil {
return "", fmt.Errorf("читання довідки %q: %w", name, err)
}
return string(b), nil
}
Чому це добре як дизайн API? Бо сигнатура тепер чесно каже: «Мені потрібна FS, яка вміє ReadFile». Не більше. Не менше.
І тут доречне коротке філософське зауваження про помилки: errors.Is і errors.As — це перевірки, що враховують wrapping. errors.Is — це розумний аналог err == target, а errors.As — розумний аналог type assertion, тільки з ланцюжком wrapping.
«Опціональні» можливості: приймаємо fs.FS, але використовуємо покращення
Іноді хочеться сказати: «Я прийму fs.FS, але якщо там є ReadFile, скористаюся ним, бо так простіше». І це нормальна ідея — головне, зробити її читабельною.
У Go це зазвичай робиться через type assertion. Ми акуратно перевіряємо, чи реалізує fsys додатковий контракт, і обираємо зручніший шлях.
package ui
import (
"io"
"io/fs"
)
func ReadAllSmart(fsys fs.FS, name string) ([]byte, error) {
if rfs, ok := fsys.(fs.ReadFileFS); ok {
return rfs.ReadFile(name)
}
f, err := fsys.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
Ця техніка хороша тим, що ваш API залишається простим (fs.FS), а реалізація використовує покращення, якщо вони доступні. При цьому ви не вимагаєте від усіх реалізацій FS «уміти все на світі».
5. Як це виглядає в main: os.DirFS і передавання залежності
Зараз важливо показати зв’язок «межа → всередину». На межі ми працюємо з диском, але всередину передаємо інтерфейс.
Припустімо, на диску є директорія "data", а всередині — "help.txt". Тоді main може виглядати так:
package main
import (
"fmt"
"os"
"tasker/ui"
)
func main() {
fsys := os.DirFS("data")
help, err := ui.LoadBanner(fsys, "help.txt")
if err != nil {
fmt.Println("помилка:", err) // помилка: читання банера "help.txt": ...
return
}
fmt.Println(help)
}
Тут main знає про os.DirFS("data"), а ui.LoadBanner взагалі не знає, де лежить файл: на диску, у пам’яті, в тестовому MapFS, у віртуальному піддереві — неважливо.
6. Тестованість — побічний ефект правильного API
Легко думати, що тестованість — це «колись потім, коли ми станемо серйозними». Але в Go тестованість часто з’являється просто тому, що ви прийняли правильний інтерфейс у параметрах.
Якщо LoadBanner приймає fs.FS, то тест перетворюється на просту підстановку fstest.MapFS. Без мок-фреймворків, без магії.
package ui
import (
"testing"
"testing/fstest"
)
func TestLoadBanner(t *testing.T) {
fsys := fstest.MapFS{
"help.txt": {Data: []byte("hello")},
}
got, err := LoadBanner(fsys, "help.txt")
if err != nil {
t.Fatalf("LoadBanner: %v", err)
}
if got != "hello" {
t.Fatalf("отримано %q", got)
}
}
І ось тут стається важлива річ: ви не «пишете тест заради тесту». Ви просто перевіряєте функцію в контрольованому середовищі, бо API це дозволяє.
7. Помилки та %w: де це справді частина контракту
У цьому місці зазвичай хочеться дати правило «завжди роби %w», але в Go є неприємна звичка: він не любить абсолютних правил. Wrapping — це не про красу тексту помилки (людині все одно буде видно повідомлення), а про те, чи зможуть зовнішні програми розпізнавати першопричину.
Якщо ви загорнули помилку, ви тим самим дозволяєте ззовні залежати від цієї причини — і це може перетворитися на обіцянку, яку потім важко змінити.
Для нашого tasker можна триматися такого практичного компромісу. Якщо помилка прийшла від залежності, яку передав той, хто викликає, — наприклад, FS, Reader або Writer, — то wrapping через %w зазвичай виправданий: це дає змогу ззовні відрізнити «нема файла» від «нема прав» або від «збій введення-виведення». А от якщо всередині вашої функції є деталі реалізації, які ви не хочете розкривати, ви можете додати контекст, але не відкривати назовні доступ до внутрішніх причин через errors.Is.
8. Типові помилки
Помилка №1: приймати dir string і всередині робити os.ReadFile(filepath.Join(dir, name)).
Такий код виглядає «нормально», доки ви не намагаєтеся протестувати його без диска. Ви раптом виявите, що вам потрібно створювати тимчасові директорії, розкладати туди файли, стежити за очищенням і залежати від прав доступу. Правильніше прийняти fs.FS (або ще вужче, fs.ReadFileFS) і винести створення os.DirFS на межу застосунку.
Помилка №2: плутати OS‑шлях і FS‑шлях, а потім виправляти це «ще одним Join».
Зазвичай це проявляється так: ви передаєте у функцію з fs.FS шлях на кшталт "data/help.txt" (або взагалі абсолютний шлях), а потім дивуєтеся, чому os.DirFS("data") не знаходить файл. Усередині FS ім’я має бути відносним до кореня цієї FS, тобто просто "help.txt" або "cfg/app.txt". Якщо потрібно збирати FS‑шляхи програмно — використовуйте path.Join, а не filepath.Join.
Помилка №3: не валідувати зовнішній шлях і сподіватися, що DirFS «сам усе захистить».
os.DirFS справді обмежує корінь, але валідувати зовнішній ввід усе одно корисно: ви отримуєте зрозумілішу помилку, передбачуваний контракт і менше сюрпризів під час перенесення коду на інші реалізації fs.FS. Якщо name приходить від користувача, fs.ValidPath має стояти перед будь-якими спробами відкрити файл.
Помилка №4: робити wrapping «абияк» і втрачати причину або, навпаки, розкривати все підряд.
Якщо ви пишете fmt.Errorf("read: %v", err), ви додаєте контекст, але втрачаєте можливість розпізнати причину через errors.Is/errors.As. Якщо ви пишете %w, ви зберігаєте причину, але тим самим робите її спостережною ззовні — а отже частиною контракту. Це не про «правильно/неправильно», а про усвідомлений вибір: які причини ви хочете, щоб код, який викликає, міг розрізняти.
Помилка №5: приймати занадто широкий інтерфейс «про всяк випадок».
Новачки інколи створюють інтерфейс "FileSystem" з купою методів, а потім половину з них ніде не використовують. У підсумку складніше писати тестові реалізації, складніше читати сигнатури й вищий шанс випадково підтягнути зайві залежності. Хороша стратегія в Go — починати з fs.FS, а якщо функція справді потребує лише читання цілком — приймати fs.ReadFileFS. Вузькість контракту — це не обмеження, а спосіб зробити код зрозумілішим.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ