[Перевод] JSON in GO

02e43952973d2d0747727d4720fe35d8.png

Это перевод одноименной статьи.

Базовое использование

Сериализации JSON в Go

В стандартном пакете encoding/json присутствуют механизмы сериализации marshaling и десериализации unmarshaling JSON.

Пример:

data, err := json.Marshal(yourVar)

Метод Marshal() принимает переменную yourVar любого типа, которую нужно сериализовать в JSON, и возвращает два значения: сериализованные данные в виде байтового массива ([]byte) и ошибку (error), если таковая возникает.

Пример:

data, err := json.Marshal(yourVar)
if err != nil {
  return err
}

// можем использовать `data` без опасений

Ошибка возвращается только в случаях, когда невозможно корректно сериализовать данные. К примеру, если ваш объект содержит следующие типы, сериализация завершится с ошибкой:

ch := make(chan struct{})
_, err := json.Marshal(ch) // returns error *json.UnsupportedTypeError

compl := complex(10, 11)
_, err = json.Marshal(compl) // returns error *json.UnsupportedTypeError

fn := func() {}
_, err = json.Marshal(fn) // returns error *json.UnsupportedTypeError

Какие поля структуры видны для json пакета?


Одна из распространенных ошибок — ожидание, что пакет json сможет обработать как публичные (public), так и приватные (private) поля структур. На самом деле, пакет json сериализует только публичные поля, имена которых начинаются с заглавной буквы. Приватные поля, начинающиеся с маленькой буквы, будут игнорироваться при сериализации, что важно учитывать при проектировании структур данных для работы с JSON.

Десериализация JSON в Go

Для десериализации JSON-данных в Go используется метод json.Unmarshal(), позволяющий преобразовать данные из JSON обратно в структуру Go.

myVal := MyVal{}
byte := `{"some":"json"}`
err := json.Unmarhal(byte, &myVal)

При десериализации могут возникнуть следующие ошибки:

  • Если данные невозможно десериализовать из-за несоответствия типов или других проблем с форматом JSON.

  • Если в качестве второго аргумента передан не указатель, то есть изменения не могут быть применены к переданной переменной.

  • Если второй аргумент — nil, что означает отсутствие целевого объекта для десериализации данных.

Обработка имен полей.

При десериализации json.Unmarshal ищет совпадения имен полей в структуре с ключами в JSON. Если точное совпадение имени не найдено, поиск повторяется, игнорируя регистр букв. В случае, если в JSON отсутствует поле, соответствующее полю структуры, значение этого поля останется неизменным (то есть будет сохранено его начальное значение).

Структурные теги в Go для работы с JSON

Мы можем использовать структурные теги для управления именами полей или изменения имен при декодировании.

Предположим у нас есть структура с двумя полями. Когда мы сериализуем эти поля, оба поля будут заглавными. Часто нам это и требуется.

type User struct {
  ID string
  Username string
}

// вывод будет примерно таким
{"ID":"some-id","Username":"admin"}

Для модификации этого поведения мы можем воспользоваться структурными тегами. После определения типа поля добавляется текст, где первым словом идет имя поля в формате JSON, далее следует разделитель :, а после — значение тега в двойных кавычках, как показано в примере ниже.

type User struct {
  ID       string `json:"id"`     // Сериализуется как "id"
  Username string `json:"user"`   // Сериализуется как "user"
}


u := User{ID: "some-id", Username: "admin"}

// вывод будет примерно таким
{"id":"some-id","user":"admin"}

В примере мы переименовали оба поля. Имя поля может быть любым валидным json ключом.

Опция omitempty позволяет исключать поля из сериализованного JSON, если их значение равно нулю (или эквивалентно нулю для данного типа).

type User struct {
  ID       string `json:"id"`
  Username string `json:"user"`
  Age      int    `json:"age,omitempty"` // Пропускается, если значение 0
}

Если мы не хотим менять имя поля мы можем пропустить его, но не забывайте использовать запятую в случае использования omitempty

type User struct {
  ID string `json:"id"`
  Username string `json:"user"`
  Age string `json:",omitempty"` // обратите внимание на запятую
}

