Aller: Désérialisation JSON avec une saisie incorrecte, ou comment contourner les erreurs de développeur d'API

image

Récemment, il m'est arrivé de développer un client http sur Go pour un service qui fournit une API REST avec json comme format d'encodage. Une tâche standard, mais au cours du travail, j'ai dû faire face à un problème non standard. Je vous dis quel est le point.

Comme vous le savez, le format json a des types de données. Quatre primitives: chaîne, nombre, booléen, null; et deux types structurels: un objet et un tableau. Dans ce cas, nous nous intéressons aux types primitifs. Voici un exemple de code json avec quatre champs de types différents:

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

Comme le montre l'exemple, la valeur de chaîne est placée entre guillemets. Numérique - n'a pas de guillemets. Un type booléen ne peut avoir qu'une ou deux valeurs: true ou false (sans guillemets). Et le type nul est donc nul (également sans guillemets).

Et maintenant, le problème lui-même. À un moment donné, lors d'un examen détaillé du code json reçu d'un service tiers, j'ai constaté que l'un des champs (appelons-le prix) a périodiquement une valeur de chaîne (le nombre entre guillemets) en plus de la valeur numérique. En d'autres termes, la même requête avec des paramètres différents peut renvoyer un nombre sous forme de nombre ou renvoyer le même nombre sous forme de chaîne. Je ne peux pas imaginer comment le code renvoyant de tels résultats est organisé à l'autre extrémité, mais apparemment, cela est dû au fait que le service lui-même est un agrégateur et tire des données de différentes sources, et les développeurs n'ont pas apporté la réponse du serveur json à un seul format. Néanmoins, il faut travailler avec ce qui est.

Mais j'ai été encore plus surpris. Le champ logique (appelons-le actif), en plus de true et false, a renvoyé les valeurs de chaîne "true", "false" et même 1 et 0 numériques (true et false, respectivement).

Toute cette confusion sur les types de données ne serait pas critique si je devais traiter json say en PHP faiblement typé, mais Go a un typage fort et nécessite une indication claire du type de champ désérialisé. En conséquence, il était nécessaire de mettre en œuvre un mécanisme qui permette de convertir toutes les valeurs du champ actif en un type logique pendant le processus de désérialisation, et toute valeur du champ de prix en un numérique.

Commençons par le champ des prix.

Supposons que nous ayons du code json comme ceci:

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

Autrement dit, json contient un tableau d'objets avec deux champs de type numérique. Le code de désérialisation standard pour ce json on Go ressemble à ceci:

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

Dans ce code, nous désérialiserons le champ id en int et le champ prix en float64. Supposons maintenant que notre code json ressemble à ceci:

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

Autrement dit, le champ de prix contient des valeurs à la fois de type numérique et de chaîne. Dans ce cas, seules les valeurs numériques du champ de prix peuvent être décodées en type float64, tandis que les valeurs de chaîne provoquent une erreur sur l'incompatibilité des types. Cela signifie que ni float64 ni aucun autre type primitif ne convient pour désérialiser ce champ, et nous avons besoin de notre propre type personnalisé avec sa propre logique de désérialisation.

En tant que tel type, déclarez une structure CustomFloat64 avec un seul champ Float64 de type float64.

type CustomFloat64 struct{
	Float64 float64
}

Et indiquez immédiatement ce type pour le champ Prix dans la structure Cible:

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

Vous devez maintenant décrire votre propre logique de décodage d'un champ de type CustomFloat64.

Le « encoding / json » paquet dispose de deux méthodes spéciales: MarshalJSON et UnmarshalJSON , qui sont conçus pour adapter le codage et le décodage logique d'un type de données d'utilisateur spécifique. Il suffit de remplacer ces méthodes et de décrire votre propre implémentation.

Substituez la méthode UnmarshalJSON pour un type arbitraire CustomFloat64. Dans ce cas, il est nécessaire de suivre strictement la signature de la méthode, sinon cela ne fonctionnera tout simplement pas et, surtout, cela ne produira pas d'erreur.

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

En entrée, cette méthode prend une tranche d'octets (données), qui contient la valeur d'un champ particulier du json décodé. Si nous convertissons cette séquence d'octets en une chaîne, alors nous verrons la valeur du champ exactement sous la forme sous laquelle il est écrit en json. Autrement dit, s'il s'agit d'un type de chaîne, alors nous verrons exactement une chaîne avec des guillemets doubles ("258"), s'il s'agit d'un type numérique, alors nous verrons une chaîne sans guillemets (258).

Pour distinguer une valeur numérique d'une valeur de chaîne, vous devez vérifier si le premier caractère est un guillemet. Étant donné que le caractère guillemet double dans la table UNICODE occupe un octet, il nous suffit de vérifier le premier octet de la tranche de données en le comparant avec le numéro de caractère dans UNICODE. Il s'agit du numéro 34. Notez qu'en général, un caractère n'est pas équivalent à un octet, car il peut prendre plus d'un octet. Un symbole dans Go équivaut à rune (rune). Dans notre cas, cette condition est suffisante:

