golang

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

  • вторник, 15 сентября 2026 г. в 00:00:15
https://habr.com/ru/companies/otus/articles/1076496/

Привет, Хабр!

На входе стоит валидатор: 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. «Основы проектирования бизнес‑логики в микросервисной архитектуре». Записаться

А полный список бесплатных уроков сентября вы найдете в дайджесте.