Команда Go for Devs подготовила перевод статьи о новом экспериментальном API для работы с JSON в Go. Спустя почти 15 лет после появления encoding/json в стандартной библиотеке разработчики столкнулись с его ограничениями. В версии Go 1.25 появился экспериментальный encoding/json/v2 — он решает старые проблемы, добавляет потоковую обработку и повышает производительность.


Введение

JavaScript Object Notation (JSON) — это простой формат обмена данными. Почти 15 лет назад мы написали о поддержке JSON в Go, добавив возможность сериализовать и десериализовать Go-типы в JSON и обратно. С тех пор JSON стал самым популярным форматом данных в Интернете. Go-программы повсеместно читают и записывают JSON, а пакет encoding/json сейчас занимает 5-е место среди самых импортируемых пакетов Go.

Со временем пакеты развиваются под потребности пользователей, и encoding/json не стал исключением. Этот пост посвящён новым экспериментальным пакетам Go 1.25 — encoding/json/v2 и encoding/json/jsontext, которые приносят долгожданные улучшения и исправления. В статье объясняется, зачем нужен новый основной вариант API, даётся обзор новых пакетов и рассказывается, как ими воспользоваться. Экспериментальные пакеты не видны по умолчанию и могут измениться в будущих версиях API.

Проблемы с encoding/json

В целом encoding/json показал себя неплохо. Идея маршалинга и анмаршалинга произвольных типов Go в стандартное JSON-представление с возможностью его настраивать оказалась очень гибкой. Однако за годы использования пользователи выявили множество недостатков.

Недочёты существующей реализации

У существующей реализации encoding/json есть несколько проблем:

  • Неточное обращение с синтаксисом JSON. Со временем стандартизация JSON усилилась, чтобы программы могли корректно обмениваться данными. В целом декодеры стали строже, отказываясь принимать неоднозначные входные данные, чтобы снизить вероятность того, что разные реализации по-разному интерпретируют одно и то же JSON-значение.

    • Сейчас encoding/json принимает некорректный UTF-8, хотя актуальный Интернет-стандарт (RFC 8259) требует использовать только валидный UTF-8. Поведение по умолчанию должно выдавать ошибку при наличии некорректного UTF-8, а не допускать тихую порчу данных, которая может привести к проблемам дальше по цепочке.

    • Сейчас encoding/json принимает объекты с дублирующимися именами полей. RFC 8259 не регламентирует, что с ними делать, поэтому реализация может выбрать любое значение, объединить их, отбросить или выдать ошибку. Но наличие дублирующегося имени создаёт JSON-значение без однозначного смысла. Это может быть использовано злоумышленниками в приложениях, где важна безопасность, и уже использовалось ранее (например, CVE-2017-12635). Поведение по умолчанию должно быть в сторону безопасности и отклонять такие дубликаты.

  • Утечка nil-состояния у slice и map. JSON часто применяется для обмена с программами, где реализации JSON не допускают, чтобы null анмаршалился в тип данных, ожидающий массив или объект. Так как encoding/json маршалит nil-slice или map в JSON-null, это может приводить к ошибкам при анмаршалинге другими реализациями. Опрос показал, что большинство пользователей Go предпочитают, чтобы nil-slices и maps по умолчанию маршалились как пустые массивы или объекты.

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

  • Непоследовательные вызовы методов. Из-за деталей реализации методы MarshalJSON, объявленные на ресивер-указателе, вызываются encoding/json непоследовательно. Хотя это признано багом, исправить его нельзя — слишком много приложений уже завязаны на текущее поведение.

Недостатки API

API encoding/json может быть неудобным или слишком ограничивающим:

  • Сложно правильно анмаршалить данные из io.Reader. Пользователи часто пишут json.NewDecoder(r).Decode(v), но этот вызов не отбрасывает «мусор» в конце входного потока.

  • Параметры можно задать у типов Encoder и Decoder, но их нельзя использовать вместе с функциями Marshal и Unmarshal. Аналогично, типы, реализующие интерфейсы Marshaler и Unmarshaler, не могут использовать параметры, и нет способа передать их по стеку вызовов. Например, параметр Decoder.DisallowUnknownFieldsперестаёт действовать при вызове метода UnmarshalJSON.

  • Функции Compact, Indent и HTMLEscape пишут только в bytes.Buffer вместо более гибких вариантов — например, []byte или io.Writer. Это ограничивает их применимость.