if data[0] == 34 {

Si la condition est remplie, la valeur a un type de chaîne et nous devons obtenir la chaîne entre guillemets, c'est-à-dire l'octet de tranche entre le premier et le dernier octet. Cette tranche contient une valeur numérique qui peut être décodée en float64 de type primitif. Cela signifie que nous pouvons lui appliquer la méthode json.Unmarshal, tout en enregistrant le résultat dans la structure CustomFloat64 dans le champ Float64.

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

Si la tranche de données ne commence pas par un guillemet, elle contient déjà un type de données numérique et nous pouvons appliquer la méthode json.Unmarshal directement à la tranche de données entière.

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

Voici le code complet de la méthode 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
}

Par conséquent, en utilisant la méthode json.Unmarshal dans notre code json, toutes les valeurs du champ prix seront converties de manière transparente en un type primitif float64 pour nous, et le résultat sera écrit dans le champ Float64 de la structure CustomFloat64.

Il nous faudra peut-être maintenant reconvertir la structure Target en json. Mais, si nous appliquons la méthode json.Marshal directement au type CustomFloat64, nous sérialisons cette structure en tant qu'objet. Nous devons coder le champ de prix en une valeur numérique. Pour personnaliser la logique de codage du type personnalisé CustomFloat64, nous implémentons la méthode MarshalJSON pour cela, tout en respectant strictement la signature de la méthode:

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

Tout ce que vous devez faire dans cette méthode est à nouveau d'utiliser la méthode json.Marshal, mais l'appliquez déjà non pas à la structure CustomFloat64, mais à son champ Float64. De la méthode, nous renvoyons la tranche d'octets reçue et l'erreur.

Voici le code complet qui affiche les résultats de la sérialisation et de la désérialisation (la vérification des erreurs est omise pour des raisons de brièveté, le numéro de l'octet avec le symbole de guillemet double est constant):

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

Résultat d'exécution du code:

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

Passons à la deuxième partie et implémentons le même code pour la désérialisation json avec des valeurs incohérentes du champ logique.

Supposons que nous ayons du code json comme ceci:

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

Dans ce cas, le champ actif implique un type logique et la présence d'une seule des deux valeurs: vrai et faux. Les valeurs non booléennes devront être converties en booléennes pendant la désérialisation.

Dans l'exemple actuel, nous admettons les correspondances suivantes. Les vraies valeurs correspondent à: true (logique), true (chaîne), 1 (chaîne), 1 (numérique). La fausse valeur correspond à: faux (logique), faux (chaîne), 0 (chaîne), 0 (numérique), "" (chaîne vide).

Tout d'abord, nous allons déclarer la structure cible pour la désérialisation. Comme type de champ Active, nous spécifions immédiatement le type personnalisé CustomBool:

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

CustomBool est une structure avec un seul champ bool de type bool:

type CustomBool struct {
	Bool bool
}

Nous implémentons la méthode UnmarshalJSON pour cette structure. Je vais donner le code tout de suite:

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

Étant donné que le champ actif dans notre cas a un nombre limité de valeurs, nous pouvons prendre une décision en utilisant la construction switch-case sur la valeur de la valeur du champ Bool de la structure CustomBool. Pour vérifier, vous n'avez besoin que de deux blocs de cas. Dans le premier bloc, nous vérifions la valeur true, dans le second - false.

Lors de l'enregistrement de valeurs possibles, vous devez faire attention au rôle du gravier (c'est un tel guillemet sur la clé avec la lettre E dans la mise en page anglaise). Ce caractère vous permet d'échapper les guillemets doubles dans une chaîne. Pour plus de clarté, j'ai encadré les valeurs avec des guillemets et sans guillemets avec ce symbole. Ainsi, «false» correspond à la chaîne false (sans guillemets, tapez bool dans json), et «faux» correspond à la chaîne «false» (avec guillemets, tapez chaîne dans json). La même chose avec les valeurs de "1" et "1". Le premier est le nombre 1 (écrit en json sans guillemets), le second est la chaîne "1" (en json écrit avec guillemets). Cette entrée `` "` est une chaîne vide, c'est-à-dire qu'au format json, elle ressemble à ceci: "".

La valeur correspondante (vrai ou faux) est écrite directement dans le champ Bool de la structure CustomBool:

cb.Bool = true

Dans le bloc par défaut, nous renvoyons une erreur indiquant que le champ a une valeur inconnue:

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

Maintenant, nous pouvons appliquer la méthode json.Unmarshal à notre code json, et les valeurs du champ actif seront converties en un bool de type primitif.

Nous implémentons la méthode MarshalJSON pour la structure CustomBool:

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

Rien de nouveau ici. La méthode sérialise le champ Bool de la structure CustomBool.

Voici le code complet qui affiche les résultats de la sérialisation et de la désérialisation (vérification d'erreur omise pour des raisons de concision):

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

Résultat d'exécution du code:

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

résultats


D'abord. Le remplacement des méthodes MarshalJSON et UnmarshalJSON pour les types de données arbitraires vous permet de personnaliser la sérialisation et la désérialisation d'un champ de code json spécifique. En plus des cas d'utilisation indiqués, ces fonctions sont utilisées pour travailler avec des champs nullables.

Deuxièmement. Le format de codage de texte json est un outil largement utilisé pour l'échange d'informations, et l'un de ses avantages par rapport aux autres formats est la disponibilité des types de données. Le respect de ces types doit être strictement contrôlé.

All Articles