JSON в Go: encoding/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, остальные разбираются в тренажёре.
- json.Unmarshal разбирает JSON в структуру. Что важно про приёмник и лишние ключи?A)Приёмник — указателем (&v); неизвестные ключи по умолчанию игнорируютсяB)Приёмник передают по значению, а неизвестные ключи вызывают ошибку разбораC)Unmarshal вернёт map — структуру он не заполняетD)Лишние ключи попадают в скрытое поле _extra структуры
показать ответ и разбор
+A)Приёмник — указателем (&v); неизвестные ключи по умолчанию игнорируются// разбор: json.Unmarshal(data, &v) требует УКАЗАТЕЛЬ на приёмник — иначе заполнять нечего (копию-значение не изменить). Ключи JSON, которым нет соответствия в структуре, по умолчанию молча игнорируются (строгий режим — decoder.DisallowUnknownFields). Сопоставление имён по тегу/полю, регистронезависимо. Спот-чек: ключ extra проигнорирован.
- json.Unmarshal разобрал число JSON в переменную типа interface{} (any). Какого Go-типа станет значение?A)int в случае целого числа без дробной части, а иначе оно становится float64B)json.Number — обёртка, сохраняющая точный текст числаC)float64 — JSON-число в any по умолчанию становится float64D)string — значения в any хранятся как строки
показать ответ и разбор
+C)float64 — JSON-число в any по умолчанию становится float64// разбор: При разборе в interface{} json применяет дефолтный маппинг: объект → map[string]interface{}, массив → []interface{}, число → float64 (даже целое), строка → string, bool → bool, null → nil. Отсюда классический баг: достаёшь «целое» из any, а там float64 — нужен каст, и на больших int теряется точность. Лечение: типизированная структура или json.Number.
- Как отличить пришедшее в JSON значение null от отсутствующего поля при разборе в структуру?A)Сравнить поле с нулевым значением его типа после разбораB)Включить строгий режим декодера — он вернёт ошибку на пропущенное полеC)Объявить поле указателем: отсутствие и null дадут nil, но не обнулят значениеD)Прочитать список присутствовавших ключей из возвращаемого Unmarshal значения
показать ответ и разбор
+C)Объявить поле указателем: отсутствие и null дадут nil, но не обнулят значение// разбор: Обычное поле в обоих случаях остаётся нулевым, и «пришёл 0» неотличим от «не пришло ничего» — на частичном обновлении это приводит к затиранию данных. Указатель различает «поле было и в нём значение» от «поля не было» (nil). Разделить null и отсутствие ключа указателем всё равно не получится: для этого нужен json.RawMessage или map[string]any.
- Зачем типу реализовывать методы MarshalJSON и UnmarshalJSON?A)Чтобы ускорить кодирование за счёт отказа от рефлексииB)Чтобы задать своё представление типа в JSONC)Чтобы разрешить сериализацию неэкспортируемых полей структурыD)Чтобы тип можно было использовать в качестве ключа объекта JSON
показать ответ и разбор
+B)Чтобы задать своё представление типа в JSON// разбор: Стандартное представление подходит не всегда: время нужно в своём формате, деньги — строкой без потери копеек, перечисление — читаемым словом вместо числа. Реализовав эти методы, тип сам решает, как выглядит снаружи, и остаётся согласованным везде, где встречается. Побочно это единственный способ протащить в JSON состояние, собранное из приватных полей.
- Чем json.Decoder удобнее json.Unmarshal при обработке тела HTTP-запроса?A)Он читает из потока и не требует держать всё тело в памятиB)Он автоматически ограничивает размер принимаемого тела запросаC)Он разбирает JSON параллельно в нескольких горутинахD)Он проверяет соответствие данных схеме, объявленной в структуре
показать ответ и разбор
+A)Он читает из потока и не требует держать всё тело в памяти// разбор: Unmarshal хочет готовый срез байтов — тело придётся прочитать целиком. Decoder работает поверх io.Reader: разбирает по мере чтения, умеет обрабатывать поток из нескольких JSON-значений подряд, а через DisallowUnknownFields ловит лишние поля. Ограничение размера он не даёт — его ставят отдельно, обернув тело в http.MaxBytesReader.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.