Недавно мне довелось разрабатывать на Go http-клиент для сервиса, предоставляющего REST API с json-ом в роли формата кодирования. Стандартная задача, но в ходе работы мне пришлось столкнуться с нестандартной проблемой. Рассказываю в чем суть.
Как известно, формат json имеет типы данных. Четыре примитивных: строка, число, логический, null; и два структурных типа: объект и массив. В данном случае нас интересуют примитивные типы. Вот пример json кода с четырьмя полями разных типов:
{
"name":"qwerty",
"price":258.25,
"active":true,
"description":null,
}
Как видно в примере, строковое значение заключается в кавычки. Числовое — не имеет кавычек. Логический тип может иметь только одно из двух значений: true или false (без кавычек). И тип null соответственно имеет значение null (также без кавычек).
А теперь собственно сама проблема. В какой-то момент, при детальном рассмотрении получаемого от стороннего сервиса json-кода, я обнаружил, что одно из полей (назовем его price) помимо числового значения периодически имеет строковое значение (число в кавычках). Т. е. один и тот же запрос с разными параметрами может вернуть число в виде числа, а может вернуть это же число в виде строки. Ума не приложу, как на том конце организован код, возвращающий такие результаты, но видимо, это связано с тем, что сервис сам является агрегатором и тянет данные из разных источников, а разработчики не привели json ответа сервера к единому формату. Тем не менее, надо работать с тем что есть.
Но далее меня ждало еще большее удивление. Логическое поле (назовем его active), помимо значений true и false, возвращало строковые значения «true», «false», и даже числовые 1 и 0 (истина и ложь соответственно).
Вся эта путаница с типами данных не была бы критичной, если бы я обрабатывал json скажем на слаботипизированном PHP, но Go имеет сильную типизацию, и требует четкого указания типа десериализуемого поля. В итоге возникла необходимость реализовать механизм, позволяющий в процессе десериализации преобразовывать все значения поля active в логический тип, и любые значения поля price — в числовой.
Начнем с числового поля price.
Предположим что у нас есть json-код следующего вида:
[
{"id":1,"price":2.58},
{"id":2,"price":7.15}
]
Т. е. json содержит массив объектов с двумя полями числового типа. Стандартный код десериализации данного json-а на Go выглядит так:
type Target struct {
Id int `json:"id"`
Price float64 `json:"price"`
}
func main() {
jsonString := `[{"id":1,"price":2.58},
{"id":4,"price":7.15}]`
targets := []Target{}
err := json.Unmarshal([]byte(jsonString), &targets)
if err != nil {
fmt.Println(err)
return
}
for _, t := range targets {
fmt.Println(t.Id, "-", t.Price)
}
}
В данном коде мы десериализуем поле id в тип int, а поле price в тип float64. Теперь предположим, что наш json-код выглядит так:
[
{"id":1,"price":2.58},
{"id":2,"price":"2.58"},
{"id":3,"price":7.15},
{"id":4,"price":"7.15"}
]
Т. е. поле price содержит значения как числового типа, так и строкового. В данном случае только числовые значения поля price могут быть декодированы в тип float64, строковые же значения вызовут ошибку о несовместимости типов. Это значит, что ни float64, ни любой другой примитивный тип не подходят для десериализации данного поля, и нам необходим свой пользовательский тип со своей логикой десериализации.
В качестве такого типа объявим структуру CustomFloat64 с единственным полем Float64 типа float64.
type CustomFloat64 struct{
Float64 float64
}
И сразу укажем данный тип для поля Price в структуре Target:
type Target struct {
Id int `json:"id"`
Price CustomFloat64 `json:"price"`
}
Теперь необходимо описать собственную логику декодирования поля c типом CustomFloat64.
В пакете «encoding/json» предусмотрены два специальных метода: MarshalJSON и UnmarshalJSON, которые и предназначены для кастомизации логики кодирования и декодирования конкретного пользовательского типа данных. Достаточно переопределить эти методы и описать собственную реализацию.
Переопределим метод UnmarshalJSON для произвольного типа CustomFloat64. При этом необходимо строго следовать сигнатуре метода, иначе он просто не сработает, а главное не выдаст при этом ошибку.
func (cf *CustomFloat64) UnmarshalJSON(data []byte) error {
На входе данный метод принимает слайс байт (data), в котором содержится значение конкретного поля декодируемого json. Если преобразовать данную последовательность байт в строку, то мы увидим значение поля именно в том виде, в каком оно записано в json. Т. е. если это строковый тип, то мы увидим именно строку с двойными кавычками («258»), если числовой тип, то увидим строку без кавычек (258).
Чтобы отличить числовое значение от строкового, необходимо проверить, является ли первый символ кавычкой. Так как символ двойной кавычки в таблице UNICODE занимает один байт, нам достаточно проверить первый байт слайса data, сравнив его с номером символа в UNICODE. Это номер 34. Обратите внимание, что в общем случае, символ не равнозначен байту, так как может занимать больше одного байта. Символу в Go равнозначен тип rune (руна). В нашем же случае достаточно данного условия:
if data[0] == 34 {
Если условие выполняется, то значение имеет строковый тип, и нам необходимо получить строку между кавычками, т. е. слайс байт между первым и последним байтом. Именно в этом слайсе содержится числовое значение, которое может быть декодировано в примитивный тип float64. Это значит, что мы можем применить к нему метод json.Unmarshal, при этом результат сохраняя в поле Float64 структуры CustomFloat64.
err := json.Unmarshal(data[1:len(data)-1], &cf.Float64)
Если же слайс data начинается не с кавычки, то значит в нем уже содержится числовой тип данных, и мы можем применить метод json.Unmarshal непосредственно ко всему слайсу data.
err := json.Unmarshal(data, &cf.Float64)
Вот полный код метода UnmarshalJSON:
func (cf *CustomFloat64) UnmarshalJSON(data []byte) error {
if data[0] == 34 {
err := json.Unmarshal(data[1:len(data)-1], &cf.Float64)
if err != nil {
return errors.New("CustomFloat64: UnmarshalJSON: " + err.Error())
}
} else {
err := json.Unmarshal(data, &cf.Float64)
if err != nil {
return errors.New("CustomFloat64: UnmarshalJSON: " + err.Error())
}
}
return nil
}
В итоге, с применением метода json.Unmarshal к нашему json-коду, все значения поля price будут прозрачно для нас преобразованы в примитивный тип float64, и результат запишется в поле Float64 структуры CustomFloat64.
Теперь нам может понадобиться преобразовать структуру Target обратно в json. Но, если мы применяем метод json.Marshal непосредственно к типу CustomFloat64, то сериализуем данную структуру в виде объекта. Нам же необходимо кодировать поле price в числовое значение. Чтобы кастомизировать логику кодирования пользовательского типа CustomFloat64, реализуем для него метод MarshalJSON, при этом строго соблюдая сигнатуру метода:
func (cf CustomFloat64) MarshalJSON() ([]byte, error) {
json, err := json.Marshal(cf.Float64)
return json, err
}
Все, что нужно сделать в этом методе, это опять же использовать метод json.Marshal, но уже применять его не к структуре CustomFloat64, а к ее полю Float64. Из метода возвращаем полученный слайс байт и ошибку.
Вот полный код с выводом результатов сериализации и десериализации (проверка ошибок опущена для краткости, номер байта с символом двойных кавычек вынесен в константу):
package main
import (
"encoding/json"
"errors"
"fmt"
)
type CustomFloat64 struct {
Float64 float64
}
const QUOTES_BYTE = 34
func (cf *CustomFloat64) UnmarshalJSON(data []byte) error {
if data[0] == QUOTES_BYTE {
err := json.Unmarshal(data[1:len(data)-1], &cf.Float64)
if err != nil {
return errors.New("CustomFloat64: UnmarshalJSON: " + err.Error())
}
} else {
err := json.Unmarshal(data, &cf.Float64)
if err != nil {
return errors.New("CustomFloat64: UnmarshalJSON: " + err.Error())
}
}
return nil
}
func (cf CustomFloat64) MarshalJSON() ([]byte, error) {
json, err := json.Marshal(cf.Float64)
return json, err
}
type Target struct {
Id int `json:"id"`
Price CustomFloat64 `json:"price"`
}
func main() {
jsonString := `[{"id":1,"price":2.58},
{"id":2,"price":"2.58"},
{"id":3,"price":7.15},
{"id":4,"price":"7.15"}]`
targets := []Target{}
_ := json.Unmarshal([]byte(jsonString), &targets)
for _, t := range targets {
fmt.Println(t.Id, "-", t.Price.Float64)
}
jsonStringNew, _ := json.Marshal(targets)
fmt.Println(string(jsonStringNew))
}
Результат выполнения кода:
1 - 2.58
2 - 2.58
3 - 7.15
4 - 7.15
[{"id":1,"price":2.58},{"id":2,"price":2.58},{"id":3,"price":7.15},{"id":4,"price":7.15}]
Перейдем ко второй части и реализуем аналогичный код для десериализации json-а с несогласованными значениями логического поля.
Предположим что у нас есть json-код следующего вида:
[
{"id":1,"active":true},
{"id":2,"active":"true"},
{"id":3,"active":"1"},
{"id":4,"active":1},
{"id":5,"active":false},
{"id":6,"active":"false"},
{"id":7,"active":"0"},
{"id":8,"active":0},
{"id":9,"active":""}
]
В данном случае поле active подразумевает логический тип и наличие только одного из двух значений: true и false. Значения не логического типа необходимо будет преобразовать в логический в ходе десериализации.
В текущем примере мы допускаем следующие соответствия. Значению true соответствуют: true (логическое), «true» (строковое), «1» (строковое), 1 (числовое). Значению false соответствуют: false (логическое), «false» (строковое), «0» (строковое), 0 (числовое), "" (пустая строка).
Для начала объявим целевую структуру десериализации. В качестве типа поля Active сразу указываем пользовательский тип CustomBool:
type Target struct {
Id int `json:"id"`
Active CustomBool `json:"active"`
}
CustomBool является структурой с одним единственным полем Bool типа bool:
type CustomBool struct {
Bool bool
}
Реализуем для данной структуры метод UnmarshalJSON. Сразу приведу код:
func (cb *CustomBool) UnmarshalJSON(data []byte) error {
switch string(data) {
case `"true"`, `true`, `"1"`, `1`:
cb.Bool = true
return nil
case `"false"`, `false`, `"0"`, `0`, `""`:
cb.Bool = false
return nil
default:
return errors.New("CustomBool: parsing \"" + string(data) + "\": unknown value")
}
}
Так как поле active в нашем случае имеет ограниченное количество значений, мы можем с помощью конструкции switch-case принять решение, о том, чему должно быть равно значение поля Bool структуры CustomBool. Для проверки понадобится всего два блока case. В первом блоке мы проверяем значение на соответствие true, во втором — false.
При записи возможных значений, следует обратить внимание на роль грависа (это такая кавычка на клавише с буквой Ё в английской раскладке). Данный символ позволяет экранировать двойные кавычки в строке. Для наглядности данным символом я обрамил и значения с кавычками и без кавычек. Таким образом, `false` соответствует строке false (без кавычек, тип bool в json), а `«false»` соответствует строке «false» (с кавычками, тип string в json). Тоже самое и со значениями `1` и `«1»` Первое — это число 1 (в json записано без кавычек), второе — строка «1» (в json записано с кавычками). Вот эта запись `""` — это пустая строка, Т. е. в формате json она выглядит так: "".
Соответствующее значение (true или false) мы записываем непосредственно в поле Bool структуры CustomBool:
cb.Bool = true
В блоке defaul возвращаем ошибку о том, что поле имеет неизвестное значение:
return errors.New("CustomBool: parsing \"" + string(data) + "\": unknown value")
Теперь мы можем применять метод json.Unmarshal к нашему json-коду, и значения поля active будут преобразовываться в примитивный тип bool.
Реализуем метод MarshalJSON для структуры CustomBool:
func (cb CustomBool) MarshalJSON() ([]byte, error) {
json, err := json.Marshal(cb.Bool)
return json, err
}
Здесь ничего нового. Метод выполняет сериализацию поля Bool структуры CustomBool.
Вот полный код с выводом результатов сериализации и десериализации (проверка ошибок опущена для краткости):
package main
import (
"encoding/json"
"errors"
"fmt"
)
type CustomBool struct {
Bool bool
}
func (cb *CustomBool) UnmarshalJSON(data []byte) error {
switch string(data) {
case `"true"`, `true`, `"1"`, `1`:
cb.Bool = true
return nil
case `"false"`, `false`, `"0"`, `0`, `""`:
cb.Bool = false
return nil
default:
return errors.New("CustomBool: parsing \"" + string(data) + "\": unknown value")
}
}
func (cb CustomBool) MarshalJSON() ([]byte, error) {
json, err := json.Marshal(cb.Bool)
return json, err
}
type Target struct {
Id int `json:"id"`
Active CustomBool `json:"active"`
}
func main() {
jsonString := `[{"id":1,"active":true},
{"id":2,"active":"true"},
{"id":3,"active":"1"},
{"id":4,"active":1},
{"id":5,"active":false},
{"id":6,"active":"false"},
{"id":7,"active":"0"},
{"id":8,"active":0},
{"id":9,"active":""}]`
targets := []Target{}
_ = json.Unmarshal([]byte(jsonString), &targets)
for _, t := range targets {
fmt.Println(t.Id, "-", t.Active.Bool)
}
jsonStringNew, _ := json.Marshal(targets)
fmt.Println(string(jsonStringNew))
}
Результат выполнения кода:
1 - true
2 - true
3 - true
4 - true
5 - false
6 - false
7 - false
8 - false
9 - false
[{"id":1,"active":true},{"id":2,"active":true},{"id":3,"active":true},{"id":4,"active":true},{"id":5,"active":false},{"id":6,"active":false},{"id":7,"active":false},{"id":8,"active":false},{"id":9,"active":false}]
Выводы
Во-первых. Переопределение методов MarshalJSON и UnmarshalJSON для произвольных типов данных позволяет кастомизировать сериализацию и десериализацию конкретного поля json-кода. Помимо указанных вариантов использования, данные функции применяются для работы с полями, допускающими значение null.
Во-вторых. Формат текстового кодирования json — это широко используемый инструмент для обмена информацией, и одним из его преимуществ перед другими форматами является наличие типов данных. За соблюдением этих типов надо строго следить.
negasus
Хороший пример для тех, кто еще не пробовал custom сериализацию.
мы не делаем проверку на длину слайса. Но я допускаю, что эта проверка опущена для сокращения статьи)Один момент смутил только, при вызове
ARechitsky
Кажется, это дефект (issue) json-библиотеки. Там должен быть не []byte, а JsonValue (интерфейс?). И лежать в нем должен один из JsonNull, JsonString, JsonNumber, JsonBool, JsonObject, JsonArray.
Если у нас есть слайс байтов, значит сам json уже распаршен (как иначе могут быть найдены границы слайса?). Значит конструкции вида
{"id":1, "price":}
досюда дойти не могут — они будут отброшены раньше. Но это неочевидно (а может даже ошибочно — я не смотрел исходники) из-за интерфейса библиотеки.fougasse
Может, они там регуляркой скобки просто ищут, хотя, конечно, замечено правильно.
ARechitsky
Регуляркой нельзя. Регулярка не отловит вложенность произвольного уровня. И «по-простому» нельзя — в строковом литерале может быть закрывающая скобка, ее нужно пропустить. Ну а если учитывать экранирование символов — так это уже полный парсинг и получается)
ilyapirogov
На самом деле, отловить произвольную вложенность регуляркой можно. Другое дело, что Golang это не поддерживает.
jeConf
Вот и правильно. А то понапишут регэкспов с рекурсией и дьявола вызывают
creker
ARechitsky
Вот есть у нас джсон `{«internal1»:{«internal2»:{}}}`. Как он распарсится? Допустим, парсим мы не в interface{} а в конкретный тип. Окей, анмаршал весь слайс как конкретный объект. Прочитали токен «internal1», смотрим — сейчас должен быть объект типа Internal1. Нужно подготовить слайс для него, то есть понять где он заканчивается. Для этого нужно пройтись и полностью распарсить {«internal2»:{}} — чтобы понять, что это наша закрывающая скобка, что здесь слайс заканчивается. В том числе определить что полю «internal2» соответствует слайс {} — иначе как мы поймем что поле «internal2» закончилось и закрывающая скобка соответствует концу нашего internal1.
Окей, определили слайс для internal1, вызвали анмаршал для этого слайса. Теперь анмаршал будет снова парсить содержимое слайса {«internal2»:{}}, в том числе определять что полю «internal2» соответствует слайс {}.
Слайс — это недостаточное количество информации. Нужно либо передавать для каста к пользовательскому типу «размеченный слайс», decodeState, узел дерева разбора; либо парсинг будет неэффективным, одни и те же слайсы будут парситься по нескольку раз. Возможно, из-за второго и требуется заменять на «что-то более быстрое»
Если аллокации неэффективны — нужно искать способы повышать эффективность (выделять пачками-массивами?)
creker
Если internal1 и internal2 оба реализуют метод UnmarshalJSON, то библиотека сама вызовет его только у internal1. То, что у него внутри вложенные типы со своим UnmarshalJSON, она пропустит. Как только встречается открывающая скобка объекта и библиотека видит, что для этого объекта есть кастомный анмаршалинг, она сразу бежит до конца этого объекта как бы вложенный он ни был. Ежели методы не реализованы, то библиотека использует рефлексию и парсит последовательно поля. Поэтому парсинг в библиотеке полностью потоковый и, собственно, есть json.Decoder, который с потоками и работает.
Для вызывающей стороны вполне достаточно. Задача этой библиотеки маршалить байты в структуры, а не предоставлять структуру JSON вызывающей стороне. Все эти типы данных JSON, токенизация, дерево разбора — все это хранится внутри и наружу не торчит.
Подобные примитивные оптимизации уже сделаны. Единственный реальный способ ускорения это генерация кода как в easyjson, либо отсутствие сериализации как fastjson, где парсер всего лишь находит границы полей и значений, а сериализация происходит только в момент обращения к конкретному полю по ключу методами вроде GetInt, GetBool. Даже строки не аллоцируются, а возвращается слайс из JSON строки.
ARechitsky
Бежит, проверяя валидность джсона внутри. Фактически, проводит работу по парсингу внутреннего объекта — без этого невозможно найти конец объекта.
Допустим у нас нет кастомного анмаршалинга. Как будет работать библиотека? Я предполагаю что так: «О, поле internal1 типа Internal1. Для типа нет кастомного анмаршалинга, что ж, найду конец поля internal1 и вызову рекурсивно анмаршалинг полученного слайса в тип Internal1». И по слайсу будет сделано два прохода — сначала с целью определить конец Internal1, потом чтобы собственно распарсить. Или конец поля ищется только для полей с кастомным анмаршаллингом?
Тут еще надо определить что вы имеете в виду под вызывающей стороной. Потребитель который вызывает условный JsonStringToObject должен предоставить только слайс/строку, да. А для восстановления объекта из кастомного джсон-представления библиотеке лучше бы использовать метод JsonNodeToObject. Нет кастомного анмаршалинга — вызывай JsonStringToObject и не парься. Есть — определи JsonNodeToObject и вызывай JsonStringToObject.
Вообще, в UnmarshalJSON всегда передается слайс, гарантированно являющийся JsonNode. По нему парсер уже прошелся и определил что он валидный (иначе бы парсер не нашел конец объекта). Тем не менее в сигнатуре это никак не отражено и единственное что с этим слайсом можно сделать — это вызвать json.Unmarshal(data, &raw). Который снова будет проверять, что в слайсе валидный джсон. На слайсах невозможно построить эффективный с точки зрения процессорного времени парсинг, по крайней мере кастомный
creker
Естественно всегда ищется. Как еще сериализовать объект, если начала и конца не знаешь. Вся разница в том, что без кастомного он идет сам рекурсивно по полям. С кастомным ему главное найти конец объекта, быстро пройдясь по всем токенам до конца.
Дело здесь не в слайсах, а в повторной валидации, если вызывать Unmarshal внутри UnmarshalJSON. И касаться это будет только объектов с кастомной сериализацией, что редкость. Значение полей все равно валидировать надо тому, кто делает сериализацию. encoding.Json максимум проверяет, что символы корректные для JSON в целом. С эффективностью тут все ок. В любом случае, это несущественная проблема в сравнении с аллокациями и если прямо надо быстро, то есть решения, которые действительно быстрые.
Слайсы это в целом правильный подход, т.к. они указывают на изначальный буфер. В любой высокопроизводительной библиотеке будет делаться так же. Даже если она будет возвращать Node, то внутри у него будет тот же слайс. И все это по максимуму будет с аллокациями на стеке.
Вообще, почитайте лучше сами исходники. Код там довольно простой и понятный.
ARechitsky
Уточню: на сырых слайсах.
Я не против слайсов в целом и не предлагаю аллоцировать новые буферы на каждый чих. Я считаю дефектом что в UnmarshalJSON передается сырой слайс (byte[]), а не завернутый в Node. Да, Node тоже надо аллоцировать, но это-то должно быть копейками.
Вызывать Unmarshal внутри UnmarshalJSON — кажется очень естественно, а текущие интерфейсы делают это неэффективным. Почитал исходники, если есть много вложенных объектов с кастомными анмаршалингами — то асимптотика становится квадратичной от уровня вложенности за счет повторных валидаций.