encoding/json и теги структур#

Модуль: Core Go · Уровень: Middle+/Senior

TL;DR#

encoding/json отображает структуры Go в JSON через рефлексию, ориентируясь на экспортируемые поля и теги вида json:"name,omitempty". При разборе неизвестные поля молча игнорируются (если не включён DisallowUnknownFields), а отсутствующее поле оставляет в структуре нулевое значение — отличить «не пришло», «пришло null» и «пришёл ноль» можно только указателем, json.RawMessage или отдельным флагом. omitempty опускает только «пустые» значения базовых типов, nil-указатели, пустые слайсы/мапы/строки, но не пустые структуры и не time.Time — для этого в Go 1.24 появился omitzero. Числа при разборе в any становятся float64 (лечится json.Decoder.UseNumber), []byte кодируется в base64, time.Time — в RFC 3339. Кастомное поведение задаётся MarshalJSON/UnmarshalJSON или encoding.TextMarshaler.

Простыми словами#

encoding/json превращает структуры Go в JSON и обратно, а управляется это тегами — строчками в обратных кавычках рядом с полями.

type User struct {
    ID        int       `json:"id"`
    Name      string    `json:"name"`
    Email     string    `json:"email,omitempty"` // пропустить, если пусто
    passwordH string                              // не экспортируется — не попадёт в JSON вообще
    Internal  string    `json:"-"`                // явно исключено
}

Два правила, которые надо усвоить сразу. Первое: сериализуются только поля с большой буквы. Поле с маленькой буквы пакет json просто не видит — не по злому умыслу, а потому что для него оно недоступно. Второе: при разборе лишние поля молча игнорируются, а недостающие оставляют в структуре нулевое значение. Никаких ошибок не будет.

Отсюда вытекает главная практическая головная боль: отличить «поле не прислали», «прислали null» и «прислали 0» по обычному int невозможно — во всех трёх случаях там окажется ноль. Для операций частичного обновления это критично, и лечится указателем (*int): nil значит «не прислали», а указатель на ноль — «прислали ноль».

Если ты с другого языка. Аннотаций и настраиваемых мапперов уровня Jackson здесь нет — есть теги и, при необходимости, свои методы. Регистр имеет значение: приватное поле не сериализуется никак, обойти это рефлексией штатными средствами нельзя. Зато при разборе Go по умолчанию нечувствителен к регистру ключей, что иногда удивляет в обратную сторону.

Словарь этой статьи:

  • struct tag — строка метаданных рядом с полем, которую читают библиотеки через рефлексию;
  • omitempty — опустить поле, если значение «пустое»;
  • json.RawMessage — отложенный кусок JSON, который разберут позже;
  • маршалинг / анмаршалинг — кодирование в JSON и разбор из JSON.

Теория#

Правила отображения#

GoJSON
structобъект (только экспортируемые поля)
map[string]Tобъект (ключи сортируются)
[]Tмассив; nil-слайс → null, пустой → []
[]byteстрока в base64
time.Timeстрока RFC 3339
int, float64число
указательзначение или null
any при разбореfloat64, string, bool, []any, map[string]any, nil

Два пункта из этой таблицы стабильно всплывают в багах: nil-слайс превращается в null, а не в [] (фронтенд к такому обычно не готов), и любое число, разобранное в any, становится float64 — на больших int64 это теряет точность.

// nil vs пустой слайс
var a []int
b := []int{}
json.Marshal(a) // null
json.Marshal(b) // []

// числа в any
var v any
json.Unmarshal([]byte(`{"id": 12345678901234567890}`), &v) // float64, точность потеряна

// лечение
d := json.NewDecoder(r)
d.UseNumber() // числа станут json.Number — строкой, которую можно разобрать точно

Теги: полный синтаксис#

type Order struct {
    ID       string    `json:"id"`                    // переименование
    Total    int       `json:"total,omitempty"`       // опустить, если 0
    Note     string    `json:"-"`                     // никогда не сериализовать
    Dash     string    `json:"-,"`                    // поле с именем "-" (редкий трюк)
    Count    int       `json:"count,string"`          // число как строка в JSON
    Meta     any       `json:",omitempty"`            // имя по умолчанию (Meta), но с omitempty
}

