Travailler avec CrystalJson
Les gestes de tous les jours, une section par tâche. Cette page suppose que vous savez ce qu'est
CrystalJson et pourquoi il a un DOM (Document Object Model), des proxies et deux chemins de
sérialisation ; sinon, lisez l'explication d'abord. Porter un parc
DataContractJsonSerializer ou Newtonsoft est un projet à part entière ; le
guide de migration couvre les diagnostics et les changements de
comportement que vous rencontrerez. Les tables complètes des attributs, des settings et des
diagnostics sont dans la référence.
Tous les exemples utilisent using SnowBank.Data.Json;.
Tip : passez-le en global using. D'autres bibliothèques d'un projet typique déclarent aussi un type nommé
JsonObject(System.Text.Json.Nodesen particulier). La première fois qu'un fichier mentionneJsonObjectsans le bonusing, l'autocomplétion de l'IDE propose d'en ajouter un, et choisir le mauvais namespace produit des erreurs déroutantes : les méthodes ont l'air identiques mais prennent d'autres arguments. Déclarerglobal using SnowBank.Data.Json;une fois dans leGlobalUsings.csdu projet supprime l'ambiguïté pour tous les fichiers d'un coup.
Sérialiser et désérialiser un type
Aucun setup n'est requis. Le chemin par réflexion construit un contrat de sérialisation à l'exécution, à partir du type lui-même :
public sealed record Book
{
public required string Id { get; init; }
public required string Title { get; init; }
public int Year { get; init; }
}
string json = CrystalJson.Serialize(book);
// => { "Id": "B123", "Title": "Dune", "Year": 1965 }
// throw si le JSON est null ou vide
Book back = CrystalJson.Deserialize<Book>(json);
// null au lieu de throw
Book? maybe = CrystalJson.Deserialize<Book>(json, defaultValue: null);
// des octets UTF-8, prêts pour une valeur en base
Slice bytes = CrystalJson.ToSlice(book);
Quand un type n'a pas de constructeur sans paramètre, CrystalJson le construit via l'un de ses autres
constructeurs : celui marqué [JsonConstructor], ou l'unique constructeur public où chaque paramètre
correspond à un membre sérialisé par nom (insensible à la casse) et par type. Un membre absent prend
la valeur par défaut du paramètre. Un record positionnel n'a donc pas besoin de propriétés init ;
CrystalJson le désérialise via son constructeur principal :
public sealed record Toy(string Name, int Size = 3);
CrystalJson.Deserialize<Toy>("""{ "Name": "ball" }""");
// => Toy { Name = ball, Size = 3 }
Pour un type qui vous appartient et que vous sérialisez souvent, préférez le source generator : déclarez un container une fois, et le compilateur émet le convertisseur que le chemin par réflexion reconstruirait sinon à l'exécution :
[CrystalJsonConverter]
[CrystalSerializable(typeof(Book))]
public static partial class AcmeSerializers { } // le code généré atterrit ici
string json = AcmeSerializers.Book.ToJsonText(book);
Book back = AcmeSerializers.Book.Deserialize(json);
Le convertisseur généré est sans réflexion (il fonctionne sous AOT et trimming), plus rapide, et
apporte les proxies typés utilisés plus loin sur cette page. Le projet qui consomme le générateur
a besoin de LangVersion 9 ou plus et du générateur référencé comme analyzer ; les deux sont un
setup unique, détaillé dans la référence.
Les deux voies produisent les mêmes octets pour le même type, donc vous pouvez commencer en ad hoc et adopter le générateur plus tard sans changer aucun document stocké. Le chemin par réflexion reste le bon outil quand il n'y a pas de schéma typé à déclarer, ou quand un quick and dirty suffit : un script, un test, un outil jetable.
Parser et naviguer du JSON inconnu
Quand la forme est dynamique ou partiellement connue, parsez vers le DOM et naviguez. Le point
d'entrée du parsing énonce comment une mauvaise forme de premier niveau se traite. Quand un
payload non-objet serait un bug du producteur, parsez directement vers le type et laissez-le throw ;
quand c'est un cas ordinaire que votre code doit gérer, parsez vers JsonValue et inspectez :
// un payload non-objet est extraordinaire ici : parser vers le type, ça throw sinon
JsonObject obj = JsonObject.Parse(json);
// un payload non-objet est un cas ordinaire ici : le gérer, puis continuer
JsonValue value = JsonValue.Parse(json);
if (value is not JsonObject o)
{
// pas un objet : rejeter la requête, sauter l'entrée, ...
return;
}
// à partir d'ici, o est l'objet typé
JsonArray.Parse(...) est le jumeau pour les tableaux, et le nommage est un pattern, pas une
coïncidence : chaque type du DOM imbrique une classe ReadOnly aux mêmes points d'entrée
(JsonValue.ReadOnly.Parse, JsonObject.ReadOnly.Parse, JsonValue.ReadOnly.FromValue), qui
rendent un document figé, sûr pour le cache, au lieu d'un mutable. La classe statique
CrystalJson sert la voie POCO (Serialize, Deserialize, ToSlice) ; le DOM se parse par
les types du DOM eux-mêmes.
Une fois parsé, la navigation a la null propagation intégrée : un indexeur ne throw jamais
sur un membre absent, il rend un objet null (JsonNull.Missing) que le hop suivant accepte,
donc une chaîne entière est sûre sans un seul null check manuel :
// Missing si un hop manque, pas de NRE
JsonValue city = obj["user"]["address"]["city"];
bool present = !city.IsNullOrMissing();
int age = obj.Get<int>("age", 0); // défaut si absent
string name = obj.Get<string>("name"); // obligatoire : throw si absent
if (obj.TryGet<string>("email", out var email)) { /* ... */ }
JsonObject meta = obj.GetObjectOrEmpty("meta"); // jamais null ; vide si absent
JsonArray items = obj.GetArray("items"); // throw si ce n'est pas un tableau
foreach (var item in items.AsObjects()) { /* seulement les JsonObject */ }
Les proxies générés, plus loin sur cette page, propagent l'absence de la même façon : une chaîne
à travers un objet interne absent continue de naviguer (proxy.Metadata.IsNullOrMissing() vous
le dit), un membre optionnel se lit comme son défaut, et un membre required absent du document
throw une JsonBindingException. Jamais de NullReferenceException, et jamais de null check
manuel.
Une distinction compte quand vous testez le null : JsonNull.Null est un null explicite dans
le document, JsonNull.Missing est un membre qui n'était pas là, et JsonNull.Error est un
accès invalide (indexer un non-tableau). Les trois rapportent IsNull == true ; utilisez
IsNullOrMissing() ou IsMissing() quand la différence compte.
Construire un document JSON
Construisez avec les factories Create ; les conversions implicites couvrent les valeurs
scalaires :
var obj = JsonObject.Create([
("name", "Alice"),
("age", 30),
("tags", JsonArray.Create("admin", "user")),
("point", JsonObject.Create([ ("x", 1), ("y", 2) ])),
]);
var arr = JsonArray.Create(1, 2, 3);
Le nommage suit le même pattern que Parse : chaque factory a sa jumelle ReadOnly
(JsonObject.ReadOnly.Create, JsonArray.ReadOnly.Create) qui produit une valeur figée avec la
même forme d'appel. La forme collection initializer (new JsonObject { ["name"] = "Alice" })
compile aussi ; ces pages utilisent les factories, qui se lisent à l'identique en mutable et en
figé.
Une valeur destinée au cache ou au partage entre threads doit être en lecture seule. Figez une mutable, ou construisez en lecture seule directement :
// copie profonde en lecture seule (elle-même si déjà figée) ; toute modification va throw
var frozen = obj.ToReadOnly();
// en lecture seule dès le départ, avec des tuples ("clé", valeur)
var ro = JsonObject.ReadOnly.Create([
("name", "Alice"),
("tags", JsonArray.ReadOnly.Create(["admin", "user"])),
]);
Muter un conteneur en lecture seule throw InvalidOperationException, et c'est la feature : un
document en cache ne peut pas être corrompu par un appelant. Pour aller d'une valeur CLR au DOM
sans texte intermédiaire, utilisez JsonValue.FromValue(poco) (ou
JsonValue.ReadOnly.FromValue(poco)).
Modifier un document
Un DOM mutable se modifie en place, avec l'indexeur (à travers les conversions implicites vers
JsonValue) ou le Set fluent et générique, qui accepte toute valeur que le sérialiseur
connaît, un POCO entier compris :
obj["status"] = "online"; // poser ou remplacer un champ
obj["point"]["x"] = 123; // quand "point" existe et est un objet
obj.Remove("obsolete");
arr.Add(4);
obj // Set fluent, une édition par ligne
.Set("count", 42)
.Set("unit", "pages")
.Set("author", author); // Set<TValue> sérialise toute valeur, POCO compris
Pour construire une valeur DOM depuis une collection existante, les helpers FromValues
couvrent les spans, les tableaux, IEnumerable<T> (avec un sélecteur optionnel) et les
dictionnaires, sur les types mutables comme sur leurs jumelles ReadOnly :
JsonArray tags = JsonArray.FromValues(book.Tags);
JsonArray titles = JsonArray.FromValues(books, b => b.Title);
JsonObject scores = JsonObject.ReadOnly.FromValues(scoresByName); // figé
Il n'y a pas d'auto-création sur le DOM brut : assigner à travers un intermédiaire absent
(obj["missing"]["x"] = 1) throw. Créez l'objet enfant d'abord.
Un document en lecture seule ne se modifie pas en place, par conception. Le « modifier », c'est un cycle copy-on-write : prendre une copie mutable, la modifier, refiger. L'original figé reste en place, donc toute référence en cache vers lui reste valide :
var frozen = obj.ToReadOnly();
var draft = frozen.ToMutable(); // copie mutable ; l'original est intact
draft["status"] = "offline";
var updated = draft.ToReadOnly(); // un second document figé, qui porte l'édition
Une copie est évitable : quand une méthode construit un document et le retourne figé, Freeze()
marque l'instance elle-même en lecture seule au lieu de la copier, le pattern builder des
documents figés. Ne l'utilisez que sur une valeur que la méthode possède exclusivement, car
toute référence vers l'instance devient en lecture seule avec elle :
static JsonObject BuildManifest()
{
var m = JsonObject.Create();
m["version"] = 3;
// ...construire librement, puis figer en place : pas de copie défensive
return m.Freeze();
}
Lire et modifier via les proxies générés
Quand un document a quarante champs et que le code en consomme trois, ne le désérialisez pas. Enveloppez le DOM parsé dans le proxy en lecture seule généré et lisez les champs voulus, typés :
JsonObject doc = JsonObject.Parse(bytes);
// une vue typée sur le document parsé ; rien n'est copié ni matérialisé
AcmeSerializers.Book.ReadOnly book = AcmeSerializers.Book.ToReadOnly(doc);
// lecture typée, avec IntelliSense
string title = book.Title;
// matérialiser le POCO, seulement si nécessaire
Book poco = book.ToValue();
Les éditions sur un proxy en lecture seule passent par le copy-on-write : l'original reste figé, vous obtenez un nouveau proxy figé (ou prenez un proxy mutable explicite) :
// copy-on-write : le proxy d'origine reste figé
AcmeSerializers.Book.ReadOnly edited = book.With(m => { m.Year = 1966; });
// ou prendre un proxy mutable explicite
AcmeSerializers.Book.Writable w = book.ToMutable();
w.Year = 1966;
Comme le document en dessous garde tous les champs avec lesquels il est arrivé, un aller-retour à travers un proxy ne perd jamais les champs que cette version du code ne connaît pas, le problème de troncature silencieuse par lequel l'explication s'ouvre.
Durcir le parsing d'un input non fiable
Le parser est volontairement permissif par défaut (les commentaires JavaScript et les virgules finales sont acceptés), ce qui est faux pour un input que vous ne contrôlez pas. Resserrez-le :
var settings = CrystalJsonSettings.JsonStrict // ni commentaires, ni virgules finales
.ThrowOnDuplicateFields(); // une clé répétée est une erreur
JsonValue value = JsonValue.Parse(payload, settings);
JsonStrict ne couvre pas les clés dupliquées à lui seul ; ajoutez ThrowOnDuplicateFields()
quand une clé répétée doit échouer. Le contenu après la valeur de premier niveau est rejeté par
défaut ; pour lire plusieurs documents consécutifs dans un même buffer, utilisez
CrystalJson.ParseFragment, pas WithTrailingData() (qui parse la première valeur et jette le
reste en silence).
Changer la sérialisation d'un seul membre
La plupart des besoins par membre sont couverts par des attributs, sans code à écrire. Commencez par eux :
[JsonProperty("id")] // renommage en sortie
public required string Id { get; init; }
[JsonProperty(DefaultValue = "draft")] // le défaut déclaré du membre
public string Status { get; init; } = "draft";
[JsonProperty(EnumFormat = JsonEnumFormat.Number)] // cet enum-là reste numérique
public BookGenre Genre { get; init; }
[JsonProperty(NumberFormat = JsonNumberFormat.String)]
// "12345678901234567" : sûr pour JS
public long AccountId { get; init; }
[JsonBooleanLiterals("0", "1")] // formes legacy des booléens
public bool Enabled { get; set; }
[JsonBooleanLiterals(null, true)] // écrit true, ou omet le membre
public bool Flagged { get; set; }
Quand aucun attribut ne couvre le besoin, que le type du membre a sa propre forme compacte, ou
que la valeur doit traverser une forme legacy, écrivez un member converter et attachez-le
avec l'attribut propre à CrystalJson. Un cas réaliste : une struct de coordonnées stockée comme
un tableau compact [lat, lon] au lieu d'un objet à deux champs nommés :
public readonly record struct GpsPosition(double Latitude, double Longitude);
public sealed class GpsPositionConverter : IJsonMemberConverter<GpsPosition>
{
public JsonValue Pack(
GpsPosition value,
CrystalJsonSettings? settings = null,
ICrystalJsonTypeResolver? resolver = null)
{
return JsonArray.Create(value.Latitude, value.Longitude);
}
public GpsPosition Unpack(JsonValue value, ICrystalJsonTypeResolver? resolver)
{
var arr = value.AsArray();
return new GpsPosition(arr.Get<double>(0), arr.Get<double>(1));
}
}
[JsonConvertWith(typeof(GpsPositionConverter))]
public GpsPosition Position { get; init; }
// => "position": [ 48.8584, 2.2945 ], sur les deux chemins et dans les deux sens
Un converter ne voit jamais null ni absent : le pipeline gère ces cas avant qu'il ne s'exécute.
Un converter qui n'implémente qu'une des deux facettes IJsonPacker<T> / IJsonDeserializer<T>
sert cette direction-là, et le traitement par défaut couvre l'autre. Nommer un type qui
n'implémente ni l'une ni l'autre est une erreur de build bruyante (CJSON0010), jamais un
fallback silencieux.
Sérialiser un type entier soi-même
Quand c'est la forme elle-même qui est sur mesure (un id compact empaqueté comme un tableau de ses parties), implémentez les interfaces directement sur le type :
public interface IJsonPackable
{
JsonValue JsonPack(CrystalJsonSettings settings, ICrystalJsonTypeResolver resolver);
}
public interface IJsonDeserializable<TSelf>
{
static abstract TSelf JsonDeserialize(
JsonValue value,
ICrystalJsonTypeResolver? resolver);
}
Un cas concret : un id de commande fait d'une région et d'un numéro de séquence, sérialisé comme
une seule string compacte ("EU-000123") au lieu d'un objet à deux champs nommés :
public readonly record struct OrderId(string Region, int Number)
: IJsonPackable, IJsonDeserializable<OrderId>
{
public JsonValue JsonPack(
CrystalJsonSettings settings,
ICrystalJsonTypeResolver resolver)
{
return JsonString.Return($"{this.Region}-{this.Number:D6}");
}
public static OrderId JsonDeserialize(
JsonValue value,
ICrystalJsonTypeResolver? resolver = null)
{
// défensif : Required<string>() throw une JsonBindingException si null ou absent
string literal = value.Required<string>();
int dash = literal.IndexOf('-');
return new OrderId(literal[..dash], int.Parse(literal[(dash + 1)..]));
}
}
string json = CrystalJson.Serialize(new OrderId("EU", 123));
// => "EU-000123", partout où le type apparaît : seul, comme membre, dans une collection
Les mêmes interfaces produisent une forme objet sur mesure. Un intervalle de dates dont la
borne haute est optionnelle décide de ses propres membres : "to" n'existe que quand
l'intervalle est fermé, quoi que disent les settings sur les membres null :
public sealed record DateRange(DateOnly From, DateOnly? To)
: IJsonPackable, IJsonDeserializable<DateRange>
{
public JsonValue JsonPack(
CrystalJsonSettings settings,
ICrystalJsonTypeResolver resolver)
{
var obj = JsonObject.Create("from", JsonString.Return(this.From));
if (this.To is not null)
{
obj["to"] = JsonString.Return(this.To.Value);
}
return obj;
}
public static DateRange JsonDeserialize(
JsonValue value,
ICrystalJsonTypeResolver? resolver = null)
{
var obj = value.AsObject();
return new DateRange(
obj.Get<DateOnly>("from"),
obj.Get<DateOnly?>("to", null));
}
}
// => { "from": "2026-01-01" } borne ouverte
// => { "from": "2026-01-01", "to": "2026-03-31" } fermé
Déclarer le paramètre resolver avec une valeur par défaut est la convention (les appelants
peuvent l'omettre, et l'interface est quand même satisfaite). JsonPack et JsonDeserialize
doivent être inverses ; pinnez l'aller-retour dans un test. Construisez les valeurs avec les
factories (JsonString.Return(...), JsonNumber.Return(...), JsonArray.ReadOnly.Create(...)),
et gérez le null et l'absent défensivement, comme Required<string>() le fait ci-dessus. Un
converter au niveau du type, attaché avec [JsonConvertWith], est l'alternative quand vous ne
pouvez pas modifier le type lui-même.