JavaRush /Курсы /Go SELF /API‑дизайн — принимаем fs.FS в функциях

API‑дизайн — принимаем fs.FS в функциях

Go SELF
43 уровень , 4 лекция
Открыта

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[main / CLI] -->|"создаёт os.DirFS('data')"| B[app-логика]
    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 чтение текстового «баннера» (например, приветствие или help‑шпаргалку), чтобы его можно было показывать в 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("load banner %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)
filepath.Join
filepath.Join(dataDir, "tasks.txt")
Путь внутри fs.FS (FS path)
path.Join
path.Join("templates", "help.txt")

И вот важный нюанс дизайна: если ваша функция принимает 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("invalid template path: %q", name)
	}
	b, err := fs.ReadFile(fsys, name)
	if err != nil {
		return "", fmt.Errorf("read template %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 функцию чтения справки (help‑текста). Нам не нужны 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("load help %q: %w", name, err)
	}
	return string(b), nil
}

Почему это хорошо как дизайн API? Потому что сигнатура теперь честно говорит: «Мне нужна FS, которая умеет ReadFile». Не больше. Не меньше.

И тут уместно маленькое философское замечание про ошибки: errors.Is и errors.As задуманы как «wrapper‑aware» проверки. 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("error:", err) // error: load banner "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("got %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)).
Такой код выглядит «нормально», пока вы не пытаетесь протестировать его без диска. Вы внезапно обнаружите, что вам нужно создавать временные директории, раскладывать туда файлы, следить за cleanup и зависеть от прав доступа. Правильнее принять 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. Узость контракта — это не ограничение, а способ сделать код яснее.

1
Задача
Go SELF, 43 уровень, 4 лекция
Недоступна
Заметка в FS
Заметка в FS
1
Задача
Go SELF, 43 уровень, 4 лекция
Недоступна
Узкий интерфейс
Узкий интерфейс
1
Задача
Go SELF, 43 уровень, 4 лекция
Недоступна
Путь шаблона
Путь шаблона
1
Задача
Go SELF, 43 уровень, 4 лекция
Недоступна
Умное чтение
Умное чтение
1
Опрос
Тестируемые файловые операции, 43 уровень, 4 лекция
Недоступен
Тестируемые файловые операции
Тестируемые файловые операции
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