1. Почему embed кажется простым… и этим опасен
Когда вы впервые используете //go:embed, возникает ощущение: «О, класс! Я положил help.txt рядом, и он навсегда со мной». Это ощущение почти правильное — но в нём скрыта ловушка: embed работает на этапе сборки и очень строго относится к путям и структуре.
Поэтому ошибки часто выглядят «нелогично»: файл существует в проекте, но не читается из embed.FS; вы поменяли содержимое файла, но программа выводит старое; на Windows всё работает, а у коллеги на Linux — нет (или наоборот).
Важно принять мысль: embed — это не файловая система вашего компьютера. Это «снимок ресурсов», запечённый в бинарник. И у этого снимка есть свои правила: какие пути допустимы, какие каталоги включаются, как работает «корень», как меняется размер программы, и почему ваш новый текст не появляется без пересборки.
Небольшой исторический факт для контекста: встроенные файлы официально появились в Go начиная с версии 1.16. Это полезно помнить, потому что вокруг embed уже сформировались устойчивые практики — и часть этих практик как раз про обход типичных граблей.
2. Пути в embed: ОС vs путь внутри fs.FS
Когда мы пишем программы, мы привыкли к путям «как в операционной системе»: C:\projects\app\data\file.txt на Windows или /home/user/app/data/file.txt на Linux/macOS. В пакете os и в filepath почти всё завязано именно на эту модель.
Но как только вы работаете с embed.FS (и в целом с fs.FS), вы попадаете в другую вселенную, где действуют другие правила.
Путь внутри fs.FS — это не «где файл лежит на диске». Это имя файла внутри виртуальной файловой системы. Для embed.FS эта система формируется из того, что вы указали в //go:embed, и именно это имя вы должны использовать при чтении.
Чтобы не путаться, удобно держать в голове простую табличку:
| Свойство | Путь ОС (обычный файл) | Путь внутри fs.FS (в т.ч. embed.FS) |
|---|---|---|
| Разделитель | зависит от ОС (\ или /) | всегда / |
| Абсолютные пути | бывают (C:\..., /home/...) | обычно нет, путь относительный к корню FS |
| Инструмент для склейки | |
чаще path.Join или вручную через / |
| Пример | |
|
Обратите внимание: здесь ключевой момент даже не в «косой черте», а в том, что корень у embed.FS свой. Он не равен корню диска, и он даже не равен корню проекта. Он равен тому, что вы встроили.
path vs filepath: как не сломать чтение ресурсов
Иногда самая дорогая ошибка в карьере программиста — это не утечка памяти и не deadlock, а «не тот пакет импортировал». У path и filepath очень похожие названия, но предназначены они для разных типов путей.
filepath — про пути ОС. Он подбирает разделитель под платформу и вообще ведёт себя «как файловая система компьютера».
path — про пути вида URL и пути внутри fs.FS, где разделитель всегда /.
Если вы строите имя файла для чтения из embed.FS через filepath.Join, на Windows вы получите обратные слеши, а embed.FS ожидает прямые. В итоге файл «не находится», хотя он встроен.
Минимальный пример (в одну маленькую боль):
package main
import (
"embed"
"fmt"
"io/fs"
"path"
"path/filepath"
)
//go:embed assets/*
var embedded embed.FS
func main() {
bad := filepath.Join("assets", "help.txt")
_, err := fs.ReadFile(embedded, bad)
fmt.Println("bad read:", err) // bad read: open assets\help.txt: file does not exist
ok := path.Join("assets", "help.txt")
_, err = fs.ReadFile(embedded, ok)
fmt.Println("ok read:", err) // ok read: <nil>
}
Заметьте, насколько это «подло»: код одинаковый по смыслу, а результат принципиально разный.
Если вы работаете с embed.FS или вообще с любым fs.FS, считайте правилом по умолчанию: filepath — для диска, path — для fs.FS.
3. Структура ресурсов и паттерны //go:embed
Проблемы с embed редко бывают «про синтаксис». Чаще они про структуру: где лежат ресурсы, как они называются, какие префиксы используются, и кто имеет право «знать» эти префиксы.
Очень полезно заранее выбрать в проекте один корневой каталог ресурсов. Например, для нашего учебного приложения (пусть это будет CLI‑утилита для задач tasker) можно завести такую структуру:
tasker/
cmd/tasker/main.go
internal/assets/
assets.go
data/
help.txt
sample.json
Здесь идея простая: всё, что встраивается, живёт внутри internal/assets/data/. Тогда в //go:embed вы почти никогда не ошибётесь, потому что «мир ресурсов» отделён от «мира кода».
assets.go может выглядеть так:
package assets
import (
"embed"
"io/fs"
)
//go:embed data/*
var embedded embed.FS
func FS() (fs.FS, error) {
return fs.Sub(embedded, "data")
}
Обратите внимание на полезный эффект: весь проект теперь не обязан помнить, что внутри embed.FS файлы лежат как data/help.txt. Внешний код просто просит assets.FS() и дальше читает "help.txt".
Паттерны //go:embed: что реально попадает в бинарник
Когда вы пишете //go:embed, вы фактически формируете список файлов, которые попадут в бинарник. И основная ловушка в том, что ваш мозг видит «папку», а компилятор видит «паттерн и правила включения».
Есть две типовые ситуации.
- Ситуация 1 — встроили только “верхний слой” файлов. Например, assets/* обычно включает файлы непосредственно в assets/, но не гарантирует рекурсивное включение подкаталогов. В итоге вы добавили assets/templates/help.txt, а в бинарник он не попал, потому что паттерн его не матчнул.
- Ситуация 2 — встроили слишком много. Например, паттерн оказался слишком широким, и в бинарник попали временные файлы, дампы, большие тестовые данные — а потом вы удивляетесь, почему бинарник весит как небольшая игра.
Практически полезное правило звучит скучно, но работает: ресурсы держим в отдельной папке, а паттерн делаем настолько узким, насколько возможно. Тогда вы и размер контролируете, и меньше шансов «не встроить нужное».
4. Диагностика: что реально встроилось
Когда fs.ReadFile возвращает ошибку, у новичка часто возникает желание «попробовать другой путь наугад». Гораздо продуктивнее быстро посмотреть, что вообще есть в вашей встроенной FS.
Для этого отлично подходит fs.ReadDir. Даже если вы потом удалите этот код, он часто экономит десятки минут.
package main
import (
"embed"
"fmt"
"io/fs"
)
//go:embed assets/*
var embedded embed.FS
func main() {
entries, err := fs.ReadDir(embedded, "assets")
if err != nil {
fmt.Println("readdir:", err)
return
}
for _, e := range entries {
fmt.Println(e.Name(), "dir?", e.IsDir())
}
}
Если вы ожидали увидеть help.txt, а видите только logo.png, значит проблема не в чтении — проблема в том, что файл не попал в «снимок» embed.
Отдельный момент: если вы используете fs.Sub, то диагностировать удобно и до, и после «переукоренения». До — чтобы понять, что вообще встроено. После — чтобы убедиться, что вы правильно выбрали поддиректорию.
5. Цена удобства: размер, пересборка и безопасность
Размер бинарника и память
embed — штука удобная, но она буквально кладёт содержимое файлов в ваш бинарник. А значит, размер бинарника растёт. Иногда это нормально: help.txt на пару килобайт никто не заметит. Иногда это внезапно превращается в проблему: вы встроили большой JSON на 15 МБ, потом ещё пару картинок, потом ещё шаблоны — и вдруг ваш «маленький CLI‑инструмент» стал весить как средняя презентация отдела продаж.
Плюс есть вторая сторона: чтение. Когда вы делаете fs.ReadFile, вы получаете []byte целиком. То есть файл читается полностью в память. Для мелких файлов это прекрасно. Для больших — вы внезапно начинаете заниматься «памятью», хотя вообще-то хотели просто показать help.
Небольшая схема, чтобы запомнить, где тут цена:
flowchart TD
A[Файл на диске] --> B["//go:embed"]
B --> C[Бинарник стал больше]
C --> D[fs.ReadFile]
D --> E["Файл целиком в памяти как []byte"]
В рамках разумных учебных проектов рецепт простой: встраиваем только то, что действительно нужно «в комплекте», и стараемся, чтобы ресурсы были небольшими. embed — не грузовой контейнер, он скорее аккуратный рюкзак.
Пересборка: почему вы поменяли файл, а программа не изменилась
Очень частая ситуация выглядит так: вы правите help.txt, запускаете программу и видите старый текст. И первая мысль: «Go не обновил файл».
На самом деле всё проще: встроенный ресурс фиксируется на этапе сборки. Если вы запускаете уже собранный бинарник, он будет содержать старую версию файла — потому что он и был собран со старой версией.
В IDE это особенно коварно: иногда кажется, что вы «просто запускаете», но фактически запускается старый артефакт, если IDE решила, что пересборка не нужна (или вы запускаете не тот конфиг). Поэтому полезная привычка — при изменении embedded‑ресурсов обращать внимание, был ли реально выполнен rebuild.
Можно сделать простую «контрольную печать» длины help‑текста, чтобы понимать, что бинарник обновился:
package main
import (
_ "embed"
"fmt"
)
//go:embed help.txt
var help string
func main() {
fmt.Println("help length:", len(help)) // help length: 123
}
Если длина не меняется, значит запускаете старую сборку (или меняете не тот файл — тоже бывает, особенно когда копий help.txt неожиданно две).
Безопасность: embed — не тайник
Есть очень важный момент, который почему-то хочется игнорировать, особенно когда «так удобно»: если вы встроили файл в бинарник, он физически присутствует внутри него. То есть любой человек, получивший бинарник, потенциально может этот файл извлечь.
Поэтому золотое правило звучит так: встроенные данные нельзя считать секретными. Никаких паролей, токенов, ключей API, приватных конфигов, «ну это же только для внутренней среды» — всё это рано или поздно становится сюжетом для постмортема.
В учебном приложении tasker мы можем спокойно встраивать справку, примеры, шаблоны вывода, sample‑данные. Но как только речь о секрете — это уже другая модель хранения (и embed сюда не подходит).
6. Типичные ошибки при работе с embed
Ошибка №1: склеивать пути для embed.FS через filepath.Join.
Это почти гарантированный источник проблем на разных ОС. filepath делает путь «как в вашей системе», а embed.FS ждёт «как в fs.FS», то есть с /. В результате вы получаете ошибку «file does not exist», хотя файл встроен. Привычка лечится просто: для FS‑путей используйте path.Join или заранее договоритесь о строках с /.
Ошибка №2: ожидать, что assets/* встроит всё рекурсивно и навсегда.
Новичок видит * и думает «всё». А компилятор видит конкретное правило сопоставления и может не включить файлы из подкаталогов. Итог — часть ресурсов «пропадает». Обычно это исправляется структурой проекта: держите встраиваемые файлы в предсказуемой папке и проверяйте содержимое через fs.ReadDir, особенно когда добавляете новые подкаталоги.
Ошибка №3: размазать строки путей по всему проекту.
Сегодня вы написали "assets/help.txt" в одном месте, завтра "data/help.txt" в другом, послезавтра сделали fs.Sub и забыли обновить половину вызовов. В итоге программа становится похожа на квест «найди правильный префикс». Решение скучное, но взрослое: один пакет владеет структурой ресурсов и отдаёт наружу либо fs.FS, либо маленькие функции ReadHelp(), ReadSample(), чтобы остальной код вообще не думал про внутренние пути.
Ошибка №4: удивляться, что файл поменяли, а программа показывает старое.
Это классическая путаница «изменил исходники» vs «пересобрал бинарник». embed запекает файлы при сборке, поэтому старый бинарник всегда будет содержать старые данные. Если вы тестируете изменения ресурса, следите, что действительно была пересборка, особенно в IDE.
Ошибка №5: встраивать большие файлы и не замечать цену.
Пара текстовых файлов — нормально. Но если вы начинаете вшивать большие JSON/CSV/картинки, бинарник растёт, а fs.ReadFile ещё и читает всё в память целиком. Это не «запрещено», но часто становится неожиданностью. Обычно достаточно держать ресурсы компактными и встраивать только то, без чего программа действительно не может стартовать.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