Приклади парсингу різнотипного 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 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів