Go 1.27: новый API для JSON (encoding/json/v2) стал стабильным
В официальном блоге Go вышла статья «A new Go API for JSON». Команда сообщает: после дополнительной обратной связи и доработок новые пакеты encoding/json/v2 и encoding/json/jsontext стабильны и общедоступны в Go 1.27. Год назад они были показаны как экспериментальный API. Статья, обновлённая версия текста, который представлял эксперимент: в ней описаны проблемы encoding/json (v1) и дан обзор новых пакетов.
Авторы признают, что encoding/json в целом выдержал проверку временем: идея маршалинга и анмаршалинга (marshal/unmarshal, перевода Go-значений в JSON и обратно) произвольных типов с представлением по умолчанию и возможностью его настройки оказалась гибкой. Но накопились недостатки трёх видов.
Первый вид, изъяны поведения. v1 принимает некорректный UTF-8, хотя актуальный стандарт (RFC 8259) требует корректного UTF-8; по умолчанию, по мнению авторов, должна выдаваться ошибка вместо тихой порчи данных. v1 принимает объекты с повторяющимися именами членов: стандарт не определяет, что с ними делать, поэтому у такого JSON нет общепринятого смысла. Это, как пишут авторы, может быть использовано атакующими и уже использовалось (CVE-2017-12635), так что по умолчанию дубликаты следует отклонять. Далее: v1 кодирует nil-срезы и nil-словари как null, что ломает разбор в других реализациях, а опрос показал, что большинство пользователей Go предпочитает пустой массив или объект. При разборе имя члена объекта сопоставляется с полем структуры без учёта регистра, по оценке авторов, это неожиданное поведение по умолчанию, потенциальная уязвимость и ограничение производительности. Методы MarshalJSON на указателе вызываются непоследовательно; это считается ошибкой, но исправить её нельзя, потому что слишком много приложений зависит от текущего поведения.
Второй вид, недостатки API. Через io.Reader трудно корректно разбирать данные: частая запись json.NewDecoder(r).Decode(v) не отвергает мусор в конце входа. Опции можно задавать у Encoder и Decoder, но не у функций Marshal и Unmarshal, и нельзя передать их вниз по стеку вызовов: например, Decoder.DisallowUnknownFields теряет силу при вызове собственного метода UnmarshalJSON. Функции Compact, Indent и HTMLEscape пишут в bytes.Buffer, а не в более гибкий []byte или io.Writer.
Третий вид, ограничения производительности, заложенные в публичный API. MarshalJSON вынуждает выделять возвращаемый []byte, а пакет обязан проверять результат на корректность и переформатировать его под отступы. UnmarshalJSON требует полного значения JSON, поэтому пакет сначала разбирает его целиком, чтобы найти конец, а затем метод разбирает его второй раз. Потоковости нет: Encoder и Decoder буферизуют всё значение в памяти, Decoder.Token много аллоцирует, а парного API для записи токенов нет. Если MarshalJSON или UnmarshalJSON рекурсивно вызывает Marshal или Unmarshal, производительность становится квадратичной.
Почему не починили v1? Эти проблемы, неизбежное следствие существующего API, и исправить их в рамках гарантии совместимости Go 1 практически невозможно. Заводить параллельные имена вроде MarshalV2 значило бы создать отдельное пространство имён внутри того же пакета, поэтому выбран отдельный пакет encoding/json/v2.
Фундамент, пакет encoding/json/jsontext. Сериализация делится на синтаксическую часть (кодирование и декодирование по грамматике JSON, encode/decode) и семантическую (связь JSON-значений с Go-значениями, marshal/unmarshal). Нижний уровень реализует jsontext, верхний, json/v2. В jsontext есть Encoder и Decoder (WriteValue/WriteToken, ReadValue/ReadToken), тип Value (именованный []byte, идентичный RawMessage из v1, который теперь является псевдонимом jsontext.Value) и непрозрачный тип Token, рассчитанный на представление токенов без аллокаций. Название пакета взято из RFC 8259: грамматика JSON там называется JSON-text. Поскольку он работает только на уровне синтаксиса, он не зависит от рефлексии Go и действительно потоковый.
Чтобы устранить проблему производительности методов MarshalJSON и UnmarshalJSON, в v2 добавлены интерфейсы MarshalerTo (метод MarshalJSONTo) и UnmarshalerFrom (метод UnmarshalJSONFrom), работающие напрямую с jsontext.Encoder и jsontext.Decoder. Тогда пакету не нужно ни проверять и форматировать результат, ни искать границы значения, это забота Encoder и Decoder. Интерфейсы Marshaler и Unmarshaler в v2 идентичны v1.
Базовый API v2: Marshal и Unmarshal с сигнатурой, похожей на v1, но принимающие опции; MarshalWrite и UnmarshalRead, работающие напрямую с io.Writer и io.Reader без временного Encoder/Decoder; MarshalEncode и UnmarshalDecode для jsontext.Encoder и Decoder, лежащие в основе остальных. Опции теперь, полноценный аргумент каждой функции. Цель авторов: код на v1 при прямом переносе на v2 должен работать в основном так же. Для примеров статья отсылает к примерам пакета, руководству «Working with JSON» и блогу Антона Жиянова.
Ключевые факты
- Пакеты encoding/json/v2 и encoding/json/jsontext, представленные год назад как эксперимент, стабильны и общедоступны в Go 1.27.
- В v1 по умолчанию принимаются некорректный UTF-8 и объекты с повторяющимися именами (пример эксплуатации, CVE-2017-12635); v2 призван по умолчанию вести себя безопаснее.
- Проблемы v1 нельзя исправить внутри гарантии совместимости Go 1, поэтому сделан отдельный пакет v2, а в основе лежит потоковый пакет jsontext без зависимости от рефлексии.
- Новые интерфейсы MarshalerTo и UnmarshalerFrom позволяют обрабатывать JSON потоково; Marshal/Unmarshal теперь принимают опции, а MarshalWrite и UnmarshalRead работают прямо с io.Writer и io.Reader.
- Цель, чтобы код на v1 при прямом переносе на v2 вёл себя в основном так же.
Почему это важно
JSON, самый популярный формат обмена данными в интернете, и encoding/json есть в стандартной библиотеке Go со времён до Go 1.0. Теперь долгожданная замена вышла из статуса эксперимента и стала стабильной частью Go 1.27. Для платформы это редкий случай, когда обновляется базовый пакет стандартной библиотеки: прежний API нельзя было исправить из-за гарантии совместимости, и команда пошла на отдельный пакет v2.
Кому это важно
Прежде всего Go-разработчикам, чьи сервисы разбирают и формируют JSON: веб-API, клиентам внешних сервисов, коду, где безопасность разбора входных данных критична (дубликаты имён, некорректный UTF-8). Также авторам библиотек с собственными методами MarshalJSON/UnmarshalJSON, для них появляются потоковые интерфейсы MarshalerTo и UnmarshalerFrom.
Как это применить
Пакеты входят в Go 1.27: импортируются как encoding/json/v2 и encoding/json/jsontext. Для миграции v1 на v2 авторы заявляют цель сохранить поведение в основном прежним; детали миграции и перечень изменённых умолчаний стоит смотреть в документации пакета, примерах и руководстве «Working with JSON», на которые ссылается статья, а также в блоге Антона Жиянова. Опции в v2 передаются аргументом в Marshal и Unmarshal. Для чтения из io.Reader предназначена UnmarshalRead, для записи в io.Writer, MarshalWrite.
Можно ли доверять
Источник, официальный блог команды Go, то есть описание от самих разработчиков пакета. Это самооценка: утверждения о недостатках v1 и преимуществах v2 принадлежат авторам, независимых замеров в тексте не приведено. Цифр производительности в разобранной части статьи нет, поэтому слова об улучшениях следует читать как заявленные цели и устройство API, а не как измеренный результат. Исходный текст был обрезан, так что более поздние разделы статьи (умолчания v2, теги структур, опции) здесь не пересказаны.
Риски и подводные камни
Заявлено лишь, что код v1 при переносе будет работать «в основном» так же, то есть отличия возможны. Часть умолчаний v2 строже: например, отклонение дубликатов имён и некорректного UTF-8 может проявиться на входных данных, которые v1 принимал. Подробности о том, какие поведения изменены, нужно проверять по документации. Из прочитанной части статьи не следует ничего о будущем v1, переход стоит планировать, опираясь на официальные материалы.
«Наша цель, чтобы код, использующий v1, при прямом переносе на v2 работал в основном так же.»
— Блог Go, статья «A new Go API for JSON»