Опция ,string полезна при работе с языками, где большие целые числа не переживают JSON (JavaScript теряет точность после 2^53).

omitempty и его границы#

omitempty опускает поле, если значение — «пустое»: false, 0, "", nil-указатель/интерфейс, пустой слайс, мапа или массив.

Чего он не делает:

type Response struct {
    User    User      `json:"user,omitempty"`    // структура НИКОГДА не опускается
    When    time.Time `json:"when,omitempty"`    // time.Time — структура, тоже не опускается
}

Пустая структура — не «пустое значение» с точки зрения пакета, поэтому в JSON уедет "user":{} и "when":"0001-01-01T00:00:00Z". Классические обходы: указатель на структуру (*User) или собственный MarshalJSON.

В Go 1.24 добавили опцию omitzero, которая проверяет именно нулевое значение типа и умеет спрашивать у типа метод IsZero():

type Response struct {
    When time.Time `json:"when,omitzero"` // теперь пропускается, если время нулевое
}

Отсутствие, null и ноль#

Самая частая архитектурная проблема при частичном обновлении (PATCH):

type UpdateUser struct {
    Name  *string `json:"name"`  // nil = не прислали, &"" = прислали пустую строку
    Email *string `json:"email"`
}

if u.Name != nil {
    // поле пришло — обновляем, даже если пустое
}

Указатель различает два состояния из трёх («не пришло» и «пришло значение»), но не отличает null от отсутствия — в обоих случаях будет nil. Если нужны все три, берут json.RawMessage или структуру-обёртку с флагом:

type Optional[T any] struct {
    Value T
    Set   bool   // поле присутствовало в JSON
    Null  bool   // и было равно null
}

Свои правила кодирования#

type Money struct {
    Cents int64
}

func (m Money) MarshalJSON() ([]byte, error) {
    return json.Marshal(fmt.Sprintf("%d.%02d", m.Cents/100, m.Cents%100))
}

func (m *Money) UnmarshalJSON(b []byte) error {
    var s string
    if err := json.Unmarshal(b, &s); err != nil {
        return err
    }
    // ...разбор строки в центы
    return nil
}

Два правила: MarshalJSON объявляют на значении (иначе он не сработает для не-указателей), UnmarshalJSON — обязательно на указателе (иначе будет менять копию).

Для простых типов часто достаточно encoding.TextMarshaler/TextUnmarshaler — их дополнительно используют для ключей мапы и другие пакеты (encoding/xml, драйверы БД).

Ловушка бесконечной рекурсии:

func (u User) MarshalJSON() ([]byte, error) {
    return json.Marshal(u) // рекурсия до переполнения стека!
}

// правильно — через тип-псевдоним без методов:
func (u User) MarshalJSON() ([]byte, error) {
    type alias User                      // у alias нет метода MarshalJSON
    return json.Marshal(alias(u))
}

Строгий разбор#

По умолчанию разбор всеядный: неизвестные поля игнорируются, регистр ключей не важен ("name" попадёт в поле с тегом "Name"). Для API это часто плохо — опечатка клиента остаётся незамеченной.

d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
if err := d.Decode(&req); err != nil {
    // теперь лишнее поле — это ошибка
}

Для HTTP-хендлеров это же место, где ограничивают размер тела:

r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 МБ

Без ограничения любой клиент может прислать гигабайтный JSON и выесть память сервера.

Потоковая обработка#

json.Marshal собирает весь результат в память. Для больших ответов и для последовательности объектов есть Encoder/Decoder:

enc := json.NewEncoder(w)
for _, item := range items {
    enc.Encode(item)   // пишет прямо в ответ, по объекту за раз
}

dec := json.NewDecoder(r)
for dec.More() {
    var item Item
    dec.Decode(&item)  // разбирает поток, не держа всё в памяти
}

Encoder.Encode добавляет перевод строки после каждого объекта — это ровно формат JSON Lines, удобный для логов и потоковых API.

Производительность#

encoding/json работает через рефлексию, но кэширует разбор структуры типа: план кодирования строится один раз на тип и переиспользуется. Тем не менее это заметно медленнее ручного кода.

