Валидатор не нашёл поле is_admin, а Go его нашёл: пять ошибок при работе с JSON
- вторник, 15 сентября 2026 г. в 00:00:15
Привет, Хабр!
На входе стоит валидатор: JSON Schema, которая знает про поле is_admin и снаружи выставлять его не даёт. Всё прочее схема считает лишним и пропускает не глядя.
За валидатором Go‑сервис, там структура с тегом json:"is_admin". Приходит тело, где ключ написан заглавными — IS_ADMIN.
Схема сравнивает строки побуквенно, такого имени не знает, пропускает. А json.Unmarshal знает и кладёт true куда надо.
Никто из двоих при этом не ошибся: схема отработала по спецификации, encoding/json — по своей документации, где регистр имён не различается с первых версий Go.
Разошлись они в другом: в том, какие ключи в этом теле вообще есть.
Go 1.27 вышел 19 августа, и encoding/json в нём переписали целиком, поверх encoding/json/v2.
Тот два релиза прожил под GOEXPERIMENT=jsonv2 и теперь стал основой. Семантику v1 сохранили намеренно, вплоть до регистра: поменялись тексты ошибок и скорость, поведение осталось прежним.
Про регистр обычно помнят коротко примерно так:
Go не различает большие и маленькие буквы.
type User struct { Name string `json:"name"` Admin bool `json:"is_admin"` } bodies := []string{ `{"NAME":"boris","IS_ADMIN":true}`, `{"Is_Admin":true}`, `{"is-admin":true}`, `{"is_admin":true,"IS_ADMIN":false}`, } for _, b := range bodies { var u User err := json.Unmarshal([]byte(b), &u) fmt.Printf("%-36s %+v err=%v\n", b, u, err) }
Вывод:
{"NAME":"boris","IS_ADMIN":true} {Name:boris Admin:true} err=<nil> {"Is_Admin":true} {Name: Admin:true} err=<nil> {"is-admin":true} {Name: Admin:false} err=<nil> {"is_admin":true,"IS_ADMIN":false} {Name: Admin:false} err=<nil>
Дефис и подчёркивание v1 всё‑таки различает, поэтому is-admin пролетел мимо.
Четвёртая строка полюбопытнее: в теле два ключа, первый совпал с тегом точно, второй только по регистру, а победил всё равно второй.
Точность совпадения тут ни при чём, важен порядок. Значения пишутся в поле по мере разбора, последнее затирает предыдущее.
Отсюда растёт вторая половина истории — повторы имён:
type Header struct { Typ string `json:"typ"` Alg string `json:"alg"` } var h Header json.Unmarshal([]byte(`{"typ":"JWS","alg":"HS256","alg":"none"}`), &h) fmt.Printf("%+v\n", h) // {Typ:JWS Alg:none}
Ошибки нет, в Alg лежит none. А рядом вполне может стоять компонент, который берёт первое вхождение. Тогда два сервиса читают один и тот же байтовый поток по‑разному.
Сравниваются имена по правилам Unicode simple folding, как в том же strings.EqualFold. Так во все это попадают буквы, которых в ASCII просто не бывает:
var c struct { Key string `json:"key"` } // U+212A KELVIN SIGN вместо обычной K json.Unmarshal([]byte("{\"\u212Aey\":\"дошло\"}"), &c) fmt.Printf("%+v\n", c) // {Key:дошло}
Знак Кельвина совпал с латинской k.
Длинная ſ (U+017F) точно так же совпадает с s, и paſsword доедет до поля password.
Валидатор, который сравнивает байты, ничего такого не увидит.
Фиксится это в 1.27 без единой правки в импортах.
Опция тега case:strict работает и в старом пакете:
type Strict struct { Admin bool `json:"is_admin,case:strict"` } var s Strict json.Unmarshal([]byte(`{"IS_ADMIN":true}`), &s) fmt.Printf("%+v\n", s) // {Admin:false}
Я бы вешал case:strict на всё, что решает про права и про деньги. Планируется миграция на v2 или нет — дела не меняет.
Тег закрывает поле, но не закрывает саму схему расхождения. Пока валидация и разбор живут в разных процессах и написаны на разных языках, кто‑то из двоих обязательно поймёт тело иначе.
Лучше вообще не проверять чужой JSON снаружи, а разбирать его один раз в том же процессе, что и принимает решение, и дальше передавать уже типизированную структуру. Тогда сравнивать нечему.
В коде этого не видно совсем, вылезает у того, кто читает ваш ответ.
Nil‑слайс и nil‑карта сериализуются в null, а не в пустой массив и пустой объект:
type Page struct { Items []string `json:"items"` Tags map[string]string `json:"tags"` } var p Page b, _ := json.Marshal(p) fmt.Println(string(b)) // {"items":null,"tags":null} p = Page{Items: []string{}, Tags: map[string]string{}} b, _ = json.Marshal(p) fmt.Println(string(b)) // {"items":[],"tags":{}}
Для клиента разница очень большая.
Для клиента разница очень большая.
items.map(...) на null падает, items.length падает, а тип string[] в TypeScript этот null не описывает и от него не спасает.
И зависит всё не от логики, а от того, дошёл ли код до строчки, где слайс инициализируется.
Пустая выборка из базы вернёт nil, а выборка на одну строку, отфильтрованная до нуля элементов, вернёт нормальный слайс нулевой длины.
Оба варианта адекватные, да и как будто на глаз они не отличаются.
Фиксится либо дисциплиной на выходе из репозитория, где вместо nil возвращают []Item{}, либо переездом на v2, там nil‑коллекции по умолчанию кодируются как [] и {}.
Обратный переключатель, если старое поведение зачем‑то нужно, называется jsonv2.FormatNilSliceAsNull.
Неизвестные ключи Unmarshal молча выбрасывает, и сигнала об этом нет никакого:
type Cfg struct { Retries int `json:"retries"` TimeoutMS int `json:"timeout_ms"` } raw := []byte(`{"retries":5,"timeuot_ms":100}`) var c Cfg err := json.Unmarshal(raw, &c) fmt.Printf("%+v err=%v\n", c, err) // {Retries:5 TimeoutMS:0} err=<nil>
Конфиг разобрался, ошибок нет. Что будет дальше, зависит от того, что этот ноль значит в вашем коде. У http.Client ноль означает «таймаута нет», и запрос будет висеть, пока не оборвётся соединение.
Переставленные буквы в timeuot_ms не подсветит никто: ни компилятор, ни линтер, ни тесты, если тесты подают правильный конфиг.
Запретить такое в v1 можно только через Decoder, у голого Unmarshal ничего подобного нет:
dec := json.NewDecoder(bytes.NewReader(raw)) dec.DisallowUnknownFields() err = dec.Decode(&c) // json: unknown field "timeuot_ms"
Ошибка вернулась, а Retries к этому моменту уже записан. Декодер идёт по потоку и встаёт на первом неизвестном ключе, тело целиком он заранее не смотрит.
Значит, при ошибке структуру надо выбрасывать, а не разбираться, что там успело заполниться.
В v2 то же самое доступно одним вызовом, без обёртки:
err = jsonv2.Unmarshal(raw, &c, jsonv2.RejectUnknownMembers(true)) // json: cannot unmarshal JSON string into Go main.Cfg: // unknown object member name "timeuot_ms"
Для конфигов это стоит включать в 99% случаях. Ну а для публичного входа не стоит: туда лишние поля обычно приходят от клиентов старых версий, и падать на них значит ломать совместимость на ровном месте.
Ошибка вылезает только на больших числах, поэтому доживает до продакшена почти всегда. Когда целевой тип — interface{} или map[string]any, числа разбираются в float64: других вариантов у пустого интерфейса нет.
raw := []byte(`{"id":9007199254740993,"amount":12345678901234567}`) var m map[string]any json.Unmarshal(raw, &m) fmt.Printf("%v %T\n", m["id"], m["id"]) // 9.007199254740992e+15 float64 out, _ := json.Marshal(m) fmt.Println(string(out)) // {"amount":12345678901234568,"id":9007199254740992}
Прикинем масштаб.
Под мантиссу в float64 отведено 52 бита плюс неявная единица, целые представляются точно до 2^53 — это 9 007 199 254 740 992.
Наш id на единицу больше, и выразить его уже нечем, ближайшее доступное значение — само 2^53, туда и округлило.
С amount та же история, только промах на единицу вверх.
Получается, прокси, который принимает JSON, разбирает в map[string]any и отдаёт дальше, тихо портит идентификаторы и суммы.
Хотя сам он их не трогает и никакой арифметики не делает.
А еще есть места, где типы заранее неизвестны: логирование тела, ретрансляция вебхуков, generic middleware.
Со структурой всё в порядке:
var s struct { ID int64 `json:"id"` Amount int64 `json:"amount"` } json.Unmarshal(raw, &s) fmt.Printf("%+v\n", s) // {ID:9007199254740993 Amount:12345678901234567}
Когда типизировать нечего, поможетjson.Number — строка, которая помнит исходную запись числа и ни к чему её не приводит:
var m2 map[string]any dec := json.NewDecoder(bytes.NewReader(raw)) dec.UseNumber() dec.Decode(&m2) fmt.Printf("%v %T\n", m2["id"], m2["id"]) // 9007199254740993 json.Number out2, _ := json.Marshal(m2) fmt.Println(string(out2)) // {"amount":12345678901234567,"id":9007199254740993}
Это единственное место из пяти, которое v2 не фиксит. jsonv2.Unmarshal в any тоже кладёт float64 и промахивается на тех же двух числах. Т.е поменять представление чисел по умолчанию значило бы сломать всех, кто рассчитывает на float64. Но и надеяться, что переезд сам всё исправит, не выйдет.
omitempty выбрасывает поле, если значение — false, 0, nil‑указатель, nil‑интерфейс либо пустые массив, слайс, карта или строка. Структур в этом списке нет и никогда не было.
type Page struct { Num int `json:"num"` Size int `json:"size"` } type Filter struct { Q string `json:"q,omitempty"` From time.Time `json:"from,omitempty"` Page Page `json:"page,omitempty"` N int `json:"n,omitempty"` } b, _ := json.Marshal(Filter{}) fmt.Println(string(b)) // {"from":"0001-01-01T00:00:00Z","page":{"num":0,"size":0}}
Строка и число из вывода ушли, а нулевое время и пустая вложенная структура остались.
Дальше это едет в чужой API, который читает from буквально и начинает выборку с первого января первого года.
Или, если с валидацией там повезло, отвечает четырёхсотым на дату вне диапазона.
С Go 1.24 есть опция тега omitzero.
Она смотрит не на «пустоту», а на нулевое значение типа, и если у типа есть метод IsZero() bool, спрашивает его.
У time.Time метод есть:
type Filter2 struct { Q string `json:"q,omitzero"` From time.Time `json:"from,omitzero"` Page Page `json:"page,omitzero"` } b, _ = json.Marshal(Filter2{}) fmt.Println(string(b)) // {}
На коллекциях две опции всё же расходятся.
omitempty выбрасывает слайс или карту нулевой длины, включая nil.
omitzero выбрасывает только nil, а инициализированный пустой слайс оставляет.
Для коллекций обычно нужна первая, для остального вторая, и ставить обе через запятую никто не мешает.
Тогда поменяется больше, чем хотелось бы. У v2 другие значения по умолчанию сразу по полутора десяткам пунктов.
Часть из них — то, ради чего переезжают. Имена сопоставляются точно, повтор ключа даёт ошибку, nil‑коллекции кодируются как [] и {}. Невалидный UTF-8 внутри строки тоже роняет разбор, а не подменяется тихо на символ замещения. Другая часть:
// v1 сортирует ключи карты, v2 — нет m := map[string]int{"z": 1, "a": 2, "m": 3, "b": 4} b1, _ := json.Marshal(m) // {"a":2,"b":4,"m":3,"z":1} b2, _ := jsonv2.Marshal(m) // {"b":4,"z":1,"a":2,"m":3} — и каждый раз по-новому // time.Duration в v2 не имеет представления по умолчанию type D struct { TTL time.Duration `json:"ttl"` } _, err := jsonv2.Marshal(D{TTL: 5 * time.Second}) fmt.Println(err) // json: cannot marshal from Go time.Duration within "/ttl": // no default representation
Недетерминированный порядок ключей ломает всё, что хеширует или диффает результат сериализации:
подписи запросов;
снапшот‑тесты;
сравнение конфигов.
Возвращается опцией jsonv2.Deterministic.
С time.Duration решение осознанное — в v1 она кодировалась числом наносекунд, что читателю JSON ни о чём не говорит, — но код, который на это опирался, упадёт в рантайме, а не на сборке.
Мельче калибром, но тоже ловится не сразу, ведь массив фиксированной длины в v2 требует такой же длины в JSON, а [N]byte кодируется в base64-строку вместо массива чисел.
Первое обычно к лучшему, второе меняет форму тела и ломает читателя на той стороне.
Обе штуки откатываются опциями UnmarshalArrayFromAnyLength и FormatByteArrayAsArray из пакета v1.
Но интереснее всего omitempty: у знакомой опции меняется смысл. В v2 поле выбрасывается, если кодируется в пустое JSON‑значение — null, пустая строка, пустой объект, пустой массив.
Ноль пустым JSON‑значением не считается:
b, _ := jsonv2.Marshal(Filter{}) fmt.Println(string(b)) // {"from":"0001-01-01T00:00:00Z","page":{"num":0,"size":0},"n":0}
Поле n с тем же самым тегом в v1 из вывода уходило, а в v2 остаётся.
Документация советует перевести все omitempty на булевых, числовых, указательных и интерфейсных полях в omitzero — эта опция в обеих версиях работает одинаково.
Переехать без такой ревизии тегов значит разом поменять форму всех исходящих тел.
Переезжать при этом можно постепенно.
Опции в v2 применяются слева направо, поздние перекрывают ранние, а jsonv1.DefaultOptionsV1() собирает весь набор старого поведения одним аргументом:
var s struct { Admin bool `json:"is_admin"` } err := jsonv2.Unmarshal([]byte(`{"IS_ADMIN":true}`), &s, jsonv1.DefaultOptionsV1(), jsonv2.MatchCaseInsensitiveNames(false)) fmt.Printf("%+v err=%v\n", s, err) // {Admin:false} err=<nil>
Тут мы остались на семантике v1 целиком и выключили ровно одно поведение — сопоставление без учёта регистра.
Так и стоит двигаться: по одному отличию за шаг, прогоняя тесты, а не большим переключателем, после которого непонятно, что поехало.
Ни одно из пяти разобранных мест не является багом.
Это решения, принятые в самом начале жизни пакета под другие задачи и с тех пор законсервированные обещанием совместимости.
Регистр не различают, чтобы {"Name": ...} попадало в поле Name без всякого тега.
null вместо [] — потому что nil‑слайс в Go честно ничего не значит.
float64 для any — потому что других чисел у пустого интерфейса нет.
По отдельности всё объяснимо, а вместе получается пакет, документация которого в Go 1.27 говорит:
значения по умолчанию у v1 менее безопасные, новый код лучше писать на v2.
Порядок я бы выбрал такой.
Сначала теги, они бесплатные и работают в старом пакете:
case:strict на всё, что решает про права и деньги;
omitzero вместо omitempty на числах, булях и time.Time.
Потом DisallowUnknownFields на конфигах и внутренних ручках, но не на публичном входе.
Переезд на encoding/json/v2 — отдельной задачей, через DefaultOptionsV1() и по одному отличию за раз.
Первым делом проверить всё, что хеширует или сравнивает сериализованный результат.
Если поедет прямо на сборке, в 1.27 есть выход GOEXPERIMENT=nojsonv2, он возвращает старую реализацию целиком.
Насчет ресурсов, особо не разбирался, но в release notes было написано, что маршалинг примерно на уровне старого, а анмаршалинг заметно быстрее.

Если при разработке сервисов приходится разбираться, почему один компонент принимает данные, а другой понимает их иначе, проблема обычно не в одной строке кода. Нужно уметь видеть границы между сервисами, находить места, где возникают расхождения, и проектировать систему так, чтобы подобные ошибки не попадали в продакшен.
На открытых уроках разберём, как глубже понимать устройство Go‑приложений и проектировать надёжные микросервисные системы:
22 сентября в 20:00. «Горутины и каналы: под капотом и нюансы в продакшене». Записаться
22 октября в 19:00. «Основы проектирования бизнес‑логики в микросервисной архитектуре». Записаться
А полный список бесплатных уроков сентября вы найдете в дайджесте.