Ограничения по производительности

Помимо внутренних деталей реализации, само публичное API накладывает ряд ограничений на производительность:

  • MarshalJSON. Метод интерфейса MarshalJSON заставляет реализацию выделять память под возвращаемый []byte. Кроме того, по своей семантике encoding/json обязан проверить, что результат — корректный JSON, и дополнительно переформатировать его в соответствии с заданным отступом.

  • UnmarshalJSON. Метод интерфейса UnmarshalJSON требует передачи полного JSON-значения (без лишних данных в конце). Это вынуждает encoding/json сначала полностью распарсить значение, чтобы определить его границы, и только после этого вызвать UnmarshalJSON. А затем сам метод должен заново распарсить переданное JSON-значение.

  • Отсутствие стриминга. Несмотря на то, что Encoder и Decoder работают с io.Writer и io.Reader, они буферизуют всё JSON-значение целиком в памяти. Метод Decoder.Token для чтения отдельных токенов создаёт много аллокаций и при этом не существует соответствующего API для записи токенов.

Кроме того, если реализация метода MarshalJSON или UnmarshalJSON рекурсивно вызывает функции Marshal или Unmarshal, производительность падает до квадратичной.

Попытка исправить encoding/json напрямую

Введение новой, несовместимой по API, мажорной версии пакета — серьёзное решение. По возможности стоит попробовать исправить существующий пакет.

Добавлять новые функции сравнительно легко, а вот менять уже существующие — сложно. К сожалению, перечисленные проблемы вытекают из текущего API, поэтому фактически их невозможно устранить, оставаясь в рамках обещания о совместимости Go 1.

Теоретически мы могли бы ввести отдельные имена вроде MarshalV2 или UnmarshalV2, но это по сути создаст параллельное пространство имён внутри того же пакета. Отсюда и идея encoding/json/v2 (далее — v2), где мы можем внести изменения в отдельном пространстве имён v2 в отличие от encoding/json (далее — v1).

Планирование encoding/json/v2

Подготовка новой мажорной версии encoding/json заняла годы. В конце 2020 года, столкнувшись с невозможностью исправить проблемы в текущем пакете, Даниэль Марти (один из мейнтейнеров encoding/json) впервые изложил свои мысли о том, каким мог бы быть гипотетический v2. Параллельно, после работы над Go-API для Protocol Buffers, Джо Цай был разочарован тем, что пакету protojson пришлось использовать собственную реализацию JSON: encoding/json не мог ни соответствовать более строгому стандарту JSON, требуемому спецификацией Protocol Buffers, ни эффективно сериализовать JSON в потоковом режиме.

Считая, что у JSON может быть более светлое будущее — и что оно достижимо, — Даниэль и Джо объединили усилия, чтобы продумать v2 и начать прототипирование (поначалу — на основе отполированной версии логики сериализации JSON из модуля Go protobuf). Со временем к работе подключились и другие (Роджер Пепп, Крис Хайнс, Йохан Брандхорст-Затцкорн и Дэмиен Нил), занимаясь обсуждением дизайна, ревью кода и регрессионным тестированием. Многие ранние обсуждения доступны публично — в записанных встречах и заметках.

Работа была открытой с самого начала, и мы всё активнее подключали сообщество Go: сначала — докладом и обсуждением на GopherCon в конце 2023 года, затем официальным предложением в начале 2025-го, и совсем недавно — принятием encoding/json/v2 в качестве эксперимента Go (доступно в Go 1.25) для широкого тестирования всеми пользователями Go.

Работа над v2 ведётся уже 5 лет, она учитывает обратную связь множества контрибьюторов и подкреплена полезным практическим опытом эксплуатации в продакшене.

Важно отметить, что в значительной степени проект разрабатывался и продвигался людьми, не работающими в Google, что демонстрирует: Go — это совместное усилие с живым мировым сообществом, нацеленным на улучшение экосистемы Go.

Опираясь на encoding/json/jsontext

