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).

Тег пишется в 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
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("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. Если хочется «короче», помните: короткий код, который падает в проде, обычно внезапно становится очень длинным, когда вы начинаете его отлаживать ночью.

1
Задача
Go SELF, 45 уровень, 1 лекция
Недоступна
Печать карточки
Печать карточки
1
Задача
Go SELF, 45 уровень, 1 лекция
Недоступна
Заметка и секрет
Заметка и секрет
1
Задача
Go SELF, 45 уровень, 1 лекция
Недоступна
Приём данных формы
Приём данных формы
1
Задача
Go SELF, 45 уровень, 1 лекция
Недоступна
Поиск опечатки
Поиск опечатки
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