---
title: "JSON: кодирование и декодирование"
description: "JSON в Go: json.Marshal и json.Unmarshal, struct tags и omitempty, вложенные структуры, срезы и карты, HTML-эскейпы, Decoder для потоков, json.RawMessage."
course: "Продвинутые возможности Go: от замыканий до рефлексии"
url: "https://golangmaster.ru/courses/go-advanced/lessons/21-json/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# JSON: кодирование и декодирование

*Курс «Продвинутые возможности Go: от замыканий до рефлексии», урок 21 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-advanced/lessons/21-json/*

Запрос «json go» — самая частая дорога в стандартную библиотеку после строк и файлов: данные нужно отдать наружу и принять обратно, и JSON — язык этого обмена. Двадцать первый урок разбирает пару «marshal unmarshal» целиком: json.Marshal превращает структуру в байты, json.Unmarshal возвращает байты в значения, struct tags решают, как поля зовутся в тексте и когда исчезают (omitempty), вложенные структуры, срезы и карты собирают формы любой глубины. Отдельно — честные мелочи, на которых спотыкаются: Marshal возвращает []byte, а не строку; кириллица выходит как есть, но <, > и & прячутся в \u003c-эскейпы, и отключаются SetEscapeHTML; json.Decoder читает поток значений вместо одного куска; json.RawMessage откладывает разбор до лучших времён. Мост из прошлого урока прямой: os.ReadFile дал байты — Unmarshal даст форму.

### JSON в Go: байты, у которых есть форма

JSON — JavaScript Object Notation — текстовый формат обмена данными: объекты в фигурных скобках, пары «ключ: значение», массивы в квадратных, строки в кавычках, числа, true/false и null. Но для Go важнее другое: JSON — это БАЙТЫ. Обычные байты UTF-8, о которых мы говорили в прошлом уроке, просто с договорённостью о том, что они значат: где кончается ключ, где начинается значение, что считать массивом. Прошлый урок дал вам os.ReadFile и контракт io.Reader — доставку байтов; этот урок даёт форму — упаковку значений в байты и обратно. Запрос «json go» на деле распадается на три навыка: кодировать значение в JSON (json.Marshal), декодировать JSON в значение (json.Unmarshal) и управлять формой текста (struct tags, omitempty, Encoder, Decoder). Этим трём навыкам и посвящён урок.

Почему вообще нужен формат вроде JSON? Срезы и структуры живут в памяти процесса и умирают вместе с ним; передать их по сети или сохранить на диск «как есть» нельзя — память процесса не транспорт. Нужна последовательность байтов, по которой получатель соберёт ту же структуру. Это называется сериализацией: упаковка значения в байтовую последовательность. Десериализация — обратная поездка: байты в значение. В Go за обе поездки отвечает один пакет — encoding/json, а маршалером и анмаршалером называют именно его функции: marshal — кодировать, unmarshal — декодировать; «marshal unmarshal» — пара, которую вы будете гуглить всю карьеру, и она же — ключевые слова этого урока.

Договоримся о честности песочницы сразу, как в прошлом уроке. Резервный движок знает только fmt, strings, strconv, math, sort, errors и time — encoding/json в этом списке нет, и мы зафиксируем точный текст отказа. Основной движок (yaegi) исполняет пакет честно: json.Marshal и json.Unmarshal с тегами, omitempty, Encoder и Decoder, RawMessage — всё работает, вывод детерминирован, каждый раннер урока прогнан трижды. А тот единственный полигон, который доступен обоим движкам, построен руками: JSON — просто текст, и его можно собрать из строки без всяких пакетов, чем мы и займёмся в первом раннере.

### JSON руками: это просто текст

Раннер двойной: здесь нет encoding/json — только fmt, поэтому код исполняют ОБА движка. Запустите: на выходе почти JSON — {"name" : "Иван", "age" : 33} — и 33 байта (кириллица по два байта на букву, это байтовое знание прошлого урока). Эксперимент: уберите пробелы вокруг двоеточия в kv — текст станет компактнее, но останется тем же JSON: пробелы между парами не значимы. Попробуйте вставить в name кавычку через name := "И\"ван" — текст сломается: кавычка внутри JSON-строки обязана быть экранирована, и именно такие мелочи Marshal делает за вас всегда.

```go
package main

import "fmt"

// kv — ручной аналог тега: ключ всегда в кавычках.
func kv(key, val string) string {
        return "\"" + key + "\" : " + val
}

func main() {
        name := "Иван"
        age := 33
        // Собираем JSON-текст конкатенацией — ровно то, что делает Marshal.
        s := "{" + kv("name", "\""+name+"\"") + ", " + kv("age", fmt.Sprintf("%d", age)) + "}"
        fmt.Println(s)
        fmt.Println("байт:", len(s))
}
```

### json.Marshal и struct tags: имя ключа решает тег

json.Marshal(v) принимает значение любого типа и возвращает пару `(b []byte, err error)`. Первое, что надо запомнить: результат — СРЕЗ БАЙТОВ, а не строка; JSON у нас байтовый, помните первый блок? Чтобы увидеть текст, нужна конверсия string(b) — та же, что у os.ReadFile в прошлом уроке. Второе: err обязателен к проверке. Marshal почти не ошибается на обычных данных (структуры, срезы, карты, числа, строки), но падает на том, что не умеет представить: канал, функция, комплексное число — для них вернётся UnsupportedTypeError, и молча проглоченная ошибка оставит вас с nil-срезом.

Для структур порядок полей и имена ключей решают struct tags — маленькие аннотации после объявления поля. Тег `json:"name"` говорит: «в JSON это поле зовётся name, не Name». Без тега Marshal использует имя поля как есть — с большой буквы; поэтому теги ставят почти всегда: API-контракты живут в нижнем регистре, а Go требует, чтобы поля были ЭКСПОРТИРУЕМЫМИ (с большой буквы) — неэкспортируемое поле Marshal молча пропустит, в JSON оно не попадает никогда, ведь пакет не может читать чужие приватные данные за пределами своего пакета. Два специальных значения тега: минус `json:"-"` полностью исключает поле из вывода, а через запятую идут опции — `json:"name,omitempty"` значит «зовётся name и прячется, когда пусто».

Теги читает рефлексия — reflect, десятый и восемнадцатый уроки курса уже дали вам её инструменты: Marshal не знает ничего о вашем типе на этапе компиляции, он в рантайме спрашивает у reflect.TypeOf поля, их теги и значения. Это же объясняет, почему тег — не комментарий: опечатка в `json:\"nane\"` не сломает компиляцию, поле просто выйдет под именем Name, и найти такой баг без теста непросто. И почему теги обязательны в виде строкового литерала: они разбираются парсером reflection-пакета по строгому формату имя,опция1,опция2.

Как Marshal кодирует базовые типы: строки — в кавычках (с нюансами, о которых ниже), числа — как есть, bool — true/false, nil-указатель и nil-интерфейс — null, срезы и массивы — массивы, карты — объекты, структуры — объекты с полями. Две неочевидные детали: срез байтов []byte кодируется СТРОКОЙ base64 ([]byte("hi") превратится в "aGk=") — это способ перевезти бинарные данные в текстовом формате; а ключи карты выводятся В ОТСОРТИРОВАННОМ порядке —.Marshal сортирует их, чтобы одинаковые данные давали одинаковые байты, что критично для кешей и подписей.

### Marshal структуры с тегами

Раннер нативный: encoding/json исполняет только основной движок — резервный ответит отказом, текст которого честно записан в блоке о песочнице ниже. Запустите: первая строка — ошибка: <nil> байт: 46, затем {"email":"iv@example.ru","tags":["go","json"]} — пустые Name и Age спрятал omitempty. Второй вывод {"name":"Анна","email":""}: поле без omitempty выходит всегда, даже пустым. Третий вывод — числа [51 51]: json.Marshal вернул []byte, и Println напечатал значения байтов (51 — код символа '3'), а не текст; конверсия string(b) — обязательный последний шаг. Эксперимент: поменяйте тег Email на json:"-" — поле исчезнет из вывода целиком.

```go
package main

import (
        "encoding/json"
        "fmt"
)

// User — тег называет ключ в JSON; omitempty прячет пустые поля.
type User struct {
        Name  string   `json:"name,omitempty"`
        Email string   `json:"email"`
        Age   int      `json:"age,omitempty"`
        Tags  []string `json:"tags,omitempty"`
}

func main() {
        u := User{Email: "iv@example.ru", Tags: []string{"go", "json"}}
        // Marshal возвращает []byte и ошибку; строка — отдельной конверсией.
        b, err := json.Marshal(u)
        fmt.Println("ошибка:", err, "байт:", len(b))
        fmt.Println(string(b))
        // Поле без omitempty выходит всегда, даже пустое.
        b2, _ := json.Marshal(User{Name: "Анна"})
        fmt.Println(string(b2))
        // А Println без string() печатает ЧИСЛА — байты среза.
        nb, _ := json.Marshal(33)
        fmt.Println(nb)
}
```

### Проверьте себя

**Вопрос:** b, err := json.Marshal(v) отработал без ошибки. Как получить JSON-текст из b?

- ✅ string(b): конверсия копирует байты среза в строку
- b.String(): у []byte есть встроенный метод String
- b.Text(): срез байтов умеет отдавать текстовое представление
- Ничего не нужно: fmt.Println(b) и так напечатает JSON

json.Marshal возвращает []byte — JSON это байты, а конверсия string(b) превращает их в строку (с копированием, как и string(data) у файлов в прошлом уроке). У []byte нет метода String и нет метода Text — оба варианта не компилируются. А Println(b) напечатает значения байтов через пробел вроде [123 34 110] — вы видели это в раннере: срез чисел, а не JSON. Ошибку err при этом всё равно проверяют: nil-срез без ошибки — редкость, но обработка err — дисциплина пары «значение, ошибка» из урока про обработку ошибок.

### omitempty: нулевое значение — нет поля

Опция omitempty в теге — самый частый гость после имени: `json:"email,omitempty"`. Правило простое: если поле имеет НУЛЕВОЕ значение своего типа, оно не попадает в JSON вообще. Список нулевых значений стоит выучить: false для bool, 0 для любых чисел, "" для строк, nil для указателей, интерфейсов, срезов и карт (важно: nil-срез и ПУСТОЙ срез make([]T, 0) — не одно и то же для Marshal без omitempty — первый выйдет null, второй []; с omitempty спрячутся оба). Ключевое исключение, о которое спотыкаются все: omitempty НЕ РАБОТАЕТ для структур. Структура с нулевыми полями — не «нулевое значение» в смысле encoding/json, и поле-структура выйдет целиком со всеми нулями внутри. Хотите прятать опциональную структуру — делайте её указателем: nil-указатель omitempty прячет, а значение — выводит.

Зачем это нужно? Чтобы контракт ответа был предсказуемым и компактным. Поле «опции не заданы» в JSON с omitempty просто отсутствует — клиент по наличию ключа понимает, было ли значение; без omitempty пришлось бы везде таскать "status": "" и "count": 0, различая «пусто» и «не прислали» по соглашениям вместо фактов. Обратная сторона: ноль — иногда валидное значение. Поле Температура со значением 0 и omitempty исчезнет из ответа, и получатель не отличит «ноль градусов» от «датчик не отвечал» — в таких случаях omitempty не ставят или переходят на указатель.

Где вы уже это видели: в курсе Gin урок про JSON-ответы — там omitempty-форма собиралась для HTTP-хендлеров, и правило «структуры не прячутся, указатели — да» разбиралось на Todo-списке. Здесь, в стандартной библиотеке, механика та же до последней буквы: c.JSON внутри зовёт ровно тот json.Marshal, который вы вызываете руками. Связка полезная: научившись здесь, вы уже знаете, почему gin-ответ выглядит именно так.

### Проверьте себя

**Вопрос:** Структура с полями Done bool `json:"done,omitempty"`, Note string `json:"note,omitempty"` и Children []string `json:"children,omitempty"`. Маршалим значение с Done: false, Note: "", Children: nil. Что выйдет?

- ✅ {}: все три поля нулевые, и omitempty спрятал каждое
- {"done":false,"note":""}: bool и строка выводятся всегда, прячется только nil-срез
- {"children":null}: нулевой срез без omitempty выходит null
- Ошибку: Marshal не умеет кодировать нулевые значения

false — нулевое значение bool, "" — нулевое значение string, nil — нулевое значение среза: все три попадают под omitempty, и от структуры не остаётся ни одной пары — Marshal вернёт пустой объект {}. Вариант с null был бы верен без omitempty: nil-срез кодируется как null, пустой make([]string, 0) как []. И это ещё одно напоминание: структура-обёртка сама при этом не прячется — omitempty на поле-структуре не работает, но здесь мы маршалим саму структуру, а не поле-структуру.

### json.Unmarshal: из байтов в значения

Обратная поездка — json.Unmarshal(data []byte, v any) error. Два правила, на которых держится вся функция. Первое: v передаётся ПО АДРЕСУ — Unmarshal должен записать результат в вашу переменную, поэтому вызов всегда выглядит как err := json.Unmarshal(b, &p); забыли амперсанд — компилятор пропустит (any принимает всё), но поля останутся нулями: функция заполнила копию, которую тут же выбросила. Второе: data — это []byte, и здесь мост из прошлого урока замыкается: os.ReadFile вернул data []byte из файла конфигурации — Unmarshal превращает эти байты в структуру; это самая частая связка в реальных программах, и она стоит двух строк.

Сверка имён: Unmarshal идёт по JSON-ключам и ищет совпадающее поле адресата — сначала точное совпадение с тегом, потом с именем поля. JSON-ключ, для которого поля нет, МОЛЧА ИГНОРИРУЕТСЯ — это осознанное решение encoding/json: API добавляет новые поля, старые клиенты не падают. Но и опечатка в ожидаемом поле молча даст нулевое значение — строгую проверку формы делают отдельными библиотеками или через Decoder с DisallowUnknownFields, о котором поговорим в блоке про поток. Поле, отсутствующее в JSON, остаётся с нулевым значением — Unmarshal никогда не обнуляет то, что вы уже записали в структуру до вызова: он обновляет только то, что нашёл.

Типы при декодировании: JSON-число ложится в int, float64 или любой числовой тип поля — Unmarshal приведёт значение к типу поля (и вернёт ошибку UnmarshalTypeError, если «порт»: "восемьдесят" не лезет в int). JSON true/false — в bool, null — в nil для указателей, срезов, карт и интерфейсов (в число null не декодируется — останется нулевым). Битые байты — не-JSON вроде {broken — дают SyntaxError с позицией; ошибку проверяют всегда, nil-структура после провала разбора — классическая тихая беда.

### Unmarshal: структура и map[string]any

Раннер нативный: запускайте на основном движке. Первый вывод — ошибка: <nil> сумма: 7: поле z без пары в структуре проигнорировано, x и y легли по тегам. Второй блок — map[string]any: кириллические ключи работают как любые другие, значения вышли через индексирование карты. Обратите внимание: nums[1] печатается как 2, но по типу это float64 — числа в any после Unmarshal всегда float64, и арифметика с ними требует приведения. Эксперимент: уберите амперсанд перед p — поля останутся нулями, сумма станет 0, а ошибка — nil: самый тихий баг урока.

```go
package main

import (
        "encoding/json"
        "fmt"
)

// Point — адресат разбора: ключи сверяются с тегами.
type Point struct {
        X int `json:"x"`
        Y int `json:"y"`
}

func main() {
        var p Point
        // Передаём АДРЕС: Unmarshal должен записать значения в нашу переменную.
        err := json.Unmarshal([]byte(`{"x": 3, "y": 4, "z": 99}`), &p)
        fmt.Println("ошибка:", err, "сумма:", p.X+p.Y)
        // Неизвестное поле z молча проигнорировано.
        // Динамический разбор: map[string]any принимает любую форму.
        var m map[string]any
        err2 := json.Unmarshal([]byte(`{"имя": "Иван", "n": 7, "ok": true, "лист": [1, 2]}`), &m)
        fmt.Println("ошибка:", err2)
        fmt.Println(m["имя"], m["n"], m["ok"], m["лист"])
        // Числа в any живут как float64; достать — утверждением типа.
        nums := m["лист"].([]any)
        fmt.Println(len(nums), nums[1])
}
```

### Что выйдет из Marshal с omitempty?

```go
package main

import (
        "encoding/json"
        "fmt"
)

// Todo — форма задачи: omitempty прячет нулевые поля.
type Todo struct {
        Title    string   `json:"title"`
        Done     bool     `json:"done,omitempty"`
        Priority int      `json:"priority,omitempty"`
        Note     string   `json:"note,omitempty"`
}

func main() {
        b, _ := json.Marshal(Todo{Title: "выучить tags"})
        fmt.Println(string(b))
}
```

**Что выведет программа?**

- ✅ {"title":"выучить tags"}: три omitempty-поля нулевые и исчезли
- {"title":"выучить tags","done":false,"priority":0,"note":""}: omitempty не трогает false и 0
- {"title":"выучить tags","done":false}: строка и число спрятались, bool — нет
- Пустую строку: без заданных полей Marshal вернёт пустой текст

false — нулевое значение bool, 0 — нулевое значение int, "" — нулевое значение string: omitempty прячет все три, и в JSON остаётся одна пара title. Порядок пар в выводе — порядок объявления полей: Title, Done, Priority, Note. Проверить себя можно тем же кодом: задайте Done: true — поле появится, поставьте Priority: -1 — отрицательные числа НЕ нулевые, поле выйдет: omitempty смотрит именно на нулевое значение, а не на «пустоватое».

### Вложенные структуры, срезы и карты

Плоские формы скучны, и к счастью, Marshal/Unmarshal рекурсивны: поле-структура кодируется вложенным объектом, срез структур — массивом объектов, карта со структурами — объектом, у которого ключи карты стали ключами JSON. Правило одно: форма в JSON повторяет форму значения, на любую глубину. Теги работают на каждом уровне: у вложенной структуры — свои теги, и их имена видны в конечном тексте. Единственное ограничение — циклы: структура, которая ссылается сама на себя через значение, не кодируется (Marshal вернёт ошибку о неподдерживаемом значении); циклы через указатели тоже не разрулить — рекурсия должна кончаться в данных, а не в типе.

Срез структур — рабочая лошадка API: []Person с вложенными Address маршалится в массив объектов, и Unmarshal так же честно соберёт срез обратно, выделив память под каждый элемент сам. Карта — способ выразить «ключи заранее неизвестны»: map[string]int выйдет объектом с числами-значениями, и наоборот, объект с произвольными ключами удобно декодировать в карту, а не в структуру. Помните про сортировку: ключи карты в выводе всегда отсортированы — Marshal не оставит вам порядок вставки, как у срезов.

Выбор между структурой и картой — выбор между контрактом и гибкостью. Структура: поля известны, имена и типы проверяет компилятор, теги дают документацию; неправильные типы JSON ловятся в момент Unmarshal. Карта: форма динамическая — конфиг с произвольными секциями, результат парсера, «передай что угодно»; типы проверять приходится вручную через утверждения типов, а числа в any живут как float64. Зрелый код часто комбинирует: верхний уровень — структура с известными полями, одно из полей — map[string]any или json.RawMessage для части, чья форма меняется.

### Соберите Marshal-программу

Соберите программу: тип с тегом, значение, json.Marshal и печать текста с проверкой ошибки. Строки импорта — по одной на пакет.

Правильный порядок строк:

1. `package main`
2. `import "encoding/json"`
3. `import "fmt"`
4. `type Note struct {`
5. `Text string `json:"text"``
6. `}`
7. `func main() {`
8. `n := Note{Text: "готово"}`
9. `b, err := json.Marshal(n)`
10. `fmt.Println(string(b), err == nil)`
11. `}`

### Восстановите вызов Unmarshal

Допишите декодирование: имя функции пакета и адрес переменной, в которую ляжет результат. Ответы вводите латиницей, второй — с амперсандом, без пробелов.

```go
package main

import (
        "encoding/json"
        "fmt"
)

// Point — адресат разбора: ключи сверяются с тегами.
type Point struct {
        X int `json:"x"`
        Y int `json:"y"`
}

func main() {
        var p Point
        // Разбираем байты в структуру: адрес обязателен.
        err := json.___([]byte(`{"x": 7, "y": 2}`), ___)
        fmt.Println(err == nil, p.X+p.Y)
}
```

Подсказка: Пара «marshal unmarshal»: декодирует функция с приставкой Un; записывать результат нужно в переменную через её адрес.

Правильные ответы в пропусках: `Unmarshal`, `&p`

### Кириллица и \u003c: HTML-эскейпы и SetEscapeHTML

Что Marshal делает с не-ASCII текстом? Ничего: кириллица, эмодзи и любой валидный UTF-8 выходят КАК ЕСТЬ — сырыми байтами. json.Marshal("привет") даст четырнадцать байт с кавычками, где сами буквы — те самые по два байта из прошлого урока; len(string(b)) посчитает их честно. Юникод-эскейпы вида \u043f Marshal для кириллицы не генерирует — если вы их видели в ответах API, это либо другой язык, либо ручная обработка. Отсюда практическое правило: JSON с кириллицей весит больше, чем «выглядит», — счёт ведётся в байтах, и Content-Length вычисляется по len(b), а не по количеству знаков.

А вот три символа — <, > и & — Marshal экранирует ВСЕГДА по умолчанию: < станет \u003c, > — \u003e, & — \u0026. Зачем: если JSON вклеивается в HTML-страницу сырыми байтами (шаблонизаторы, inline-скрипты), строка без экранирования могла бы вынести за пределы JSON-литерала тег <script> — это вектор атаки под названием XSS. Экранирование не меняет смысла данных: любой JSON-парсер развернёт \u003c обратно в <. Для обычных API это не мешает вообще.

Когда байты важны — например, вы считаете хеш тела для кеша или клиенту нужен точный текст — экранирование отключают, и делается это не функцией Marshal, а Encoder'ом: enc := json.NewEncoder(w) создаёт энкодер, пишущий JSON прямо в Writer w; метод enc.SetEscapeHTML(false) выключает HTML-эскейпы, а enc.Encode(v) кодирует значение и дописывает в конец перевод строки — единственная байтовая разница помимо экранирования. В Gin вы это уже видели: c.PureJSON — это именно Encoder с SetEscapeHTML(false) против обычного c.JSON; теперь вы знаете, что под капотом. Метод Encode принимает любой io.Writer — буфер в памяти, файл из прошлого урока, сетевое соединение: контракт Reader/Writer снова экономит целый слой кода.

### Encoder, SetEscapeHTML и точные байты

Раннер нативный: encoding/json и strings.Builder исполняет основной движок. Запустите: marshal-строка несёт \u003cb\u003e\u0026 — три экранированных символа, encoder-строка — честные <b>&. Внимание на байты: буфер держит 24 — фигурные скобки и двоеточие, кавычки, "шаблон" по два байта на шесть букв (двенадцать плюс кавычки), значение <b>& из четырёх знаков и ДОПИСАННЫЙ Encode'ом перевод строки: вес ответа считается в байтах, не в знаках. Эксперимент: уберите SetEscapeHTML(false) — обе строки совпадут с экранированием, а длина вырастет на пятнадцать байт: по пять на каждый \u00XX вместо одиночного символа.

```go
package main

import (
        "encoding/json"
        "fmt"
        "strings"
)

func main() {
        // Маршал по умолчанию экранирует < > и & юникод-эскейпами.
        b, _ := json.Marshal(map[string]string{"шаблон": "<b>&"})
        fmt.Println("marshal:", string(b))
        // Encoder с SetEscapeHTML(false) пишет символы как есть.
        var sb strings.Builder
        enc := json.NewEncoder(&sb)
        enc.SetEscapeHTML(false)
        enc.Encode(map[string]string{"шаблон": "<b>&"})
        fmt.Print("encoder: ")
        fmt.Print(sb.String())
        // Encode дописал перевод строки — видно по длине буфера.
        fmt.Println("байт:", sb.Len())
}
```

### json.Decoder против Unmarshal: поток

json.Unmarshal работает с готовым []byte: весь JSON должен лежать в памяти до вызова. json.Decoder — второй способ декодирования: dec := json.NewDecoder(r) принимает ЛЮБОГО io.Reader и читает значение МЕТОДОМ dec.Decode(&v) — по одному за вызов, подтягивая байты из источника по мере необходимости. Зачем второй способ? Три причины. Первая — поток из нескольких значений: источники вроде NDJSON (по одному JSON-объекту на строку) и JSON-стримов отдают НЕ один документ, а конвейер документов подряд — {"n":1}{"n":2}: Unmarshal съест только первый и подавится вторым, а Decoder прожуёт их циклом: for { err := dec.Decode(&v); if err != nil { break } } — выход из цикла по ошибке, которая в конце потока и есть io.EOF, штатный сигнал конца из прошлого урока.

Вторая причина — память и латентность: Decoder не держит весь документ целиком, он читает ровно столько байтов, сколько нужно для следующего значения; гигабайтный лог JSON-строками можно обрабатывать построчно-потоково, не грузя файл в память, — в связке с bufio.Scanner из прошлого урока это стандартный жанр обработки больших выгрузок. Третья — настройки: у Decoder есть методы вроде dec.DisallowUnknownFields(), превращающий молчаливое игнорирование незнакомых полей в ошибку, — строгий режим для контрактов, где опечатка в ключе должна быть громкой, а не тихой.

Когда какой способ выбирать — правило простое. Один JSON-объект, уже лежащий в байтах (os.ReadFile, []byte из ответа, который вы всё равно прочитали целиком) — Unmarshal: одна строка, ноль церемоний. Поток из многих значений, источник без конца (сеть), документ слишком велик для памяти или нужны строгие настройки — Decoder. У Encoder/Decoder есть и младший брат: json.MarshalIndent(v, "", "  ") — тот же Marshal с отступами для людей: логи, диффы, отладочные дампы. Для машинных ответов отступы — просто лишние байты.

### json.RawMessage: разбери потом

Последний инструмент урока — json.RawMessage. По определению это просто синоним []byte, но с двумя суперспособностями: при МАРШАЛИНГЕ RawMessage вставляется в вывод КАК ЕСТЬ, сырыми байтами, без повторного кодирования; при ДЕКОДИРОВАНИИ RawMessage забирает кусок JSON целиком, не разбирая его. Классический паттерн — конверт: обёртка с известными полями kind и body, где форма body зависит от kind. Структура объявляет Body json.RawMessage, Unmarshal разбирает только конверт, а вы, посмотрев на e.Kind, отправляете e.Body на второй Unmarshal — уже в конкретный тип. Один разбор — два прохода, каждый со своей формой.

Зачем не map[string]any? Карта разбирает ВСЁ сразу и прячет типы за float64 и утверждениями; RawMessage сохраняет точные байты — их можно передать дальше, положить в другую структуру, залогировать дословно или разобрать лениво, только когда ветка кода действительно понадобилась (не разбирали — не платили). Это техника «отложенного решения», и в больших системах она экономит и время, и нервы: парсер конвертов не знает о тысяче видов body, а каждый обработчик знает только свою.

Граница применимости честная: RawMessage — инструмент для ДВУХЭТАПНОГО разбора и прозрачной пересылки. Если форма известна заранее — структура проще и надёжнее; если форма неведома и нужна возня с полями — карта честнее. RawMessage занимает нишу между ними: форма известна ПОЗЖЕ, а не никогда. И маленькая деталь на память: RawMessage в структуре с omitempty не прячется — nil-срез выйдет как null; если пустой конверт должен быть без body, чистите поле руками или используйте указатель *json.RawMessage.

> **Честно о песочнице: где исполняется encoding/json.** Резервный движок знает только fmt, strings, strconv, math, sort, errors и time — encoding/json в списке нет, и import "encoding/json" даёт отказ «пакет "encoding/json" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)». Поэтому все JSON-раннеры урока — нативные: основной движок (yaegi) исполняет пакет честно, и мы проверили каждый трижды: json.Marshal и json.Unmarshal с тегами и omitempty, Encoder с SetEscapeHTML, Decoder с потоком значений, RawMessage, MarshalIndent — вывод детерминирован. Единственный both-полигон — первый раннер: JSON собран руками из строки, без пакета, и исполняется обоими движками. Запуск JSON-раннеров на резервном движке бесполезен — переключитесь на основной.

### Шпаргалка: JSON из этого урока

| Инструмент | Что делает | Как выглядит | Осторожно |
| --- | --- | --- | --- |
| json.Marshal | значение → []byte с JSON | b, err := json.Marshal(v) | вернёт []byte: текст — string(b) |
| json.Unmarshal | []byte → значение по адресу | err := json.Unmarshal(b, &v) | без & поля останутся нулями |
| struct tags | имя ключа и опции поля | `json:"name,omitempty"` | опечатка в теге не ловится компилятором |
| omitempty | спрятать нулевое поле | json:"email,omitempty" | false/0/""/nil/пустые slice+map; структуры не прячет |
| json:"-" | исключить поле из JSON | Token string `json:"-"` | неэкспортируемые поля и так молча пропускаются |
| map[string]any | динамический разбор | var m map[string]any; m["k"] | числа float64; типы — утверждениями |
| json.Encoder | потоковая запись в Writer | enc := json.NewEncoder(w) | Encode дописывает перевод строки |
| SetEscapeHTML(false) | отключить \u003c-эскейпы | enc.SetEscapeHTML(false) | по умолчанию < > & экранируются всегда |
| json.Decoder | потоковое чтение из Reader | dec := json.NewDecoder(r) | цикл Decode до io.EOF; DisallowUnknownFields |
| json.MarshalIndent | тот же Marshal с отступами | json.MarshalIndent(v, "", "  ") | для людей: логи и диффы, не для API-ответов |
| json.RawMessage | сырые байты: разобрать потом | Body json.RawMessage | маршалится как есть; nil выходит null |
| []byte в JSON | срез байтов кодируется base64 | json.Marshal([]byte("hi")) | даст "aGk=": так возят бинарные данные |

### Правила гигиены JSON

- Marshal возвращает []byte: текст — string(b); Println(b) напечатает числа, а не JSON.
- Проверяйте err у обоих вызовов: Marshal на непредставимых значениях (канал, функция) вернёт ошибку, Unmarshal — на битых байтах и неверных типах.
- Unmarshal зовите с адресом: &p, а не p — без амперсанда функция заполнит копию, и поля молча останутся нулями.
- Неизвестные JSON-ключи Unmarshal игнорирует молча: это совместимость, но и укрытие для опечаток — строгий режим включается Decoder'ом с DisallowUnknownFields.
- omitempty прячет false, 0, "", nil и пустые срезы с картами — но НИКОГДА не прячет структуры: опциональная структура — это указатель.
- Пустой срез и nil-срез — разные байты: make([]T, 0) выйдет [], nil выйдет null; для списков инициализируйте срез даже пустым.
- Кириллица в JSON — сырые байты UTF-8, счёт веса в байтах; <, > и & по умолчанию экранируются \u003c, \u003e и \u0026.
- Точные байты тела — Encoder с SetEscapeHTML(false) (так работает PureJSON в Gin); Encode дописывает перевод строки в конец.
- Ключи карт в выводе отсортированы: одинаковые данные дают одинаковые байты — на этом держатся кеши и подписи.
- Поток из многих JSON-документов — Decoder и цикл Decode до io.EOF; один готовый []byte — Unmarshal.
- Форма известна позже, а не никогда — json.RawMessage: конверт разбираем, body оставляем байтами до второго прохода.

### Что дальше: JSON выходит в сеть

Соберём урок в одну карту. Кодирование: json.Marshal — значение в []byte, теги называют ключи, omitempty прячет нулевые (но не структуры), неэкспортируемое поле не выходит вовсе, ключи карт сортированы, []byte превращается в base64. Декодирование: json.Unmarshal — байты в значение по адресу, незнакомые ключи молча мимо, типы приводятся к типам полей, а в map[string]any всё живёт динамикой с float64-числами. Поток: Encoder пишет в Writer (SetEscapeHTML(false) для точных байтов, \n в конце), Decoder читает из Reader по одному значению до io.EOF. Отложенный разбор: json.RawMessage хранит сырые байты между двумя проходами. И весь урок держится на прошлом: JSON — байты UTF-8, os.ReadFile их доставляет, контракт io.Reader делает их потоком.

Двадцать второй урок — HTTP-клиент: http.Get, http.Client, таймауты и чтение ответа. Связка самая прямая в курсе: тело ответа — это io.Reader, и живой жанр «запросил API — декодируй ответ» собирается из двух уроков подряд: resp.Body отдаёт байты, json.Decoder или Unmarshal превращает их в структуры. Вы уже умеете вторую половину; скоро появится первая. А если хочется увидеть JSON в роли сервера — [урок про JSON-ответы в Gin](/courses/gin/lessons/05-json-otvety/) показывает ту же encoding/json с другой стороны очереди: теги, omitempty и PureJSON на настоящих хендлерах.

До встречи на [странице курса](/courses/go-advanced/). Если захочется освежить байтовую базу — [урок про файлы и ввод-вывод](/courses/go-advanced/lessons/20-fajly-io/): os.ReadFile, поток и EOF — то, во что JSON упирается с обеих сторон. А если станет интересно, как Marshal вообще видит ваши поля и теги — [урок про рефлексию](/courses/go-advanced/lessons/18-refleksiya/): reflect.TypeOf, reflect.Value и StructTag — три инструмента, из которых encoding/json собран внутри.

### Карточки урока

- **json.Marshal: что и во что кодирует** — json.Marshal(v) — значение любого типа в []byte с JSON (err второй парой; проверять). Текст — string(b), Println(b) печатает числа байтов. Поля структур — только экспортируемые; тег json:"имя" переименовывает ключ, json:"-" исключает поле, omitempty прячет нулевое. Ключи карт выводятся отсортированными; []byte кодируется строкой base64; nil-указатели и nil-интерфейсы — null; срезы и массивы — массивы.
- **struct tags и omitempty** — Тег — строковый литерал после поля: `json:"name,omitempty"` (имя, опции через запятую). Читается рефлексией в рантайме — опечатка не ловится компилятором. omitempty прячет нулевые значения: false, 0, "", nil-указатель, nil-интерфейс, nil-срез, nil-карту (пустой срез make([]T,0) тоже спрячет). Структуры omitempty НЕ прячет никогда — опциональная структура объявляется указателем. Без omitempty nil-срез выйдет null, пустой — [].
- **json.Unmarshal: адрес, неизвестные ключи, типы** — err := json.Unmarshal(data, &v) — ОБЯЗАТЕЛЬНО адрес: без & заполнится копия и поля останутся нулями. JSON-ключ без пары в структуре молча игнорируется (совместимость версий); поле без ключа в JSON остаётся нулевым. Числа приводятся к типу поля; тип-несовпадение — UnmarshalTypeError; битые байты — SyntaxError с позицией. В map[string]any числа живут как float64, значения достаются утверждениями типов.
- **Encoder и Decoder: поток вместо целиком** — enc := json.NewEncoder(w) — пишет JSON в любой Writer; enc.Encode(v) кодирует и ДОПИСЫВАЕТ перевод строки; SetEscapeHTML(false) отключает \u003c/\u003e/\u0026 (так работает PureJSON). dec := json.NewDecoder(r) — читает из любого Reader; dec.Decode(&v) в цикле разбирает поток из многих JSON подряд, конец — io.EOF (штатный). Unmarshal — один готовый []byte; Decoder — сеть, NDJSON, большие документы, DisallowUnknownFields для строгих контрактов.
- **json.RawMessage и карта как запасные формы** — json.RawMessage — синоним []byte: при Marshal вставляется в вывод КАК ЕСТЬ, при Unmarshal забирает кусок JSON целиком без разбора. Паттерн-конверт: структура с kind и Body json.RawMessage, второй Unmarshal по kind в конкретный тип. Двухэтапный разбор и прозрачная пересылка байт; nil-RawMessage с omitempty не прячется (выйдет null). Карта map[string]any — форма неведома совсем: гибкость ценой ручных утверждений типов и float64-чисел.
- **JSON и песочница: что исполняется** — Резервный движок encoding/json не знает: import "encoding/json" даёт отказ «пакет "encoding/json" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)». Основной движок (yaegi) исполняет пакет честно: Marshal/Unmarshal с тегами, omitempty, Encoder, Decoder, RawMessage, MarshalIndent — вывод детерминирован. Кодировку JSON можно показать и без пакета — собрав текст руками из строки (both-полигон урока).

## Коротко: что запомнить

- JSON — байты UTF-8 с договорённостью о форме: кодирование — json.Marshal (значение → []byte), декодирование — json.Unmarshal ([]byte → значение); пара «marshal unmarshal» — две половины одной сериализации.
- json.Marshal возвращает []byte и err: текст — string(b); поля структур — только экспортируемые, тег json:"имя" переименовывает ключ, json:"-" исключает поле.
- omitempty прячет нулевые значения — false, 0, "", nil-указатель, nil-срез, nil-карту; структуры не прячет никогда: опциональная структура — это указатель.
- json.Unmarshal зовётся с адресом (&p): без амперсанда заполнится копия и поля молча останутся нулями; неизвестные ключи игнорируются, отсутствующие оставляют нули.
- map[string]any — динамический разбор любой формы: числа живут как float64, значения достаются утверждениями типов; структуры — контракт, проверяемый компилятором и Unmarshal'ом.
- Вложенные структуры, срезы структур и карты кодируются рекурсивно: форма JSON повторяет форму значения; ключи карт в выводе всегда отсортированы.
- Кириллица выходит сырыми байтами UTF-8 без эскейпов; <, > и & по умолчанию экранируются \u003c, \u003e, \u0026 — защита от XSS при вклейке в HTML.
- Точные байты — json.Encoder с SetEscapeHTML(false): Encode пишет в любой Writer и дописывает перевод строки; так устроен PureJSON в Gin.
- json.Decoder читает поток из любого io.Reader: Decode в цикле прожёвывает несколько JSON-документов подряд до io.EOF; Unmarshal — для одного готового []byte.
- json.RawMessage хранит сырые байты между двумя проходами: конверт разбираем сразу, body — по kind и по потребности.
- Песочница честна: encoding/json в фолбэке нет (точный текст отказа зафиксирован в уроке), нативный движок исполняет Marshal, Unmarshal, Encoder, Decoder и RawMessage детерминированно.

## Домашнее задание

Часть первая — Marshal: опишите структуру Книга (Название, Автор, Год, Подзаголовок с omitempty) и выведите Marshal'ом три значения: полностью заполненное, с пустым Подзаголовком и с нулевым Годом; в комментарии объясните, какие ключи и почему исчезли, и посчитайте len(b) для кириллического названия, сверив с числом букв. Часть вторая — Unmarshal: разберите байты {"город": "Уфа", "население": 1125000} в map[string]any, напечатайте оба значения и тип числа через fmt.Printf("%T"); затем опишите структуру под этот же JSON и разберите в неё — в комментарии сравните два подхода. Часть третья — эскейпы: маршальте map со строкой "a<b&c" двумя способами (Marshal и Encoder с SetEscapeHTML(false)), посчитайте len обоих результатов и объясните разницу в шести байтах. Часть четвёртая — Decoder: соберите строку из двух JSON-объектов подряд, прочитайте её json.NewDecoder(strings.NewReader(...)) в цикле с подсчётом объектов и напечатайте причину остановки; затем попробуйте то же Unmarshal'ом и опишите, что он съел. Комментарии в коде — по-русски.

## Вопросы и ответы по уроку

### Почему json.Marshal возвращает []byte, а не string?

Потому что JSON в Go — это в первую очередь байты, готовые к отправке: http.ResponseWriter, os.File и сетевые соединения принимают именно []byte, и лишняя конверсия в строку была бы лишним копированием в самом горячем месте программы. Строка нужна только тогда, когда вы дальше работаете с текстом руками (лог, сравнение, склейка) — тогда делаете string(b) явно, как string(data) у os.ReadFile. Косвенный бонус: тип результата напоминает, что len считает байты, а не знаки — для кириллических JSON это критично при выставлении Content-Length.

### Почему поле с omitempty всё равно попадает в JSON?

Проверьте два типа ловушек. Первая: omitempty прячет только НУЛЕВЫЕ значения своего типа — false, 0, "", nil; любая непустая строка, ненулевое число и даже отрицательное значение выводятся. Вторая: omitempty не работает для полей-структур — структура с нулевыми полями не считается нулевым значением и выходит целиком; чтобы прятать, объявите поле указателем (*Address): nil-указатель спрячется, значение — выйдет. И не забудьте, что тег должен начинаться с json: и опция пишется через запятую после имени: `json:"email,omitempty"`.

### Чем json.Decoder лучше json.Unmarshal?

Это не «лучше/хуже», а разные источники. Unmarshal берёт готовый []byte — весь JSON уже в памяти, один вызов. Decoder обёрнут вокруг io.Reader и читает байты по мере надобности: разбирает ПОТОК из нескольких JSON-документов подряд (цикл Decode до io.EOF), не требует весь документ в памяти разом и даёт настройки вроде DisallowUnknownFields. Один маленький объект из os.ReadFile — Unmarshal; NDJSON-лог на гигабайт, бесконечный стрим по сети или строгий контракт — Decoder. Механика разбора у них одна и та же.

### Работает ли encoding/json в песочнице самоучителя?

Только на основном движке. Резервный знает лишь fmt, strings, strconv, math, sort, errors и time — import "encoding/json" даёт отказ «пакет "encoding/json" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time)». Основной движок (yaegi) исполняет пакет честно: Marshal и Unmarshal с тегами и omitempty, Encoder с SetEscapeHTML, Decoder с потоком, RawMessage и MarshalIndent — все JSON-раннеры урока нативные и прогнаны трижды. Форму JSON можно изучить и без пакета: первый раннер собирает JSON-текст руками из строки, и такой код исполняют оба движка.