Прежде чем обсуждать API v2, познакомимся с экспериментальным пакетом encoding/json/jsontext, который закладывает основу для будущих улучшений работы с JSON в Go.

Сериализацию JSON в Go можно разложить на два основных компонента:

  • синтаксические возможности, отвечающие за обработку JSON на уровне его грамматики;

  • семантические возможности, определяющие соответствие между значениями JSON и значениями Go.

Мы используем термины «encode» и «decode» для описания синтаксических возможностей и «marshal» и «unmarshal» — для описания семантических. Наша цель — чётко разделить функциональность, которая занимается исключительно кодированием, и функциональность маршалинга.

Эта схема иллюстрирует такое разделение. Фиолетовые блоки обозначают типы, синие — функции и методы. Направление стрелок приблизительно показывает поток данных. Нижняя половина схемы, реализованная пакетом jsontext, содержит функциональность, работающую только с синтаксисом. Верхняя половина, реализованная пакетом json/v2, придаёт семантический смысл синтаксическим данным, с которыми работает нижний уровень.

Базовый API jsontext выглядит так:

package jsontext

type Encoder struct { ... }
func NewEncoder(io.Writer, ...Options) *Encoder
func (*Encoder) WriteValue(Value) error
func (*Encoder) WriteToken(Token) error

type Decoder struct { ... }
func NewDecoder(io.Reader, ...Options) *Decoder
func (*Decoder) ReadValue() (Value, error)
func (*Decoder) ReadToken() (Token, error)

type Kind byte
type Value []byte
func (Value) Kind() Kind
type Token struct { ... }
func (Token) Kind() Kind

Пакет jsontext предоставляет средства взаимодействия с JSON на синтаксическом уровне и получил своё название по разделу 2 RFC 8259, где грамматика JSON прямо называется «JSON-text». Поскольку он работает только на уровне синтаксиса, ему не требуется рефлексия в Go.

Encoder и Decoder обеспечивают поддержку кодирования и декодирования JSON-значений и токенов. Их конструкторы принимают вариативные параметры, которые влияют на конкретное поведение при кодировании и декодировании. В отличие от типов Encoder и Decoder из v1, новые типы в jsontext не смешивают синтаксис и семантику и действительно работают в потоковом режиме.

JSON-значение — это завершённая единица данных, представленная в Go как именованный []byte. Оно идентично RawMessage из v1. JSON-значение синтаксически состоит из одного или нескольких JSON-токенов. JSON-токен в Go представлен непрозрачным типом Token с конструкторами и методами доступа. Он аналогичен Token в v1, но спроектирован так, чтобы представлять произвольные JSON-токены без выделения памяти.

Чтобы решить фундаментальные проблемы производительности методов интерфейса MarshalJSON и UnmarshalJSON, нужен эффективный способ кодирования и декодирования JSON как потоковой последовательности токенов и значений. В v2 мы вводим методы интерфейса MarshalJSONTo и UnmarshalJSONFrom, которые работают с Encoder или Decoder, позволяя их реализациям обрабатывать JSON полностью в потоковом режиме. Таким образом, пакет json больше не обязан проверять или форматировать JSON-значение, возвращаемое MarshalJSON, и не должен сам определять границы JSON-значения, передаваемого в UnmarshalJSON. Эти задачи теперь возлагаются на Encoder и Decoder.

Знакомство с encoding/json/v2

Опираясь на пакет jsontext, представляем экспериментальный пакет encoding/json/v2. Он создан для устранения упомянутых ранее проблем, при этом оставаясь привычным для пользователей v1. Наша цель — чтобы код на v1 в основном работал так же при прямой миграции на v2.

В этой статье мы рассмотрим в основном высокоуровневый API v2. Примеры его использования можно найти в самом пакете v2 или в статье Антона Жиянова, посвящённом этой теме.

Базовый API v2 выглядит так:

package json

func Marshal(in any, opts ...Options) (out []byte, err error)
func MarshalWrite(out io.Writer, in any, opts ...Options) error
func MarshalEncode(out *jsontext.Encoder, in any, opts ...Options) error

