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).
Тег пишется в backticks после типа поля и выглядит так: 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("bad JSON at byte:", syn.Offset) // bad JSON at byte: ...
return
}
fmt.Println("other error:", 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. Если хочется «короче», помните: короткий код, который падает в проде, обычно внезапно становится очень длинным, когда вы начинаете его отлаживать ночью.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