Назад к блогу

Go 1.27 и JSON v2: что сломается при миграции

Go 1.27 и JSON v2: что сломается при миграции

Go 1.27 незаметно меняет поведение `encoding/json`: теперь старый пакет работает поверх нового движка JSON v2, и привычный код может выдавать другие байты или ошибки. Разбираем, что именно ломается при миграции — от nil-слайсов и `omitempty` до способов откатиться на прежнюю реализацию.

В Go 1.27 пакет encoding/json перестаёт быть самостоятельной реализацией: теперь он опирается на новый движок JSON v2, а его функции Marshal и Unmarshal семантически эквивалентны вызову v2-эквивалентов с набором опций DefaultOptionsV1. Это значит, что код, который годами полагался на поведение v1, может внезапно получить другие байты на выходе или другие ошибки на входе. Разбираем, что именно меняется, как устроены новые пакеты и как мигрировать без сюрпризов.

Два пакета вместо одного

В v2 функциональность разделена на два пакета. jsontext отвечает за синтаксис — он работает по грамматике JSON и не зависит от рефлексии, поэтому у него маленькое дерево зависимостей и минимальный прирост бинарника. json (он же encoding/json/v2) отвечает за семантику — придаёт значениям смысл как Go-значениям, реализован через jsontext и зависит от reflect, чтобы маршалить произвольные Go-значения.

Терминология тоже разведена: синтаксические функции называются encode/decode, семантические — marshal/unmarshal.

Пакет v1 (jsonv1) теперь реализован через v2: jsonv1.Marshal становится jsonv2.Marshal(..., jsonv1.DefaultOptionsV1). Это даёт единую реализацию и постепенную миграцию. Опции всех трёх пакетов совместимы: jsonv2.Marshal и jsonv2.Unmarshal принимают Options, а DefaultOptionsV1 задаёт полный набор опций для поведения v1.

DefaultOptionsV1 и DefaultOptionsV2

DefaultOptionsV1 — это полный набор опций, задающих семантику v1: все перечисленные в нём булевы опции выставлены в true, а все прочие опции отсутствуют. DefaultOptionsV2 — полный набор опций, задающих семантику v2: те же опции выставлены в false, все прочие опции отсутствуют. Отличие ровно в этом: одни и те же опции в V1 стоят в true, в V2 — в false.

Чтобы получить поведение v1 при вызове функций v2, нужно передать jsonv1.DefaultOptionsV1() в вызов v2-функции. Отдельные поведения переключаются на v2 добавлением опций после DefaultOptionsV1() — более поздние опции переопределяют более ранние, например json.Marshal(u, jsonv1.DefaultOptionsV1(), json.FormatNilSliceAsNull(false)).

Как включён jsonv2 и как его отключить

В Go 1.27 новая реализация включена по умолчанию. Отключить её можно только на этапе сборки: GOEXPERIMENT=nojsonv2. Этот флаг восстанавливает именно исходную реализацию v1 за encoding/json, а не старые семантики поверх нового движка. Отказ временный: release notes говорят, что эту опцию планируется удалить в будущем релизе, так что это stopgap — и по поводу того, что заставило вас её включить, стоит завести issue.

Отдельно от флага существует способ получить v1-семантику поверх v2 — передать DefaultOptionsV1().

Ломающие изменения на уровне представления значений

nil-срез и nil-мапа

По умолчанию v2 записывает nil-срез как [], а nil-мапу как {}; v1 вместо этого писал null. Чтобы вернуть поведение v1, включаются опции FormatNilSliceAsNull(true) и FormatNilMapAsNull(true). Обе входят в полный набор DefaultOptionsV1.

omitempty против omitzero

omitzero пропускает поле, если Go-значение является нулевым для своего типа или если у типа есть метод IsZero() bool, сообщающий о нулевом значении. Проверка выполняется до вызова маршалера.

omitempty в v2 пропускает поле, если его JSON-представление пустое (null, пустая строка, объект или массив). Заранее это видно только при отсутствии кастомных маршалеров; иначе значение маршалится и затем «разписывается», если оказалось пустым.

При включённой опции OmitEmptyWithLegacySemantics omitempty использует правила v1, реализованные в isLegacyEmpty: false для bool, 0 для целых и чисел с плавающей точкой, нулевая длина для строк, map, slice и array, nil для указателей и интерфейсов.

Ключевое различие: omitzero определяется через систему типов Go, omitempty — через систему типов JSON. Для nil-слайса или map они расходятся: под omitzero опускается только nil-срез или nil-мапа, тогда как под omitempty пустой срез или мапа опускается независимо от nil.

[]byte и [N]byte

