30. Работа с JSON
В мире распределенных систем формат JSON (JavaScript Object Notation) стал универсальным эсперанто. На нем общаются микросервисы, веб-браузеры, базы данных NoSQL и мобильные клиенты. В скриптовых языках с динамической типизацией (Python, JavaScript, PHP) парсинг JSON напоминает наливание воды в сосуд произвольной формы: функции вроде json.loads или json_decode автоматически разворачивают текстовый поток в произвольные вложенные словари и списки.
В языке Go такой фокус не пройдет. Go — язык строгой статической типизации и механического сочувствия к оборудованию. Вместо того чтобы создавать аморфные структуры в памяти, рантайм Go требует заблаговременного и точного описания того, во что именно должны превратиться байты из сети.
Вся работа с форматом в стандартной библиотеке сосредоточена в пакете encoding/json. Его архитектура построена вокруг двух принципов: максимальная предсказуемость типов данных и осознанный компромисс между удобством рефлексии и процессорными тактами.
В этой главе мы разберем базовые операции сериализации и десериализации, изучим управление полями через структурные теги, разберем потоковую обработку через Encoder/Decoder, исследуем причины медлительности стандартного парсера и познакомимся с альтернативами на базе SIMD и кодогенерации.
Базовые операции: Marshal и Unmarshal
В терминологии Go вместо понятий «сериализация» и «десериализация» приняты лаконичные глаголы:
Marshal— упаковка структуры Go в срез байтов JSON (Struct -> []byte).Unmarshal— распаковка среза байтов JSON в конкретную структуру Go ([]byte -> Struct).
Unmarshal: JSON -> Struct
Чтобы парсер смог наполнить поля структуры данными, должны соблюдаться два ключевых условия:
- Поля обязаны быть экспортируемыми (начинаться с заглавной буквы), иначе рефлексия рантайма их проигнорирует.
- В функцию
json.Unmarshalнеобходимо передать указатель на структуру, иначеUnmarshalничего не заполнит и вернёт ошибкуjson: Unmarshal(non-pointer main.User)(*json.InvalidUnmarshalError).
Ключи JSON сопоставляются с полями (и их тегами) без учёта регистра: {"NAME": "Alice"} тоже заполнит поле Name.
package main
import (
"encoding/json"
"fmt"
"log"
)
// Поля структуры экспортируемы (с заглавной буквы)
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func main() {
jsonData := []byte(`{"id": 1, "name": "Alice", "email": "alice@example.com"}`)
var user User
// Передаем указатель (&user), чтобы Unmarshal заполнил поля оригинала
err := json.Unmarshal(jsonData, &user)
if err != nil {
log.Fatalf("Unmarshal error: %v", err)
}
fmt.Printf("%+v\n", user) // {ID:1 Name:Alice Email:alice@example.com}
}
Marshal: Struct -> JSON
Упаковка структуры в срез байтов выполняется вызовом json.Marshal:
user := User{ID: 2, Name: "Bob", Email: "bob@example.com"}
// Marshal возвращает слайс байт и ошибку
jsonData, err := json.Marshal(user)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(jsonData)) // {"id":2,"name":"Bob","email":"bob@example.com"}
Структурные теги (Struct Tags)
В языке Go имена полей структур пишутся в CamelCase с заглавной буквы. В мире JSON доминируют другие соглашения: snake_case или lowerCamelCase. Для сопоставления ключей JSON с полями структур в Go используются структурные теги (Struct Tags) — строковые литералы в обратных кавычках, считываемые рантаймом через механизм рефлексии:
type ServerConfig struct {
Host string `json:"host"` // Маппинг на ключ "host"
Port int `json:"port"` // Маппинг на ключ "port"
SecretKey string `json:"-"` // Ключ "-" означает: ПОЛНОСТЬЮ ИГНОРИРОВАТЬ поле
Timeout int `json:"timeout,omitempty"` // Опускать поле, если оно равно zero-value (0, "", nil, false)
}
Директива omitempty сообщает сериализатору: если значение поля «пустое» (0 для чисел, "" для строк, false для булевых значений, nil для указателей и интерфейсов, а также пустые срезы и мапы), ключ не должен включаться в результирующий JSON. Структуры omitempty пустыми не считает никогда.
Произвольные данные: any и json.RawMessage
Что делать, если точная схема входящего JSON заранее неизвестна, или полезная нагрузка зависит от типа входящего события?
1. map[string]any (бывший map[string]interface{})
Это классический путь для полностью динамического разбора. Однако вы платите за него потерей типобезопасности и лавиной аллокаций памяти в куче:
var data map[string]any
json.Unmarshal([]byte(`{"key": "value", "num": 123}`), &data)
// Придется делать type assertion, что неидиоматично и чревато паниками:
num, ok := data["num"].(float64) // Внимание: JSON числа ВСЕГДА парсятся во float64!
Обратите внимание: поскольку спецификация JSON не разделяет целые и вещественные числа, стандартный парсер Go при разборе в any по умолчанию преобразует любые числовые литералы в тип float64 (если не включить Decoder.UseNumber(), о нём — ниже)!
2. json.RawMessage: Отложенный парсинг
json.RawMessage — один из самых элегантных приемов в арсенале бэкенд-инженера. По своей сути RawMessage — это просто срез байтов (type RawMessage []byte; с Go 1.27 — псевдоним type RawMessage = jsontext.Value, где jsontext.Value тоже определён как []byte), который реализует интерфейсы json.Marshaler и json.Unmarshaler.
При десериализации парсер не пытается разобрать содержимое json.RawMessage, а сохраняет исходные байты в нетронутом виде:
type Envelope struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"` // Данные не парсятся, остаются как байты
}
payload := []byte(`{"type": "user_created", "data": {"id": 1, "name": "Alice"}}`)
var env Envelope
json.Unmarshal(payload, &env)
// Парсим Data только когда поняли тип
if env.Type == "user_created" {
var user User
json.Unmarshal(env.Data, &user)
}
Потоковая работа: Encoder и Decoder
Функции json.Marshal и json.Unmarshal оперируют монолитными срезами памяти []byte. Если в ваш сервис по HTTP поступает JSON-документ размером в сотни мегабайт, попытка вычитать его целиком через os.ReadFile или io.ReadAll приведет к мгновенному взлету потребления памяти и срабатыванию OOM Killer.
Для потоковой работы пакет предоставляет типы json.Encoder и json.Decoder. Они работают напрямую с фундаментальными абстракциями io.Reader и io.Writer, детально изученными в главе 29. Работа с файлами и IO:
// Запись (сервер отправляет JSON в http.ResponseWriter)
func handler(w http.ResponseWriter, r *http.Request) {
user := User{ID: 1, Name: "Alice"}
// Encoder избавляет от ручного []byte: сериализует значение
// во внутренний буфер из пула и пишет его в w одним Write
err := json.NewEncoder(w).Encode(user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
// Чтение (сервер читает JSON из тела запроса)
func handlerPost(w http.ResponseWriter, r *http.Request) {
var user User
// Decoder читает из r.Body очередное JSON-значение целиком и разбирает его
// (размер тела ограничивайте через http.MaxBytesReader)
err := json.NewDecoder(r.Body).Decode(&user)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
}
Учтите: Encoder и Decoder избавляют от ручного []byte и лишнего копирования, но одно значение всё равно сериализуется в буфер целиком, а Decode читает в память всё очередное JSON-значение. Реальная экономия памяти появляется при потоке значений: NDJSON (по документу на строку) или разбор большого массива по элементам через Decoder.Token() и Decoder.More(). Для тел HTTP-запросов ограничивайте размер через http.MaxBytesReader.
Mechanical Sympathy: Рефлексия и аллокации
Почему стандартный пакет encoding/json регулярно критикуют за невысокую скорость работы по сравнению с аналогами на C++ или Rust?
Взглянем на внутреннюю механику работы Unmarshal:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Входящий []byte"]:::process --> B["json.Unmarshal"]
B --> C["reflect.TypeOf<br/>Получаем метаданные типа"]
C --> D["reflect.ValueOf<br/>Создаем обертку значения"]
D --> E["Цикл по полям JSON"]
C --> T["Один раз на тип:<br/>разбор тегов и кэш полей"]
E --> G["Поиск совпадения<br/>ключ-поле в кэше"]
G --> H["Установка значения<br/>через reflect"]
H --> I["Аллокация в куче<br/>Escape to heap"]
I --> E
Главные узкие места стандартного парсера:
- Тяжелая рефлексия (
reflect): Описание полей типа (с разобранными тегами) строится один раз и кэшируется, но каждое значение всё равно читается и записывается черезreflect.Valueс проверками типов. Такие вызовы не поддаются инлайнингу (Inlining) компилятором и сбивают предсказатель ветвлений CPU. - Утечки в кучу (Escape Analysis): Использование универсальных интерфейсов
anyи промежуточных структурreflect.Valueзаставляет аллокатор выносить промежуточные данные в кучу. Каждая аллокация увеличивает объем работы для фонового Garbage Collector (подробнее в 7. Глубокий Go (Внутреннее устройство)). - Проверка UTF-8 на лету: Стандарт JSON требует валидности кодировки UTF-8. Парсер сканирует все строковые литералы байт за байтом, расходуя процессорные такты (а невалидные байты API v1 не отвергает, а заменяет символом U+FFFD).
Производительность: Кодогенерация и SIMD
Как высоконагруженные Go-сервисы (обрабатывающие от десятков тысяч запросов в секунду) обходят ограничения стандартного парсера?
Решение заключается в переносе работы с этапа выполнения на этап компиляции:
easyjson: Генерирует специализированный Go-код парсинга для каждой конкретной структуры заранее. Вместо рефлексии сгенерированный код содержит статические переходыswitch/caseпо байтам ключей. Работает в 2–4 раза быстрее стандартного пакета и практически не аллоцирует память в куче.go-json: Прозрачная drop-in замена стандартного пакета от сообщества. Применяет агрессивное кэширование метаданных структур и специализированные ассемблерные вставки для быстрого поиска символов.sonic(от инженеров ByteDance): Самый производительный JSON-парсер на сегодняшний день. Использует JIT-компиляцию на лету и векторные процессорные инструкции SIMD (AVX2/AVX-512 на x86-64 и NEON на ARM64), обрабатывая сразу по 32 или 64 байта за один такт процессора.
HTML-экранирование: Сюрприз для REST API
В стандартном пакете encoding/json заложена скрытая особенность, нередко ставящая в тупик бэкенд-разработчиков.
По умолчанию метод json.Marshal экранирует специальные символы <, > и &, заменяя их на Unicode-последовательности \u003c, \u003e, \u0026. Это было сделано из соображений безопасности: чтобы сформированный JSON можно было без риска внедрять внутрь HTML-страниц без опасности возникновения XSS-уязвимостей.
Однако при создании классических REST API такое поведение искажает строковые данные в ответах. Изменить эту настройку в функции Marshal API v1 невозможно. В API v1 экранирование отключают через Encoder (а encoding/json/v2 HTML не экранирует вовсе):
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false) // Отключаем принудительное экранирование символов
enc.Encode(data)
Итог
- Статическая дисциплина: В Go сериализация опирается на строгую типизацию структур и явные структурные теги.
- Потоковый ввод-вывод: Используйте
json.NewEncoderиjson.NewDecoderдля сетевого ввода-вывода: они избавляют от ручных буферов, а для потока значений (NDJSON,Token()/More()) реально экономят память. - Отложенная обработка: Тип
json.RawMessageпозволяет экономить процессорные такты при обработке полиморфных событий и очередей сообщений. - Рефлексия и кодогенерация: В высоконагруженных системах (
10k+ RPS) стандартный парсер становится узким местом. Переход на кодогенерацию (easyjson) или SIMD-парсинг (sonic) — стандартная практика оптимизации. - Безопасность типов: Для 64-битных целых чисел помните об ограничении мантиссы
float64в $2^{53}$ и используйтеjson.Numberили строгие типыint64.
Байты и форматы обмена данными разобраны. Но ни один сетевой сервис не может существовать вне контекста реального физического времени: таймауты запросов, периодические фоновые задачи, замеры задержек (Latency) и интервалы кэширования требуют безупречного понимания временной шкалы.
В следующей лекции мы детально изучим подсистему времени в Go. В главе 31. Время и даты. time.Time, Duration, Timer, Ticker мы исследуем структуру time.Time, разберем, как монотонные часы защищают сервис от сбоев синхронизации NTP, и узнаем, как таймеры рантайма связываются с системным шедулером.