Если мы хотим игнорировать публичное поле, мы должны использовать тэг -

type User struct {
  ID       string `json:"id"`
  Username string `json:"user"`
  Age      int    `json:"-"` // Полностью игнорируется при сериализации
}


u := User{ID: "some-id", Username: "admin", Age: 18}

// поле age отсутствует:
{"id":"some-id","user":"admin"}

Важно помнить:

  • Структурные теги обрабатываются во время выполнения программы (runtime).

  • Компилятор не выдаст ошибки за неправильно сформированные структурные теги, что означает необходимость внимательной проверки синтаксиса тегов.

  • Опция omitempty и тег - предоставляют гибкость в управлении выводом JSON, позволяя создавать более чистый и оптимизированный вывод.

Использование json.Encoder и json.Decoder в Go

В пакете encoding/json Go также представлены json.Decoder и json.Encoder, которые дополняют функциональность json.Marshal() и json.Unmarshal().

Основное различие между этими инструментами заключается в том, что json.Encoder и json.Decoder предназначены для работы с потоками данных и напрямую взаимодействуют с объектами, поддерживающими интерфейс io.Writer для записи и io.Reader для чтения, соответственно. В отличие от этого, json.Marshal() и json.Unmarshal() оперируют массивами байтов.

Это делает json.Decoder и json.Encoder предпочтительными для использования в ситуациях, когда требуется обрабатывать данные на лету, еще до их полного получения или когда работа ведется с потоковыми данными.

Давайте, для примера, реализуем чтение тела запроса. Будем использовать и Unmarshal и Decoder.

req := CreateOrderRequest{}
if err := json.Decoder(r.Body).Decode(&req); err != nil {
  // обработка ошибки
}

// req готов к использованию 

Теперь используем Unmarshal для сравнения читабельности кода.

req := CreateOrderRequest{}
body, err := io.ReadAll(r.Body)
if err != nil {
  // обработка ошибки
}

if err = json.Unmarshal(body, &req); err != nil {
  // обработка ошибки
}

Еще одно различие, которое может подсказать нам, какой инструмент выбрать, заключается в том, что мы можем многократно применять json.Decoder и json.Encoder к одному и тому же io.Reader и io.Writer. Это означает, что если поток данных, передаваемый декодеру, содержит несколько объектов JSON, мы можем создать декодер один раз, но вызывать метод Decode() многократно.

req := CreateOrderRequest{}
decoder := json.Decoder(r.Body)

for err := decoder.Decode(&req); err != nil {
	// обработка одного запроса
}

В случае, когда у нас есть данные в формате []byte и мы предпочитаем использовать json.Decoder для их обработки, нам может помочь использование пакета bytes и его компонента Buffer. Buffer позволяет легко преобразовать наш слайс байтов в поток (io.Reader), который необходим для работы с json.Decoder.

var body []byte
buf := bytes.NewBuffer(body)

decoder := json.Decoder(buf)
for err := decoder.Decode(&req); err != nil {
	// обработка одного запроса
}

Тест

Я написал простые тесты, чтобы сравнить оба подхода.

package jsons

import (
	"bytes"
	"encoding/json"
	"io"
	"testing"
)

var j = []byte(`{"user":"Johny Bravo","items":[{"id":"4983264583302173928","qty": 5}]}`)
var createRequest = CreateOrderRequest{
	User: "Johny Bravo",
	Items: []OrderItem{
		{ID: "4983264583302173928", Qty: 5},
	},
}
var err error
var body []byte

// OrderItem представляет элемент заказа.
type OrderItem struct {
	ID  string `json:"id"` // Идентификатор элемента
	Qty int    `json:"qty"` // Количество
}

// CreateOrderRequest описывает запрос на создание заказа.
type CreateOrderRequest struct {
	User  string      `json:"user"` // Пользователь, совершающий заказ
	Items []OrderItem `json:"items"` // Список элементов заказа
}

// BenchmarkJsonUnmarshal измеряет производительность функции json.Unmarshal.
func BenchmarkJsonUnmarshal(b *testing.B) {
	b.ReportAllocs() // Отчет о выделениях памяти
	req := CreateOrderRequest{}
	b.ResetTimer() // Сброс таймера для чистого измерения

	for i := 0; i < b.N; i++ {
		err = json.Unmarshal(j, &req) // Десериализация JSON в структуру
	}
}