По умолчанию v2 кодирует []byte или [N]byte в JSON-строку с бинарным значением в кодировке Base 64 по RFC 4648, раздел 4. При декодировании в непустой []byte длина среза сбрасывается в ноль, и декодированный ввод к нему добавляется. При декодировании в [N]byte ввод должен декодироваться ровно в N байт, иначе возвращается SemanticError.

По умолчанию v2 строго соблюдает RFC 4648: раздел 3.2 требует паддинг, раздел 3.3 требует отвергать неалфавитные символы (например, \r или \n), а раздел 3.5 разрешает отвергать лишние ненулевые биты в последнем кванте, но поскольку это опционально, такие входы не отвергаются.

Опция ParseBytesWithLooseRFC4648 указывает при разборе бинарных данных в base32 или base64 игнорировать присутствие \r и \n, тогда как по умолчанию v2 сообщает об ошибке ради строгого соответствия разделу 3.3. Влияет только на анмаршалинг, игнорируется при маршалинге; значение по умолчанию в v1 — true.

Опция FormatBytesWithLegacySemantics задаёт, что обработка типов []~byte и [N]~byte следует устаревшей семантике: Go []~byte трактуется как использующий некоторую форму бинарного кодирования (RFC 4648), в отличие от поведения v2 по умолчанию, которое трактует как бинарные данные только []byte. В частности, v2 не трактует срезы именованных байтовых типов как бинарные данные. При маршалинге, если именованный байт реализует метод маршалинга, срез сериализуется как JSON-массив элементов, каждый из которых вызывает этот метод. При анмаршалинге, если вход — JSON-массив, он разбирается в []~byte как в обычный Go-срез, тогда как по умолчанию v2 сообщает об ошибке при разборе JSON-массива, когда ожидается некоторая форма бинарного кодирования. Влияет либо на маршалинг, либо на анмаршалинг; значение по умолчанию в v1 — true.

time.Time и time.Duration

По умолчанию v2 кодирует time.Time как строку JSON с меткой времени в формате RFC 3339 с наносекундной точностью. time.Duration по умолчанию не имеет представления и приводит к SemanticError, если не задана опция FormatDurationAsNano.

Опция ParseTimeWithLooseRFC3339 разрешает при разборе исторически некорректные представления времени: отклонения в формате часа, разделителе долей секунды и представлении часового пояса. По умолчанию v2 строго следует грамматике RFC 3339. Влияет только на unmarshaling, игнорируется при marshaling.

Опция FormatDurationAsNano задаёт форматирование time.Duration как числа JSON, представляющего количество наносекунд, вместо ошибки по умолчанию v2. Влияет как на marshaling, так и на unmarshaling.

Историческая причина разбора quoted numbers в int/uint/float: для исторических причин v1 разбирал число в кавычках согласно синтаксису Go и допускал null в кавычках.

Структурные теги и имена полей

Как строится дерево полей

makeStructFields обходит поля через очередь (breadth-first), начиная с корневого типа. Для каждого поля, помеченного как embedded-структура, её тип добавляется в очередь, чтобы индексы полей росли монотонно по глубине. Для каждого поля вызывается parseFieldOptions; если тег равен -, поле игнорируется, а для неэкспортированного невстроенного поля возвращается ignored=true. Embedded-поля без явного имени помечаются f.embed=true, и для них проверяется, что тип — структура, map со строковым ключом или jsontext.Value; иначе поле трактуется как обычное.

Конфликты имён внутри одной структуры фиксируются через namesIndex и порождают ошибку «conflict over JSON object name», но поле всё равно добавляется в allFields. После обхода поля сортируются по имени, глубине и наличию явного имени; доминирующим считается единственное поле на наименьшей глубине или уникально помеченное именем, остальные отбрасываются.

parseFieldOptions разбирает опции: case:ignore включает сопоставление имени поля без учёта регистра, case:strict — строго с учётом регистра, embed — встраивание поля, omitzero — пропуск поля при нулевом Go-значении, omitempty — пропуск поля при пустом JSON-значении, string — строковизацию значения. Опция format требует значения и должна идти последней.

Сопоставление имён

По умолчанию v2 при демаршалинге использует сопоставление с учётом регистра для определения поля Go-структуры, соответствующего имени JSON-объекта.

MatchCaseInsensitiveNames включает сопоставление имён членов JSON-объекта с полями Go-структуры без учёта регистра. Если имя совпадает с несколькими полями, выбирается поле с точным совпадением имени; если такого нет — сообщается об ошибке. Поля, явно помеченные тегами case:strict или case:ignore, всегда используют соответственно чувствительное или нечувствительное к регистру сопоставление независимо от значения этой опции.

