GoSuda

Analisi delle modifiche comportamentali di encoding/json/v2 in Go 1.25 e del livello di compatibilità con v1

By lemonmint views ...

Il pacchetto encoding/json v2 di Go è una nuova implementazione volta a migliorare alcuni limiti della v1 (incoerenze, comportamenti sorprendenti, problemi di prestazioni). Essa viene attivata tramite il build tag goexperiment.jsonv2 e costituisce una funzionalità sperimentale.

Il punto più importante è che quando la v2 è attiva, la v1 opera come un livello di compatibilità che emula il comportamento della v1 sopra l'implementazione della v2. Ciò avviene tramite la funzione DefaultOptionsV1() nel file v2_v1.go. In altre parole, la v2 offre opzioni che consentono di riprodurre in modo completo il comportamento della v1, ma propone anche nuovi comportamenti predefiniti più rigorosi e prevedibili.

Gli obiettivi principali della v2 sono i seguenti.

  1. Miglioramento della correttezza e della prevedibilità: applicare per impostazione predefinita regole più stringenti (ad esempio, distinzione tra maiuscole e minuscole, divieto di chiavi duplicate) per ridurre i comportamenti inattesi.
  2. Miglioramento delle prestazioni: la progettazione dei motori di parsing e codifica è stata rivista per aumentarne l'efficienza.
  3. Maggiore flessibilità e controllo: l'introduzione di un sistema di opzioni dettagliato consente agli sviluppatori di controllare nel dettaglio il modo in cui viene gestito il JSON.

Principali differenze di significato e di comportamento

Le differenze di comportamento tra v1 e v2 sono riassunte di seguito per voce, con riferimento principale al file v2_test.go.

1. Corrispondenza dei nomi dei campi (distinzione tra maiuscole e minuscole)

  • Comportamento della v1: durante l'unmarshaling di un membro di un oggetto JSON in un campo di una struct Go, la corrispondenza ignora le maiuscole e le minuscole (case-insensitive). Sia "FirstName" sia "firstname" vengono associati al campo FirstName.
  • Comportamento della v2: per impostazione predefinita, la corrispondenza distingue le maiuscole dalle minuscole (case-sensitive) e associa solo i campi che corrispondono esattamente.
  • Motivazione del cambiamento: la corrispondenza insensibile alle maiuscole può causare comportamenti inattesi e rallentare l'elaborazione dei campi che non corrispondono. La v2 adotta come comportamento predefinito una modalità più chiara e prevedibile.
  • Opzioni correlate: nella v2 è possibile attivare esplicitamente l'ignoranza delle maiuscole per singolo campo tramite l'opzione di tag json:"...,case:ignore", oppure applicarla globalmente con l'opzione json.MatchCaseInsensitiveNames(true).

2. Modifica del significato dell'opzione di tag omitempty

  • Comportamento della v1: il campo viene omesso in base allo "stato vuoto" del valore Go. Per "stato vuoto" si intendono false, 0, i puntatori o le interfacce nil, e gli array, le slice, le mappe e le stringhe di lunghezza zero.
  • Comportamento della v2: il campo viene omesso in base allo "stato vuoto" del valore JSON codificato. In altre parole, viene omesso quando viene codificato come null, "", {} o [].
  • Motivazione del cambiamento: la definizione della v1 dipende dal sistema di tipi di Go. La v2 si basa sul sistema di tipi JSON, offrendo un comportamento più coerente. Ad esempio, nella v1 il valore false di un campo di tipo bool viene omesso, mentre nella v2 false è un valore JSON non vuoto e quindi non viene omesso. Nella v2 è stata aggiunta l'opzione omitzero, che può sostituire il comportamento della omitempty della v1 applicato a 0 o false.
  • Opzioni correlate: per ottenere un comportamento identico a quello della v1 nella v2, si utilizza l'opzione json.OmitEmptyWithLegacyDefinition(true).

3. Modifica del comportamento dell'opzione di tag string

  • Comportamento della v1: si applica ai campi di tipo numerico, booleano e stringa. Il valore viene nuovamente codificato all'interno di una stringa JSON (ad esempio, int(42) diventa "42"). Non viene applicata ricorsivamente ai valori all'interno di tipi composti (slice, mappe e simili).
  • Comportamento della v2: si applica solo ai tipi numerici ed è applicata in modo ricorsivo. Ciò significa che anche i numeri all'interno di una slice come []int vengono codificati come stringhe JSON.
  • Motivazione del cambiamento: l'uso principale dell'opzione string è rappresentare i numeri a 64 bit come stringhe, per evitare la perdita di precisione. Il comportamento della v1 era limitato e poco coerente. La v2 si concentra su questo uso fondamentale ed estende il comportamento in modo ricorsivo, rendendolo più utile.
  • Opzioni correlate: l'opzione json.StringifyWithLegacySemantics(true) permette di imitare il comportamento della v1.

4. Marshaling di slice e mappe nil

  • Comportamento della v1: le slice nil e le mappe nil vengono marshalate come null.
  • Comportamento della v2: per impostazione predefinita, le slice nil vengono marshalate come [] (array vuoto) e le mappe nil come {} (oggetto vuoto).
  • Motivazione del cambiamento: nil è un dettaglio implementativo del linguaggio Go, e non è opportuno esporlo in un formato JSON indipendente dal linguaggio. Le notazioni [] e {}, che rappresentano collezioni vuote, sono rappresentazioni più diffuse.
  • Opzioni correlate: nella v2 è possibile ottenere il marshaling come null, come nella v1, tramite le opzioni json.FormatNilSliceAsNull(true) o json.FormatNilMapAsNull(true).

