1. Чому embed здається простим… і саме тому небезпечний
Коли ви вперше використовуєте //go:embed, виникає відчуття: «О, клас! Я поклав help.txt поруч — і він назавжди зі мною». Відчуття майже правильне, але в ньому ховається пастка: embed працює на етапі збірки й дуже суворо ставиться до шляхів та структури.
Тому помилки часто виглядають нелогічно: файл є в проєкті, але не читається з embed.FS; ви змінили вміст файла, а програма виводить старе; на Windows усе працює, а в колеги на Linux — ні, або навпаки.
Важливо усвідомити: embed — це не файлова система вашого комп’ютера. Це «знімок ресурсів», запечений у бінарний файл. У цього знімка є власні правила: які шляхи допустимі, які каталоги включаються, як працює корінь, як змінюється розмір програми і чому новий текст не з’являється без перезбирання.
Коротко для орієнтиру: вбудовані файли офіційно з’явилися в Go, починаючи з версії 1.16. Це корисно пам’ятати, бо навколо embed уже сформувалися сталі практики, і частина з них якраз допомагає обходити типові граблі.
2. Шляхи в embed: ОС проти шляху усередині 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 проти 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("невдале читання:", err) // невдале читання: open assets\help.txt: file does not exist
ok := path.Join("assets", "help.txt")
_, err = fs.ReadFile(embedded, ok)
fmt.Println("успішне читання:", err) // успішне читання: <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("читання каталогу:", 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 цілком. Тобто файл повністю завантажується в пам’ять. Для дрібних файлів це чудово. Для великих — ви раптово починаєте працювати з пам’яттю, хоча хотіли лише показати довідку.
Невелика схема, щоб запам’ятати, де тут ціна:
flowchart TD
A[Файл на диску] --> B["//go:embed"]
B --> C[Бінарник стає більшим]
C --> D[fs.ReadFile]
D --> E["Файл повністю в пам’яті як []byte"]
У межах розумних навчальних проєктів рецепт простий: вбудовуємо лише те, що справді потрібно в комплекті, і намагаємося, щоб ресурси були невеликими. embed — не вантажний контейнер, а радше акуратний рюкзак.
Перезбирання: чому ви змінили файл, а програма не змінилася
Дуже часта ситуація виглядає так: ви правите help.txt, запускаєте програму й бачите старий текст. І перша думка: «Go не оновив файл».
Насправді все простіше: вбудований ресурс фіксується під час збирання. Якщо ви запускаєте вже зібраний бінарник, він міститиме стару версію файла — саме з нею й було зібрано цей бінарник.
В IDE це особливо підступно: інколи здається, що ви просто запускаєте програму, але фактично стартує старий артефакт, якщо IDE вирішила, що перезбирання не потрібне, або якщо ви запускаєте не той профіль. Тому корисна звичка — під час зміни embedded‑ресурсів стежити, чи справді було виконано перезбирання.
Можна вивести довжину довідкового тексту як просту перевірку, щоб побачити, чи бінарник оновився:
package main
import (
_ "embed"
"fmt"
)
//go:embed help.txt
var help string
func main() {
fmt.Println("довжина довідки:", len(help)) // довжина довідки: 123
}
Якщо довжина не змінюється, значить ви запускаєте стару збірку або змінюєте не той файл — таке теж буває, особливо коли копій help.txt раптом стає дві.
Безпека: embed — не схованка
Є дуже важливий момент, який чомусь хочеться ігнорувати, особливо коли «так зручно»: якщо ви вбудували файл у бінарник, він фізично міститься всередині нього. Тобто будь-яка людина, яка отримала бінарник, потенційно може цей файл витягти.
Тому золоте правило звучить так: вбудовані дані не можна вважати секретними. Жодних паролів, токенів, ключів API, приватних конфігурацій, «ну це ж лише для внутрішнього середовища» — усе це рано чи пізно стає підставою для розбору інциденту.
У навчальному застосунку tasker ми можемо спокійно вбудовувати довідку, приклади, шаблони виводу, зразкові дані. Але щойно йдеться про секрет, це вже інша модель зберігання, і 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: дивуватися, що файл змінили, а програма показує старе.
Це класична плутанина між «змінив вихідний файл» і «перезібрав бінарник». embed запікає файли під час збірки, тому старий бінарник завжди міститиме старі дані. Якщо ви тестуєте зміни ресурсу, стежте, щоб справді було виконано перезбирання, особливо в IDE.
Помилка №5: вбудовувати великі файли й не помічати ціну.
Пара текстових файлів — нормально. Але якщо ви починаєте вбудовувати великі JSON/CSV/зображення, бінарник росте, а fs.ReadFile ще й читає все в пам’ять цілком. Це не «заборонено», але часто стає несподіванкою. Зазвичай достатньо тримати ресурси компактними та вбудовувати лише те, без чого програма справді не може стартувати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