Приклади парсингу різнотипного JSON-у в Go. Обіцяне продовження

Автор або джерело: Ярослав Характерник

Першоджерело

Go JSON, tech,

Повна версія

Приклади парсингу різнотипного JSON-у в Go. Обіцяне продовження

Підписуйтеся наTelegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

Привіт, мене звати Ярослав. Уже три роки працюю з 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"
    "github.com/stretchr/testify/require"
    "testing"
)

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"
    "github.com/stretchr/testify/require"
    "testing"
)

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"
    "github.com/stretchr/testify/require"
    "testing"
)

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"
    "github.com/stretchr/testify/require"
    "testing"
)

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"
    "github.com/stretchr/testify/require"
    "testing"
)

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 (
    "github.com/stretchr/testify/require"
    "testing"
)

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"
    "github.com/stretchr/testify/require"
    "testing"
)

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"
    "github.com/stretchr/testify/require"
    "testing"
)

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"
    "github.com/stretchr/testify/require"
    "testing"
)

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 coutnries, countriesUnmarshalErr = ParseCountries(i.Countries)
    if countriesUnmarshalErr != nil {
        return countriesUnmarshalErr
    }

    p.ID = i.ID
    p.Active = i.Active
    p.Countries = coutnries

    return nil
}

func ParseCountries(source interface{}) ([]Country, error) {
    // ...
}


package main

import (
    "encoding/json"
    "github.com/stretchr/testify/require"
    "sort"
    "testing"
)

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"
    "github.com/stretchr/testify/require"
    "testing"
)

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"
    "github.com/stretchr/testify/require"
    "testing"
)

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"
    "github.com/stretchr/testify/require"
    "testing"
)

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.

Якщо хочете, то й мені можете написати, я також відповім, але краще рекрутерам.