В этой статье я расскажу о том, как эффективно парсить большие объемы JSON-данных используя Go.
Мы рассмотрим библиотеку go-faster/jx, легковесного форка jsoniter, созданной для высокопроизводительной низкоуровневой работы с JSON.
Входные данные
Для примера возьмем следующий JSON-объект:
{ "Timestamp": "1586960586000000000", "Attributes": { "http.status_code": 500, "http.url": "http://example.com", "my.custom.application.tag": "hello" }, "Resource": { "service.name": "donut_shop", "service.version": "2.0.0", "k8s.pod.uid": "1138528c-c36e-11e9-a1a7-42010a800198" }, "TraceId": "13e2a0921288b3ff80df0a0482d4fc46", "SpanId": "43222c2d51a7abe3", "SeverityText": "INFO", "SeverityNumber": 9, "Body": "20200415T072306-0700 INFO I like donuts" }
Это JSON-представление записи лога из модели данных OpenTelemetry.
Go-представление
Чтобы эффективно парсить JSON, нужно иметь подходящую структуру данных в Go, которая позволит минимизировать накладные расходы при декодировании. Чтобы этого добиться, мы можем использовать наши знания о модели данных. Например, мы знаем, что TraceId всегда представлен как 32-х символьная шестнадцатеричная строка, а SeverityText может быть опущен, если нам известно числовое значение SeverityNumber.
Вот пример такой структуры:
type OTEL struct { Timestamp jx.Num Attributes Map Resource Map TraceID [16]byte SpanID [8]byte Severity byte Body Raw }
Map
Обратим внимание на тип Map, который используется для полей Attributes и Resource. Он особенно важен для эффективного декодирования.
Но начнем мы с типа Bytes, который хранит срез байтов и позиции ключей/значений внутри этого среза. По сути это представление []string, но без лишних аллокаций:
type Pos struct { Start int End int } type Bytes struct { Buf []byte Pos []Pos }
Таким образом, Bytes позволяет нам хранить массив строк в компактном и локальном по памяти виде. Тип Pos указывает на начало и конец каждой строки внутри буфера Buf.
Определим несколько методов для работы с Bytes:
func (b Bytes) Elem(i int) []byte { p := b.Pos[i] return b.Buf[p.Start:p.End] } func (b Bytes) ForEachBytes(f func(i int, b []byte) error) error { for i, p := range b.Pos { if err := f(i, b.Buf[p.Start:p.End]); err != nil { return err } } return nil } func (b *Bytes) Append(v []byte) { start := len(b.Buf) b.Buf = append(b.Buf, v...) end := len(b.Buf) b.Pos = append(b.Pos, Pos{Start: start, End: end}) } func (b *Bytes) Reset() { b.Buf = b.Buf[:0] b.Pos = b.Pos[:0] }
Метод Elem позволяет получить i-й элемент, ForEachBytes итерирует по всем элементам Bytes, используя коллбек и избегая аллокаций. Метод Append добавляет новый элемент в Bytes, а Reset очищает содержимое.
Теперь мы можем определить тип Map, который использует Bytes для хранения ключей и значений:
type Map struct { Keys Bytes Values Bytes }
Пары ключ-значение мы сможем получить, используя индексы из Keys.Pos и Values.Pos, а запись в такую структуру будет выглядеть следующим образом:
func (m *Map) Append(k, v []byte) { m.Keys.Append(k) m.Values.Append(v) }
Декодирование
Теперь, когда у нас есть структура данных, мы можем приступить к декодированию JSON.
Опять же, начнем с типа Map. Для простоты и последовательности, мы будем использовать следующую сигнатуру:
type Decoder interface { Decode(d *jx.Decoder) error }
Map
Метод Decode для типа Map будет выглядеть вот так:
func (m *Map) Decode(d *jx.Decoder) error { return d.ObjBytes(func(d *jx.Decoder, k []byte) error { v, err := d.Raw() if err != nil { return errors.Wrap(err, "value") } m.Append(k, v) return nil }) }
Тут используется метод ObjBytes, который итерирует по всем полям объекта, предоставляя ключи в виде срезов байтов. Ключи ссылаются на исходный буфер, что позволяет избежать лишних аллокаций.
Метод Raw возвращает сырое json значение в виде среза байтов, что также помогает минимизировать накладные расходы.
Основной тип
Теперь мы вручную декодируем каждое поле основного типа OTEL.
Выглядеть это будет примерно так:
func (o *OTEL) Decode(d *jx.Decoder) error { return d.ObjBytes(func(d *jx.Decoder, key []byte) error { switch string(key) { case "Body": v, err := d.RawAppend(o.Body[:0]) if err != nil { return errors.Wrap(err, "body") } o.Body = v return nil case "Attributes": // ... default: return errors.Errorf("unknown key %q", key) } }) }
Body
Поле Body это просто сырое значение JSON, поэтому мы можем использовать метод RawAppend, который совершает append сырого значения в переданный срез байтов.
v, err := d.RawAppend(o.Body[:0]) if err != nil { return errors.Wrap(err, "body") } o.Body = v
SeverityNumber
Поле SeverityNumber содержит значения от 1 до 24, поэтому мы можем хранить его в одном байте. Используем метод Uint8 для декодирования:
v, err := d.Uint8() if err != nil { return errors.Wrap(err, "severity number") } o.Severity = v
SeverityText
Поле SeverityText может быть опущено, если известно числовое значение SeverityNumber.
Мы используем оптимизированный метод d.Skip() для пропуска этого поля.
Timestamp
Для работы с числовым полем Timestamp мы используем специальный тип jx.Num и его метод декодера NumAppend.
v, err := d.NumAppend(o.Timestamp[:0]) if err != nil { return errors.Wrap(err, "timestamp") } o.Timestamp = v
Тип jx.Num и методы декодера Num, NumAppend специализированы на эффективной работе с числами, представленными как строки или числа в JSON.
TraceId и SpanId
Поля TraceID и SpanID это шестнадцатеричные строки фиксированной длины.
Мы используем метод StrBytes для получения байтового представления строки, а затем декодируем его с помощью hex.Decode из пакета encoding/hex:
v, err := d.StrBytes() if err != nil { return errors.Wrap(err, "trace id") } if _, err := hex.Decode(o.TraceID[:], v); err != nil { return errors.Wrap(err, "trace id decode") } return nil
Attributes и Resource
Эти поля мы декодируем с помощью метода Decode нашего типа Map, который мы определили ранее.
Итог
В итоге мы получаем эффективный парсер JSON, который минимизирует аллокации и работает с большими объемами данных.
На AMD Ryzen 9 7950X мы получаем следующие показатели производительности (на одно ядро):
Тест | Скорость | Аллокации |
|---|---|---|
Decode | 1279 MB/s | 0 allocs/op |
Validate | 1914 MB/s | 0 allocs/op |
Encode | 1202 MB/s | 0 allocs/op |
Write | 2055 MB/s | 0 allocs/op |
Описание Write и Encode выходит за рамки этой статьи, но вы можете ознакомиться с ними в тестах библиотеки jx.
