Приклади парсингу різнотипного JSON-у в Go. Обіцяне продовження
Привіт, мене звати Ярослав. Уже три роки працюю з Go і за цей час помітив, що у гоферів виникають скадності з парсингом різнотипного JSON-у. Зазвичай, поле яке може містити різні типи, парсять через interface{} або map[string]interface{} з приведенням типів (теж так робив раніше).
Різнотипний JSON зустрічається, коли в Go потрібно взаємодіяти з API, написаним на PHP, Node.js або інших динамічно типізованих мовах програмування, а отже стаття також буде корисною фахівцям, які переписують проєкт на Go.
А якщо ви хочете перекваліфікуватись на Gо, то ось вам стаття, як перекваліфікуватись з PHP на Go.
Ілюстрація Марії Вакуленко
Навчальні матеріали про JSON в інтернеті
Одна з особливостей Go, яка мені дуже подобається, це те, що я легко можу відкрити код майже будь-якої бібліотеки та порозглядати, як там все влаштовано. Якщо трапляється якась помилка або паніка, то замість пошуку в мережі, можу пошукати повідомлення про помилку локально в підключених бібліотеках. У файлах з тестами пакету encoding/json детально описані можливості парсингу JSON-у.
Але коли я бачу статті про JSON, то зазвичай там описують тільки базові можливості. Щось складніше з різнотипними полями вже парсять через interface{}, []interface{} або map[string]interface{}, хоча парсинг можна написати ефективніше, знаючи приклади з тестів стандартного пакету encoding/json.
А ще про парсинг різнотипного JSON-у пишуть рідко, бо зазвичай це трапляється у проєкта, які, для прикладу, переносять з PHP на Go, або де адмінка на PHP і відповідне внутрішнє API на PHP.
Матеріалів на тему парсингу різнотипного JSON-у мало, а отже стаття має цінність.
Термінологія
У кожної мови своя назва перетворення об’єкту в JSON та JSON-у в об’єкт, і щоб уникнути плутанини, ось таблиця для різних мов:
| Programming language | Encode | Decode |
|---|---|---|
| Go | json.Marshal() | json.Unmarshal() |
| PHP | json_encode() | json_decode() |
| JavaScript | JSON.stringify() | JSON.parse() |
| C# | JsonSerializer.Serialize() | JsonSerializer.Deserialize() |
У цій статті будуть використовуватись терміни серіалізація та десеріалізація, а термін парсинг у контексті різнотипного JSON-у.
Кастомізація серіалізації та десеріалізації
У стандартній бібліотеці encoding/json є можливість кастомізувати серіалізацію за допомогою інтерфейсу:
package json
// Marshaler is the interface implemented by types that
// can marshal themselves into valid JSON.
type Marshaler interface {
MarshalJSON() ([]byte, error)
}Давайте розглянемо на прикладі серіалізації формату float з налаштуванням точності:
package main
import "strconv"
type Float64 float64
func (f Float64) MarshalJSON() ([]byte, error) {
const (
precision = 8
)
return strconv.AppendFloat(make([]byte, 0, 20), float64(f), 'f', precision, 64), nil
}
type Float64Wrap struct {
Value float64
}
func (f Float64Wrap) MarshalJSON() ([]byte, error) {
const (
precision = 10
)
return strconv.AppendFloat(make([]byte, 0, 20), f.Value, 'f', precision, 64), nil
}package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestFloat64_MarshalJSON(t *testing.T) {
type Response struct {
NativeFloat64 float64 `json:"native_float_64"`
StringFloat64 float64 `json:"string_float_64,string"`
CustomFloat64 Float64 `json:"custom_float_64"`
CustomFloat64Wrap Float64Wrap `json:"custom_float_64_wrap"`
}
var response = Response{
NativeFloat64: 0.00000001,
StringFloat64: 0.00000002,
CustomFloat64: 0.00000003,
CustomFloat64Wrap: Float64Wrap{
0.00000004,
},
}
const (
// language=JSON
expectedJSON = `{"native_float_64":1e-8,"string_float_64":"2e-8","custom_float_64":0.00000003,"custom_float_64_wrap":0.0000000400}`
)
var content, err = json.Marshal(response)
require.NoError(t, err)
require.Equal(t, []byte(expectedJSON), content)
}go test ./... -v -count=1
=== RUN TestFloat64_MarshalJSON --- PASS: TestFloat64_MarshalJSON (0.00s) PASS ok gitlab.com/go-yp/json-unmarshal-research/promise 0.002s
{
"native_float_64": 1e-8,
"string_float_64": "2e-8",
"custom_float_64": 0.00000003,
"custom_float_64_wrap": 0.0000000400
}Ще один приклад з MarshalJSON, який покаже більше можливостей:
package main
import (
"encoding/json"
"strconv"
)
type Point struct {
X int `json:"x"`
Y int `json:"y"`
}
type CustomPoint struct {
X int
Y int
}
func (p CustomPoint) MarshalJSON() ([]byte, error) {
if p.X == 0 && p.Y == 0 {
return []byte("[0,0]"), nil
}
const safeLazyA = false
if safeLazyA {
return json.Marshal([2]int{p.X, p.Y})
}
const safeLazyB = false
if safeLazyB {
return json.Marshal([]interface{}{p.X, p.Y})
}
const safeExperimentalC = false
if safeExperimentalC {
return json.Marshal(map[string]int{
"x": p.X,
"y": p.Y,
"z": 0,
})
}
const safeExperimentalD = false
if safeExperimentalD {
type Standard CustomPoint
return json.Marshal(Standard(p))
}
const unsafeLoopE = false
if unsafeLoopE {
// fatal error: stack overflow
return json.Marshal(p)
}
var result []byte
result = append(result, '[')
result = strconv.AppendInt(result, int64(p.X), 10)
result = append(result, ',')
result = strconv.AppendInt(result, int64(p.Y), 10)
result = append(result, ']')
return result, nil
}package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestCustomPoint_MarshalJSON(t *testing.T) {
type Response struct {
Point Point `json:"point"`
CustomPoint CustomPoint `json:"custom_point"`
ZeroCustomPoint CustomPoint `json:"zero_custom_point"`
NilCustomPoint *CustomPoint `json:"nil_custom_point"`
}
var response = Response{
Point: Point{
X: 1,
Y: 2,
},
CustomPoint: CustomPoint{
X: 3,
Y: 4,
},
ZeroCustomPoint: CustomPoint{
X: 0,
Y: 0,
},
NilCustomPoint: nil,
}
const (
// language=JSON
expectedJSON = `{"point":{"x":1,"y":2},"custom_point":[3,4],"zero_custom_point":[0,0],"nil_custom_point":null}`
)
var content, err = json.Marshal(response)
require.NoError(t, err)
require.Equal(t, []byte(expectedJSON), content)
}go test ./... -v -count=1
=== RUN TestCustomPoint_MarshalJSON --- PASS: TestCustomPoint_MarshalJSON (0.00s) PASS ok gitlab.com/go-yp/json-unmarshal-research 0.002s
{
"point": {
"x": 1,
"y": 2
},
"custom_point": [
3,
4
],
"zero_custom_point": [
0,
0
],
"nil_custom_point": null
}Бібліотека encoding/json перевіряє результат від MarshalJSON на валідність:
func addrMarshalerEncoder(e *encodeState, v reflect.Value, opts encOpts) {
va := v.Addr()
if va.IsNil() {
e.WriteString("null")
return
}
m := va.Interface().(Marshaler)
b, err := m.MarshalJSON()
if err == nil {
// copy JSON into buffer, checking validity.
err = compact(&e.Buffer, b, opts.escapeHTML)
}
if err != nil {
e.error(&MarshalerError{v.Type(), err, "MarshalJSON"})
}
}Використання кастомної логіки сповільнює серіалізацію та збільшує число алокацій пам’яті, бо в кожному MarshalJSON створюється новий слайс байтів.
Я розглянув серіалізацію тільки як вступ до десеріалізації.
Для кастомізації десеріалізації в стандартній бібліотеці encoding/json також є інтерфейс:
package json
// Unmarshaler is the interface implemented by types
// that can unmarshal a JSON description of themselves.
// The input can be assumed to be a valid encoding of
// a JSON value. UnmarshalJSON must copy the JSON data
// if it wishes to retain the data after returning.
//
// By convention, to approximate the behavior of Unmarshal itself,
// Unmarshalers implement UnmarshalJSON([]byte("null")) as a no-op.
type Unmarshaler interface {
UnmarshalJSON([]byte) error
}Щоб зрозуміти, що саме передається в метод UnmarshalJSON, реалізуємо відкладену десеріалізацію:
{
"delayed_bool": true,
"delayed_int": 1,
"delayed_float": 2.5,
"delayed_object": {
"x": 1,
"y": 2,
"z": 3
},
"delayed_array": [
"hello",
"world"
],
"delayed_untype_array": [
1,
"2",
3.5,
true
],
"delayed_null": null
}package main
type DelayedUnmarshal []byte
func (u *DelayedUnmarshal) UnmarshalJSON(data []byte) error {
var dataCopy = make([]byte, len(data))
copy(dataCopy, data)
*u = dataCopy
return nil
}package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestDelayedUnmarshal_UnmarshalJSON(t *testing.T) {
const (
// language=JSON
content = `{
"delayed_bool": true,
"delayed_int": 1,
"delayed_float": 2.5,
"delayed_object": {
"x": 1,
"y": 2,
"z": 3
},
"delayed_array": [
"hello",
"world"
],
"delayed_untype_array": [
1,
"2",
3.5,
true
],
"delayed_null": null
}`
)
type Request struct {
DelayedBool DelayedUnmarshal `json:"delayed_bool"`
DelayedInt DelayedUnmarshal `json:"delayed_int"`
DelayedFloat DelayedUnmarshal `json:"delayed_float"`
DelayedObject DelayedUnmarshal `json:"delayed_object"`
DelayedArray DelayedUnmarshal `json:"delayed_array"`
DelayedUntypeArray DelayedUnmarshal `json:"delayed_untype_array"`
DelayedNull DelayedUnmarshal `json:"delayed_null"`
}
var (
expected = Request{
DelayedBool: DelayedUnmarshal([]byte(`true`)),
DelayedInt: DelayedUnmarshal([]byte(`1`)),
DelayedFloat: DelayedUnmarshal([]byte(`2.5`)),
DelayedObject: DelayedUnmarshal(`{
"x": 1,
"y": 2,
"z": 3
}`),
DelayedArray: DelayedUnmarshal([]byte(`[
"hello",
"world"
]`)),
DelayedUntypeArray: DelayedUnmarshal([]byte(`[
1,
"2",
3.5,
true
]`)),
DelayedNull: DelayedUnmarshal([]byte(`null`)),
}
actual Request
)
require.NoError(t, json.Unmarshal([]byte(content), &actual))
require.Equal(t, expected, actual)
}go test ./... -v -run=TestDelayedUnmarshal_UnmarshalJSON -count=1
=== RUN TestDelayedUnmarshal_UnmarshalJSON --- PASS: TestDelayedUnmarshal_UnmarshalJSON (0.00s) PASS ok gitlab.com/go-yp/json-unmarshal-research 0.003s
Тест показав, що в метод UnmarshalJSON передається json в сирому виді, без форматування.
Тепер, коли ми дізнались, в якому вигляді передається json, то можемо розібрати ще пару простих прикладів.
Перший приклад. Уявімо, що в API true та false замінили на 1 та 0 і потрібно врахувати обидва формати:
{
"true_native_bool": true,
"false_native_bool": false,
"true_short_bool": true,
"false_short_bool": false,
"int_1_short_bool": 1,
"int_0_short_bool": 0
}package main
import "errors"
type ShortBool bool
func (s *ShortBool) UnmarshalJSON(data []byte) error {
switch string(data) {
case "true", "1":
*s = true
return nil
case "false", "0":
*s = false
return nil
}
return errors.New("ShortBool: unknown value")
}package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestShortBool_UnmarshalJSON(t *testing.T) {
const (
// language=JSON
content = `{
"true_native_bool": true,
"false_native_bool": false,
"true_short_bool": true,
"false_short_bool": false,
"int_1_short_bool": 1,
"int_0_short_bool": 0
}`
)
type Request = struct {
TrueNativeBool bool `json:"true_native_bool"`
FalseNativeBool bool `json:"false_native_bool"`
TrueShortBool ShortBool `json:"true_short_bool"`
FalseShortBool ShortBool `json:"false_short_bool"`
Int1ShortBool ShortBool `json:"int_1_short_bool"`
Int0ShortBool ShortBool `json:"int_0_short_bool"`
}
var (
expected = Request{
TrueNativeBool: true,
FalseNativeBool: false,
TrueShortBool: true,
FalseShortBool: false,
Int1ShortBool: true,
Int0ShortBool: false,
}
actual Request
)
require.NoError(t, json.Unmarshal([]byte(content), &actual))
require.Equal(t, expected, actual)
}go test ./... -v -run=TestShortBool_UnmarshalJSON -count=1
=== RUN TestShortBool_UnmarshalJSON --- PASS: TestShortBool_UnmarshalJSON (0.00s) PASS ok gitlab.com/go-yp/json-unmarshal-research 0.002s
Та другий приклад, коли потрібно розпарсити масив в структуру:
[1, 2]
package main
import (
"encoding/json"
)
type CustomPoint struct {
X int
Y int
}
func (p *CustomPoint) UnmarshalJSON(data []byte) error {
var value [2]int
var err = json.Unmarshal(data, &value)
if err != nil {
return err
}
p.X, p.Y = value[0], value[1]
return nil
}package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestCustomPoint_UnmarshalJSON(t *testing.T) {
const (
// language=JSON
content = `{
"location": [1, 2]
}`
)
type Request struct {
Location CustomPoint `json:"location"`
}
var actual Request
require.NoError(t, json.Unmarshal([]byte(content), &actual))
require.Equal(t, Request{
Location: CustomPoint{
X: 1,
Y: 2,
},
}, actual)
}go test ./... -v -run=TestCustomPoint_UnmarshalJSON -count=1
=== RUN TestCustomPoint_UnmarshalJSON --- PASS: TestCustomPoint_UnmarshalJSON (0.00s) PASS ok gitlab.com/go-yp/json-unmarshal-research 0.003s
Відкладена серіалізація та десеріалізація
У попередніх прикладах була реалізована відкладена десеріалізація:
type DelayedUnmarshal []byte
func (u *DelayedUnmarshal) UnmarshalJSON(data []byte) error {
var dataCopy = make([]byte, len(data))
copy(dataCopy, data)
*u = dataCopy
return nil
}А в пакеті json вже реалізована значно краща версія:
package json
// RawMessage is a raw encoded JSON value.
// It implements Marshaler and Unmarshaler and can
// be used to delay JSON decoding or precompute a JSON encoding.
type RawMessage []byte
// MarshalJSON returns m as the JSON encoding of m.
func (m RawMessage) MarshalJSON() ([]byte, error) {
if m == nil {
return []byte("null"), nil
}
return m, nil
}
// UnmarshalJSON sets *m to a copy of data.
func (m *RawMessage) UnmarshalJSON(data []byte) error {
if m == nil {
return errors.New("json.RawMessage: UnmarshalJSON on nil pointer")
}
*m = append((*m)[0:0], data...)
return nil
}
var _ Marshaler = (*RawMessage)(nil)
var _ Unmarshaler = (*RawMessage)(nil)Якщо у вас є закешований JSON в БД, то json.RawMessage може вирішити проблему подвійної конвертації.
Приклад з подвійною конвертацією:
package main
import "encoding/json"
// emulate key-value database result
func getUserOnlineStatsJSON() []byte {
const (
// language=JSON
content = `[{"hour":0,"users":100},{"hour":1,"users":87},{"hour":2,"users":25}]`
)
return []byte(content)
}| Подвійна конвертація | json.RawMessage |
|---|---|
type UserOnline struct {
Hour uint8 `json:"hour"`
Users uint32 `json:"users"`
}
type UserOnlineResponse struct {
AppVersion string `json:"v"`
Content []UserOnline `json:"content"`
}
func GetUserOnlineResponse() ([]byte, error) {
var content = getUserOnlineStatsJSON()
var stats []UserOnline
var err = json.Unmarshal(content, &stats)
if err != nil {
return nil, err
}
return json.Marshal(UserOnlineResponse{
AppVersion: "1.2.3",
Content: stats,
})
} | type UserOnlineFastResponse struct {
AppVersion string `json:"v"`
Content json.RawMessage `json:"content"`
}
func GetUserOnlineFastResponse() ([]byte, error) {
var (
stats = getUserOnlineStatsJSON()
response = &UserOnlineFastResponse{
AppVersion: "1.2.3",
Content: json.RawMessage(stats),
}
)
return json.Marshal(response)
} |
package main
import (
"testing"
"github.com/stretchr/testify/require"
)
const (
// language=JSON
expectedUserOnlineResponse = `{"v":"1.2.3","content":[{"hour":0,"users":100},{"hour":1,"users":87},{"hour":2,"users":25}]}`
)
func BenchmarkGetUserOnlineResponse(b *testing.B) {
// assert
{
var content, err = GetUserOnlineResponse()
require.NoError(b, err)
require.Equal(b, []byte(expectedUserOnlineResponse), content)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, _ = GetUserOnlineResponse()
}
})
}
func BenchmarkGetUserOnlineFastResponse(b *testing.B) {
// assert
{
var content, err = GetUserOnlineFastResponse()
require.NoError(b, err)
require.Equal(b, []byte(expectedUserOnlineResponse), content)
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_, _ = GetUserOnlineFastResponse()
}
})
}go test ./... -v -run=$^ -bench=BenchmarkGetUserOnline -benchmem
GetUserOnlineResponse 599 ns/op 560 B/op 13 allocs/op GetUserOnlineFastResponse 222 ns/op 240 B/op 3 allocs/op PASS ok gitlab.com/go-yp/json-unmarshal-research 3.397s
Парсинг різнотипного JSON-у з API написаним на PHP
Ось приклад API на PHP:
<?php $data = array( array( "id" => 1, "active" => true, "countries" => array( "UA" => array( "code" => "UA", "name" => "Ukraine", ), "LT" => array( "code" => "LT", "name" => "Lithuania", ), ), ), array( "id" => 2, "active" => false, "countries" => array(), ), ); echo json_encode($data, \JSON_PRETTY_PRINT);
php index.php
[
{
"id": 1,
"active": true,
"countries": {
"UA": {
"code": "UA",
"name": "Ukraine"
},
"LT": {
"code": "LT",
"name": "Lithuania"
}
}
},
{
"id": 2,
"active": false,
"countries": []
}
]Коли поле countries заповнене, то серіалізується в JSON-об’єкт, а коли пусте, то в JSON-масив. Подібна поведінка створює складності на стороні парсингу цього різнотипного JSON-у.
Якщо ми захочемо, отриманий від PHP різнотипний JSON спарсити в тип []Project, то отримаємо логічну помилку json: при парсингу поля «countries» очікував «{», а отримав «[».
package main
type Country struct {
Code string `json:"code"`
Name string `json:"name"`
}
type Project struct {
ID int `json:"id"`
Active bool `json:"active"`
Countries map[string]Country `json:"countries"`
}Варіанти рішення цієї помилки на стороні PHP вже є на Stack Overflow, мені сподобався варіант з (object)array():
<?php $data = array( array( "id" => 1, "active" => true, "countries" => (object)array( "UA" => array( "code" => "UA", "name" => "Ukraine", ), "LT" => array( "code" => "LT", "name" => "Lithuania", ), ), ), array( "id" => 2, "active" => false, "countries" => (object)array(), ), ); echo json_encode($data, \JSON_PRETTY_PRINT);
php index.php
[
{
"id": 1,
"active": true,
"countries": {
"UA": {
"code": "UA",
"name": "Ukraine"
},
"LT": {
"code": "LT",
"name": "Lithuania"
}
}
},
{
"id": 2,
"active": false,
"countries": {}
}
]Цю помилку треба виправляти на стороні PHP, якщо є така можливість, але коли бізнес хоче рішення вже сьогодні, то обгортка з UnmarshalJSON буде в нагоді:
package main
import (
"bytes"
"encoding/json"
)
type Country struct {
Code string `json:"code"`
Name string `json:"name"`
}
type Countries map[string]Country
type Project struct {
ID int `json:"id"`
Active bool `json:"active"`
Countries Countries `json:"countries"`
}
func (c *Countries) UnmarshalJSON(data []byte) error {
if bytes.Equal(data, []byte("[]")) {
*c = nil
return nil
}
var result map[string]Country
var err = json.Unmarshal(data, &result)
if err != nil {
return err
}
*c = result
return nil
}package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestCountries_UnmarshalJSON(t *testing.T) {
const (
// language=JSON
content = `[
{
"id": 1,
"active": true,
"countries": {
"UA": {
"code": "UA",
"name": "Ukraine"
},
"LT": {
"code": "LT",
"name": "Lithuania"
}
}
},
{
"id": 2,
"active": false,
"countries": []
}
]`
)
var projects []Project
require.NoError(t, json.Unmarshal([]byte(content), &projects))
require.Equal(t, []Project{
{
ID: 1,
Active: true,
Countries: map[string]Country{
"UA": {
Code: "UA",
Name: "Ukraine",
},
"LT": {
Code: "LT",
Name: "Lithuania",
},
},
},
{
ID: 2,
Active: false,
Countries: nil,
},
}, projects)
}go test ./... -v
=== RUN TestCountries_UnmarshalJSON --- PASS: TestCountries_UnmarshalJSON (0.00s) PASS ok gitlab.com/go-yp/json-unmarshal-research 0.004s
Попередньо наведених прикладів достатньо для розуміння, як парсити різнотипний JSON. Якщо ви дочитали до цього моменту, то дайте знати в коментарях, напишіть у селі я осилив силу сала або у селі я осилила силу сала.
Подумайте самостійно як запарсити наступний приклад JSON-у:
[
{
"id": 1,
"active": true,
"countries": {
"UA": {
"code": "UA",
"name": "Ukraine"
},
"LT": {
"code": "LT",
"name": "Lithuania"
}
}
},
{
"id": 2,
"active": true,
"countries": [
{
"code": "CA",
"name": "Canada"
}
]
},
{
"id": 3,
"active": false,
"countries": []
}
]Оптимізація парсингу безключового JSON-у
Ми використаємо JSON з попереднього прикладу та переведемо його в безключовий:
[
{
"id": 1,
"active": true,
"countries": {
"UA": {
"code": "UA",
"name": "Ukraine",
"vacancy_count": 1234
},
"LT": {
"code": "LT",
"name": "Lithuania",
"vacancy_count": 789
}
}
},
{
"id": 2,
"active": false,
"countries": null
}
] | [ [ 1, true, [ [ "UA", "Ukraine", 1234 ], [ "LT", "Lithuania", 789 ] ] ], [ 2, false, null ] ] |
Тепер ми розпарсимо цей JSON за допомогою json.RawMessage, а потім оптимізуємо:
package main
import (
"encoding/json"
"errors"
)
type Country struct {
Code string
Name string
VacancyCount int
}
type Project struct {
ID int
Active bool
Countries []Country
}
func (p *Project) UnmarshalJSON(data []byte) error {
const (
expectedColumnCount = 3
)
var values = make([]json.RawMessage, 0, expectedColumnCount)
var err = json.Unmarshal(data, &values)
if err != nil {
return err
}
if len(values) < expectedColumnCount {
return errors.New("some error message")
}
err = json.Unmarshal(values[0], &p.ID)
if err != nil {
return err
}
err = json.Unmarshal(values[1], &p.Active)
if err != nil {
return err
}
err = json.Unmarshal(values[2], &p.Countries)
if err != nil {
return err
}
return nil
}
func (c *Country) UnmarshalJSON(data []byte) error {
const (
expectedColumnCount = 3
)
var values = make([]json.RawMessage, 0, expectedColumnCount)
var err = json.Unmarshal(data, &values)
if err != nil {
return err
}
if len(values) < expectedColumnCount {
return errors.New("some error message")
}
err = json.Unmarshal(values[0], &c.Code)
if err != nil {
return err
}
err = json.Unmarshal(values[1], &c.Name)
if err != nil {
return err
}
err = json.Unmarshal(values[2], &c.VacancyCount)
if err != nil {
return err
}
return nil
}package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func BenchmarkProject_UnmarshalJSON(b *testing.B) {
const (
// language=JSON
content = `[
[
1,
true,
[
[
"UA",
"Ukraine",
1234
],
[
"LT",
"Lithuania",
789
]
]
],
[
2,
false,
null
]
]`
)
var projects []Project
require.NoError(b, json.Unmarshal([]byte(content), &projects))
require.Equal(b, []Project{
{
ID: 1,
Active: true,
Countries: []Country{
{
Code: "UA",
Name: "Ukraine",
VacancyCount: 1234,
},
{
Code: "LT",
Name: "Lithuania",
VacancyCount: 789,
},
},
},
{
ID: 2,
Active: false,
Countries: nil,
},
}, projects)
b.ResetTimer()
for i := 0; i < b.N; i++ {
var projects []Project
_ = json.Unmarshal([]byte(content), &projects)
}
}go test ./... -v -bench=. -benchmem
BenchmarkProject_UnmarshalJSON 13397 ns/op 4544 B/op 61 allocs/op PASS ok gitlab.com/go-yp/json-unmarshal-research 2.671s
Парсинг безключового JSON-у виявився рутинним.
Оптимізувати можна частину з []json.RawMessage, який ще раз розглянемо:
package json
type RawMessage []byte
// UnmarshalJSON sets *m to a copy of data.
func (m *RawMessage) UnmarshalJSON(data []byte) error {
if m == nil {
return errors.New("json.RawMessage: UnmarshalJSON on nil pointer")
}
// safe full slice copy
*m = append((*m)[0:0], data...)
return nil
}У цьому випадку json.RawMessage забезпечує безпечну відкладену десеріалізацію за допомогою повного копіювання, але так як ми одразу продовжуємо десеріалізацію то повне копіювання зайве, а тому напишемо свій UnsafeRawMessage:
type UnsafeRawMessage []byte
func (m *UnsafeRawMessage) UnmarshalJSON(data []byte) error {
if m == nil {
return errors.New("UnsafeRawMessage: UnmarshalJSON on nil pointer")
}
// only slice header copy
*m = data
return nil
}Тепер нам потрібно лише замінити json.RawMessage на UnsafeRawMessage:
type FastCountry struct {
Code string
Name string
VacancyCount int
}
type FastProject struct {
ID int
Active bool
Countries []FastCountry
}
func (p *FastProject) UnmarshalJSON(data []byte) error {
const (
expectedColumnCount = 3
)
var values = make([]UnsafeRawMessage, 0, expectedColumnCount)
// ... same
return nil
}
func (c *FastCountry) UnmarshalJSON(data []byte) error {
const (
expectedColumnCount = 3
)
var values = make([]UnsafeRawMessage, 0, expectedColumnCount)
// ... same
return nil
}func BenchmarkFastProject_UnmarshalJSON(b *testing.B) {
// ... same content
var projects []FastProject
require.NoError(b, json.Unmarshal([]byte(content), &projects))
require.Equal(b, []FastProject{
// ... same
}, projects)
b.ResetTimer()
for i := 0; i < b.N; i++ {
var projects []FastProject
_ = json.Unmarshal([]byte(content), &projects)
}
}go test ./... -v -bench=. -benchmem
BenchmarkProject_UnmarshalJSON 12959 ns/op 4544 B/op 61 allocs/op BenchmarkFastProject_UnmarshalJSON 11601 ns/op 4304 B/op 49 allocs/op PASS ok gitlab.com/go-yp/json-unmarshal-research 2.950s
Як бачимо, безключовий JSON став парситись швидше.
Парсинг через interface{}
Парсинг через interface{} - це один з найпоширеніших варіантів, які описують в статтях.
Варіанти з json.Unmarshaler та json.RawMessage я описав раніше, та саме їх рекомендую для використання, але щоб доповнити картину, маю описати і варіант з interface{}:
Для прикладу, ми знову беремо попередній різнотипний JSON, можна жартувати що 90% це приклад цього JSON-у і інші 10% — це пояснення до нього та код на Go.
[
{
"id": 1,
"active": true,
"countries": {
"UA": {
"code": "UA",
"name": "Ukraine",
"vacancy_count": 1234
},
"LT": {
"code": "LT",
"name": "Lithuania",
"vacancy_count": 789
}
}
},
{
"id": 2,
"active": true,
"countries": [
{
"code": "CA",
"name": "Canada",
"vacancy_count": 567
}
]
}
]type Country struct {
Code string
Name string
VacancyCount int
}
type Project struct {
ID int `json:"id"`
Active bool `json:"active"`
Countries interface{} `json:"countries"`
}Цей Countries interface{} потрібно далі привести до типу []Country, що виглядає рутинно:
package main
import (
"errors"
"fmt"
)
type Country struct {
Code string
Name string
VacancyCount int
}
func ParseCountries(source interface{}) ([]Country, error) {
switch source.(type) {
case []interface{}:
source := source.([]interface{})
var result = make([]Country, 0, len(source))
for _, value := range source {
var country, err = ParseCountry(value)
if err != nil {
return nil, err
}
result = append(result, country)
}
return result, nil
case map[string]interface{}:
source := source.(map[string]interface{})
var result = make([]Country, 0, len(source))
for _, value := range source {
var country, err = ParseCountry(value)
if err != nil {
return nil, err
}
result = append(result, country)
}
return result, nil
}
return nil, errors.New(fmt.Sprintf("unexpected type %T", source))
}
func ParseCountry(source interface{}) (Country, error) {
var kv, ok = source.(map[string]interface{})
if ok {
var (
code, cOK = kv["code"].(string)
name, nOK = kv["name"].(string)
vacancyCount, vOK = kv["vacancy_count"].(float64)
)
if cOK && nOK && vOK {
return Country{
Code: code,
Name: name,
VacancyCount: int(vacancyCount),
}, nil
}
}
return Country{}, errors.New(fmt.Sprintf("unexpected type %T", source))
}Схожий код парсингу interface{} зустрічав в реальних проєктах, а ще й повторював. Тепер тест:
package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestCountries_UnmarshalJSON(t *testing.T) {
const (
// language=JSON
content = `...`
)
var projects []Project
require.NoError(t, json.Unmarshal([]byte(content), &projects))
require.Equal(t, []Project{
{
ID: 1,
Active: true,
Countries: map[string]interface{}{
"UA": map[string]interface{}{
"code": "UA",
"name": "Ukraine",
"vacancy_count": float64(1234),
},
"LT": map[string]interface{}{
"code": "LT",
"name": "Lithuania",
"vacancy_count": float64(789),
},
},
},
{
ID: 2,
Active: true,
Countries: []interface{}{
map[string]interface{}{
"code": "CA",
"name": "Canada",
"vacancy_count": float64(567),
},
},
},
}, projects)
{
var countries, err = ParseCountries(projects[0].Countries)
require.NoError(t, err)
require.Equal(t, []Country{
{
Code: "UA",
Name: "Ukraine",
VacancyCount: 1234,
},
{
Code: "LT",
Name: "Lithuania",
VacancyCount: 789,
},
}, countries)
}
{
var countries, err = ParseCountries(projects[1].Countries)
require.NoError(t, err)
require.Equal(t, []Country{
{
Code: "CA",
Name: "Canada",
VacancyCount: 567,
},
}, countries)
}
}go test ./... -v -count=10
Тест завалився, бо в функції ParseCountries ітерація по мапі map[string]interface{} є рандомною, а тому додамо сортування:
var countries, err = ParseCountries(projects[0].Countries)
require.NoError(t, err)
sort.Slice(countries, func(i, j int) bool {
return countries[i].VacancyCount > countries[j].VacancyCount
})
require.Equal(t, []Country{
{
Code: "UA",
Name: "Ukraine",
VacancyCount: 1234,
},
{
Code: "LT",
Name: "Lithuania",
VacancyCount: 789,
},
}, countries)
go test ./... -v -count=10
=== RUN TestCountries_UnmarshalJSON --- PASS: TestCountries_UnmarshalJSON (0.00s) ... === RUN TestCountries_UnmarshalJSON --- PASS: TestCountries_UnmarshalJSON (0.00s) PASS ok gitlab.com/go-yp/json-unmarshal-research 0.002s
Змішаний парсинг через interface{}
Це варіант з мого досвіду, перехідний процес, коли вже щось знав про json.Unmarshaler, але продовжував парсити через interface{}:
package main
import (
"encoding/json"
"errors"
"fmt"
)
type Country struct {
Code string
Name string
VacancyCount int
}
type Project struct {
ID int
Active bool
Countries []Country
}
type InternalProject struct {
ID int `json:"id"`
Active bool `json:"active"`
Countries interface{} `json:"countries"`
}
func (p *Project) UnmarshalJSON(data []byte) error {
var i InternalProject
var projectUnmarshalErr = json.Unmarshal(data, &i)
if projectUnmarshalErr != nil {
return projectUnmarshalErr
}
var countries, countriesUnmarshalErr = ParseCountries(i.Countries)
if countriesUnmarshalErr != nil {
return countriesUnmarshalErr
}
p.ID = i.ID
p.Active = i.Active
p.Countries = countries
return nil
}
func ParseCountries(source interface{}) ([]Country, error) {
// ...
}package main
import (
"encoding/json"
"sort"
"testing"
"github.com/stretchr/testify/require"
)
func TestCountries_UnmarshalJSON(t *testing.T) {
const (
// language=JSON
content = `...`
)
var projects []Project
require.NoError(t, json.Unmarshal([]byte(content), &projects))
for _, project := range projects {
sort.Slice(project.Countries, func(i, j int) bool {
return project.Countries[i].VacancyCount > project.Countries[j].VacancyCount
})
}
require.Equal(t, []Project{
{
ID: 1,
Active: true,
Countries: []Country{
{
Code: "UA",
Name: "Ukraine",
VacancyCount: 1234,
},
{
Code: "LT",
Name: "Lithuania",
VacancyCount: 789,
},
},
},
{
ID: 2,
Active: true,
Countries: []Country{
{
Code: "CA",
Name: "Canada",
VacancyCount: 567,
},
},
},
}, projects)
}
go test ./... -v -count=10
=== RUN TestCountries_UnmarshalJSON --- PASS: TestCountries_UnmarshalJSON (0.00s) ... === RUN TestCountries_UnmarshalJSON --- PASS: TestCountries_UnmarshalJSON (0.00s) PASS ok gitlab.com/go-yp/json-unmarshal-research 0.003s
Коли потрібен парсинг через interface{}
Коли у нас є інформація, які структури очікувати, навіть якщо вони різнотипні, то ми можемо парсити через json.Unmarshaler та json.RawMessage, але коли нам потрібний універсальний код, який буде перетворювати будь-який JSON в YAML, JSON в Go або JSON в Protobuf, тоді дійсно потрібно парсити в interface{}, бо з ним працювати буде трохи простіше, ніж парсити сирий JSON байтів.
Далі цей interface{} ми можемо перетворювати в один з універсальних типів:
- []interface{}
- map[string]interface{}
- string
- float64
- bool
Навіть якщо в JSON-і є слайс, який повністю скаладається зі строк [«UA», «LT», «CA»] то запарситься в:
[]interface{}{string("UA"), string("LT"), string("CA")}Ще наведу прикладів, як буде парситись в interface{}:
{
"int": 1,
"float": 1.1,
"string": "UA",
"bool": true,
"ints": [1, 2, 3],
"floats": [1.2, 2.2, 3.3],
"strings": ["UA", "LT", "CA"],
"bools": [true, true, false, true],
"mixs": [1, 1.1, "UA", true],
"country": {
"code": "UA",
"name": "Ukraine",
"vacancy_count": 25000
},
"null": null
}package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestInterface(t *testing.T) {
const (
// language=JSON
content = `{
"int": 1,
"float": 1.1,
"string": "UA",
"bool": true,
"ints": [1, 2, 3],
"floats": [1.2, 2.2, 3.3],
"strings": ["UA", "LT", "CA"],
"bools": [true, true, false, true],
"mixs": [1, 1.1, "UA", true],
"country": {
"code": "UA",
"name": "Ukraine",
"vacancy_count": 25000
},
"null": null
}`
)
var expected = map[string]interface{}{
"int": float64(1),
"float": float64(1.1),
"string": "UA",
"bool": true,
"ints": []interface{}{float64(1), float64(2), float64(3)},
"floats": []interface{}{float64(1.2), float64(2.2), float64(3.3)},
"strings": []interface{}{"UA", "LT", "CA"},
"bools": []interface{}{true, true, false, true},
"mixs": []interface{}{float64(1), float64(1.1), "UA", true},
"country": map[string]interface{}{
"code": "UA",
"name": "Ukraine",
"vacancy_count": float64(25000),
},
"null": nil,
}
{
var actual interface{}
require.NoError(t, json.Unmarshal([]byte(content), &actual))
require.Equal(t, expected, actual)
}
{
var actual map[string]interface{}
require.NoError(t, json.Unmarshal([]byte(content), &actual))
require.Equal(t, expected, actual)
}
}Раніше в функції ParseCountries вже наводив як парсити далі через приведення типів.
Популярність бібліотек для Elasticsearch залежить від правильного парсингу JSON-у
На DOU вже є стаття про Elasticsearch, де Євген використовував бібліотеку github.com/olivere/elastic (6.2k stars on GitHub), хоча є офіційна бібліотека github.com/elastic/go-elasticsearch (3.6k stars on GitHub).
Я вибрав github.com/olivere/elastic, бо ця бібліотека віддає мені json.RawMessage, який я можу запарсити в потрібну мені структуру:
func companySearch(client *elastic.Client, searchSource *elastic.SearchSource) ([]elasticsearch.Company, uint32, error) {
var result, err = client.Search("companies").SearchSource(searchSource).Do(context.Background())
if err != nil {
return nil, 0, err
}
var companies = make([]elasticsearch.Company, 0, len(result.Hits.Hits))
for _, hit := range result.Hits.Hits {
var company elasticsearch.Company
var source json.RawMessage = hit.Source
var unmarshalErr = json.Unmarshal([]byte(source), &company))
if unmarshalErr != nil {
logger.Error(errors.Trace(unmarshalErr))
continue
}
companies = append(companies, company)
}
return companies, uint32(result.Hits.TotalHits.Value), nil
}А офіційна бібліотека github.com/elastic/go-elasticsearch змушує своїх користувачів страждати з парсингом через interface{}.
Парсинг чисел з рядків
Коли нам потрібно запарсити {"value":"1.23«}, то в Go для цього є тип json.Number. Розглянемо його в порівнянні з json.RawMessage:
// A Number represents a JSON number literal.
type Number string
// String returns the literal text of the number.
func (n Number) String() string { return string(n) }
// Float64 returns the number as a float64.
func (n Number) Float64() (float64, error) {
return strconv.ParseFloat(string(n), 64)
}
// Int64 returns the number as an int64.
func (n Number) Int64() (int64, error) {
return strconv.ParseInt(string(n), 10, 64)
} | type RawMessage []byte
// MarshalJSON returns m as the JSON encoding of m.
func (m RawMessage) MarshalJSON() ([]byte, error) {
if m == nil {
return []byte("null"), nil
}
return m, nil
}
// UnmarshalJSON sets *m to a copy of data.
func (m *RawMessage) UnmarshalJSON(data []byte) error {
if m == nil {
return errors.New("json.RawMessage: ...")
}
*m = append((*m)[0:0], data...)
return nil
} |
Як бачимо, в json.Number відсутні методи MarshalJSON() та UnmarshalJSON(), бо в бібліотеці encoding/json тип Number вшитий:
var numberType = reflect.TypeOf(Number(""))
func stringEncoder(e *encodeState, v reflect.Value, opts encOpts) {
if v.Type() == numberType {
numStr := v.String()
// In Go1.5 the empty string encodes to "0", while this is not a valid number literal
// we keep compatibility so check validity after this.
if numStr == "" {
numStr = "0" // Number's zero-val
}
if !isValidNumber(numStr) {
e.error(fmt.Errorf("json: invalid number literal %q", numStr))
}
if opts.quoted {
e.WriteByte('"')
}
e.WriteString(numStr)
if opts.quoted {
e.WriteByte('"')
}
return
}
// ...
}Я привів код перевірки на numberType при серіалізації. Для десеріалізації така перевірка також є, зверніть увагу, що дані додатково перевіряються у функції isValidNumber.
Тепер на прикладі тестів розглянемо, як працює json.Number в порівнянні зі звичайним float64 та json.RawMessage:
package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestJsonNumberMarshal(t *testing.T) {
type Response struct {
Float float64 `json:"float"`
StringFloat float64 `json:"string_float,string"`
JsonNumber json.Number `json:"json_number"`
StringJsonNumber json.Number `json:"string_json_number,string"`
JsonRawMessage json.RawMessage `json:"json_raw_message"`
StringJsonRawMessage json.RawMessage `json:"string_json_raw_message"`
}
var actual, err = json.Marshal(Response{
Float: 20.01,
StringFloat: 15.12,
JsonNumber: json.Number("15.12"),
StringJsonNumber: json.Number("15.12"),
JsonRawMessage: json.RawMessage(`9.2`),
StringJsonRawMessage: json.RawMessage(`"9.2"`),
})
const (
// language=JSON
expected = `{"float":20.01,"string_float":"15.12","json_number":15.12,"string_json_number":"15.12","json_raw_message":9.2,"string_json_raw_message":"9.2"}`
)
require.NoError(t, err)
require.Equal(t, expected, string(actual))
}{
"float": 20.01,
"string_float": "15.12",
"json_number": 15.12,
"string_json_number": "15.12",
"json_raw_message": 9.2,
"string_json_raw_message": "9.2"
}package main
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestJsonNumberMarshalValidate(t *testing.T) {
type Response struct {
JsonNumber json.Number `json:"json_number"`
StringJsonNumber json.Number `json:"string_json_number,string"`
}
var actual, err = json.Marshal(Response{
JsonNumber: json.Number("not a number"),
StringJsonNumber: json.Number("too"),
})
require.EqualError(t, err, `json: invalid number literal "not a number"`)
require.Equal(t, []byte(nil), actual)
}
func TestJsonNumberUnmarshal(t *testing.T) {
type Response struct {
FloatJsonNumber json.Number `json:"json_number"`
StringJsonNumber json.Number `json:"string_json_number"`
StringTagJsonNumber json.Number `json:"string_tag_json_number,string"`
}
const (
// language=JSON
content = `{"json_number":0.1,"string_json_number":"0.02","string_tag_json_number":"0.03"}`
)
var response Response
require.NoError(t, json.Unmarshal([]byte(content), &response))
require.Equal(t, Response{
FloatJsonNumber: json.Number("0.1"),
StringJsonNumber: json.Number("0.02"),
StringTagJsonNumber: json.Number("0.03"),
}, response)
}
func TestJsonNumberUnmarshalValidate(t *testing.T) {
type Response struct {
JsonNumber json.Number `json:"json_number"`
}
const (
// language=JSON
content = `{"json_number":false}`
)
var response Response
var err = json.Unmarshal([]byte(content), &response)
require.EqualError(t, err, "json: cannot unmarshal bool into Go struct field Response.json_number of type json.Number")
require.Equal(t, Response{}, response)
}json.Number можна використовувати для оптимізації серіалізації, щоб уникнути подвійного конвертування, коли число вже у виді рядку.
Епілог
У цій статті я описав, як за допомогою інтерфейсу json.Unmarshaler та відкладеної десеріалізації json.RawMessage парсити різнотипні, але очікуванні, JSON-и.
Якщо ви пропустили попередню статтю про парсинг різнотипного JSON-у на прикладі вебсокетів, то ось вона: «Як парсити різнотипний JSON».
Для порівняння можете глянути, як JSON парсять на Stack Overflow:
- /questions/31129379/golang-unmarshal-complex-json
- /questions/48506800/parsing-json-in-golang-with-answer-with-array-without-keys
І хоч парсинг різнотипного JSON-у має зустрічатись рідко в реальних проєктах, але у всіх проєктах, в яких працював, цей парсинг чомусь був.
Реклама вакансії проєкту в сфері реклами
Більше двох років я співпрацюю з компанією Evrius, про Evrius є мій відгук, Evrius я часто згадував у своїх попередніх статтях.
Зараз рекрутери в Evrius шукають Go-розробника на проєкт AdTech, ось детальний опис вакансії, якщо у вас є бажання попрацювати з технологіями, які описував в попередніх статтях та розібратись, як працює рекламна мережа, то надсилайте резюме через форму вакансії на DOU або пишіть напряму Надії в LinkedIn.
Якщо хочете, то й мені можете написати, я також відповім, але краще рекрутерам.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.
17 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів