Ir: deserialización JSON con tipeo incorrecto, o cómo evitar errores de desarrollador de API

imagen

Recientemente, desarrollé un cliente http en Go para un servicio que proporciona una API REST con json como formato de codificación. Una tarea estándar, pero en el curso del trabajo tuve que enfrentar un problema no estándar. Te digo cuál es el punto.

Como sabes, el formato json tiene tipos de datos. Cuatro primitivas: cadena, número, booleano, nulo; y dos tipos estructurales: un objeto y una matriz. En este caso, estamos interesados ​​en los tipos primitivos. Aquí hay un ejemplo de código json con cuatro campos de diferentes tipos:

{
	"name":"qwerty",
	"price":258.25,
	"active":true,
	"description":null,
}

Como muestra el ejemplo, el valor de la cadena está entre comillas. Numérico: no tiene comillas. Un tipo booleano solo puede tener uno de dos valores: verdadero o falso (sin comillas). Y el tipo nulo es en consecuencia nulo (también sin comillas).

Y ahora el problema en sí. En algún momento, en un examen detallado del código json recibido de un servicio de terceros, descubrí que uno de los campos (llamémoslo precio) periódicamente tiene un valor de cadena (el número entre comillas) además del valor numérico. Es decir, la misma consulta con diferentes parámetros puede devolver un número como número, o puede devolver el mismo número como una cadena. No puedo imaginar cómo el código que devuelve dichos resultados está organizado en el otro extremo, pero aparentemente esto se debe al hecho de que el servicio en sí es un agregador y extrae datos de diferentes fuentes, y los desarrolladores no llevaron la respuesta del servidor json a un solo formato. Sin embargo, es necesario trabajar con lo que es.

Pero luego estaba aún más sorprendido. El campo lógico (llamémoslo activo), además de verdadero y falso, devolvió los valores de cadena "verdadero", "falso" e incluso los números 1 y 0 (verdadero y falso, respectivamente).

Toda esta confusión sobre los tipos de datos no sería crítica si procesara json say en PHP con un tipo débil, pero Go tiene un tipo fuerte y requiere una indicación clara del tipo de campo deserializado. Como resultado, era necesario implementar un mecanismo que permitiera convertir todos los valores del campo activo a un tipo lógico durante el proceso de deserialización, y cualquier valor del campo de precio a uno numérico.

Comencemos con el campo de precios.

Supongamos que tenemos un código json como este:

[
	{"id":1,"price":2.58},
	{"id":2,"price":7.15}
]

Es decir, json contiene una matriz de objetos con dos campos de tipo numérico. El código de deserialización estándar para este json en Go se ve así:

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)
	}
}

En este código, deserializaremos el campo id a int y el campo price a float64. Ahora supongamos que nuestro código json se ve así:

[
	{"id":1,"price":2.58},
	{"id":2,"price":"2.58"},
	{"id":3,"price":7.15},
	{"id":4,"price":"7.15"}
]

Es decir, el campo de precio contiene valores de un tipo numérico y una cadena. En este caso, solo los valores numéricos del campo de precio se pueden decodificar en tipo float64, mientras que los valores de cadena causarán un error sobre la incompatibilidad de los tipos. Esto significa que ni float64 ni ningún otro tipo primitivo son adecuados para deserializar este campo, y necesitamos nuestro propio tipo personalizado con su propia lógica de deserialización.

Como tal tipo, declare una estructura CustomFloat64 con un solo campo Float64 de tipo float64.

type CustomFloat64 struct{
	Float64 float64
}

E inmediatamente indique este tipo para el campo Precio en la estructura Objetivo:

type Target struct {
	Id    int           `json:"id"`
	Price CustomFloat64 `json:"price"`
}

Ahora necesita describir su propia lógica para decodificar un campo de tipo CustomFloat64.

El paquete encoding / json proporciona dos métodos especiales: MarshalJSON y UnmarshalJSON , que están diseñados para personalizar la lógica de codificación y decodificación de un tipo de datos de usuario específico. Es suficiente anular estos métodos y describir su propia implementación.

Anule el método UnmarshalJSON para un tipo arbitrario CustomFloat64. En este caso, es necesario seguir estrictamente la firma del método; de lo contrario, simplemente no funcionará y, lo más importante, no producirá un error.

