Deser: Rust-serialisatie opnieuw nadenken

Hier zijn drie voorbeelden van 'corner cases' in Serde die wijzen op slechte interacties tussen functies of onverwachte beperkingen:

Een getal dat een map is

Een intern getagde enum, waarbij de arbitraryprecision feature van serdejson is ingeschakeld:

#[derive(Deserialize)]
#[serde(tag = "type")]
enum Shape {
    Circle { radius: f64 },
}
serde_json::from_str::<Shape>(r#"{"type": "Circle", "radius": 1.5}"#)
// fout: invalid type: map, expected f64

Het datamodel van Serde heeft geen ruimte voor getallen met willekeurige precisie, waardoor serde_json gebruikmaakt van in-band signalering met een map en een 'magic key'. De enum moet de velden bufferen totdat de tag is gezien, maar de buffer is niet op de hoogte van de magic key. Omdat Cargo-features worden geünificeerd, is het voldoende dat één crate in je dependency graph deze feature inschakelt om dit gedrag te veroorzaken.

Flattening verbreekt integer-sleutels

#[derive(Deserialize)]
struct Stats {
    scores: HashMap<u32, u32>,
}

#[derive(Deserialize)]
struct Report {
    name: String,
    #[serde(flatten)]
    stats: Stats,
}
serde_json::from_str::<Report>(r#"{"name": "x", "scores": {"42": 23}}"#)
// fout: invalid type: string "42", expected u32 at line 1 column 35

Stats op zichzelf parseert {"scores": {"42": 23}} probleemloos. JSON-sleutels zijn altijd strings, en serde_json zet ze alleen om in integers als het type daarom vraagt. Echter, zodra flatten de waarde buffert, is "42" gewoon een string. Bovendien wijst de foutmelding naar het einde van het document in plaats van naar de specifieke sleutel.

Adapters zijn niet combineerbaar (compose)

fn from_hex<'de, D: Deserializer<'de>>(d: D) -> Result<u32, D::Error> { ... }

#[derive(Deserialize)]
struct Theme {
    #[serde(deserialize_with = "from_hex")]
    primary: u32,
    #[serde(deserialize_with = "from_hex")]
    accent: Option<u32>,
}
//error[E0308]: `?` operator has incompatible types
//  |
//  |     #[serde(deserialize_with = "from_hex")]
//  |                                ^^^^^^ expected `Option<u32>`, found `u32`

Een functie kan niet als typeparameter worden meegegeven, waardoor er geen manier is om fromhex toe te passen op de inhoud van een Option, Vec of map. Men moet een aparte functie schrijven voor elke wrapper; zodra je fromopt_hex hebt, is het veld niet langer optioneel, tenzij je ook #[serde(default)] toevoegt.

Geen van deze problemen is eenvoudig op te lossen in Serde, omdat ze voortvloeien uit het fundamentele ontwerp en dit ontwerp wordt beschermd door de stabiliteitsgaranties van Serde.

De naam en het idee

In 2022 begon ik aan een experiment genaamd Deser. Het is een serialisatiebibliotheek voor Rust die de gebruikerservaring van Serde overneemt, maar deze bouwt op een volledig andere architectuur, geïnspireerd door miniserde. Na enkele jaren stilte heb ik het project weer opgepakt en het is nu op een punt gekomen waarop het de moeite waard is om te bekijken.

De naam is simpelweg 'Serde' waarbij de twee helften zijn omgewisseld. Waar in Serde het type het deserialisatieproces aanstuurt (een Deserialize implementatie vraagt de deserializer om het type waarde dat het verwacht en het formaat roept terug naar een visitor), draait Deser dit om. Bij Deser vertelt het formaat het type van de volgende waarde en pusht het events naar een 'sink'. Wanneer een sink het begin van een geneste waarde bereikt, roept deze niet recursief aan, maar geeft een nieuwe sink terug aan een driver, die alle status op de heap bewaart (in een arena). Bij het uitgaan retourneren emitters hun geneste waarden in plaats van recursief in ze te duiken.

Dit betekent dat Deser geen formaten zoals Protobuf ondersteunt die niet zelf-beschrijvend zijn; deze zijn bewust buiten het ontwerp gelaten. Als je Serde wilt "verbeteren", moet je dus andere compromissen sluiten.

De wortels van Deser: Sentry Relay

De meeste ideeën achter Deser komen voort uit Sentry Relay, dat enorme hoeveelheden onbetrouwbare JSON-data verwerkt. Veel problemen zijn geen bugs in Serde, maar consequenties van het ontwerp. Door de stabiliteitsgaranties kunnen deze niet worden opgelost zonder elk formaat en elke handgeschreven implementatie te breken. De problemen komen voort uit drie beslissingen:

  1. Eén set traits voor alle formaten. Serde bedient zowel zelf-beschrijvende formaten (JSON, YAML, TOML) als formaten waarbij de reader vooraf het type moet kennen (postcard, bincode, protobuf). Dit is nuttig, maar betekent dat sommige functies alleen met bepaalde formaten werken, wat pas tijdens runtime blijkt.
  2. Een vast datamodel dat informatie verliest bij buffering. Intern getagde en ongetagde enums, en flatten, moeten waarden bufferen voordat ze weten wat ze ermee moeten doen. De buffer kan niet alles vasthouden wat het formaat wist, waardoor foutlocaties verloren gaan en extensies in het ecosysteem moeten vertrouwen op in-band signalering (zoals magic keys voor arbitrary precision numbers).
  3. Recursie op de call stack. Elk niveau van nesting verbruikt stackruimte. Formaten beschermen zich hiertegen met een recursielimiet, maar zodra een codepad zonder limiet wordt gebruikt (zoals bij schrijven of dynamische waarden), kan diep geneste data het proces laten crashen. Ook betekent dit dat deserialisatie niet gepauzeerd kan worden terwijl er gewacht wordt op meer input.

Serde onttronen

Het is onwaarschijnlijk dat Serde volledig vervangen kan worden; de 'orphan rule' verankert Serde stevig in het ecosysteem. Deser richt zich echter op compleetheid. Het implementeert momenteel alle belangrijke zelf-beschrijvende formaten: YAML, JSON, TOML, CBOR, JSON5, XML en plist. Vooral XML is iets wat Serde heeft geweigerd te ondersteunen.

Een tweede probleem is dat het oplossen van de issues van Serde vaak ten koste gaat van compileertijd of runtime-prestaties. Deser is hierin niet anders. Hoewel de compileertijden iets beter zijn, is de binary bloat groter en zijn de runtime-prestaties gemengd. Gemiddeld is het ongeveer 10% trager bij het lezen van JSON, maar bij schrijven is het een wash. Voor YAML en TOML is het merkbaar sneller dan de Serde-gebaseerde crates, wat echter meer te maken heeft met de implementaties van de formaten dan met de architectuur.

Het ontwerp van Deser

Op het oppervlak probeert Deser niet wezenlijk anders te zijn dan Serde. Voor de meeste toepassingen gebruik je derive(Serialize, Deserialize). De meeste attributen zijn vergelijkbaar, hoewel ze nu Rust-expressies accepteren in plaats van strings.

use deser::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
#[deser(rename_all = "camelCase")]
pub struct Account {
    id: u64,
    account_holder: String,
    #[deser(default)]
    is_deactivated: bool,
}

let account: Account = deser_json::from_str(json)?;

Het verschil wordt duidelijk bij het implementeren van een serializer of deserializer. In plaats van visitors die recursief naar elkaar verwijzen, creëert het deserialiseren van een type een sink die events ontvangt die direct door de parser worden verzonden. Serialiseren produceert emitters die waarden aanleveren. Geneste sinks en emitters worden teruggegeven aan een driver die ze op de heap bewaart.

Dit ontwerp (overgenomen van miniserde) heeft interessante gevolgen:

  • Geen stack overflows: Structuren kunnen willekeurig diep genest zijn. Voor onbetrouwbare input stel je limieten in via een layer, onafhankelijk van je stackruimte.
  • Onderbreekbaar (Suspendable): Omdat de status in de driver leeft, kan deserialisatie input ontvangen zodra deze arriveert. Het is tevens Send, waardoor het tussen threads kan bewegen tijdens IO-wachtrijen (ideaal voor gebruik met tokio).
  • Uitbreidbaar datamodel: Het kernmodel is klein (atoms, maps, sequences). Voor de rest zijn er extensiewaarden (DateTime, Uuid, etc.) met een fallback voor formaten die ze niet begrijpen.
  • Verliesvrije buffering: Wanneer een waarde gebufferd moet worden, registreert Deser de events samen met alle informatie die het formaat erover wist. Protocolspecifieke extensies of foutlocaties blijven behouden.
  • Layers: Een middleware-systeem tussen het formaat en je types. Hiermee kun je paden tracken, veiligheidslimieten afdwingen, sleutels hernoemen of waarden redigeren zonder specifieke codepaden aan te passen.
  • Native flattening: Dit vindt plaats zonder buffering.

Daarnaast zijn er diverse kwaliteitsverbeteringen:

  • Enum-tags kunnen elk type zijn, niet alleen strings.
  • Adapters die combineerbaar zijn (bijv. as = Option<Vec<DisplayFromStr>>).
  • Validatie als adapter.
  • Derive-attributen zijn echte Rust-expressies.
  • Bytes als kernfunctionaliteit in het datamodel.
  • Dubbele sleutels worden standaard geweigerd met foutmeldingen die precies naar het probleem wijzen.

Voorbeeld van configuratie en validatie

use deser::adapters::DisplayFromStr;
use deser::de::Recording;
use deser::{Deserialize, Serialize};
use deser_encoding::Hex;
use deser_validate::{Check, NonEmpty, Range};
use ipnet::IpNet;

#[derive(Debug, Serialize, Deserialize)]
pub struct Config {
    // minstens één 256-bit sleutel, geschreven als hex
    #[deser(as = Check<NonEmpty, Vec<Hex>>)]
    secret_keys: Vec<[u8; 32]>,
    // `IpNet` kent deser niet, maar heeft `FromStr` en `Display`
    #[deser(as = Option<Vec<DisplayFromStr>>)]
    allowed_networks: Option<Vec<IpNet>>,
    listeners: Vec<Listener>,
}

#[derive(Debug, Serialize, Deserialize)]
#[deser(tag = "type", rename_all = "snake_case")]
pub enum Listener {
    Unix { path: PathBuf },
    Tcp {
        host: IpAddr,
        #[deser(as = Check<Range<1, 65535>>)]
        port: u16,
    },
    // types die deze versie niet kent, worden bewaard en teruggeschreven
    #[deser(other)]
    Other(#[deser(tag)] String, Recording),
}

Foutmeldingen zijn in Deser zeer accuraat. Als een waarde onjuist is (bijvoorbeeld een poort buiten het bereik in een intern getagde enum waarbij de tag pas aan het einde komt), kan Deser met de PathLayer precies aangeven waar het probleem zit: Unexpected: invalid value: must be between 1 and 65535 at line 10 column 8 (path: listeners[1].port)

Meta Data en XML

XML is een extreem voorbeeld van het verschil tussen Deser en Serde. XML gebruikt namespaces, wat betekent dat namen gematcht moeten worden op hun namespace en niet op de prefix in het document.

use chrono::{DateTime, Utc};
use deser::Deserialize;
use deser_value::Value;
use deser_xml::DeserializerConfig;

deser_xml::namespace!(
    atom = "http://www.w3.org/2005/Atom",
    dc = "http://purl.org/dc/elements/1.1/",
);

#[derive(Debug, Deserialize)]
struct Entry {
    #[deser(rename = atom!("title"))]
    title: String,
    #[deser(rename = dc!("creator"))]
    creators: Vec<String>,
    #[deser(rename = atom!("updated"))]
    updated: DateTime<Utc>,
}

#[derive(Debug, Deserialize)]
#[deser(untagged)]
enum Item {
    Entry(Entry),
    Other(Value),
}

quick-xml (de populairste XML-crate voor Serde) negeert namespaces volledig. Bovendien is de afhandeling van split lists problematisch; zonder specifieke features faalt een Entry bij dubbele velden voor creator. Zelfs met die features gaat informatie verloren wanneer de struct in een ongetagde enum wordt gewrapt, omdat Serde de entry buffert en vervolgens alleen de laatste creator behoudt. In Deser blijft de buffer intact en worden beide creators verzameld in de Vec.

Ook TOML-datetimes zijn een voorbeeld. In Serde worden deze via een map met een magic key doorgegeven. In Deser is een datetime een extensiewaarde die formaten die dit begrijpen behouden, en andere formaten als string wegschrijven.

De kosten

Het ontwerp van Deser is niet gratis. Het vertrouwt op dynamic dispatch en sinks/emitters op de heap, wat runtime-overhead veroorzaakt.

  • Prestaties: Voor JSON is Deser gemiddeld 10% trager bij het lezen. Schrijfprestaties zijn gemiddeld gelijk aan Serde. Voor YAML en TOML is het merkbaar sneller.
  • Compileertijd: Iets beter, maar niet dramatisch. Omdat niet alles gemonomorfiseerd wordt, zijn release builds van derived code ongeveer 2,3 keer zo snel als bij Serde.
  • Veiligheid: Deser maakt intern gebruik van unsafe om de keten van borrowed sinks op de heap te houden.
  • Ecosysteem: De grootste kost is dat het simpelweg niet Serde is.

Ondersteuning

Deser ondersteunt een breed scala aan formaten:

  • JSON: JSON, JSONC, JSON5 en HJSON.
  • Binair: CBOR en MessagePack.
  • Overig: YAML 1.1 en 1.2, TOML, XML, alle drie de smaken Apple plist, CSV/TSV, urlencoded data en omgevingsvariabelen.
  • Extra's: Path info, locatie-data, debug printing, validatie tijdens het parsen, diverse binaire encodings (naast base64), bridging naar Serde, het vastleggen van dynamische waarden, transcoding tussen formaten en integratie met tokio.