MatchCaseSensitiveDelimiter указывает, что подчёркивания и дефисы не игнорируются при нечувствительном к регистру сопоставлении имён, которое происходит при MatchCaseInsensitiveNames или теге case:ignore. Таким образом, нечувствительное к регистру сопоставление становится идентичным strings.EqualFold.

Тег nocase при демаршалинге указывает, что если имя JSON-объекта не совпадает точно с JSON-именем ни одного поля структуры, то предпринимается попытка сопоставить поле без учёта регистра, игнорируя также дефисы и подчёркивания.

RejectUnknownMembers и ErrUnknownName

ErrUnknownName — это ошибка, означающая, что член JSON-объекта не удалось демаршалировать, потому что его имя неизвестно целевой Go-структуре; она напрямую оборачивается в SemanticError при возникновении.

RejectUnknownMembers(v bool) указывает, что неизвестные члены должны отвергаться при демаршалинге JSON-объекта. Влияет только на демаршалинг, игнорируется при маршалинге.

Чтобы достать ошибку, нужно вызвать errors.AsType[*json.SemanticError] и проверить, что serr.Err == json.ErrUnknownName. Имя неизвестного поля извлекается через serr.JSONPointer: ptr.LastToken() возвращает само неизвестное имя.

Семантика ошибок и позиции

SemanticError описывает ошибку определения смысла JSON-данных как Go-данных или наоборот. SyntacticError — это отдельный тип из пакета jsontext, распознаваемый функцией isSyntacticError.

SemanticError несёт поля ByteOffset (смещение в байтах, на котором или после которого произошла ошибка), JSONPointer (указатель на место внутри JSON-значения в нотации JSON Pointer), JSONKind (вид JSON, который не удалось обработать), JSONValue (число или строка JSON, которые не удалось демаршалить; при маршалинге не заполняется), GoType (Go-тип, который не удалось обработать) и Err (нижележащая ошибка). Поля InputOffset и OutputOffset принадлежат кодировщику/декодеру jsontext и используются при построении SemanticError: для кодировщика ByteOffset вычисляется как OutputOffset() плюс CountNextDelimWhitespace(), для декодера — как InputOffset() плюс CountNextDelimWhitespace(). Полей Pointer и Value в структуре SemanticError нет; вместо них используются JSONPointer и JSONValue.

collapseSemanticErrors сворачивает два вложенных SemanticError в один: если внешний SemanticError содержит во внутреннем поле Err другой SemanticError, то ByteOffset внутреннего увеличивается на ByteOffset внешнего, JSONPointer внутреннего дополняется JSONPointer внешнего, а затем содержимое внешнего заменяется содержимым внутреннего.

Позиции ошибок

Позиция ошибки при разборе конфигурации вычисляется из смещения и стека декодера: InputOffset возвращает смещение в байтах во входных данных, а StackPointer — указатель на текущее место в JSON-значении. В errors.go смещение ошибки берётся как d.InputOffset() плюс число разделительных пробелов до следующего токена, а указатель — через AppendStackPointer. Для кодирования аналогично используется e.OutputOffset() с тем же добавлением пробелов и построением указателя.

StackIndex(i) возвращает вид (Kind) и смещение для уровня стека i, что позволяет пройти по вложенным уровням и восстановить путь до места ошибки. При разборе конфигурационных файлов логика разбора часто должна сообщать ошибку с указанием строки и столбца, где во входных данных произошла ошибка.

Стриминг и jsontext

jsontext.Decoder — потоковый декодер, читающий последовательность JSON-значений верхнего уровня, разделённых необязательными пробельными символами; вызовы ReadToken и ReadValue можно чередовать.

ReadToken читает следующий Token и сдвигает позицию чтения, возвращая io.EOF, когда токенов больше нет. Возвращённый токен действителен только до следующего вызова Peek, Read или Skip. ReadValue читает следующее значение целиком.

StackDepth возвращает глубину конечного автомата для уже прочитанных данных: каждый уровень соответствует вложенному объекту или массиву, увеличиваясь при BeginObject/BeginArray и уменьшаясь при EndObject/EndArray. StackDepth равен 0 вне объекта или массива — до чтения токенов, после значения верхнего уровня и между значениями потока; равен 1 внутри объекта/массива верхнего уровня, 2 внутри вложенного и так далее.

Кастомизация: интерфейсы и опции

Marshaler и Unmarshaler

В v2 поддерживаются v1-совместимые интерфейсы Marshaler с методом MarshalJSON() ([]byte, error) и UnmarshalerV1 с методом UnmarshalJSON([]byte) error. Если тип реализует и v1, и v2 интерфейсы, приоритет у v2.