// BenchmarkJsonDecoder измеряет производительность использования json.Decoder.
func BenchmarkJsonDecoder(b *testing.B) {
	b.ReportAllocs()
	req := CreateOrderRequest{}
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		b.StopTimer() // Остановка таймера на время подготовки
		buff := bytes.NewBuffer(j) // Создание буфера для чтения
		b.StartTimer() // Возобновление измерения времени

		decoder := json.NewDecoder(buff) // Создание декодера
		err = decoder.Decode(&req) // Декодирование JSON в структуру
	}
}

// BenchmarkJsonMarshal измеряет производительность функции json.Marshal.
func BenchmarkJsonMarshal(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		body, err = json.Marshal(createRequest) // Сериализация структуры в JSON
	}
}

// BenchmarkJsonEncoder измеряет производительность использования json.Encoder.
func BenchmarkJsonEncoder(b *testing.B) {
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		encoder := json.NewEncoder(io.Discard) // Создание энкодера, вывод в /dev/null
		err = encoder.Encode(createRequest) // Кодирование структуры в JSON
	}
}

После запуска (по крайней мере на моих данных) json.Unmarshal() в три раза быстрее чем json.Decoder.
С другой стороны json.Marshal()и json.Encoder
показывают схожие результаты.

BenchmarkJsonUnmarshal-10        1345796               894.4 ns/op           336 B/op          9 allocs/op
BenchmarkJsonDecoder-10           522276              2226 ns/op            1080 B/op         13 allocs/op
BenchmarkJsonMarshal-10          6257662               193.1 ns/op           128 B/op          2 allocs/op
BenchmarkJsonEncoder-10          6867033               174.9 ns/op            48 B/op          1 allocs/op

Я призываю вас не полагаться на мои результаты, а провести подобные тесты на своих приложениях.

Форматирование JSON

Вы, возможно, заметили, что JSON-файл, полученный с помощью функций json.Marshal или json.Encoder, представляет собой компактную строку. Это означает, что он не содержит дополнительных пробелов, которые могли бы сделать его более читаемым для человека. В результате такой JSON идеален для передачи данных, но может быть неудобен для восприятия.

В качестве решения этой проблемы можно использовать функцию json.MarshalIndent, которая позволяет отформатировать вывод JSON, делая его более удобным для чтения.

Пример использования json.MarshalIndent:

data := map[string]int{
		"a": 1,
		"b": 2,
	}

	b, err := json.MarshalIndent(data, "<префикс>", "<отступ>")
	if err != nil {
		log.Fatal(err)
	}

	fmt.Println(string(b))
	
	// вывод
	{
<префикс><отступ>"a": 1,
<префикс><отступ>"b": 2
<префикс>}

MarshalJSON and UnmarshalJSON

Чтобы задать для типа собственные правила сериализации, нужно добавить методы MarshalJSON() и UnmarshalJSON():

type Marshaler interface {
	MarshalJSON() ([]byte, error)
}

type Unmarshaler interface {
	UnmarshalJSON([]byte) error
}

UnmarshalJSON example

Рассмотрим на примере. Предположим, мы получаем JSON с характеристиками компьютера, где размеры оперативной памяти и диска указаны в байтах, и нам нужно представить эти данные в более читаемом виде.

{
	"cpu": "Intel Core i5",
	"operatingSystem": "Windows 11",
	"memory": 17179869184,
	"storage": 274877906944
}

В таком формате данные сложно воспринимать. Подготовим структуру для их хранения:

type PC struct {
	CPU string
	OperatingSystem string
	Memory string
	Storage string
}

Для решения задачи потребуется создать новый тип. Мы реализуем для него метод UnmarshalJSON, который позволит кастомизировать процесс десериализации JSON в этот тип.

type Memory string // Определяем тип Memory как строку