func (cf *CustomFloat64) UnmarshalJSON(data []byte) error {

En la entrada, este método toma una porción de bytes (datos), que contiene el valor de un campo particular del json decodificado. Si convertimos esta secuencia de bytes en una cadena, veremos el valor del campo exactamente en la forma en que está escrito en json. Es decir, si es un tipo de cadena, veremos exactamente una cadena con comillas dobles ("258"), si es un tipo numérico, veremos una cadena sin comillas (258).

Para distinguir un valor numérico de un valor de cadena, debe verificar si el primer carácter es una comilla. Dado que el carácter de comillas dobles en la tabla UNICODE ocupa un byte, solo necesitamos verificar el primer byte del segmento de datos comparándolo con el número de caracteres en UNICODE. Este es el número 34. Tenga en cuenta que, en general, un carácter no es equivalente a un byte, ya que puede tomar más de un byte. Un símbolo en Go es equivalente a rune (rune). En nuestro caso, esta condición es suficiente:

if data[0] == 34 {

Si se cumple la condición, el valor tiene un tipo de cadena, y necesitamos obtener la cadena entre comillas, es decir, el byte de corte entre el primer y el último byte. Este segmento contiene un valor numérico que puede decodificarse en el tipo primitivo float64. Esto significa que podemos aplicarle el método json.Unmarshal, mientras guardamos el resultado en el campo Float64 de la estructura CustomFloat64.

err := json.Unmarshal(data[1:len(data)-1], &cf.Float64)

Si el segmento de datos no comienza con una comilla, entonces ya contiene un tipo de datos numéricos y podemos aplicar el método json.Unmarshal directamente a todo el segmento de datos.

err := json.Unmarshal(data, &cf.Float64)

Aquí está el código completo para el método 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
}

Como resultado, utilizando el método json.Unmarshal para nuestro código json, todos los valores del campo de precio se convertirán de manera transparente a un tipo primitivo float64 para nosotros, y el resultado se escribirá en el campo Float64 de la estructura CustomFloat64.

Ahora es posible que necesitemos convertir la estructura de destino nuevamente a json. Pero, si aplicamos el método json.Marshal directamente al tipo CustomFloat64, serializamos esta estructura como un objeto. Necesitamos codificar el campo de precio en un valor numérico. Para personalizar la lógica de codificación del tipo personalizado CustomFloat64, implementamos el método MarshalJSON, mientras observamos estrictamente la firma del método:

func (cf CustomFloat64) MarshalJSON() ([]byte, error) {
	json, err := json.Marshal(cf.Float64)
	return json, err
}

Todo lo que necesita hacer en este método es usar nuevamente el método json.Marshal, pero ya no lo aplica a la estructura CustomFloat64, sino a su campo Float64. Del método devolvemos el segmento de bytes recibido y el error.

Aquí está el código completo que muestra los resultados de la serialización y deserialización (la comprobación de errores se omite por brevedad, el número del byte con el símbolo de comillas dobles está en constante):

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))
}

Resultado de ejecución del código:

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}]

Pasemos a la segunda parte e implementemos el mismo código para la deserialización de json con valores inconsistentes del campo lógico.

Supongamos que tenemos un código json como este:

[
	{"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":""}
]

En este caso, el campo activo implica un tipo lógico y la presencia de solo uno de dos valores: verdadero y falso. Los valores no booleanos deberán convertirse a booleanos durante la deserialización.

En el ejemplo actual, admitimos las siguientes coincidencias. Los valores verdaderos corresponden a: verdadero (lógico), verdadero (cadena), 1 (cadena), 1 (numérico). El valor falso corresponde a: falso (lógico), falso (cadena), 0 (cadena), 0 (numérico), "" (cadena vacía).

Primero, declararemos la estructura objetivo para la deserialización. Como el tipo del campo Activo, especificamos inmediatamente el tipo personalizado CustomBool:

type Target struct {
	Id     int        `json:"id"`
	Active CustomBool `json:"active"`
}

CustomBool es una estructura con un solo campo bool de tipo bool:

type CustomBool struct {
	Bool bool
}

Implementamos el método UnmarshalJSON para esta estructura. Te daré el código de inmediato:

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")
	}
}

Dado que el campo activo en nuestro caso tiene un número limitado de valores, podemos usar la construcción de mayúsculas y minúsculas para decidir cuál debería ser el valor del campo Bool de la estructura CustomBool. Para verificar, solo necesita dos bloques de cajas. En el primer bloque, verificamos el valor de verdadero, en el segundo - falso.

Al registrar posibles valores, debe prestar atención al papel de la grava (esta es una comilla en la tecla con la letra E en el diseño en inglés). Este personaje te permite escapar de comillas dobles en una cadena. Para mayor claridad, enmarqué los valores con comillas y sin comillas con este símbolo. Por lo tanto, `false` corresponde a la cadena false (sin comillas, escriba bool en json), y` false 'corresponde a la cadena “false” (con comillas, escriba string en json). Lo mismo con los valores de '1' y '1'. El primero es el número 1 (escrito en json sin comillas), el segundo es la cadena "1" (en json escrito con comillas). Esta entrada `` "` es una cadena vacía, es decir, en formato json se ve así: "".

El valor correspondiente (verdadero o falso) se escribe directamente en el campo Bool de la estructura CustomBool:

cb.Bool = true

En el bloque predeterminado, devolvemos un error que indica que el campo tiene un valor desconocido:

return errors.New("CustomBool: parsing \"" + string(data) + "\": unknown value")

Ahora podemos aplicar el método json.Unmarshal a nuestro código json, y los valores del campo activo se convertirán a un tipo bool primitivo.

Implementamos el método MarshalJSON para la estructura CustomBool:

func (cb CustomBool) MarshalJSON() ([]byte, error) {
	json, err := json.Marshal(cb.Bool)
	return json, err
}

Nada nuevo aquí. El método serializa el campo Bool de la estructura CustomBool.

Aquí está el código completo que muestra los resultados de la serialización y deserialización (verificación de errores omitida por brevedad):

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))
}

Resultado de ejecución del código:

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}]

recomendaciones


Primeramente. Anular los métodos MarshalJSON y UnmarshalJSON para tipos de datos arbitrarios le permite personalizar la serialización y deserialización de un campo de código json específico. Además de los casos de uso indicados, estas funciones se utilizan para trabajar con campos anulables.

En segundo lugar. El formato de codificación de texto json es una herramienta ampliamente utilizada para el intercambio de información, y una de sus ventajas sobre otros formatos es la disponibilidad de tipos de datos. El cumplimiento de estos tipos debe ser estrictamente monitoreado.

All Articles