JavaRush /Курсы /Go SELF /embed + io/fs: fs.Sub и единый доступ к ресурсам

embed + io/fs: fs.Sub и единый доступ к ресурсам

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

1. Зачем нужен fs.Sub

Когда проект маленький, строки вида "assets/help.txt" кажутся невинными. Но как только ресурсов становится больше двух, начинаются классические «весёлые приключения»: в одном месте написали "assets/help.txt", в другом — "asset/help.txt", в третьем — "./assets/help.txt", и вот уже программа работает только в фазе Луны «растущая, но по чётным». Наша цель — сделать так, чтобы структура ресурсов была одной, и чтобы остальной код не занимался гаданием по префиксам.

Представим, что у нас учебное приложение — маленький CLI‑таск‑трекер tasker. Ранее он умел читать/писать задачи в файл, а теперь мы хотим добавить встроенные ресурсы: текст справки и шаблон «примерного файла задач». Структура проекта может быть такой:

tasker/
  main.go
  assets/
    help.txt
    templates/
      sample_tasks.txt

Если мы будем везде писать "assets/...", то это превращается в «магическую строку», размазанную по проекту. В Go для таких проблем есть очень практичная штука: fs.Sub.

2. fs.Sub: переукореняем файловую систему

fs.Sub — это функция, которая берёт любую fs.FS и «отрезает» от неё поддерево, делая выбранный каталог новым корнем. Звучит абстрактно, но идея очень бытовая: как будто вы сказали коду «считай, что папка assets/ — это твой /».

Сигнатура выглядит так:

sub, err := fs.Sub(fsys, "assets")

И после этого внутри sub файл "help.txt" соответствует исходному "assets/help.txt".

Это решает сразу две проблемы. Во-первых, мы перестаём таскать префикс "assets/" по всему коду. Во-вторых, мы ограничиваем доступ: если функция получила sub, то она физически не может случайно прочитать что-то «вне ресурсов». Это очень приятная «безопасность по конструкции», а не «по надежде».

3. Единый вход к ресурсам: пакет assets

Мы хотим, чтобы директива //go:embed и структура путей жили в одном месте. Остальной код должен работать с чем-то простым: «дай мне fs.FS ресурсов» или «прочитай мне help».

В реальных проектах так делают постоянно: например, в исходниках pkgsite (сайт документации Go) упоминается, что добавление сторонних библиотек связано с правкой строки //go:embed в fs.go.

Создадим пакет assets (можно положить его в internal/assets, но для простоты назовём просто assets).

Встраиваем дерево ресурсов

Подводка простая: нам нужен embed.FS, потому что файлов несколько.

package assets

import "embed"

//go:embed assets/* assets/templates/*
var embedded embed.FS

Здесь важно, что //go:embed работает только для переменных уровня пакета, а не внутри функций — это мы уже видели. И ещё важный момент: паттерны надо выбирать аккуратно, иначе вы встроите «весь домашний фотоархив 2007», а потом удивитесь размеру бинарника (размер бинарника вообще отдельная «религиозная» тема в Go‑мире).

Возвращаем «укоренённую» FS через fs.Sub

Теперь сделаем главный трюк лекции — вернём наружу fs.FS, уже «подрезанную» до assets/.

package assets

import "io/fs"

func FS() (fs.FS, error) {
	return fs.Sub(embedded, "assets")
}

Обратите внимание на стиль: мы не возвращаем embed.FS, мы возвращаем fs.FS. Это снижает связанность: остальному коду не нужно знать про embed вообще, ему нужна «файловая система для чтения».

4. Читаем ресурсы без «магических путей»

Теперь хочется сделать так, чтобы в main не появлялись строки "templates/sample_tasks.txt" и тем более не появлялись конструкции вида «ну тут допишем префикс, а там уберём». В хороших проектах пути к ресурсам обычно централизуют.

Функция HelpText()

Подводка: справка нужна часто, и хочется, чтобы вызов был максимально тупым (в хорошем смысле).

package assets

import (
	"fmt"
	"io/fs"
)

func HelpText() (string, error) {
	fsys, err := FS()
	if err != nil {
		return "", err
	}

	b, err := fs.ReadFile(fsys, "help.txt")
	if err != nil {
		return "", fmt.Errorf("read help.txt: %w", err)
	}
	return string(b), nil
}

Тут два практических момента. Во-первых, fs.ReadFile возвращает []byte, а мы превращаем в string. Во-вторых, мы оборачиваем ошибку, чтобы наверху было понятно, что именно читали.

Шаблон из подкаталога templates/

Теперь добавим функцию, которая берёт шаблон файла задач из templates/.

package assets

import (
	"fmt"
	"io/fs"
)

func SampleTasks() ([]byte, error) {
	fsys, err := FS()
	if err != nil {
		return nil, err
	}

	b, err := fs.ReadFile(fsys, "templates/sample_tasks.txt")
	if err != nil {
		return nil, fmt.Errorf("read sample_tasks.txt: %w", err)
	}
	return b, nil
}

Заметьте: путь "templates/sample_tasks.txt" — это путь уже относительно «переукоренённой» FS (после Sub("assets")). То есть мы работаем так, как будто assets/ — корень.

5. Почему filepath.Join может «предать»

Сейчас будет тема, на которой ломаются даже бодрые разработчики, потому что привычки сильны. Если вы до этого много работали с диском, рука сама тянется к filepath.Join. И это логично: он «правильно» собирает путь под текущую ОС.

Но FS‑пути внутри fs.FS — это не OS‑пути. Для embed.FS и вообще для большинства fs.FS‑реализаций путь — это строка с разделителем /. Даже на Windows. Даже если вы очень попросите.

Чтобы не путать, держим в голове простую таблицу:

