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

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

👍НравитсяПонравилось3
В избранноеВ избранном4
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

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

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

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