Lire et écrire du JSON en Go avec encoding/json
1. Aperçu
JSON est le format d’échange de données le plus courant entre les services. Go supporte nativement JSON dans sa bibliothèque standard via le package encoding/json, vous n’avez rien d’autre à installer.
Cet article vous aide à convertir les struct en JSON et inversement, contrôler les noms de champs avec des struct tag, traiter du JSON dynamique et faire du streaming de grandes données. Vous apprendrez aussi comment écrire des types personnalisés et les tester. À la fin, nous verrons les erreurs courantes à éviter avant de mettre le code en production.
Les exemples utilisent Go 1.24 ou plus récent.
2. Écrire du JSON avec json.Marshal
Commençons par une commande contenant les informations du client :
package main
import (
"encoding/json"
"fmt"
"log"
)
type Customer struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
}
type Order struct {
ID int `json:"id"`
Customer Customer `json:"customer"`
Items []string `json:"items"`
Total float64 `json:"total"`
Note string `json:"-"`
createdBy string
}
func main() {
order := Order{
ID: 1001,
Customer: Customer{Name: "Lan"},
Items: []string{"Cà phê", "Bánh mì"},
Total: 45000,
Note: "livrer avant 9h",
createdBy: "admin",
}
data, err := json.Marshal(order)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(data))
}
Résultat :
{"id":1001,"customer":{"name":"Lan"},"items":["Cà phê","Bánh mì"],"total":45000}
Il y a plusieurs points à noter dans le code ci-dessus :
json.Marshalretourne[]byteeterror. Vous devez toujours vérifier l’error.- Le tag
json:"id"change le nom du champ eniddans le JSON. Sans tag, Go utilise le nom du champ tel quel, soitID. Emailest vide et est omis grâce àomitempty.Notea le tagjson:"-"et est donc toujours omis.createdBycommence par une minuscule, c’est-à-dire qu’il n’est pas exporté (unexported).encoding/jsonne voit pas ce champ.- Les caractères vietnamiens sont préservés en UTF-8, pas d’échappement.
Si vous avez besoin de JSON lisible, par exemple pour les logs ou les fichiers de configuration, utilisez json.MarshalIndent :
data, _ := json.MarshalIndent(order.Customer, "", " ")
fmt.Println(string(data))
{
"name": "Lan"
}
3. Struct tag : contrôler précisément la sortie
Le tag json a la forme json:"nom,option,…". Les options courantes :
| Tag | Signification |
|---|---|
json:"name" |
Changer le nom du champ |
json:",omitempty" |
Omettre le champ s’il est zéro : "", 0, false, nil, slice ou map vide |
json:",omitzero" |
Omettre le champ s’il est zéro, y compris les struct comme time.Time (Go 1.24+) |
json:",string" |
Écrire les nombres ou booléens comme des chaînes |
json:"-" |
Toujours omettre |
omitempty n’a pas d’effet sur les struct. L’exemple ci-dessous montre la différence :
type Shipment struct {
OrderID int64 `json:"order_id,string"`
ShippedAt time.Time `json:"shipped_at,omitempty"`
PaidAt time.Time `json:"paid_at,omitzero"`
}
data, _ := json.Marshal(Shipment{OrderID: 1001})
fmt.Println(string(data))
{"order_id":"1001","shipped_at":"0001-01-01T00:00:00Z"}
ShippedAt est toujours présent car omitempty ne considère pas un struct time.Time vide comme « vide ». PaidAt utilise omitzero et est donc correctement omis. OrderID est écrit comme la chaîne "1001" grâce à l’option string. C’est utile quand le client est JavaScript et ne peut pas traiter correctement les entiers 64-bit.
4. Lire du JSON avec json.Unmarshal
Dans l’autre sens, vous passez un pointeur vers la variable cible :
input := []byte(`{
"id": 1002,
"Customer": {"name": "Minh", "email": "minh@example.com"},
"items": ["Trà đào"],
"total": 20000,
"coupon": "SALE10"
}`)
var order Order
if err := json.Unmarshal(input, &order); err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", order)
{ID:1002 Customer:{Name:Minh Email:minh@example.com} Items:[Trà đào] Total:20000 Note: createdBy:}
Remarquez deux comportements par défaut :
- Les clés sont appairées en ignorant la casse.
"Customer"correspond toujours au tagcustomer. - Les clés inconnues comme
"coupon"sont silencieusement ignorées. Les champs absents du JSON conservent leur valeur actuelle.
Note : Si vous passez une valeur au lieu d’un pointeur, par exemple
json.Unmarshal(input, order), vous recevrez l’erreurjson: Unmarshal(non-pointer main.Order). Les données ne seront écrites nulle part.
4.1. Rejeter les champs inconnus
Pour une API recevant des requêtes client, ignorer les clés inconnues cache souvent les erreurs de typage. Vous pouvez activer le mode strict via json.Decoder :
dec := json.NewDecoder(bytes.NewReader(input))
dec.DisallowUnknownFields()
var order Order
err := dec.Decode(&order)
fmt.Println(err)
json: unknown field "coupon"
DisallowUnknownFields n’existe que sur Decoder, pas d’option équivalente pour json.Unmarshal.
5. JSON dynamique avec map[string]any
Quand la structure est inconnue à l’avance, par exemple un webhook d’un tiers, vous pouvez décoder dans map[string]any :
var payload map[string]any
_ = json.Unmarshal([]byte(`{"event":"order.paid","order_id":9007199254740993}`), &payload)
fmt.Printf("%T %v\n", payload["event"], payload["event"])
fmt.Printf("%T %.0f\n", payload["order_id"], payload["order_id"])
string order.paid
float64 9007199254740992
Tous les nombres sont décodés en float64. Donc order_id perd en précision : la valeur d’origine finit par 3 mais le résultat finit par 2. C’est une erreur difficile à détecter.
La solution est d’appeler UseNumber, pour que les nombres restent sous forme de chaîne json.Number :
dec := json.NewDecoder(strings.NewReader(`{"order_id":9007199254740993}`))
dec.UseNumber()
var payload map[string]any
_ = dec.Decode(&payload)
id, _ := payload["order_id"].(json.Number).Int64()
fmt.Println(id)
9007199254740993
Si seule une partie du payload est dynamique, vous pouvez utiliser json.RawMessage pour différer le décodage de cette partie :
type Event struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
Après avoir lu Type, vous appelez json.Unmarshal(e.Data, &order) avec la struct appropriée. C’est beaucoup plus sûr que de chercher chaque clé dans la map.
6. Faire du streaming de données avec Encoder et Decoder
Marshal et Unmarshal travaillent sur []byte, donc toutes les données doivent tenir en mémoire. json.Encoder et json.Decoder travaillent directement avec io.Writer et io.Reader. Ils conviennent pour le corps HTTP, les fichiers et les données NDJSON (une ligne = un JSON).
func main() {
input := strings.NewReader(`{"id":1,"total":10000}
{"id":2,"total":25000}
{"id":3,"total":5000}`)
dec := json.NewDecoder(input)
enc := json.NewEncoder(os.Stdout)
enc.SetEscapeHTML(false)
sum := 0.0
for {
var o Order
err := dec.Decode(&o)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
log.Fatal(err)
}
sum += o.Total
}
_ = enc.Encode(map[string]float64{"sum": sum})
}
{"sum":40000}
La boucle appelle Decode jusqu’à rencontrer io.EOF, chaque fois lisant exactement un objet. Encode ajoute automatiquement une nouvelle ligne après chaque valeur. SetEscapeHTML(false) empêche Go de convertir <, >, & en \u003c, \u003e, \u0026. Par défaut, Go échappe ces caractères pour la sécurité lors de l’imbrication de JSON dans HTML.
Dans un gestionnaire HTTP, voici le modèle correct :
func createOrder(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // maximum 1 MB
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var o Order
if err := dec.Decode(&o); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(o)
}
http.MaxBytesReader limite la taille du corps. Cela empêche le client d’envoyer une charge utile énorme qui épuiserait la mémoire du serveur.
7. Types personnalisés
Supposons que le statut de la commande est stocké en tant qu’int dans le code mais doit s’afficher sous forme de chaîne dans JSON. Vous implémentez encoding.TextMarshaler et encoding.TextUnmarshaler :
type Status int
const (
StatusPending Status = iota
StatusPaid
StatusShipped
)
var statusNames = map[Status]string{
StatusPending: "pending",
StatusPaid: "paid",
StatusShipped: "shipped",
}
func (s Status) MarshalText() ([]byte, error) {
name, ok := statusNames[s]
if !ok {
return nil, fmt.Errorf("status invalide : %d", s)
}
return []byte(name), nil
}
func (s *Status) UnmarshalText(b []byte) error {
for k, v := range statusNames {
if v == string(b) {
*s = k
return nil
}
}
return fmt.Errorf("status invalide : %q", b)
}
Quand vous l’utilisez dans une struct :
type OrderStatus struct {
ID int `json:"id"`
Status Status `json:"status"`
}
data, _ := json.Marshal(OrderStatus{ID: 1001, Status: StatusPaid})
fmt.Println(string(data))
{"id":1001,"status":"paid"}
MarshalText utilise un value receiver donc fonctionne avec Status et *Status. UnmarshalText nécessite un pointer receiver car il doit modifier la valeur. Utiliser TextMarshaler au lieu de json.Marshaler a un avantage supplémentaire : le type Status peut être utilisé comme clé de map, par exemple map[Status]int.
Quand vous avez besoin de contrôler l’objet entier, vous écrivez MarshalJSON. Attention à la récursion infinie : si à l’intérieur vous appelez json.Marshal(o) avec le même type, la fonction s’appellera récursivement. La solution est d’utiliser un type alias :
func (o Order) MarshalJSON() ([]byte, error) {
type alias Order // alias n'a pas la méthode MarshalJSON
return json.Marshal(struct {
alias
ItemCount int `json:"item_count"`
}{alias(o), len(o.Items)})
}
alias a les mêmes champs que Order mais pas de méthode, donc json.Marshal utilise le comportement par défaut. Le champ imbriqué est « aplati » dans l’objet externe, avec item_count en plus.
8. Écrire des tests
Tester le round-trip, c’est-à-dire écrire puis relire, vous aide à détecter les erreurs si quelqu’un change un tag ou la logique personnalisée. Fichier status_test.go :
package main
import (
"bytes"
"encoding/json"
"testing"
)
func TestStatusRoundTrip(t *testing.T) {
tests := []struct {
status Status
want string
}{
{StatusPending, `{"id":1,"status":"pending"}`},
{StatusPaid, `{"id":1,"status":"paid"}`},
{StatusShipped, `{"id":1,"status":"shipped"}`},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
data, err := json.Marshal(OrderStatus{ID: 1, Status: tt.status})
if err != nil {
t.Fatalf("erreur marshal : %v", err)
}
if string(data) != tt.want {
t.Errorf("got %s, want %s", data, tt.want)
}
var back OrderStatus
if err := json.Unmarshal(data, &back); err != nil {
t.Fatalf("erreur unmarshal : %v", err)
}
if back.Status != tt.status {
t.Errorf("got %v, want %v", back.Status, tt.status)
}
})
}
}
func TestRejectUnknownField(t *testing.T) {
dec := json.NewDecoder(bytes.NewBufferString(`{"id":1,"coupon":"X"}`))
dec.DisallowUnknownFields()
var o OrderStatus
if err := dec.Decode(&o); err == nil {
t.Fatal("erreur unknown field attendue, reçu nil")
}
}
go test ./...
ok example.com/shop 0.003s
Les tests basés sur une table (table-driven) sont une approche courante en Go. Chaque cas s’exécute dans t.Run séparé, donc si un cas échoue, vous voyez immédiatement lequel.
9. Erreurs courantes
- Le champ n’apparaît pas dans JSON. Presque toujours parce que le nom du champ n’est pas exporté (minuscule). Changez-le en majuscule et utilisez un tag pour nommer le JSON.
nullau lieu de[]. Une slicenilest écrite ennull. Si le client a besoin d’un array vide, initialisezItems: []string{}.- Perte de précision sur les grands nombres. Se produit lors du décodage dans
any. Utilisez une struct avec un type spécifique (int64), ouUseNumber, ou un tag,string. - Les erreurs de type de données sont avalées. Quand
Unmarshalrencontre*json.UnmarshalTypeError, il remplit quand même les autres champs. N’utilisez pas cette struct sierr != nil. []bytedevient base64. C’est un comportement intentionnel. Si vous voulez du texte ordinaire, utilisezstring.time.Timesuit RFC 3339. Pour un format différent commedd/MM/yyyy, vous devez écrire un type personnalisé.- Appeler
json.Marshalà l’intérieur deMarshalJSONavec le même type. Le résultat est un débordement de pile (stack overflow). Utilisez un alias type comme à la section 7.
Note :
encoding/jsonutilise la réflexion, donc ce n’est pas le choix le plus rapide. Pour la plupart des API, sa vitesse est largement suffisante. Ce n’est que si le profiling montre que JSON est un goulot d’étranglement que vous devriez envisager une bibliothèque générant du code. Go 1.25 propose également une version expérimentaleencoding/json/v2, activée viaGOEXPERIMENT=jsonv2. Cette version n’est pas stable, elle n’est donc pas appropriée pour la production.
10. Conclusion
Vous avez parcouru les opérations principales avec encoding/json :
- Utilisez
MarshaletUnmarshalpour les petites données. - Utilisez les struct tag (
omitempty,omitzero,string,-) pour contrôler la sortie. - Utilisez
DecoderavecDisallowUnknownFieldsetUseNumberpour lire les données de façon plus sûre. - Utilisez
EncoderetDecoderpour faire du streaming de données. - Utilisez
TextMarshalerouMarshalJSONpour les types personnalisés.
Le principe général est de préférer les struct avec des types explicites à la place de map[string]any, et toujours vérifier l’error. Vous devriez aussi écrire des tests round-trip pour les types personnalisés. Ces trois habitudes vous aident à éviter la plupart des erreurs JSON dans les services écrits en Go.