Практические ориентиры:

  • переиспользуй Encoder/Decoder вместо Marshal/Unmarshal в горячих путях, чтобы не создавать промежуточные буферы;
  • json.RawMessage откладывает разбор части документа — полезно, когда нужен только один ключ из большого тела;
  • если профиль показывает, что сериализация стала узким местом, есть альтернативы: bytedance/sonic (JIT), goccy/go-json, кодогенераторы вроде easyjson. Но сначала — измерить;
  • в Go 1.25 появился экспериментальный encoding/json/v2 (включается через GOEXPERIMENT=jsonv2) с более строгим и более быстрым API. В прод его пока не тащат, но знать о его существовании полезно.

Подводные камни / gotchas#

  • Неэкспортируемые поля не сериализуются. Никогда, ни с каким тегом.
  • nil-слайс даёт null. Инициализируй []T{}, если контракт обещает массив.
  • Числа в any — это float64. Большие int64 теряют точность; лечится UseNumber() или конкретным типом.
  • omitempty не работает для структур и time.Time. Нужен указатель или omitzero (Go 1.24+).
  • Разбор нечувствителен к регистру ключей. {"NAME":"x"} попадёт в поле с тегом json:"name".
  • Ошибка в теге не заметна. json:"name, omitempty" (с пробелом) — тег с именем name и опцией " omitempty", которая не сработает. go vet это ловит.
  • MarshalJSON на указателе не вызовется, если сериализуешь значение. Объявляй на значении.
  • Рекурсия в MarshalJSON при вызове json.Marshal на том же типе.
  • Тело запроса без ограничения размера — способ уронить сервис по памяти.
  • json.Marshal экранирует HTML (<, >, &<…) по умолчанию. Отключается через Encoder.SetEscapeHTML(false).

Вопросы на собеседовании#

В: Как отличить «поле не прислали» от «прислали нулевое значение»? О: Через указатель: nil означает отсутствие, ненулевой указатель — присланное значение, в том числе нулевое. Если нужно отличать ещё и явный null, берут json.RawMessage или обёртку с флагами присутствия.

В: Почему поле не попало в JSON? О: Три типовые причины: поле неэкспортируемое, стоит тег json:"-", или сработал omitempty на пустом значении.

В: Что происходит с неизвестными полями при разборе? О: По умолчанию они игнорируются. Чтобы получать ошибку, нужен Decoder.DisallowUnknownFields().

В: Почему omitempty не сработал на time.Time? О: Потому что это структура, а omitempty не считает пустую структуру «пустым значением». Решения: указатель *time.Time, свой MarshalJSON или опция omitzero в Go 1.24+.

В: Во что превращается число при разборе в any и почему это опасно? О: В float64. Целые числа больше 2^53 теряют точность, поэтому для идентификаторов это недопустимо. Лечится разбором в конкретный тип или Decoder.UseNumber().

В: Как переопределить сериализацию своего типа? О: Реализовать MarshalJSON() ([]byte, error) на значении и UnmarshalJSON([]byte) error на указателе. Для простых типов часто достаточно encoding.TextMarshaler, который заодно работает для ключей мапы и в других пакетах.

В: Чем Decoder лучше Unmarshal в HTTP-хендлере? О: Он читает из потока, не требуя держать всё тело в памяти, позволяет включить строгий режим и обработать последовательность объектов. Дополнительно тело стоит ограничить http.MaxBytesReader.

В: Как encoding/json работает под капотом и почему он не самый быстрый? О: Через рефлексию: пакет обходит поля типа, читает теги и строит план кодирования, кэшируя его по типу. Рефлексия не инлайнится и аллоцирует, отсюда отставание от кодогенерации и JIT-реализаций.

На что копают на senior+#

  • Проектирование API поверх ограничений пакета: как выражается частичное обновление, что отдавать вместо null в массивах, как версионировать контракт.
  • Понимание источника поведения: всё завязано на рефлексию и теги, отсюда и требование экспортируемости, и стоимость.
  • Безопасность разбора: ограничение размера тела, строгий режим, отсутствие доверия к клиентскому вводу.
  • Знание отличий omitempty и omitzero и умение объяснить, почему потребовалась вторая опция.
  • Follow-up: «Как сериализовать интерфейс и разобрать обратно?» (нужен дискриминатор типа и ручной разбор через RawMessage), «Почему ключи мапы сортируются?» (детерминированный вывод), «Когда переходить на кодогенерацию?».