Валидатор не нашёл поле is_admin, а Go его нашёл: пять ошибок при работе с JSON

На входе стоит валидатор: 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 сохранили намеренно, вплоть до регистра: поменялись тексты ошибок и скорость, поведение осталось прежним.
Поле is_admin отзывается на IS_ADMIN, Is_Admin и на знак Кельвина
Про регистр обычно помнят коротко примерно так:
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‑слайс на выходе, null на фронтенде
В коде этого не видно совсем, вылезает у того, кто читает ваш ответ.
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% случаях. Ну а для публичного входа не стоит: туда лишние поля обычно приходят от клиентов старых версий, и падать на них значит ломать совместимость на ровном месте.
Идентификатор...993 приезжает как...992
Ошибка вылезает только на больших числах, поэтому доживает до продакшена почти всегда. Когда целевой тип — 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. Но и надеяться, что переезд сам всё исправит, не выйдет.
time.Time и вложенная структура, которых omitempty не видит
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?
Тогда поменяется больше, чем хотелось бы. У 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. «Основы проектирования бизнес‑логики в микросервисной архитектуре». Записаться
А полный список бесплатных уроков сентября вы найдете в дайджесте.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.