Содержание урока · 14 разделов
- JSON в Go: байты, у которых есть форма
- json.Marshal и struct tags: имя ключа решает тег
- omitempty: нулевое значение — нет поля
- json.Unmarshal: из байтов в значения
- Вложенные структуры, срезы и карты
- Кириллица и \u003c: HTML-эскейпы и SetEscapeHTML
- json.Decoder против Unmarshal: поток
- json.RawMessage: разбери потом
- Шпаргалка: JSON из этого урока
- Правила гигиены JSON
- Что дальше: JSON выходит в сеть
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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 делает за вас всегда.
Запустите код, чтобы увидеть вывод.
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:"-" — поле исчезнет из вывода целиком.
Запустите код, чтобы увидеть вывод.
Проверьте себя
b, err := json.Marshal(v) отработал без ошибки. Как получить JSON-текст из b?
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. Что выйдет?
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: самый тихий баг урока.
Запустите код, чтобы увидеть вывод.
Что выйдет из Marshal с omitempty?
Прочитайте код и выберите, что он выведет:
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))
}Вложенные структуры, срезы и карты#
Плоские формы скучны, и к счастью, 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 и печать текста с проверкой ошибки. Строки импорта — по одной на пакет.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Восстановите вызов Unmarshal
Допишите декодирование: имя функции пакета и адрес переменной, в которую ляжет результат. Ответы вводите латиницей, второй — с амперсандом, без пробелов.
Кириллица и \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 вместо одиночного символа.
Запустите код, чтобы увидеть вывод.
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.
Резервный движок знает только 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 показывает ту же encoding/json с другой стороны очереди: теги, omitempty и PureJSON на настоящих хендлерах.
До встречи на странице курса. Если захочется освежить байтовую базу — урок про файлы и ввод-вывод: os.ReadFile, поток и EOF — то, во что JSON упирается с обеих сторон. А если станет интересно, как Marshal вообще видит ваши поля и теги — урок про рефлексию: reflect.TypeOf, reflect.Value и StructTag — три инструмента, из которых encoding/json собран внутри.
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- 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-текст руками из строки, и такой код исполняют оба движка.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией