Приклади парсингу різнотипного 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:

І хоч парсинг різнотипного JSON-у має зустрічатись рідко в реальних проєктах, але у всіх проєктах, в яких працював, цей парсинг чомусь був.

Реклама вакансії проєкту в сфері реклами

Більше двох років я співпрацюю з компанією Evrius, про Evrius є мій відгук, Evrius я часто згадував у своїх попередніх статтях.

Зараз рекрутери в Evrius шукають Go-розробника на проєкт AdTech, ось детальний опис вакансії, якщо у вас є бажання попрацювати з технологіями, які описував в попередніх статтях та розібратись, як працює рекламна мережа, то надсилайте резюме через форму вакансії на DOU або пишіть напряму Надії в LinkedIn.

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

👍НравитсяПонравилось6
В избранноеВ избранном7
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

у селі я осилив силу сала

Якась каша
Стаття про Go та JSON
Навіщо пхати сюди ПХП?

Забагато зайвої води. Величезні лістинги.
Достатньо було про інтерфейси сказати та про json.RawMessage

Просто зізнайтесь що вам було лінь читати статтю

Коли поле countries заповнене, то серіалізується в JSON-об’єкт, а коли пусте, то в JSON-масив. Подібна поведінка створює складності на стороні парсингу цього різнотипного JSON-у.
Якщо ми захочемо, отриманий від PHP різнотипний JSON спарсити в тип []Project, то отримаємо логічну помилку json: при парсингу поля «countries» очікував «{», а отримав «[».

Це проблема не парсингу, а маппінгу.

Одна справа — розпарсати JSON в якесь дерево об’єктів які представляють собою JSON-об’єкти, JSON-масиви та примітивні типи. Інша справа — замапити це на свої класи/структури, які очікують конкретні поля з конкретними типами.

Автор — відрізняй парсинг від маппінгу.

У кожної мови своя назва перетворення об’єкту в JSON та JSON-у в об’єкт

Назва? API же.

Більше, того API не в мов програмування а в бібліотек.

В деяких мовах програмування JSON-парсинг є в стандартних бібліотеках, але в більшості — немає. Але є багато різних thirdparty. В .Net є Newtonsoft.Json (thirdparty) та System.Text.Json (рідна), в Java є Jackson, Gson, Json-io, Genson. І т.д.

І окремо питання про маппінг.

В JS немає маппінгу, наприклад, бо JS не має строгої типізації.

Що треба виправити щоб стаття стала кращою? Перейменувати?

Треба би логічно виокремити парсинг (бачу тут це робиться через десеріалізацію в interface) і маппінг.

Із-за різноманітної термінології на початку статті зробив примітку:

У цій статті будуть використовуватись терміни серіалізація та десеріалізація, а термін парсинг у контексті різнотипного JSON-у

Я читав примітку :-)

Але в тексті все одно використовується таке:

розпарсити масив в структуру

Плюс це якась висмоктана з пальця термінологія, яка бере існуючі терміни з цілком конкретним змістом, і в рамках окремої статті вкладає в них зовсім інший зміст, для якого теж вже є конкретні терміни (а саме маппінг). При чому ці речі (парсинг і маппінг) не є взаємовиключинми — але в статті парсинг і десеріалізація (фактично парсинг з маппінгом і парсинг без маппінгу) подаються як альтернативи між якими потрібно обирати.

Плюс сама стаття цієї саморобної термінології не дотримуєтся.
Спочатку:

термін парсинг у контексті різнотипного JSON-у

А потім

розпарсити масив в структуру:
[1, 2]
розпарсити масив в структуру

Погоджуюсь, згідно попередньої примітки мав написати:

десеріалізувати масив в структуру

Визнаю свою помилку.

При чому ці речі (парсинг і маппінг) не є взаємовиключними — але в статті парсинг і десеріалізація (фактично парсинг з маппінгом і парсинг без маппінгу) подаються як альтернативи між якими потрібно обирати.

В статті є ж змішаний парсинг через interface{}.

Визнаю свою помилку.

Еее... ок.
Я взагалі для інформації написав, щоб ви були в курсі — при написанні наступних статей особливо.

В статті є ж змішаний парсинг через interface{}.

Це не зовсім те. Точніше зовсім не те.

В Java є така бібліотека Jackson яка дозволяє зробити спочатку окремо парсинг (в таку абстракцію як JsonNode — щось типу як DOM документ для XML), а потім окремо маппінг (через treeToValue).

І взагалі ці ж всі речі робили колись надцять років тому з XML, а тепер JSON лише повторює їх. XML раніше всі парсили у DOM дерево (або streaming AKA SAX парсери використовували — але це окрема тема), а потім вже почали маппінг додавати з XML-я на свої типи. З JSON-ом все те саме, але на 10 років пізніше — немає сенсу вигадувати нову термінологію, вже все давно існує.

Я впевнений що є загальні статті про парсинг JSON-у з правильною термінологією, навіть офіційний сайт має українську локалізацію, але статей з хорошими практичними прикладами парсингу в Go мало або взагалі відсутні: golang unmarshal incorrect json.

Стаття саме про практичні приклади бо це важливіше.

но как по факту «маршалинг» как раз самая точная терминология в контексте ))

Я не кажу що стаття погана, але є помилки. Це нормально, не треба через це так переживати. Чи вам потрібні або компліментарні коментарі, або ніяких?

Добре, ви молодець що написали стільки статей про десеріалізацію JSON в Go

Очень хороший и толковый материал, спасибо большое !

сало то добре, але стосовно іншого — треба обдумати

Подписаться на комментарии