1. Навіщо потрібні Marshal/Unmarshal
Коли ви вперше чуєте: «Зараз ми перетворимо структуру на JSON однією функцією», мозок одразу підозрює підступ. І недарма: підступ справді є, але цілком добрий. encoding/json бере на себе рутинну роботу: обходить поля структури, перетворює числа, рядки й масиви на JSON-формат, екранує символи тощо.
Найважливіше — зрозуміти, що це не автоматична телепатія, а цілком конкретний конвеєр: ми або кодуємо Go-значення в набір байтів, або декодуємо набір байтів у Go-змінну. І цей конвеєр дуже чутливий до типів та до того, які поля ми зробили експортованими.
У нашому навчальному застосунку — умовному CLI‑todo — це потрібно, щоб уміти: виводити задачі в JSON (наприклад, для інтеграції з іншими інструментами), читати задачі з JSON, а також чітко описати зовнішній контракт: які поля як називаються, які можна пропускати, а які не можна показувати назовні.
encoding/json: дві операції
На базовому рівні в encoding/json нас цікавлять дві функції:
- json.Marshal(v) — бере Go-значення v і повертає JSON у вигляді []byte.
- json.Unmarshal(data, &v) — бере JSON-байти data і заповнює змінну v за адресою.
Якщо хочеться запам’ятати зовсім по-людськи, то Marshal — це «запакувати», а Unmarshal — це «розпакувати». Причому розпакувати саме в уже наявну коробку, тому й потрібна адреса змінної: туди треба складати результат.
Ця ідея — спочатку закодувати, потім декодувати — спільна для багатьох форматів (JSON, XML, gob тощо): дані треба перетворити на переносний вигляд, а потім відновити назад.
json.Marshal: Go → JSON
Зараз ми зробимо найпростіше: візьмемо структуру задачі й перетворимо її на JSON. Зверніть увагу: Marshal повертає байти і помилку. Помилка — не декорація, а реальність: наприклад, не все можна коректно серіалізувати (особливо коли в структурі з’являються функції, канали та інші радощі життя).
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int
Title string
Done bool
}
func main() {
t := Task{ID: 1, Title: "Read about JSON", Done: false}
b, err := json.Marshal(t)
if err != nil {
fmt.Println("marshal:", err)
return
}
fmt.Println(string(b)) // {"ID":1,"Title":"Read about JSON","Done":false}
}
Тут є два важливі спостереження.
По-перше, JSON вийшов з іменами полів ID, Title, Done — рівно як у Go. Це типова поведінка.
По-друге, результат — один рядок без переносів. Це нормальний JSON, просто компактний. Для людини він не надто зручний, зате для машин — ідеальний.
json.Unmarshal: JSON → Go і чому потрібен &
Тепер зробимо зворотну операцію: візьмемо JSON-рядок і розберемо його в Task. Найчастіша помилка новачка — забути &.
Unmarshal влаштований так: він не створює вам змінну, а заповнює вже наявну. Тому йому потрібно знати адресу, куди записувати результат.
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int
Title string
Done bool
}
func main() {
data := []byte(`{"ID":2,"Title":"Write code","Done":true}`)
var t Task
err := json.Unmarshal(data, &t)
if err != nil {
fmt.Println("unmarshal:", err)
return
}
fmt.Printf("%+v\n", t) // {ID:2 Title:Write code Done:true}
}
Якщо написати json.Unmarshal(data, t), компілятор не завжди підкаже це красиво — але сенс помилки буде один: ви не дали функції можливості змінити змінну.
Чому кодуються лише експортовані поля
Перш ніж ми почнемо красиво керувати іменами полів і omitempty, треба прийняти одне залізне правило encoding/json.
У серіалізації беруть участь лише експортовані поля структури, тобто ті, що починаються з великої літери. Це не примха encoding/json, а базова логіка Go: якщо поле не експортується, зовнішні пакети (включно з encoding/json) не повинні до нього лізти.
Подивіться, як поле title просто зникає з JSON, навіть якщо воно заповнене:
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int
title string
}
func main() {
t := Task{ID: 1, title: "Hidden"}
b, _ := json.Marshal(t)
fmt.Println(string(b)) // {"ID":1}
}
Частий наївний план — поставити тег json:"title" на title string і сподіватися, що це почне працювати. На жаль, ні. Теги керують тим, як поле кодується, але вони не перетворюють неекспортоване поле на експортоване.
2. Теги й зовнішній JSON-контракт
Коли програма спілкується із зовнішнім світом, назви полів у Go і в JSON майже ніколи не збігаються ідеально. У Go прийнято Title, а в JSON часто хочуть "title", "created_at", "task_id" тощо. Щоб не перейменовувати поля в Go на щось незручне, використовують теги (struct tags).
Тег пишеться у зворотних лапках після типу поля і має такий вигляд: json:"...".
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func main() {
t := Task{ID: 1, Title: "Tags are useful", Done: false}
b, _ := json.Marshal(t)
fmt.Println(string(b)) // {"id":1,"title":"Tags are useful","done":false}
}
Тепер зовнішній контракт виглядає в стилі JSON, а Go-код при цьому лишається читабельним.
Міні-таблиця за тегами
| Тег | Що робить під час роботи з JSON |
|---|---|
|
Поле називається "title" у JSON |
|
Поле повністю ігнорується (не кодується і не декодується) |
|
Під час кодування поле пропускається, якщо воно «порожнє» |
(тегу немає) |
Використовується ім’я поля з Go (Title → "Title") |
І так,
json:"-" — це чудовий спосіб не показувати назовні те, що не треба показувати. Наприклад, внутрішній токен, налагоджувальну інформацію або службовий прапорець.
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Secret string `json:"-"`
}
func main() {
t := Task{ID: 1, Title: "Public", Secret: "top-secret"}
b, _ := json.Marshal(t)
fmt.Println(string(b)) // {"id":1,"title":"Public"}
}
omitempty: що саме робить
З omitempty зазвичай виникає плутанина, і це нормально: слово звучить так, ніби поле просто необов’язкове. Але насправді omitempty робить зовсім інше.
omitempty впливає лише на кодування (Marshal): якщо значення поля вважається «порожнім», поле не потрапить у результатний JSON. Під час декодування (Unmarshal) omitempty нічого не змінює.
Подивімося на це на прикладі поля Note. Коли Note == "", воно не потрапляє в JSON:
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
Title string `json:"title"`
Note string `json:"note,omitempty"`
}
func main() {
t := Task{Title: "Task A"} // Note == ""
b, _ := json.Marshal(t)
fmt.Println(string(b)) // {"title":"Task A"}
}
А тепер заповнимо Note:
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
Title string `json:"title"`
Note string `json:"note,omitempty"`
}
func main() {
t := Task{Title: "Task A", Note: "Call mom"}
b, _ := json.Marshal(t)
fmt.Println(string(b)) // {"title":"Task A","note":"Call mom"}
}
Що вважається «порожнім» для omitempty
Це місце корисно один раз спокійно проговорити, бо потім ви ловитимете «чому воно зникло?» у реальних проєктах.
| Тип поля | Вважається порожнім для omitempty, якщо… |
|---|---|
|
|
|
|
числа ( , …) |
|
|
(і , і порожній) |
|
|
|
|
Із особливо несподіваних пунктів — слайси та мапи. І nil, і []string{} вважатимуться порожніми (в обох довжина 0), і при omitempty поле зникне.
omitempty і читання Unmarshal
Коли ви читаєте JSON у структуру, можливі два сценарії: поле є в JSON або його немає. Якщо поля немає, encoding/json просто залишає в структурі те, що вже було — а якщо структура була «нульовою», то поле залишиться з нульовим значенням (zero value).
Це дуже зручна поведінка, але вона часто створює хибне відчуття валідації. Насправді це не перевірка, а значення за замовчуванням.
package main
import (
"encoding/json"
"fmt"
)
type Task struct {
Title string `json:"title"`
Note string `json:"note,omitempty"`
}
func main() {
data := []byte(`{"title":"A"}`) // note відсутнє
var t Task
_ = json.Unmarshal(data, &t)
fmt.Printf("Title=%q Note=%q\n", t.Title, t.Note) // Title="A" Note=""
}
Тут важливо запам’ятати: відсутність поля в JSON не означає, що все добре за правилами бізнес-логіки. Це означає лише: значення не надіслали, отже буде нуль. Перевірка required-полів — окремий крок (але сьогодні ми свідомо його не розбираємо).
3. Практика: список задач, гарний JSON і помилки
json.MarshalIndent: JSON для людини
Компактний JSON добре підходить для передавання мережею й зберігання, але коли ви налагоджуєте застосунок, хочеться бачити структуру наочно. Для цього є json.MarshalIndent.
Він робить те саме, що Marshal, але додає перенесення рядків і відступи. Це зручно для логів, прикладів і діагностики.
package main
import (
"encoding/json"
"fmt"
)
func main() {
v := map[string]any{
"id": 1,
"title": "Pretty JSON",
"tags": []string{"go", "json"},
}
b, _ := json.MarshalIndent(v, "", " ")
fmt.Println(string(b))
// {
// "id": 1,
// "tags": [
// "go",
// "json"
// ],
// "title": "Pretty JSON"
// }
}
Невелика ремарка з розряду приємних дрібниць: хоча звичайний обхід map у Go не гарантує порядок, encoding/json під час кодування JSON-об’єкта сортує ключі, щоб результат був стабільним. Тому за однакових даних ви побачите однаковий вивід, а це дуже допомагає в тестах і під час порівняння.
Кодуємо й декодуємо список задач
Тепер давайте зробимо те, що зазвичай і роблять у застосунках: не одну задачу, а список задач. Ми поки не ліземо у файли й не будуємо прапорці CLI — нам важливо закріпити саме контракт Marshal/Unmarshal + теги.
Зробімо двох маленьких помічників: encodeTasks і decodeTasks.
package main
import "encoding/json"
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
Note string `json:"note,omitempty"`
}
func encodeTasks(tasks []Task) ([]byte, error) {
return json.Marshal(tasks)
}
А тепер декодування:
package main
import "encoding/json"
func decodeTasks(data []byte) ([]Task, error) {
var tasks []Task
if err := json.Unmarshal(data, &tasks); err != nil {
return nil, err
}
return tasks, nil
}
І перевіримо це в main, щоб побачити «кругообіг задач у природі»:
package main
import (
"fmt"
)
func main() {
tasks := []Task{
{ID: 1, Title: "Learn Marshal", Done: false},
{ID: 2, Title: "Learn Unmarshal", Done: true, Note: "already done"},
}
b, err := encodeTasks(tasks)
if err != nil {
fmt.Println("encode:", err)
return
}
fmt.Println(string(b))
// [{"id":1,"title":"Learn Marshal","done":false},{"id":2,"title":"Learn Unmarshal","done":true,"note":"already done"}]
decoded, err := decodeTasks(b)
if err != nil {
fmt.Println("decode:", err)
return
}
fmt.Printf("%+v\n", decoded)
// [{ID:1 Title:Learn Marshal Done:false Note:} {ID:2 Title:Learn Unmarshal Done:true Note:already done}]
}
Тут ви, можливо, помітили: у першої задачі note відсутній у JSON (дякуємо omitempty), але після Unmarshal поле Note стало порожнім рядком. Це нормально й очікувано.
Помилки Unmarshal: не втрачайте контекст
У реальному житті JSON часто буває кривуватим: зайва кома, пропущена лапка, неправильний тип поля. Тому обробка error — це не для галочки, а базова гігієна.
У encoding/json є типізовані помилки, наприклад *json.SyntaxError, де можна дізнатися позицію (offset) проблеми. Це корисно хоча б для того, щоб у логах зрозуміти, де саме зламалося. У стандартних матеріалах з обробки помилок навіть наводять приклад із json.SyntaxError і полем Offset.
Міні-приклад синтаксичної помилки:
package main
import (
"encoding/json"
"errors"
"fmt"
)
func main() {
data := []byte(`{"title":}`) // пошкоджений JSON
var v map[string]any
err := json.Unmarshal(data, &v)
var syn *json.SyntaxError
if errors.As(err, &syn) {
fmt.Println("помилка JSON на байті:", syn.Offset) // помилка JSON на байті: ...
return
}
fmt.Println("інша помилка:", err)
}
Ми тут не заглиблюємося в гарний UX помилок (це окрема велика тема), але правило просте: помилку від Unmarshal не можна ігнорувати, особливо якщо JSON приходить ззовні.
4. Типові помилки під час роботи з Marshal/Unmarshal, тегами та omitempty
Помилка №1: забувають & у Unmarshal.
Це найчастіша історія: пишуть json.Unmarshal(data, t) і дивуються, чому нічого не заповнюється (або чому компілятор лається). Unmarshal має змінювати змінну, отже йому потрібна адреса: &t. Ментально проговорюйте: «Куди записати результат?».
Помилка №2: роблять поля структури неекспортованими й чекають, що теги допоможуть.
Поле title string не стане частиною JSON навіть із тегом json:"title". encoding/json працює лише з експортованими полями. Якщо поле має брати участь у контракті — робіть Title string. Якщо поле не має брати участь — краще явно виключіть його
json:"-", щоб це було видно в коді.
Помилка №3: плутають сенс omitempty і «необов’язковість».
omitempty не валідовує вхід і не каже, що поле можна не надсилати. Воно лише каже: «Якщо значення порожнє — не виводь його під час Marshal». Required-поля перевіряються окремою логікою, інакше ви отримаєте мовчазні zero values і дуже дивні баги.
Помилка №4: дивуються, що порожній []T{} поводиться як nil при omitempty.
Для omitempty важливо «порожньо чи ні», а не «nil чи не nil». Порожній слайс і nil-слайс однаково мають len == 0, тому поле буде пропущено. Якщо вам потрібно розрізняти ці стани на рівні контракту, доведеться свідомо проєктувати формат (але це вже не вирішується однією лише галочкою в тегу).
Помилка №5: ігнорують помилку Marshal/Unmarshal, бо «та JSON же простий».
JSON справді простий… поки не прилетить {"id":"oops"} замість числа або поки хтось не додасть зайву кому. Завжди перевіряйте err. Якщо хочеться коротше, пам’ятайте: короткий код, що падає в проді, зазвичай раптово стає дуже довгим, коли ви починаєте налагоджувати його вночі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