JavaRush /Курси /Go SELF /io/fs: інтерфейси

io/fs: інтерфейси fs.FS, fs.File, fs.ReadFileFS

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

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 (хелпер), а показуємо «ручний» шлях через Opendefer CloseStat:

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. І в цього імені є важливі властивості:

  1. Зазвичай роздільник — /, незалежно від ОС.
  2. Зазвичай шлях відносний — без «абсолютного сенсу».
  3. Правила безпеки та валідності шляху — окрема тема, але вже зараз корисно не змішувати «ім’я всередині FS» і «шлях ОС».

Щоб не плутатися, тримайте просту шпаргалку:

Що ми описуємо Який пакет для шляхів Який роздільник очікуємо
Шлях в операційній системі (диск/каталоги)
path/filepath
залежить від ОС (\ або /)
Шлях/ім’я всередині fs.FS (віртуальне дерево)
path
завжди /

Давайте подивимося на різницю в коді. Ось складання шляху ОС — коли ви справді працюєте з диском і хочете з’єднати каталог та ім’я файла:

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 читає файл повністю. Для невеликих текстів це чудово, але для великих файлів може бути недоречно. Навіть якщо сьогодні ми читаємо «банер» і довідку, пам’ятайте: повне читання — це свідомий вибір, а не варіант за замовчуванням на всі випадки життя.

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