Опция CallMethodsWithLegacySemantics задаёт, что вызовы методов маршалинга/анмаршалинга следуют старым правилам. В частности, при маршалинге метод на pointer-receiver вызывается только если значение адресуемо: значения из интерфейса или элемента map не адресуемы, а из указателя или элемента slice — адресуемы; элемент массива или поле структуры наследуют адресуемость родителя. Именно из-за этого методы MarshalJSON и UnmarshalJSON, объявленные на pointer-receiver, вызываются неконсистентно.

WithMarshalers и JoinMarshalers

WithMarshalers принимает *Marshalers — список функций, которые могут переопределить поведение маршалинга для конкретных типов; nil *Marshalers эквивалентен пустому списку. У самого типа Marshalers нет экспортируемых полей или методов.

JoinMarshalers строит «уплощённый» список функций маршалинга: если для значения данного типа применимы несколько функций, стоящие в списке раньше имеют приоритет над стоящими позже. Если функция возвращает errors.ErrUnsupported, вызывается следующая применимая функция, иначе используется поведение маршалинга по умолчанию. JoinUnmarshalers устроен так же, но для функций демаршалинга.

MarshalFunc создаёт правило маршалинга для одного конкретного типа T: оно говорит, как именно превращать значения этого типа в JSON, и подключается к общему списку через WithMarshalers. T может быть любым типом, кроме именованного указателя. Если T — интерфейс или указатель, функция всегда получает ненулевое значение указателя. Реализации должны следовать требованиям Marshaler и не должны сохранять значение T.

Тип Options

Options — это алиас на jsonopts.Options. Он настраивает Marshal, MarshalWrite, MarshalEncode, Unmarshal, UnmarshalRead и UnmarshalDecode: каждая принимает вариативный список опций, где свойства, заданные в более поздних опциях, переопределяют значения ранее заданных. Значение Options представляет либо одну опцию, либо набор опций, и его можно рассматривать как Go-карту свойств опций (хотя реализация избегает Go-карт ради производительности).

JoinOptions объединяет несколько значений опций вместе, а GetOption ищет значение параметра опций.

Есть единый тип Options, используемый и для marshal, и для unmarshal. На оба влияют StringifyNumbers и MatchCaseInsensitiveNames, тогда как Deterministic и OmitZeroStructFields влияют только на marshal, а опции, не влияющие на конкретную операцию, игнорируются.

Практика миграции

Пошаговая миграция начинается с того, что вызовы переводят на функции v2, но передают jsonv1.DefaultOptionsV1() — это «a trivial and safe change». После этого отдельные поведения переключают на v2, добавляя опции после неё, так как более поздние опции переопределяют более ранние.

Что меняет каждая из опций:

  • MergeWithLegacySemantics управляет слиянием при демаршалинге в ненулевое значение. При JSON null сохраняет исходное значение для bool, int, uint, float, string, array, struct, иначе обнуляет. При ненулевом JSON сливает в элементы массивов, срезов, поля структур (но не значения map), указатели и интерфейсы (только ненулевой указатель). v2 по умолчанию сливает для полей структур, значений map, указателей и интерфейсов.
  • OmitEmptyWithLegacySemantics задаёт определение пустоты для тега omitempty: поле опускается, если значение false, 0, nil-указатель, nil-интерфейс или пустой array, slice, map, string. Это переопределяет v2-семантику, где поле пусто, если маршалится как JSON null или пустая JSON-строка, объект или массив.
  • StringifyWithLegacySemantics разрешает тегу string строковизировать bool и string, действуя только на поля, где верхнеуровневый тип — bool, string, числовой вид или указатель на такой вид. v2 по умолчанию допускает string только для типов, которые иначе сериализовались бы как JSON-число.
  • UnmarshalArrayFromAnyLength разрешает демаршалить Go-массивы из JSON-массивов любой длины: при коротком массиве оставшиеся элементы обнуляются, при длинном лишние пропускаются. v2 по умолчанию ожидает массив точно такой же длины.
  • unmarshalAnyWithRawNumber задаёт, что демаршалинг JSON-числа в пустой Go-интерфейс использует тип Number вместо float64.

Что даёт одинаковые байты под v1 и v2

Структура Portable даёт одинаковые байты под v1 и v2: тест TestPortableSameBytes маршалит её через jsonv1.Marshal и json.Marshal и сравнивает строки — оба варианта совпадают.