Что собираем Чем склеиваем Разделитель Пример
Путь на диске ОС
filepath.Join
зависит от ОС
C:\data\tasks.json
или
/home/me/tasks.json
Путь внутри
fs.FS
path.Join
всегда
/
templates/sample_tasks.txt

Подводка здесь такая: filepath — это «про файловую систему ОС», а path — это «про пути вида URL/FS». И embed.FS живёт ближе ко второму.

Демонстрация бага «вроде всё правильно, но не работает»

Представим, мы хотим читать шаблон по имени файла:

package assets

import (
	"io/fs"
	"path/filepath"
)

func ReadTemplateBad(name string) ([]byte, error) {
	fsys, _ := FS()

	// filepath.Join может вернуть "templates\sample_tasks.txt" на Windows
	p := filepath.Join("templates", name)
	return fs.ReadFile(fsys, p)
}

На Linux/macOS вы можете долго не замечать проблемы (потому что там разделитель тоже /). А на Windows внезапно получите «file does not exist», хотя он точно встроен. И вот вы уже начинаете подозревать компилятор в заговоре, а не себя (классика).

Правильная версия:

package assets

import (
	"io/fs"
	"path"
)

func ReadTemplateOK(name string) ([]byte, error) {
	fsys, _ := FS()

	// path.Join всегда использует "/"
	p := path.Join("templates", name)
	return fs.ReadFile(fsys, p)
}

Эта мелочь — одна из главных причин, почему в лекции так много внимания fs.Sub и «единому доступу»: мы хотим, чтобы ошибки путей были локализованы в одном пакете, а не вылезали по всему проекту.

6. Как подключить это к CLI tasker

Сейчас соберём «мостик» к приложению, чтобы это не было абстрактной теорией. В tasker у нас есть команда help, которая должна печатать встроенный help.txt. И есть команда init, которая создаёт файл задач из встроенного шаблона (на диске, уже обычными os.WriteFile, потому что embedded FS read‑only).

Печать help в main

Подводка: main должен быть скучным. Чем скучнее main, тем меньше шанс, что вы будете дебажить его в 3 часа ночи.

package main

import (
	"fmt"
	"os"

	"tasker/assets"
)

func main() {
	help, err := assets.HelpText()
	if err != nil {
		fmt.Fprintln(os.Stderr, "error:", err)
		os.Exit(1)
	}

	fmt.Print(help)
}

Обратите внимание: main вообще не знает, где лежит help.txt, какой у него путь, и использовали ли мы fs.Sub. Он просто просит текст.

Команда init: пишем файл на диск из embedded‑шаблона

Подводка: встроенные ресурсы часто используют как «дефолты» — конфиги, шаблоны, демо‑данные. Это удобно: пользователь не должен бегать по репозиторию, чтобы найти пример.

package main

import (
	"fmt"
	"os"

	"tasker/assets"
)

func initTasksFile(dst string) error {
	b, err := assets.SampleTasks()
	if err != nil {
		return err
	}

	return os.WriteFile(dst, b, 0644)
}

Здесь приятно то, что мы чётко разделяем два мира. Чтение шаблона идёт через fs.FS (встроенный мир), а запись идёт через os.WriteFile (мир диска). Никакой каши.

Схема: слой ресурсов после fs.Sub

Иногда полезно визуализировать, что вообще происходит с «корнем». Вот простая схема:

flowchart TD
    A["embedded embed.FS
с корнем проекта"] --> B["fs.Sub(embedded, 'assets')
под-FS"] B --> C["fs.ReadFile(fsys, 'help.txt')"] B --> D["fs.ReadFile(fsys, 'templates/sample_tasks.txt')"]

Смысл схемы в одном: после Sub все пути становятся короче, а ваш код меньше зависит от структуры каталогов в исходном проекте.

7. Типичные ошибки при fs.Sub и доступе к ресурсам

Ошибка №1: продолжать писать пути с префиксом после fs.Sub.
Это выглядит так: вы сделали fs.Sub(embedded, "assets"), а потом продолжаете читать "assets/help.txt". В результате будет «не найдено», и это логично: новый корень уже assets, повторять его не надо. Обычно лечится дисциплиной: после Sub("assets") используем пути вида "help.txt" и "templates/...".

Ошибка №2: игнорировать ошибку fs.Sub.
fs.Sub возвращает (fs.FS, error) не из вредности. Если вы ошиблись в имени каталога или ресурс не встроен так, как вы думаете, Sub честно скажет «не могу». Если проигнорировать err, можно получить странные ошибки позже, в другом месте, и потратить время на «почему не читается help». Правильный подход — проверять err сразу и падать понятным сообщением на границе приложения.

Ошибка №3: использовать filepath.Join для путей внутри fs.FS.
Это одна из самых коварных ловушек, потому что на Unix‑подобных системах проблема может не проявляться вообще. Но ваша программа может уехать на Windows (или просто в CI), и внезапно сломаться. Для путей внутри fs.FS используйте path.Join, а filepath.Join оставьте для работы с путями ОС.

Ошибка №4: размазать строки путей по проекту вместо централизованного пакета.
Когда "templates/sample_tasks.txt" встречается в трёх разных пакетах, любая перестановка каталогов превращается в мини‑миграцию. Гораздо лучше, когда пути живут в пакете assets, а наружу торчат функции вроде HelpText() и SampleTasks(). Тогда вы можете поменять структуру ресурсов почти без затрагивания логики приложения.

Ошибка №5: считать embedded‑ресурсы «секретными».
Встроенный файл находится внутри бинарника. Если у вас там ключи, токены или пароли — считайте, что вы их опубликовали. Даже если «никто не догадается», это не стратегия безопасности, а надежда. Встраивайте только то, что можно безопасно распространять.

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