сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Веб и тесты на Go

JSON в Go: encoding/json

JSON: теги, типы и потоки

Разобрал число 9007199254740993 в map[string]any и напечатал обратно: получил 9007199254740992. Последняя цифра поменялась, потому что по дороге число стало float64. Спрашивают такие вещи и ещё две: какие поля попадут в вывод и как отличить null от отсутствия поля.

Стержень: кодировщик работает через рефлексию и видит только экспортируемые поля, а без конкретного типа числа становятся float64.

// Формулировки: «почему поле не попало в JSON?», «во что разберётся число?», «как отличить null от отсутствия?»

Что попадает в вывод: посмотрел на одной структуре

Собрал структуру из шести полей и посмотрел, что выйдет. Получилось {"id":1,"name":"Аня","NoTag":"n","Typo":"t"} - половина полей просто исчезла.

Разбираем по одному. Поле с маленькой буквы пропало молча: кодировщик работает через рефлексию и неэкспортируемые поля не видит вовсе. Поле с тегом «-» исключено намеренно. Поле с omitempty исчезло, потому что значение было пустым. NoTag уехал под своим именем с заглавной буквы - тега на нём не было.

// А четвёртое поле - главная подстава. Я написал в теге jsonn вместо json, и оно уехало под именем Typo. Тег компилятор не проверяет: опечатка не ломает сборку, поле просто получает имя по умолчанию. Ловится это тестом или линтером, глазами никогда.

omitempty
пропустить поле, если значение нулевое
опечатка в теге
сборка проходит, имя тихо становится дефолтным

Числа: где теряется точность

Разбор в структуру с точными типами - основной путь: структура документирует ожидаемую форму данных и ловит расхождение прямо на границе. Разбор в any или map[string]any превращает в float64 любое число, даже целое.

Дальше два разных следствия. Ассерция к int паникует, и текст паники прямой: interface conversion: interface {} is float64, not int. Хуже другое - у float64 всего 53 бита мантиссы, поэтому большие целые молча портятся: моё число потеряло единицу в последнем разряде, и никакой ошибки при этом не было.

// Лечится включением decoder.UseNumber(): числа приходят как json.Number, то есть исходной записью, и n.Int64() у меня вернул ровно 9007199254740993. Идентификаторы, суммы в копейках и метки времени в миллисекундах через float64 гонять нельзя.

json.Number
исходная запись числа без приведения к float64
53 бита мантиссы
предел точности float64 для целых

null против отсутствия поля

Разобрал три входа в структуру с полями-указателями: {"name":"Боря"}, {"name":null} и {}. В первом указатель ненулевой. А во втором и третьем он nil одинаково. То есть указатель отличает «пришло значение» от «не пришло», но null и отсутствие поля не разводит.

Для частичного обновления, где это принципиально, разбирают в map[string]json.RawMessage. Тогда видно всё: ключа нет вовсе, ключ есть со значением null, ключ есть со значением. Я проверил все три ветки, они различаются чисто.

// Второй путь - свой метод UnmarshalJSON на типе поля, который поднимает флаг «поле присутствовало». Он аккуратнее для больших структур, но писать его придётся руками.

json.RawMessage
кусок входных данных как есть, без разбора
частичное обновление
когда null и отсутствие поля значат разное

Потоки и лишние поля

json.Decoder работает поверх io.Reader: разбирает прямо из потока, не требуя держать всё тело в памяти, и умеет читать несколько значений подряд.

У него же есть DisallowUnknownFields. Проверил на входе {"id":1,"lishnee":5}: обычный разбор вернул nil, то есть лишнее поле просто выбросил, а с этой настройкой пришла ошибка json: unknown field "lishnee". Для внутренних контрактов полезно, для публичных - наоборот, ломает совместимость вперёд.

// Ограничение размера тела Decoder не даёт вовсе. Его ставят отдельно, обернув r.Body в http.MaxBytesReader, иначе клиент пришлёт гигабайт и сервер честно попробует его разобрать.

DisallowUnknownFields
ошибка на неизвестные поля во входных данных
MaxBytesReader
потолок размера тела запроса

Как отвечать: «Почему поле не попало в JSON и во что разберётся число?»

Причин ровно четыре, я их все проверял на одной структуре. Первая и самая частая: поле объявлено с маленькой буквы. Кодировщик работает через рефлексию и неэкспортируемые поля не видит, ошибки при этом не будет, JSON просто окажется неполным. Вторая - тег «минус», который исключает поле намеренно. Третья - omitempty при нулевом значении. И четвёртая, самая подлая: опечатка в теге. Компилятор теги не проверяет, поэтому написанное jsonn вместо json собирается молча, а поле уезжает под именем по умолчанию. Ловится только тестом. Про числа: при разборе в структуру с точными типами всё приходит как объявлено, это мой основной путь. А в any или map любое число становится float64, поэтому ассерция к int паникует, и это ещё мягкий случай. Хуже, что у float64 всего 53 бита мантиссы: я разбирал число девять квадриллионов с хвостиком и получил обратно значение с изменённой последней цифрой, без единой ошибки. Поэтому идентификаторы и деньги через float64 не гоняю, включаю decoder.UseNumber и работаю с json.Number. И отдельно про null: указатель отличает «пришло значение» от «не пришло», но null и отсутствие поля для него одинаковы - там нужен map[string]json.RawMessage или свой UnmarshalJSON.