func Unmarshal(in []byte, out any, opts ...Options) error
func UnmarshalRead(in io.Reader, out any, opts ...Options) error
func UnmarshalDecode(in *jsontext.Decoder, out any, opts ...Options) error

Функции Marshal и Unmarshal имеют сигнатуру, похожую на v1, но дополнительно принимают параметры для настройки поведения. Функции MarshalWrite и UnmarshalRead работают напрямую с io.Writer и io.Reader, избавляя от необходимости создавать Encoder или Decoder только ради записи или чтения. Функции MarshalEncode и UnmarshalDecode работают с jsontext.Encoder и jsontext.Decoder и фактически представляют собой основную реализацию ранее упомянутых функций.

В отличие от v1, параметры в v2 являются полноценным аргументом каждой функции маршалинга и анмаршалинга, что значительно расширяет гибкость и настраиваемость API. В v2 доступно множество параметров, которые не рассматриваются в этой статье.

Кастомизация на уровне типов

Как и в v1, в v2 типы могут задавать собственное представление в JSON, реализуя определённые интерфейсы:

type Marshaler interface {
    MarshalJSON() ([]byte, error)
}
type MarshalerTo interface {
    MarshalJSONTo(*jsontext.Encoder) error
}

type Unmarshaler interface {
    UnmarshalJSON([]byte) error
}
type UnmarshalerFrom interface {
    UnmarshalJSONFrom(*jsontext.Decoder) error
}

Интерфейсы Marshaler и Unmarshaler полностью идентичны интерфейсам из v1. Новые MarshalerTo и UnmarshalerFrom позволяют типу представлять себя в JSON с помощью jsontext.Encoder или jsontext.Decoder. Это даёт возможность передавать параметры дальше по стеку вызовов, так как их можно получить через метод Options у Encoder или Decoder.

См. пример OrderedObject, который показывает, как реализовать пользовательский тип, сохраняющий порядок полей объекта JSON.

Кастомизация на уровне вызова

В v2 вызывающий Marshal и Unmarshal также может задать собственное представление JSON для любого произвольного типа. При этом функции, определённые вызывающим, имеют приоритет над методами, реализованными самим типом, или стандартным представлением.

func WithMarshalers(*Marshalers) Options

type Marshalers struct { ... }
func MarshalFunc[T any](fn func(T) ([]byte, error)) *Marshalers
func MarshalToFunc[T any](fn func(*jsontext.Encoder, T) error) *Marshalers

func WithUnmarshalers(*Unmarshalers) Options

type Unmarshalers struct { ... }
func UnmarshalFunc[T any](fn func([]byte, T) error) *Unmarshalers
func UnmarshalFromFunc[T any](fn func(*jsontext.Decoder, T) error) *Unmarshalers

Функции MarshalFunc и MarshalToFunc создают кастомный маршалер, который можно передать в вызов Marshal через WithMarshalers, чтобы переопределить маршалинг определённых типов. Аналогично, UnmarshalFunc и UnmarshalFromFunc позволяют задать свою логику анмаршалинга через WithUnmarshalers.

Пример c ProtoJSON демонстрирует, как с помощью этой возможности вся сериализация типов proto.Message может обрабатываться пакетом protojson.

Отличия в поведении

Хотя v2 в целом стремится вести себя так же, как v1, некоторые моменты были изменены для устранения проблем v1. Наиболее заметные изменения:

  • v2 выдаёт ошибку при наличии некорректного UTF-8.

  • v2 выдаёт ошибку, если объект JSON содержит дублирующееся имя.

  • v2 маршалит nil-slice Go как пустой массив JSON, а nil-map — как пустой объект JSON.

  • v2 анмаршалит объект JSON в структуру Go, сопоставляя имена полей с учётом регистра.

  • v2 переопределяет параметр тега omitempty: поле опускается, если оно кодируется в «пустое» JSON-значение (null, "", [], {}).

  • v2 выдаёт ошибку при попытке сериализовать time.Duration (так как у него нет представления по умолчанию), но при этом предоставляет параметры, позволяющие вызывающему решить, как именно сериализовать.

Для большинства изменений предусмотрены теги или параметры вызова, которые позволяют настроить поведение под семантику v1 или v2, либо выбрать другое, определённое пользователем. См. раздел «Migrating to v2».

