JavaRush /Курси /Go SELF /Marshal/Unmarshal, struct tags і omitempty

Marshal/Unmarshal, struct tags і omitempty

Go SELF
Рівень 45 , Лекція 1
Відкрита

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
json:"title"
Поле називається "title" у JSON
json:"-"
Поле повністю ігнорується (не кодується і не декодується)
json:"note,omitempty"
Під час кодування поле пропускається, якщо воно «порожнє»

(тегу немає)

Використовується ім’я поля з 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, якщо…
string
""
bool
false

числа (

int
, …)

0
slice, map
len == 0
nil
, і порожній)
*T
nil
any
nil

Із особливо несподіваних пунктів — слайси та мапи. І 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. Якщо хочеться коротше, пам’ятайте: короткий код, що падає в проді, зазвичай раптово стає дуже довгим, коли ви починаєте налагоджувати його вночі.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