5. Unmarshaling di array

  • Comportamento della v1: durante l'unmarshaling in un array Go ([N]T), se la lunghezza dell'array JSON differisce da quella dell'array Go non viene generato alcun errore. Se l'array JSON è più corto, lo spazio rimanente viene riempito con valori zero; se è più lungo, la parte in eccesso viene scartata.
  • Comportamento della v2: la lunghezza dell'array JSON deve coincidere esattamente con quella dell'array Go. In caso contrario viene generato un errore.
  • Motivazione del cambiamento: in Go, la lunghezza di un array di dimensione fissa ha spesso un significato importante. Il comportamento della v1 può causare perdite silenziose di dati. La v2 aumenta la correttezza con regole più rigorose.
  • Opzioni correlate: l'opzione json.UnmarshalArrayFromAnyLength(true) permette di imitare il comportamento della v1.

6. Gestione di time.Duration

  • Comportamento della v1: time.Duration viene trattato internamente come int64 e codificato come numero JSON in nanosecondi.
  • Comportamento della v2: viene codificato come stringa JSON in un formato come "1h2m3s", utilizzando il metodo time.Duration.String().
  • Motivazione del cambiamento: i nanosecondi numerici sono poco leggibili, mentre la rappresentazione testuale standard di time.Duration è più utile.
  • Opzioni correlate: è possibile utilizzare il comportamento della v1 tramite l'opzione di tag json:",format:nano" oppure tramite l'opzione json.FormatTimeWithLegacySemantics(true).

7. Gestione dell'UTF-8 non valido

  • Comportamento della v1: durante il marshaling o l'unmarshaling, se una stringa contiene byte UTF-8 non validi, questi vengono sostituiti silenziosamente con il carattere di sostituzione Unicode (\uFFFD).
  • Comportamento della v2: per impostazione predefinita, in presenza di UTF-8 non valido viene restituito un errore.
  • Motivazione del cambiamento: serve a prevenire la corruzione silenziosa dei dati e a conformarsi allo standard JSON più rigoroso (RFC 7493).
  • Opzioni correlate: l'opzione jsontext.AllowInvalidUTF8(true) permette di imitare il comportamento della v1.

8. Gestione dei nomi di membri duplicati in un oggetto

  • Comportamento della v1: un oggetto JSON può contenere membri con lo stesso nome. Viene mantenuto l'ultimo valore, che sovrascrive i precedenti.
  • Comportamento della v2: per impostazione predefinita, in presenza di nomi di membri duplicati viene restituito un errore.
  • Motivazione del cambiamento: lo standard RFC 8259 non definisce il comportamento in caso di nomi duplicati, quindi le implementazioni possono comportarsi in modo diverso. Ciò può costituire una causa di vulnerabilità di sicurezza. La v2 rifiuta esplicitamente questi casi, aumentando correttezza e sicurezza.
  • Opzioni correlate: l'opzione jsontext.AllowDuplicateNames(true) permette di imitare il comportamento della v1.

Differenze di implementazione e di architettura

  • v1: si basa in larga misura su decodeState in decode.go e su una macchina a stati scritta a mano in scanner.go. Si tratta di una struttura monolitica in cui la logica di parsing e l'analisi semantica sono fortemente accoppiate.
  • v2: l'architettura è stata resa più modulare.
    • encoding/json/jsontext: fornisce un tokenizer JSON (Decoder) e un encoder (Encoder) di basso livello e ad alte prestazioni. Questo pacchetto si concentra esclusivamente sugli aspetti sintattici di JSON.
    • encoding/json/v2: basandosi su jsontext, gestisce la conversione semantica tra tipi Go e valori JSON.
    • Grazie a questa separazione, l'analisi sintattica e quella semantica sono distinte, con un miglioramento della chiarezza del codice e delle prestazioni.

Nuove API e funzionalità della v2

La v2 offre un controllo molto flessibile tramite il sistema json.Options.

  • json.Options: è un insieme di opzioni che modificano il comportamento di marshaling e unmarshaling.
  • json.JoinOptions(...): unisce più opzioni in una sola.
  • WithMarshalers / WithUnmarshalers: una funzionalità potente che consente di iniettare la logica di serializzazione e deserializzazione per tipi specifici senza implementare le interfacce Marshaler o Unmarshaler. È particolarmente utile quando si gestiscono tipi di pacchetti esterni.
  • Nuove opzioni: RejectUnknownMembers, Deterministic(false), FormatNilSliceAsNull e altre ancora consentono un controllo del comportamento che in v1 non era possibile.

Conclusioni

encoding/json v2 è un'implementazione moderna che, sulla base dell'esperienza maturata con la v1, migliora in modo significativo correttezza, prestazioni e flessibilità. Il comportamento predefinito è diventato più rigoroso, ma grazie a un sistema di Options articolato è possibile riprodurre integralmente tutti i comportamenti della v1, mantenendo la compatibilità con il codice esistente e adottando gradualmente i vantaggi della v2.

  • Per i nuovi progetti, è consigliabile utilizzare la v2 come impostazione predefinita.
  • Per i progetti esistenti, è possibile continuare a usare jsonv1 così com'è, oppure eseguire la migrazione a jsonv2 introducendo gradualmente le regole più rigorose della v2 tramite DefaultOptionsV1().