Хабрахабр

[Из песочницы] Go: десериализация JSON с неправильной типизацией, или как обходить ошибки разработчиков API

image

Недавно мне довелось разрабатывать на 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.582 - 2.583 - 7.154 - 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 - true2 - true3 - true4 - true5 - false6 - false7 - false8 - false9 - 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 — это широко используемый инструмент для обмена информацией, и одним из его преимуществ перед другими форматами является наличие типов данных. За соблюдением этих типов надо строго следить.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»