Vai al contenuto
jsonbeautifiers
Italiano

JSON non ha commenti, ed è stata una scelta

I commenti sono stati tolti da JSON per proteggere l’interoperabilità, e da allora quella decisione la paga ogni file di configurazione.

Ogni affermazione di questa pagina è misurata oppure ha una fonte. Quando non è né l’una né l’altra, la pagina lo dice.

Aggiungi una riga a un file di configurazione per spiegare perché un timeout è di 45 secondi e non di 30, il deploy fallisce, e il messaggio non aiuta per niente:

JSON.parse('{\n  // 45s: upstream p99 is 38s\n  "timeout": 45\n}')
// Expected property name or '}' in JSON at position 4 (line 2 column 3)

Il parser ha visto una barra dove doveva esserci una chiave e si è arreso. Python non è più esplicito sulla causa:

json.loads('{\n  // 45s: upstream p99 is 38s\n  "timeout": 45\n}')
# JSONDecodeError: Expecting property name enclosed in double quotes: line 2 column 3 (char 4)

Nessuno dei due messaggi nomina i commenti, perché per quanto riguarda la grammatica non c’è nulla da nominare. La RFC 8259 definisce esattamente quattro caratteri ammessi tra i token: spazio, tabulazione, ritorno a capo e avanzamento di riga. Tutto il resto o fa parte di un valore o è un errore di sintassi.

Perché furono rimossi

I commenti c’erano nelle prime versioni di JSON e Douglas Crockford li tolse. La ragione che dichiarò è la parte interessante: la gente non li usava per la prosa, li usava per trasportare direttive di parsing. Un suggerimento sulla codifica, un puntatore a uno schema, scritto dentro un commento, che un particolare consumatore avrebbe letto ed eseguito. A quel punto il commento non è più un commento. È un secondo canale di dati non documentato che viaggia dentro un formato il cui unico argomento di vendita era che qualsiasi parser, ovunque, avrebbe letto gli stessi valori dagli stessi byte.

La via d’uscita che suggerì lo stesso Crockford era passare il file commentato attraverso un minificatore prima di darlo a un parser. È tuttora la forma giusta di risposta, e il resto di questo articolo parla soprattutto di come farlo bene.

La decisione era difendibile per ciò che JSON era nei suoi primi anni: un formato di trasporto per spostare un valore tra due programmi che si erano già accordati sul suo significato. Nessuno commenta un pacchetto di rete.

Perché fa male lo stesso

JSON non è rimasto un formato di trasporto. È diventato il linguaggio di configurazione predefinito dell’intera catena di strumenti, e la configurazione è proprio il caso in cui il ragionamento dietro un valore conta più del valore. Un retries: 0 senza spiegazione finisce «sistemato» dalla persona successiva, in perfetta buona fede. Un retries: 0 con sopra // voluto, questo endpoint non è idempotente, no.

Così ogni ecosistema che ha adottato JSON per la configurazione si è costruito la propria toppa sopra, e non sono compatibili tra loro.

Le cinque opzioni

Una chiave _comment

{
  "_comment": "45s perché il p99 a monte è 38s",
  "timeout": 45
}

È JSON stretto, si analizza dappertutto e non richiede alcuno strumento. I problemi però sono reali. Il tuo schema ora deve permetterlo, altrimenti il validatore lo rifiuta. È un dato, quindi esce verso i client, finisce nei log e compare nelle diff come cambio di valore anziché come cambio di commento. E ne hai esattamente uno per oggetto: la RFC 8259 dice che le chiavi DOVREBBERO essere uniche e lascia i duplicati indefiniti, con JavaScript e Python che tengono entrambi l’ultima, quindi un secondo _comment allo stesso livello si mangia il primo in silenzio. La gente aggira con _comment1, _comment2, che è il punto in cui l’approccio smette di valerne la pena.

Usala per una nota d’intestazione in cima a un file. Non usarla per annotare riga per riga.

JSONC

JSONC è JSON più due cose: commenti // e /* */, e virgole finali. Nient’altro. È ciò che VS Code usa per i propri settings.json e keybindings.json, e ciò che TypeScript accetta in tsconfig.json.

{
  // il p99 a monte è 38s
  "timeout": 45,
  "retries": 0, // questo endpoint non è idempotente
}

Conviene essere schietti sul suo status: non esiste una specifica indipendente di JSONC. Nessuna RFC, nessun numero di versione, nessuna suite di conformità. È una convenzione con dietro un’implementazione a forma di editor, e i dialetti divergono ai bordi (se una virgola finale dopo l’ultimo elemento di un array è ammessa, se i commenti sopravvivono a un’andata e ritorno). È l’opzione più sicura quando il tuo consumatore è già uno strumento che la supporta, e una pessima opzione per qualsiasi cosa consegni a terzi.