Поле Name помечено тегом json:"name,case:ignore" — это заставляет v2 продолжать принимать имя поля в другом регистре: второй тест декодирует "NAME" в Name. Поле Tags помечено json:"tags,omitempty" — на срезе это убирает и nil, и пустой срез, обходя вопрос null против []. Поля Admin и Retries помечены json:"admin,omitzero" и json:"retries,omitzero" — omitzero заменяет omitempty на bool и int.

Разница между omitzero и omitempty в том, что omitzero определяется через систему типов Go, а omitempty — через систему типов JSON: только nil-срез или nil-карта опускаются под omitzero, тогда как пустой срез или карта опускаются под omitempty независимо от nil. Поэтому безопасная для совместимости комбинация — omitempty на срезах/картах и omitzero на типах с чётко определённым нулевым значением или методом IsZero.

Как сравнивали и что получилось

Стенд и методология

Замеры проводились на Apple M4 Pro. Бенчмарк вызывал обычный encoding/json, двенадцать раз на сборке по умолчанию 1.27.1 и шесть раз с GOEXPERIMENT=nojsonv2.

Использовалась одна нагрузка — массив из 500 элементов структуры с int64, строкой, float, срезом из трёх строк, map[string]string из двух записей и вложенной структурой, около 79 КБ JSON.

Участники сравнения — v2-движок (по умолчанию) и старый движок (nojsonv2). Времена — округлённые медианы, счётчики аллокаций были одинаковы при каждом прогоне. Ограничения ресурсов и конфигурация участников, кроме указанных сборок, не описаны.

Результаты

СценарийДвижокВремяАллокации
unmarshalv2 (по умолчанию)~695 µs5,011
unmarshalстарый (nojsonv2)~920 µs7,018
marshalv2 (по умолчанию)~336 µs2,003
marshalстарый (nojsonv2)~241 µs2,502

На демаршалинге v2 быстрее старого движка (~695 µs против ~920 µs) и аллоцирует меньше (5,011 против 7,018). На маршалинге картина обратная: v2 медленнее (~336 µs против ~241 µs), хотя аллоцирует тоже меньше (2,003 против 2,502).

Оговорки

Замеры сделаны на одной полезной нагрузке и одной машине, поэтому не являются вердиктом. Прогоны unmarshal были шумными (537–878 µs на новом движке), тогда как счётчики аллокаций совпадали во всех прогонах.

Если маршалинг на горячем пути, рекомендуется бенчмаркать на 1.27 с nojsonv2 и без него перед раскаткой. Для серверов, где нельзя предсказать все полезные нагрузки тестами, предлагается jsonsplit с режимами вроде CallBothButReturnV1, который прогоняет обе реализации, возвращает результат v1 и сообщает различия, чтобы продакшн-трафик показал нужные опции. Запуск обеих реализаций примерно удваивает стоимость маршалинга, поэтому использовать это следует только на время миграции, а не постоянно. В любом случае стоит измерять собственные горячие пути.

Что из этого следует на практике

  • Сборка по умолчанию уже на v2. encoding/json в 1.27 вызывает v2 с DefaultOptionsV1(). Если код не полагается на тонкости v1-семантики, поведение сохранится; если полагается — нет.
  • GOEXPERIMENT=nojsonv2 — временный откат. Он возвращает именно старую реализацию v1, а не старые семантики поверх нового движка, и его планируется удалить в будущем релизе. Это stopgap, а не решение.
  • Миграция через DefaultOptionsV1() — безопасный первый шаг. Перевод вызовов на v2 с этой опцией не меняет семантику, а дальнейшие опции после неё переключают поведения по одному, потому что более поздние переопределяют более ранние.
  • nil-срез и nil-мапа — самое заметное ломающее изменение. v1 писал null, v2 пишет [] и {}. Возврат к старому поведению — FormatNilSliceAsNull(true) и FormatNilMapAsNull(true).
  • time.Duration без опции — ошибка. По умолчанию v2 не имеет представления для time.Duration и возвращает SemanticError; FormatDurationAsNano кодирует его как число наносекунд.
  • omitzero и omitempty различаются для nil-слайсов и map. omitzero опускает только nil, omitempty — и nil, и пустое. Для совместимости безопасно: omitempty на срезах и картах, omitzero на типах с чётко определённым нулевым значением или методом IsZero.
  • Методы на pointer-receiver вызываются неконсистентно. Это историческое поведение v1, которое сохраняется при CallMethodsWithLegacySemantics: метод вызывается только если значение адресуемо, а адресуемость зависит от того, откуда значение получено.
  • На маршалинге v2 может быть медленнее. По замерам на одной нагрузке v2 дал ~336 µs против ~241 µs у старого движка, хотя аллокаций меньше. На демаршалинге v2 быстрее и аллоцирует меньше.

Где смотреть в коде

Источники

Похожее