Lezione 3 di 6 · 25 min di lettura
JSON
Il formato con cui si scambiano dati quasi tutti i programmi del mondo. Il pacchetto gleam_json, leggere JSON con i decoder, gli errori di sintassi e di forma, null e Option, e scrivere JSON a partire dai tuoi tipi.
La lingua franca dei dati
Quando due programmi devono scambiarsi dati, un server e un browser, un’app e il suo backend, un programma e il file dove salva le impostazioni, quasi sempre lo fanno in JSON:
{
"title": "Dune",
"year": 1965,
"tags": ["sci-fi", "classic"],
"rating": null
}Il JSON è testo, e ha pochi ingredienti: oggetti tra graffe (coppie chiave-valore, con le chiavi sempre tra virgolette), array tra quadre, stringhe, numeri, true, false e null. Il nome viene da JavaScript Object Notation, perché nasce dalla sintassi degli oggetti di JavaScript; ma oggi lo parlano tutti i linguaggi.
Dettagli nerd Perché trasformare i dati in testo? (la serializzazione)
Dentro un programma, un record Book è una struttura in memoria: una tupla con dentro dei puntatori a un binario, a una lista, e così via. Quella struttura ha senso solo dentro quel programma, in quel momento: gli indirizzi di memoria non significano niente per un altro computer, e spariscono quando il programma termina.
Per spedire un dato o salvarlo, bisogna trasformarlo in una sequenza di byte che si possa rileggere altrove: si chiama serializzazione (e l’operazione inversa, deserializzazione). Il JSON è un formato di serializzazione testuale: meno compatto di un formato binario, ma leggibile da un essere umano, e capito ovunque.
Il pacchetto gleam_json
La libreria standard non legge né scrive JSON: serve il pacchetto gleam_json. Dalla cartella exercises:
gleam add gleam_jsonIl modulo si chiama gleam/json, e fa due cose: json.parse trasforma testo JSON in valori Gleam, usando un decoder della lezione precedente; le funzioni json.object, json.string e compagnia costruiscono JSON a partire dai tuoi valori.
Leggere JSON
Leggere un JSON è decodificarlo: json.parse(from: testo, using: decoder) controlla la sintassi e poi applica il decoder, esattamente come decode.run.
import gleam/dynamic/decode
import gleam/io
import gleam/json
import gleam/option.{type Option}
import gleam/string
pub type Book {
Book(title: String, year: Int, tags: List(String), rating: Option(Float))
}
fn book_decoder() -> decode.Decoder(Book) {
use title <- decode.field("title", decode.string)
use year <- decode.field("year", decode.int)
use tags <- decode.field("tags", decode.list(decode.string))
use rating <- decode.field("rating", decode.optional(decode.float))
decode.success(Book(title:, year:, tags:, rating:))
}
pub fn main() -> Nil {
let text =
"{
\"title\": \"Dune\",
\"year\": 1965,
\"tags\": [\"sci-fi\", \"classic\"],
\"rating\": null
}"
io.println(string.inspect(json.parse(text, book_decoder())))
}Ok(Book("Dune", 1965, ["sci-fi", "classic"], None))Il testo JSON è una normale stringa Gleam, su più righe, con le virgolette interne scritte \" (lezione 1.6). Nei programmi veri arriva da un file o dalla rete, e le virgolette non vanno protette; lo faremo nella prossima lezione.
Il campo rating vale null, e il decoder decode.optional(decode.float) lo trasforma in None: in JSON null vuol dire “nessun valore”, e in Gleam è Option a dirlo. Un voto presente, come 4.5, diventerebbe Some(4.5). Se un oggetto JSON ha campi in più, che il decoder non chiede, vengono semplicemente ignorati.
Quando il JSON è sbagliato
json.parse può fallire in due modi molto diversi: il testo non è JSON valido, oppure è JSON valido ma non ha la forma che il decoder si aspetta. Il tipo json.DecodeError li distingue:
import gleam/dynamic/decode
import gleam/io
import gleam/json
import gleam/string
pub fn main() -> Nil {
let decoder = decode.list(decode.int)
io.println(string.inspect(json.parse("[1, 2, 3]", decoder)))
io.println(string.inspect(json.parse("[1, 2, 3", decoder)))
io.println(string.inspect(json.parse("[1, two, 3]", decoder)))
io.println(string.inspect(json.parse("[1, 2.5, 3]", decoder)))
}Ok([1, 2, 3])
Error(UnexpectedEndOfInput)
Error(UnexpectedByte("0x77"))
Error(UnableToDecode([DecodeError("Int", "Float", ["1"])]))UnexpectedEndOfInput: il testo finisce prima del previsto (manca la]).UnexpectedByte("0x77"): a un certo punto c’è un carattere che non può stare lì.0x77è il codice del carattere, in esadecimale: è lawditwo. E perché non lat? Perché unatin quel punto va ancora bene: il parser pensa che stia arrivandotrue, e si accorge dell’errore solo alla lettera dopo. (Le stringhe, in JSON, vogliono le virgolette.)UnableToDecode(...): il JSON è corretto, ma non è quello che il decoder vuole. Dentro ci sono iDecodeErrordella lezione precedente: all’indice 1 c’è unFloat, non unInt.
Quiz
json.parse("{\"year\": \"1965\"}", decoder), con un decoder che vuole year intero. Che errore ottieni?
Scrivere JSON
Per la direzione opposta, gleam/json ha una funzione per ogni tipo di valore JSON: json.string, json.int, json.float, json.bool, json.null(). Poi json.object riceve una lista di coppie chiave-valore, e json.array(from: lista, of: funzione) trasforma ogni elemento di una lista Gleam. Il risultato è un valore di tipo json.Json, che json.to_string trasforma in testo.
Di solito si scrive una funzione encoder per ogni tipo, simmetrica al decoder:
import gleam/io
import gleam/json
import gleam/option.{type Option, None, Some}
pub type Book {
Book(title: String, year: Int, tags: List(String), rating: Option(Float))
}
fn book_to_json(book: Book) -> json.Json {
json.object([
#("title", json.string(book.title)),
#("year", json.int(book.year)),
#("tags", json.array(book.tags, json.string)),
#("rating", json.nullable(book.rating, json.float)),
])
}
pub fn main() -> Nil {
let dune = Book("Dune", 1965, ["sci-fi", "classic"], Some(4.5))
let emma = Book("Emma", 1815, [], None)
io.println(json.to_string(book_to_json(dune)))
io.println(json.to_string(json.array([dune, emma], book_to_json)))
}{"title":"Dune","year":1965,"tags":["sci-fi","classic"],"rating":4.5}
[{"title":"Dune","year":1965,"tags":["sci-fi","classic"],"rating":4.5},{"title":"Emma","year":1815,"tags":[],"rating":null}]json.nullable è il contrario di decode.optional: Some(4.5) diventa 4.5, None diventa null. json.to_string produce JSON compatto, senza spazi né a capo: è fatto per le macchine, non per gli occhi.
Un decoder e un encoder per lo stesso tipo sono una coppia: con i due insieme, un valore Gleam può fare il giro completo, testo → Book → testo, senza perdere niente. Un buon test da scrivere, per un tipo importante, è proprio questo.
Esercizio · sul tuo computer
Il meteo
Crea src/weather.gleam. Parti da questo JSON, una lista di letture di temperatura (nota che quella di Oslo e quella di Lima non hanno decimali):
[
{"city": "Rome", "temperature": 21.5},
{"city": "Oslo", "temperature": 4},
{"city": "Cairo", "temperature": 30.5},
{"city": "Lima", "temperature": 16}
]Definisci un tipo Reading(city: String, temperature: Float) e il suo decoder, che deve accettare temperature intere e con la virgola. Leggi la lista con json.parse, poi stampa la città più calda, e un oggetto JSON con la città più calda e la temperatura media:
Warmest: Cairo
{"warmest":"Cairo","average":18.0}Se il JSON non si legge, stampa Invalid data.
Mostra una soluzione (prima prova da solo!)
import gleam/dynamic/decode
import gleam/int
import gleam/io
import gleam/json
import gleam/list
pub type Reading {
Reading(city: String, temperature: Float)
}
fn number() -> decode.Decoder(Float) {
decode.one_of(decode.float, or: [decode.int |> decode.map(int.to_float)])
}
fn reading_decoder() -> decode.Decoder(Reading) {
use city <- decode.field("city", decode.string)
use temperature <- decode.field("temperature", number())
decode.success(Reading(city:, temperature:))
}
pub fn main() -> Nil {
let text =
"[
{\"city\": \"Rome\", \"temperature\": 21.5},
{\"city\": \"Oslo\", \"temperature\": 4},
{\"city\": \"Cairo\", \"temperature\": 30.5},
{\"city\": \"Lima\", \"temperature\": 16}
]"
case json.parse(text, decode.list(reading_decoder())) {
Ok([first, ..rest]) -> report(first, rest)
Ok([]) -> io.println("No readings")
Error(_) -> io.println("Invalid data")
}
}
fn report(first: Reading, rest: List(Reading)) -> Nil {
let warmest =
list.fold(rest, first, fn(best, reading) {
case reading.temperature >. best.temperature {
True -> reading
False -> best
}
})
let readings = [first, ..rest]
let total = list.fold(readings, 0.0, fn(sum, r) { sum +. r.temperature })
let average = total /. int.to_float(list.length(readings))
io.println("Warmest: " <> warmest.city)
json.object([
#("warmest", json.string(warmest.city)),
#("average", json.float(average)),
])
|> json.to_string
|> io.println
}Il pattern Ok([first, ..rest]) fa due cose in una: controlla che la lettura sia andata bene e che la lista non sia vuota. Così report riceve per forza almeno una lettura, e il fold per la più calda può partire dalla prima.
Ricapitolando
- Il JSON è testo con oggetti, array, stringhe, numeri, booleani e
null: il formato più comune per scambiare dati. - Serve il pacchetto
gleam_json(gleam add gleam_json), modulogleam/json. json.parse(from: testo, using: decoder)legge; l’errore èUnexpectedEndOfInput,UnexpectedByte,UnexpectedSequence(sintassi) oUnableToDecode(forma).- I numeri senza decimali sono
Int:decode.one_ofcondecode.map(int.to_float)accetta entrambi. nullsi legge condecode.optionale si scrive conjson.nullable: in Gleam èOption.json.object,json.array,json.string… costruiscono unjson.Json;json.to_stringlo trasforma in testo.- Per ogni tipo importante: un decoder e un encoder, uno lo specchio dell’altro.
Nella prossima lezione il JSON uscirà dalle stringhe: leggeremo e scriveremo file, e daremo istruzioni al programma dalla riga di comando.