JSON5

JSON5 è una specifica vera, con storico delle versioni, e si spinge parecchio oltre JSONC:

  • Chiavi di oggetto senza virgolette, quando la chiave è un identificatore ES5 valido
  • Stringhe con apici singoli
  • Virgole finali in oggetti e array
  • Commenti di riga e di blocco
  • Numeri esadecimali
  • Punti decimali iniziali e finali, quindi .5 e 5. sono numeri
  • Infinity, -Infinity e NaN

L’ultimo punto è quello da soppesare bene. NaN e gli infiniti non hanno alcuna rappresentazione in JSON, quindi un documento JSON5 che li usa non può essere convertito in JSON senza una decisione con perdita su cosa metterci al posto. Le altre estensioni sono cosmetiche e sopravvivono bene a una conversione. Usa JSON5 quando l’autore principale del file è una persona e un’estensione .json5 è accettabile; non usarlo come formato di API.

Smetti di usare JSON

Se il file è configurazione che controlli da un capo all’altro, e nulla di esterno lo consuma, il formato è una scelta libera e JSON non è ovviamente la migliore. YAML e TOML hanno commenti di prima classe. Entrambi hanno i loro costi, e il confronto merita una lettura prima di impegnarti, perché YAML in particolare ti consegnerà il problema norvegese: con la semantica di YAML 1.1, che PyYAML e il Psych di Ruby implementano, un no senza virgolette viene letto come il booleano false.

Rimuovili in fase di build

Tieni il file annotato come fonte di verità, rimuovi i commenti nella CI, pubblica JSON stretto. È il suggerimento di Crockford e accontenta tutti: chi modifica e chi rivede vede i commenti, il tuo parser a runtime vede un documento che soddisfa la RFC 8259, e nessun consumatore ha bisogno di sapere che uno dei due formati esiste.

Rimuovere i commenti senza rompere gli URL

L’implementazione ovvia è un’espressione regolare, e l’espressione regolare ovvia è sbagliata:

// Non farlo.
text.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');

Eseguila su questo e guardala distruggere un valore:

{
  "endpoint": "https://api.example.com/v2/orders", // produzione
  "note": "consulta /* il runbook */ prima di cambiarlo"
}

La prima regola trova // dentro https:// e cancella il resto della riga, virgoletta di chiusura e virgola comprese. La seconda trova un commento di blocco dentro una stringa. Ti ritrovi con una stringa non terminata e un errore che punta a un posto del tutto estraneo. Una regex non può fare questo lavoro perché non può sapere se una barra sta dentro una stringa, e il contesto di stringa in JSON dipende dal conteggio degli escape.

Ti serve uno scanner che porti dietro esattamente uno stato:

function stripJsonComments(text) {
  let out = '';
  let inString = false;
  let inLine = false;
  let inBlock = false;

  for (let i = 0; i < text.length; i++) {
    const c = text[i];
    const next = text[i + 1];

    if (inLine) {
      if (c === '\n') { inLine = false; out += c; }
      continue;
    }
    if (inBlock) {
      // conserva gli a capo così i numeri di riga restano allineati
      if (c === '*' && next === '/') { inBlock = false; i++; }
      else if (c === '\n') { out += c; }
      continue;
    }
    if (inString) {
      out += c;
      if (c === '\\') { out += next; i++; continue; }  // escape, consuma entrambi
      if (c === '"') inString = false;
      continue;
    }
    if (c === '"') { inString = true; out += c; continue; }
    if (c === '/' && next === '/') { inLine = true; i++; continue; }
    if (c === '/' && next === '*') { inBlock = true; i++; continue; }
    out += c;
  }
  return out;
}

Il ramo dell’escape è la parte che la gente omette. Senza, la virgoletta con escape in "ha detto \"vai su https://example.com\" oggi" viene letta come fine della stringa, quindi il // che segue viene preso per l’inizio di un commento e il resto della riga sparisce.

Nota anche cosa questo non fa. Rimuovere i commenti lascia le virgole finali, che falliscono per conto loro con il loro errore: V8 segnala Expected double-quoted property name in JSON at position 7 (line 1 column 8) per {"a":1,}, e il tutt’altro Unexpected token ']', "[1,2,]" is not valid JSON per [1,2,]. Una conversione da JSONC a JSON deve gestire entrambi.

Se preferisci non portarti dietro lo scanner, incolla il file in Riparare JSON, che toglie commenti e virgole finali in una passata sola e ti restituisce JSON stretto, poi conferma il risultato con il validatore. Entrambi girano interamente nel tuo browser, il che conta quando il file che stai sistemando è una configurazione di produzione con dentro delle credenziali. Le altre pagine di errore coprono cosa fare quando il guasto si rivela non essere affatto un commento.