Сильный ответ: названы все четыре причины исчезновения поля с упором на непроверяемость тега, поведение чисел разобрано до потери точности с конкретным примером, и честно поправлено ходовое заблуждение про указатель и null. Это те грабли, на которых ломаются в проде.

На чём валят

  • Объявляют поле с маленькой буквы, и оно молча исчезает из вывода.
  • Делают ассерцию к int после разбора в map: там всегда float64.
  • Гоняют большие идентификаторы через float64 и теряют младшие разряды без ошибки.
  • Считают, что поле-указатель отличает null от отсутствия. Оба случая дают nil.
  • Читают тело целиком вместо Decoder и не ставят потолок размера.

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

Пять вопросов из банка по этой подтеме. Всего их 6, остальные разбираются в тренажёре.

  1. #go_json1 / 5
    json.Unmarshal разбирает JSON в структуру. Что важно про приёмник и лишние ключи?
    A)Приёмник — указателем (&v); неизвестные ключи по умолчанию игнорируются
    B)Приёмник передают по значению, а неизвестные ключи вызывают ошибку разбора
    C)Unmarshal вернёт map — структуру он не заполняет
    D)Лишние ключи попадают в скрытое поле _extra структуры
    показать ответ и разбор
    +A)Приёмник — указателем (&v); неизвестные ключи по умолчанию игнорируются

    // разбор: json.Unmarshal(data, &v) требует УКАЗАТЕЛЬ на приёмник — иначе заполнять нечего (копию-значение не изменить). Ключи JSON, которым нет соответствия в структуре, по умолчанию молча игнорируются (строгий режим — decoder.DisallowUnknownFields). Сопоставление имён по тегу/полю, регистронезависимо. Спот-чек: ключ extra проигнорирован.

  2. #go_json2 / 5
    json.Unmarshal разобрал число JSON в переменную типа interface{} (any). Какого Go-типа станет значение?
    A)int в случае целого числа без дробной части, а иначе оно становится float64
    B)json.Number — обёртка, сохраняющая точный текст числа
    C)float64 — JSON-число в any по умолчанию становится float64
    D)string — значения в any хранятся как строки
    показать ответ и разбор
    +C)float64 — JSON-число в any по умолчанию становится float64

    // разбор: При разборе в interface{} json применяет дефолтный маппинг: объект → map[string]interface{}, массив → []interface{}, число → float64 (даже целое), строка → string, bool → bool, null → nil. Отсюда классический баг: достаёшь «целое» из any, а там float64 — нужен каст, и на больших int теряется точность. Лечение: типизированная структура или json.Number.

  3. #go_json3 / 5
    Как отличить пришедшее в JSON значение null от отсутствующего поля при разборе в структуру?
    A)Сравнить поле с нулевым значением его типа после разбора
    B)Включить строгий режим декодера — он вернёт ошибку на пропущенное поле
    C)Объявить поле указателем: отсутствие и null дадут nil, но не обнулят значение
    D)Прочитать список присутствовавших ключей из возвращаемого Unmarshal значения
    показать ответ и разбор
    +C)Объявить поле указателем: отсутствие и null дадут nil, но не обнулят значение

    // разбор: Обычное поле в обоих случаях остаётся нулевым, и «пришёл 0» неотличим от «не пришло ничего» — на частичном обновлении это приводит к затиранию данных. Указатель различает «поле было и в нём значение» от «поля не было» (nil). Разделить null и отсутствие ключа указателем всё равно не получится: для этого нужен json.RawMessage или map[string]any.

  4. #go_json4 / 5
    Зачем типу реализовывать методы MarshalJSON и UnmarshalJSON?
    A)Чтобы ускорить кодирование за счёт отказа от рефлексии
    B)Чтобы задать своё представление типа в JSON
    C)Чтобы разрешить сериализацию неэкспортируемых полей структуры
    D)Чтобы тип можно было использовать в качестве ключа объекта JSON
    показать ответ и разбор
    +B)Чтобы задать своё представление типа в JSON

    // разбор: Стандартное представление подходит не всегда: время нужно в своём формате, деньги — строкой без потери копеек, перечисление — читаемым словом вместо числа. Реализовав эти методы, тип сам решает, как выглядит снаружи, и остаётся согласованным везде, где встречается. Побочно это единственный способ протащить в JSON состояние, собранное из приватных полей.

  5. #go_json5 / 5
    Чем json.Decoder удобнее json.Unmarshal при обработке тела HTTP-запроса?
    A)Он читает из потока и не требует держать всё тело в памяти
    B)Он автоматически ограничивает размер принимаемого тела запроса
    C)Он разбирает JSON параллельно в нескольких горутинах
    D)Он проверяет соответствие данных схеме, объявленной в структуре
    показать ответ и разбор
    +A)Он читает из потока и не требует держать всё тело в памяти

    // разбор: Unmarshal хочет готовый срез байтов — тело придётся прочитать целиком. Decoder работает поверх io.Reader: разбирает по мере чтения, умеет обрабатывать поток из нескольких JSON-значений подряд, а через DisallowUnknownFields ловит лишние поля. Ограничение размера он не даёт — его ставят отдельно, обернув тело в http.MaxBytesReader.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.