// UnmarshalJSON - специализированный метод для десериализации данных JSON в тип Memory.
// Этот метод реализует интерфейс json.Unmarshaler, позволяя кастомизировать процесс десериализации.
func (m *Memory) UnmarshalJSON(b []byte) error {
    // Преобразуем входящие байты в строку и пытаемся конвертировать в число.
    // Это число предполагается быть размером памяти в байтах.
    size, err := strconv.Atoi(string(b))
    if err != nil {
        return err // Возвращаем ошибку, если конвертация не удалась
    }

    // Перебираем предопределенные размеры памяти и соответствующие суффиксы (например, MB, GB).
    for i, d := range memorySizes {
        if size > d {
            // Если размер больше текущего делителя, форматируем вывод используя этот делитель
            // и соответствующий суффикс, чтобы получить читаемое представление объема памяти.
            *m = Memory(fmt.Sprintf("%d %s", size/d, sizeSuffixes[i]))
            return nil // Завершаем функцию успешно
        }
    }

    // Если размер меньше любого из предопределенных делителей, выводим его как количество байт.
    *m = Memory(fmt.Sprintf("%d b", size))
    return nil
}

Этот метод позволяет десериализовать численное значение размера памяти из JSON в удобочитаемый формат с использованием соответствующих единиц измерения (например, GB, MB).
Полный код

Часто задаваемые вопросы (FAQ)

Что делать, если я не знаю схему JSON?

Если вы не уверены в структуре входящего JSON, у вас есть несколько способов обработать его. Одним из вариантов является использование словарей (maps). Например, если вы получаете JSON и хотите работать с ним динамически:

req := map[string]interface{}{}
if err != json.Decoder(r.Body).Decode(&req); err != nil {
  // обработка err
}

Таким образом, вы помещаете все данные в словарь. Теперь можно итерировать по нему и разрабатывать логику в зависимости от содержимого. Для определения типов данных можно использовать рефлексию:

for k, v := range req {
		refVal := reflect.TypeOf(v)
		fmt.Printf("ключ '%s' содержит значение типа %s\n", k, refVal)
	}
	
	/* пример вывода: 
	ключ 'two' содержит значение типа string 
	ключ 'three' содержит значение типа float64 
	ключ 'one' содержит значение типа int 
	*/

Почему я не вижу свои поля в JSON после сериализации?

Если после сериализации ваши поля не отображаются в JSON, это может быть связано с одной из двух причин:

  1. Поле не является публичным. В Go публичные поля начинаются с заглавной буквы. Если ваше поле начинается со строчной буквы, оно считается приватным и не будет участвовать в сериализации.

  2. Использован тэг json:"-" для поля. Этот тэг указывает библиотеке сериализации пропустить данное поле и не включать его в результирующий JSON. Пример использования.

Пропускать ли проверку ошибок в Marshal ()?

Строго говоря, нет. Но если вам кажется, что это необходимо, убедитесь, что ваши юнит-тесты надежно покрывают все случаи сериализации, которые могут привести к ошибке. В таком контексте, возможно, можно обойтись без явной проверки ошибки. Однако, не забывайте ясно комментировать в коде, почему проверка ошибки опущена, и каким образом тесты обеспечивают надежность вашего подхода. Это важно для понимания и безопасности вашего кода в долгосрочной перспективе.

Достаточно ли стандартного пакета encoding/json?

Для большинства задач, связанных с JSON в Go, стандартного пакета encoding/json вполне достаточно. Он обеспечивает все необходимые инструменты для сериализации и десериализации JSON, поддерживая при этом чистоту и простоту кода. Однако, если вы сталкиваетесь с особо крупными JSON-файлами или обрабатываете их в большом количестве, может появиться необходимость в поиске альтернативных решений, способных обеспечить более высокую производительность. В таких случаях стоит рассмотреть использование специализированных библиотек. В остальном же, стандартный пакет отлично справляется с задачами по работе с JSON.

Outside of the standard library

Если вы ищете более быструю альтернативу, вы можете обратить внимание на https://github.com/goccy/go-json. Это замена для стандартного пакета encoding/json.

Если JSON файл огромен, но вам нужна только его часть, вы можете использовать https://github.com/buger/jsonparser, который позволяет анализировать только часть всего файла.

© Habrahabr.ru