Оптимизации производительности

Производительность Marshal в v2 примерно сопоставима с v1: иногда немного быстрее, иногда немного медленнее. Зато производительность Unmarshal в v2 значительно выше — бенчмарки показывают ускорение до 10 раз.

Чтобы добиться ещё большего прироста производительности, существующим реализациям Marshaler и Unmarshaler стоит перейти к реализациям MarshalerTo и UnmarshalerFrom. Это позволит обрабатывать JSON в полностью потоковом режиме. Например, рекурсивный парсинг спецификаций OpenAPI в методах UnmarshalJSON сильно снижал производительность в одном из сервисов Kubernetes (см. kubernetes/kube-openapi#315), тогда как переход на UnmarshalJSONFrom улучшил её на порядки.

Более подробную информацию можно найти в репозитории go-json-experiment/jsonbench.

Улучшение encoding/json задним числом

Мы хотим избежать ситуации, когда в стандартной библиотеке Go существуют две отдельные реализации JSON, поэтому крайне важно, чтобы v1 «под капотом» была реализована через v2.

Такой подход даёт несколько преимуществ:

  • Постепенная миграция. Функции Marshal и Unmarshal в v1 и v2 представляют собой набор стандартных поведений, работающих по семантике v1 или v2. Можно указывать опции, позволяющие настроить Marshal или Unmarshal так, чтобы они работали полностью как v1, в основном как v1 с элементами v2, в смешанном режиме, в основном как v2 с элементами v1 или полностью как v2. Это обеспечивает постепенную миграцию между версиями.

  • Наследование возможностей. Когда в v2 добавляются обратно совместимые функции, они автоматически становятся доступными и в v1. Например, в v2 появились новые параметры тегов структур — такие как inline и format, а также поддержка интерфейсов MarshalJSONTo и UnmarshalJSONFrom, которые более производительны и гибки. Если v1 реализована через v2, она наследует и эти возможности.

  • Снижение затрат на сопровождение. Поддержка широко используемого пакета требует значительных усилий. Если v1 и v2 используют одну реализацию, нагрузка снижается: одно изменение исправляет баги, улучшает производительность или добавляет функциональность сразу для обеих версий. Нет необходимости отдельно переносить изменения из v2 в v1.

Хотя отдельные части v1 со временем могут быть признаны устаревшими (если v2 выйдет из статуса эксперимента), весь пакет никогда не будет объявлен устаревшим. Переход на v2 будет поощряться, но не станет обязательным. Проект Go не откажется от поддержки v1.

Эксперименты с jsonv2

Новые API в пакетах encoding/json/jsontext и encoding/json/v2 по умолчанию не видны. Чтобы использовать их, соберите код с установленной переменной окружения GOEXPERIMENT=jsonv2 или с билд-тегом goexperiment.jsonv2. Суть эксперимента в том, что API нестабилен и может измениться в будущем. Тем не менее реализация обладает высоким качеством и уже успешно применяется в продакшене несколькими крупными проектами.

Поскольку v1 реализована через v2, при сборке с экспериментальным jsonv2 внутренняя реализация v1 полностью меняется. Без изменений в коде вы можете прогнать свои тесты под jsonv2 — теоретически не должно появиться новых сбоев:

GOEXPERIMENT=jsonv2 go test ./...

Переходная реализация v1 через v2 стремится к идентичному поведению в рамках обещания совместимости Go 1, хотя могут встречаться различия, например, в формулировке сообщений об ошибках. Мы призываем вас запускать тесты под jsonv2 и сообщать о любых регрессиях в ишью трекере.

Появление jsonv2 в качестве эксперимента в Go 1.25 — важный шаг на пути к официальному включению encoding/json/jsontext и encoding/json/v2 в стандартную библиотеку. Однако цель эксперимента — собрать более широкий опыт. Именно ваша обратная связь определит дальнейшие шаги и итог этого эксперимента, который может завершиться как отказом от идеи, так и принятием пакетов в стабильном виде в Go 1.26. Поделитесь своим опытом на go.dev/issue/71497 и помогите определить будущее Go.

Русскоязычное Go сообщество

Друзья! Эту статью перевела команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!

Комментарии (0)