CrystalXml : sortie XML générée pour CrystalJson
CrystalXml est une surcouche de sortie XML en écriture seule pour le source generator de
CrystalJson. Un container qui génère déjà des sérialiseurs JSON peut activer le XML avec un seul
attribut, et chaque type qu'il enregistre gagne une famille de sorties ToXmlText / WriteXmlTo
générées à la compilation : zéro réflexion à l'exécution, pas de System.Xml.Serialization, et une
sortie exacte à l'octet sur les sinks texte.
Il existe pour permettre à une application de remplacer la production XML basée sur
DataContractSerializer (le « format DCS ») par du code généré, tout en gardant la compatibilité à
l'octet avec les documents que ses consommateurs (par exemple une couche de rendu XSLT) analysent
déjà, et, indépendamment, pour donner aux containers modernes orientés JSON une projection XML
propre.
Il n'y a délibérément pas de FromXml : CrystalXml écrit du XML, il ne le lit jamais.
Déclarer la sortie
Deux niveaux : le container dit quels formats il produit, les membres disent à quoi ils ressemblent dans le format XML.
Un container est un marqueur neutre vis-à-vis du format, plus un attribut par format de sortie. Les types qu'il sérialise sont enregistrés une seule fois, de façon neutre vis-à-vis du format : le même enregistrement alimente chaque format que le container produit.
// niveau container : le marqueur neutre, puis un attribut de sortie par format
// « cette classe héberge du code généré »
[CrystalConverter]
[CrystalJsonOutput(CrystalJsonSerializerDefaults.DataContractCompat)]
// activation : chaque type du container reçoit une sortie XML
[CrystalXmlOutput]
// enregistrement neutre vis-à-vis du format
[CrystalSerializable(typeof(ClientAccount))]
public static partial class LegacyRenderSerializers { }
| Attribut | Namespace | Rôle |
|---|---|---|
[CrystalConverter] |
SnowBank.Data |
le marqueur de container ; ne dit rien sur les formats |
[CrystalSerializable(typeof(T))] |
SnowBank.Data |
enregistre un type racine ; répétable ; alimente chaque format de sortie |
[CrystalJsonOutput(...)] |
SnowBank.Data.Json |
demande le format JSON, et porte ses paramètres (profil, politique de nommage, insensibilité à la casse) |
[CrystalXmlOutput(...)] |
SnowBank.Data.Xml |
demande le format XML, et porte ses paramètres (le préréglage de format, DictionaryFormat) |
[CrystalJsonConverter(...)] |
SnowBank.Data.Json |
alias mono-format : [CrystalConverter] + [CrystalJsonOutput] avec les mêmes paramètres |
[CrystalXmlConverter(...)] |
SnowBank.Data.Xml |
alias mono-format : [CrystalConverter] + [CrystalXmlOutput] avec les mêmes paramètres |
[CrystalJsonSerializable(typeof(T))] est l'ancienne orthographe de [CrystalSerializable]. Elle
fonctionne toujours (et génère du code identique à l'octet) mais elle est [Obsolete] :
l'enregistrement n'a jamais été spécifique à JSON.
La table de vérité
| Attributs du container | Généré |
|---|---|
[CrystalConverter] + [CrystalJsonOutput] |
JSON uniquement |
[CrystalJsonConverter] |
JSON uniquement (alias de la ligne ci-dessus) |
[CrystalConverter] + [CrystalXmlOutput] |
XML uniquement : pas de Serialize/Pack/Unpack, pas de proxies JSON, pas de facette IJsonConverter, pas de TypeMapper |
[CrystalXmlConverter] |
XML uniquement (alias de la ligne ci-dessus) |
[CrystalConverter] + les deux sorties |
les deux formats, à partir d'un seul jeu de types enregistrés |
[CrystalJsonConverter] + [CrystalXmlOutput] |
rejeté (CRYS0002) : les alias mono-format ne se combinent pas |
[CrystalXmlConverter] + [CrystalJsonOutput] |
rejeté (CRYS0002), symétriquement |
[CrystalConverter] seul |
rejeté (CRYS0001) : un container qui ne nomme aucun format de sortie ne génère rien |
| plusieurs marqueurs de container sur une même classe | rejeté (CRYS0003) |
Un container XML uniquement n'a aucun profil JSON dont dériver, donc [CrystalXmlOutput] sans
paramètre se résout vers le format général, et ses noms d'éléments sont les noms de membres déclarés
(la politique de nommage est un paramètre de [CrystalJsonOutput]). Un container qui a besoin à la
fois d'une politique de nommage JSON et de son miroir XML déclare les deux sorties.
[CrystalXmlOutput] / [CrystalXmlConverter] choisissent le format par un préréglage du
constructeur : [CrystalXmlOutput(CrystalXmlSerializerDefaults.General)] ou
[CrystalXmlOutput(CrystalXmlSerializerDefaults.DataContractCompat)]. La forme sans paramètre (Inherit) dérive
le format du profil JSON du container (un profil JSON DataContractCompat donne le format DCS, tout
le reste donne le format général). Une combinaison incohérente (une politique de nommage à côté du
format DCS) est une erreur de build (CXML0001). Les options nommées :
| Option | Signification |
|---|---|
DictionaryFormat |
valeur par défaut du container pour la forme de dictionnaire (voir le profil général ci-dessous) |
OmitNamespaces |
format DCS uniquement : reproduit le fil dépouillé sans namespaces, octet pour octet. Sur le profil général l'option est inerte, et CXML0012 le dit |
// niveau MEMBER : tout le XML vit dans [XmlProperty] (namespace SnowBank.Data.Xml)
// sucre syntaxique : normalisé au build en Name="id" + Attribute=true
[XmlProperty("@id")]
// forme de collection encapsulée, nommage des entrées pour les dictionnaires
[XmlProperty(ItemName = "tag")]
Échelle de résolution par setting (jamais tout ou rien) :
- les valeurs par défaut du profil du container (compat ou général) ;
[JsonProperty]/[JsonPropertyName]: fournissent le nom, pris tel quel (jamais remodelé par la politique de nommage) ;[XmlProperty]: surcharge finale, option par option (unItemNameseul laisse le nom retomber sur l'étape 2, puis sur le nom de membre .NET via la politique de nommage).
ItemName est un concept purement XML : il ne rejoint jamais [JsonProperty].
Règle absolue : aucune forme de sortie n'est jamais choisie par une heuristique sur les données. Si la sortie varie, c'est qu'un attribut ou une option l'a demandé explicitement en amont. Tout cas inexprimable est une erreur de build (la plage de diagnostics CXML) ou une exception typée à l'exécution, jamais un fallback silencieux.
Pipeline d'exécution
code généré (un corps par type)
| WriteXml<TEmitter>(ref TEmitter emitter, T value) where TEmitter : struct, ICrystalXmlEmitter
v
ICrystalXmlEmitter -- jeu d'événements : StartElement / Attribute / Text / EndElement / RawAscii
|
+-- CrystalXmlWriter<TRune, TWriter> TEXT : l'unique implémentation char + byte
| where TRune : unmanaged (char|byte) formes exactes à l'octet, toujours passée par ref
| where TWriter : struct, IBufferWriter<TRune>
|
+-- CrystalXDocumentEmitter infoset : construit le DOM directement
+-- CrystalXmlWriterEmitter infoset : délègue à System.Xml (interop)
Les noms d'éléments et d'attributs sont précalculés par le générateur en double représentation
(une string plus un littéral UTF-8 figé) dans des champs statiques CrystalXmlName, avec le
namespace de contrat incorporé au nom sur le format DCS, si bien que le chemin byte ne transcode
jamais un nom à l'exécution. Un nom ne porte jamais de préfixe : l'emitter attribue les préfixes
selon ce qui est en portée à sa profondeur. Les membres non publics passent par les mêmes thunks
[UnsafeAccessor] que du côté JSON. Le polymorphisme est un switch généré sur les types dérivés
connus du graphe ; un type d'exécution hors du graphe lève une exception typée.
Sorties publiques sur le holder généré (aucune ne passe par une autre) :
| Sortie | Chemin réel |
|---|---|
ToXmlText(value) |
cœur char sur IBufferWriter<char> |
WriteXmlTo(TextWriter, value) |
adaptateur vers le cœur char |
ToXmlSlice(value) / ToXmlBytes(value) |
cœur byte (UTF-8, sans string intermédiaire) |
WriteXmlTo(Stream / IBufferWriter<byte>, value) |
cœur byte |
ToXDocument(value) / WriteXmlTo(XmlWriter, value) |
emitters infoset : garanties au niveau infoset seulement, jamais exactes à l'octet |
Chaque sortie accepte un rootName optionnel et un CrystalJsonSettings optionnel (les valeurs
par défaut viennent du profil du container ; ShowNullMembers, formats de date/durée/enum).
Interfaces miroir du côté JSON : ICrystalXmlSerializer<T> (la facette implémentée par les
holders générés ; point d'extension pour les convertisseurs sur mesure par membre, vérifié au
moment de la génération), ICrystalXmlElementSerializer<T> (son extension de composition :
WriteXmlElement plus les deux noms avec lesquels un appelant compose, implémentée par chaque
convertisseur généré) et ICrystalXmlSerializable (hook d'instance : le type écrit son propre
XML).
Racines collection et scalaire
L'enregistrement d'une collection ou d'un scalaire nu ne génère aucun convertisseur (le générateur
signale l'avertissement CJSON0019 : enregistrez le type d'élément, pas la collection). Ces documents
passent par des points d'entrée sur CrystalXml, qui reflètent
les huit sorties ci-dessus :
// une séquence d'items de contrat, composée à partir de la facette du type d'item
string xml = CrystalXml.ToText(LegacySerializers.Shelf.Default, shelves);
// <ArrayOfShelf xmlns="..."><Shelf>...</Shelf><Shelf>...</Shelf></ArrayOfShelf>
// une racine scalaire nue, sur la classe imbriquée Scalar
string xml = CrystalXml.Scalar.ToText("hello");
// <string xmlns="http://schemas.microsoft.com/2003/10/Serialization/">hello</string>
Le nom de racine est résolu, jamais deviné : le rootName de l'appelant gagne ; le format DCS se
rabat sur sa convention ArrayOfX, dans le namespace du contrat de l'item ; le profil général n'a
pas de convention, donc une racine collection sans rootName lève CrystalXmlRootNameException.
Les éléments d'item gardent le nom d'élément du type d'item, et itemName le remplace. Les points
d'entrée scalaires écrivent le fil de référence des types lexicaux xsd (le nom lexical dans le
namespace Serialization, nil quand la valeur est null) ; un type hors de cet ensemble lève
CrystalXmlUnknownTypeException. Les scalaires vivent sur la classe imbriquée CrystalXml.Scalar
plutôt qu'en surcharges : une méthode générique prenant un T? nu capturerait tous les appels que
les surcharges à sérialiseur ne prennent pas, et un argument mal typé doit échouer à la
compilation plutôt qu'à l'écriture.
Le profil de compatibilité : le format DCS
La spécification exécutable est une suite comparée à un oracle DataContractSerializer réel
(SnowBank.Core.Tests/Xml/DcsOutputFidelityFacts.cs, avec les règles de namespaces verrouillées
dans DcsNamespaceReferenceFacts.cs ; registre de couverture à côté, dans COVERAGE.md), sous
deux règles d'acceptation. La sortie par défaut est tenue au fil standard sur les noms étendus :
cette émission omet les déclarations qu'elle peut prouver inutilisées et écrit les autres sur le
premier élément qui en a besoin, donc ses octets diffèrent de ceux du sérialiseur de référence
alors que chaque élément et chaque attribut se résolvent vers la même paire (namespace, nom
local). La sortie OmitNamespaces = true est tenue au fil dépouillé octet pour octet. Points
saillants :
- Noms de racine et de contrat :
[DataContract(Name=)]respecté, les génériques composentXOfYavec expansion{0}/{#}(empreinte de namespace volontairement omise), types imbriquésOuter.Inner,XmlConvert.EncodeLocalNameappliqué. - Ordre des membres : classe de base d'abord (récursivement), les membres sans
Order=dans l'ordre ordinal-alphabétique du nom de sortie, puis les groupesOrder=croissants avec départage alphabétique. - Membres en lecture seule : une propriété get-only n'atteint jamais la sortie, ce qui correspond à
ce que le chemin par réflexion du sérialiseur de référence prend sur un POCO ordinaire. Une
propriété avec un setter non public (
privateouinternal) portant[DataMember]sur un type[DataContract]est sérialisée depuis son getter, le sens écriture seule dont ce format a besoin ;DataContractSerializerla lit et l'écrit via ce setter non public. Seule une vraie propriété[DataMember]get-only (sans aucun setter) est rejetée au moment de la génération (CXML0013), où la vérification « pas de méthode set » du sérialiseur de référence rejette le contrat d'emblée (InvalidDataContractException, "No set method for property"). Un champ[DataMember]readonlyest une forme différente (cette vérification ne concerne que les propriétés) et atteint bien la sortie, octet pour octet avec l'oracle réel. - Membres null :
<X nil="true" />par défaut ;[DataMember(EmitDefaultValue = false)]rend le membre absent quand il est à sa valeur CLR par défaut. - Collections : l'élément d'item est nommé d'après le nom de contrat du type d'item (
<string>,<int>,<dateTime>,<Shelf>,<ArrayOfstring>pour une liste imbriquée) ; une collection vide s'auto-ferme, une string vide garde une paire de balises ouvrante et fermante. - Dictionnaires :
<KeyValueOfstringstring><Key>..</Key><Value>..</Value></KeyValueOfstringstring>, et<KeyValueOfstringShelf>quand la valeur est un type de contrat. - Namespaces : le namespace de contrat de l'élément racine (
[DataContract(Namespace = ...)], sinonhttp://schemas.datacontract.org/2004/07/plus le namespace CLR) est son namespace par défaut, et un élément membre vit dans le namespace du contrat qui le déclare. Cinq namespaces intégrés couvrent ce qu'aucun namespace CLR ne dérive : XMLSchema-instance (les attributsi:nileti:type), XMLSchema (le QName dui:typed'un primitif boxé), Arrays (les collections et dictionnaires génériques non annotés), Serialization (les racines scalaires nues) et le contrat System (DateTimeOffset). Un nom porte le nom local et le namespace, jamais de préfixe : le writer attribue les préfixes, garde les déclarations en portée et déclare chaque namespace sur le premier élément qui en a besoin. Le namespace d'instance remonte à la racine quand deux sous-arbres ou plus peuvent porter un marqueur nil ou type. - Polymorphisme : attribut
i:type="<QName du contrat>"seulement quand le contrat d'exécution diffère du contrat déclaré ; le nom d'élément reste celui du type déclaré, dans le namespace du contrat déclarant. Un type dérivé dans le namespace du slot écrit un nom local nu ; un type d'un autre namespace écrit un QName préfixé et déclare le préfixe sur le même élément. Une instance d'une racine polymorphe concrète écrit son propre corps, sans annotation, ce que fait l'oracle, et c'est là que ce profil s'écarte volontairement du profil général (voir plus bas). - Dialecte
ISerializable: chaque entréeSerializationInfodevient un élément nommé d'après la clé (encodée), les valeurs déclaréesobjectportent un discriminanttype=. - Scalaires : formes lexicales DCS (dates ISO tronquées selon
DateTimeKind, durées ISO 8601,charcomme son point de code,decimalgardant son échelle, doubles en round-trip, enums par[EnumMember(Value=)]ou par nom via un switch généré,DateTimeOffsetcomme la structure à deux éléments{DateTime, OffsetMinutes},byte[]en base64). - Texte : pas de déclaration XML, auto-fermeture
<X />avec une espace, fins de ligne du texte en CRLF brut. OmitNamespaces = true: les namespaces, préfixes et déclarations disparaissent (i:nilarrive commenil,i:typecommetype, le discriminant ne garde que son nom local). C'est le fil dépouillé historique que certains consommateurs stockent et analysent, conservé comme option explicite, certifiée à l'octet.
Trois écarts délibérés par rapport au DCS brut, chacun verrouillé par un test dédié, sont des exigences :
- Les noms d'entrées de dictionnaire ne portent pas d'empreinte de hash de namespace
(
KeyValueOfstringShelf, pasKeyValueOfstringShelfQU_P9Vt29). Mesuré : zéro consommateur de l'empreinte. - Les caractères de contrôle sont assainis au niveau de la valeur (le DCS brut émet
, un document qu'un parser conforme rejette). Un mode de reproduction stricte existe pour les harnais de certification. Sur les sinks texte uniquement : ce filtre vit dansCrystalXmlWriter, c'est lui qui produit la sortie. Les emitters infoset (CrystalXDocumentEmitter,CrystalXmlWriterEmitter) n'en appliquent rien, le DOM voit les caractères tels quels, etXmlWriteren répond sous son propreCheckCharacters. - Les exceptions typées (
CrystalXmlCycleException,CrystalXmlUnknownTypeException,CrystalXmlRootNameException,NotSupportedException,XmlException) remplacentSerializationException.
Le profil général : le XML qu'un lecteur JSON prédirait
| JSON | XML général |
|---|---|
{"title": "x"} |
<title>x</title> : même échelle de nommage que le JSON |
| racine | le nom du type via la même échelle ; rootName optionnel par appel |
| membre null | absent (comme JSON par défaut) ; WithNullMembers() donne <x nil="true" /> ; [JsonIgnore(Condition = ...)] par membre respecté |
"tags": ["a","b"] |
non encapsulé par défaut : <tags>a</tags><tags>b</tags> ; [XmlProperty(ItemName = "tag")] encapsule : <tags><tag>a</tag>...</tags> ; une collection imbriquée nue (List<List<T>>) est une erreur de build (CXML0006) : introduisez un type intermédiaire |
| dictionnaire | CrystalXmlDictionaryFormat { Default, Direct, KeyAttribute, KeyValueAttributes, KeyValueElements } ; la valeur par défaut générale est Direct (<scores><math>12</math></scores>, clé non-NCName = exception typée à l'exécution) |
"$type": "cat" |
attribut type="cat" : le discriminant est une annotation |
[XmlProperty("@id")] |
<book id="42"> : donnée comme attribut, scalaires seulement ; interdit sur le profil de compatibilité (DCS n'a pas d'attributs utilisateur) |
Une instance d'une racine polymorphe concrète est rejetée ici avec
CrystalXmlUnknownTypeException, là où le profil de compatibilité écrit le corps propre de la
racine. Ce format correspond au côté JSON, qui ne porte pas non plus de discriminant pour cette
valeur : un lecteur ne pourrait pas la distinguer d'un sous-type dont l'annotation aurait disparu,
donc elle est rejetée plutôt qu'écrite sous une forme que personne ne peut interpréter.
Contrairement au profil de compatibilité, le format général ne porte aucune restriction sur les
membres en lecture seule : une propriété get-only, un champ readonly et un membre init-only sont
tous émis, à l'image du format JSON (qui ne filtre jamais non plus sur le caractère lecture seule,
seul le désérialiseur généré s'abstient d'en réassigner un).
Exemple
[CrystalConverter]
[CrystalJsonOutput(CrystalJsonSerializerDefaults.Web)] // camelCase
[CrystalXmlOutput] // format dérivé : général
[CrystalSerializable(typeof(Book))]
public static partial class AcmeSerializers { }
public sealed record Book
{
[XmlProperty("@id")]
public required int Id { get; init; }
public required string Title { get; init; }
[XmlProperty(ItemName = "tag")]
public List<string> Tags { get; init; } = [];
public Dictionary<string, int> Scores { get; init; } = [];
public string? Subtitle { get; init; }
}
var book = new Book { Id = 42, Title = "Dune", Tags = ["sf", "space"], Scores = { ["math"] = 12 } };
string xml = AcmeSerializers.Book.ToXmlText(book);
// <book id="42"><title>Dune</title><tags><tag>sf</tag><tag>space</tag></tags><scores><math>12</math></scores></book>
Un type présent dans deux containers a deux sérialiseurs, chacun avec le format de son profil. Le
code générique prend la facette : void Export<T>(ICrystalXmlSerializer<T> serializer, T value, IBufferWriter<byte> output).
Diagnostics et gardes à l'exécution
Trois façons dont une construction est rejetée, et laquelle s'applique est une règle, pas un choix au cas par cas :
| Mécanisme | Quand |
|---|---|
| diagnostic CXML | la construction est rejetée au moment de la génération, décidable à partir des seules DÉCLARATIONS (un attribut, un type, un nom de contrat). Il pointe la déclaration fautive et porte un remède. Un membre de la plage, CXML0012, est plutôt un Info : il ne rejette rien, il nomme un setting que le format résolu ne consulte jamais. |
#error dans la source émise |
une impossibilité structurelle découverte pendant l'émission, qu'aucune déclaration n'aurait pu prédire. Gardé aussi comme filet inaccessible sous un diagnostic qui couvre déjà le cas. |
| exception typée | la décision dépend des données : seule la valeur en cours d'écriture peut la prendre (un type d'exécution hors du graphe, une clé de dictionnaire non-NCName, une valeur d'enum non déclarée, un graphe plus profond que le plafond, une racine collection que ni l'appelant ni le profil ne nomment). |
Les règles sur le container dans son ensemble (quels formats de sortie il nomme, et si ses marqueurs se combinent) ne concernent aucun des deux formats, donc elles portent plutôt un identifiant neutre :
| Id | Rejette |
|---|---|
| CRYS0001 | [CrystalConverter] ne nommant aucun format de sortie : le container ne générerait rien |
| CRYS0002 | un alias mono-format ([CrystalJsonConverter], [CrystalXmlConverter]) à côté d'un attribut de sortie : l'alias EST le choix du format |
| CRYS0003 | plusieurs marqueurs de container sur une même classe |
Les diagnostics au moment du build sur le format XML lui-même vivent dans la plage CXML :
| Id | Rejette |
|---|---|
| CXML0001 | incohérence profil/politique sur le container : une politique de nommage (camelCase et consorts) à côté du format XML DataContract, dont les noms d'éléments viennent du contrat de données. PropertyNameCaseInsensitive n'est PAS un déclencheur : il décide comment un nom entrant est apparié à la lecture du JSON, et cette surcouche ne lit jamais |
| CXML0002 | forme d'enregistrement : [CrystalXmlOutput] sur une classe qui n'héberge aucun sérialiseur généré |
| CXML0003 | projection en attribut d'un membre sans forme lexicale |
| CXML0004 | les attributs de nommage XML sur le profil de compatibilité |
| CXML0005 | deux membres se résolvant vers le même nom XML, discriminant compris |
| CXML0006 | une collection imbriquée nue sur le profil général |
| CXML0007 | tout nom qui n'est pas un NCName légal : un nom [XmlProperty] ou ItemName déclaré, un @ seul, la contradiction "@x" + Attribute = false, un nom de membre DÉRIVÉ de son nom JSON, et un [DataContract(Name = ...)] qui nommerait l'élément racine. Profil général uniquement pour les cas dérivé et racine : le format de compatibilité encode chaque nom via XmlConvert.EncodeLocalName |
| CXML0008 | un convertisseur de membre sans la facette XML |
| CXML0009 | un membre projeté en attribut avec un convertisseur sur mesure |
| CXML0010 | [CollectionDataContract] sur le type d'un membre compat |
| CXML0011 | un dictionnaire dont la forme résolue porte la valeur comme texte (KeyAttribute, KeyValueAttributes) alors que le type de la valeur n'a pas de forme lexicale |
| CXML0012 | Info, pas une erreur : un setting qui a été écrit explicitement, résolu, puis jamais consulté : un [XmlProperty(ItemName = ...)] sur un membre sans items, sur un membre dont la forme de dictionnaire RÉSOLUE est Direct (dont les entrées sont nommées d'après leur propre clé), ou sur un membre dont le type écrit son propre contenu XML (ICrystalXmlSerializable, ce qui rend aussi inerte un DictionaryFormat au niveau du membre : seul le NOM d'élément vient encore du membre là) ; un [JsonIgnore(Condition = Never)] sur un membre projeté en attribut (un attribut n'a pas de forme nil, donc un attribut null est absent de toute façon) ; un [CrystalXmlOutput(DictionaryFormat = ...)] sur un container dont le profil résolu est celui de compatibilité (qui a une seule forme de dictionnaire) ; et un [CrystalXmlOutput(OmitNamespaces = true)] sur un container dont le profil résolu est le général (le fil dépouillé est une variante du format DCS) |
| CXML0013 | profil de compatibilité uniquement : une PROPRIÉTÉ get-only (sans aucun setter) portant [DataMember] sur un type [DataContract] : le sérialiseur de référence rejette ce contrat d'emblée (InvalidDataContractException, "No set method for property"), donc il n'y a aucun format à reproduire. Une propriété [DataMember] avec un setter non public est sérialisée depuis son getter depuis la 7.4.6 et ne déclenche pas ceci. Ne se déclenche pas sur un CHAMP [DataMember] readonly (la vérification du DCS ne concerne que les propriétés) ni sur un membre init-only (un flag différent ; le DCS l'émet) |
À l'exécution, les graphes plus profonds que CrystalXml.MaxDepth (64 niveaux de récursion
générée, la valeur par défaut de System.Text.Json) lèvent
CrystalXmlCycleException : la garde ne peut pas distinguer un vrai cycle d'un graphe acyclique
légitimement plus profond, et son message le dit. Le compteur de profondeur ne peut pas traverser
un appel vers ICrystalXmlSerializer<T>.WriteXml ou ICrystalXmlSerializable.WriteXml : un cycle
qui passe entièrement par de tels hooks n'est pas couvert par la garde.
Les formats JSON générés partagent le même plafond, CrystalJsonWriter.MaxDepth
(CrystalXml.MaxDepth en est un alias). Sur le chemin Pack généré, les gardes voyagent dans un
CrystalJsonPackContext que IJsonPacker<T>.Pack prend par ref, si bien qu'elles survivent aux
helpers de collection/dictionnaire (PackObject/PackArray/PackList/PackEnumerable dans
JsonSerializerExtensions) comme aux convertisseurs de membre sur mesure : un cycle qui passe par
un membre List<T> ou Dictionary<TKey, TValue> lève l'erreur de récursion là aussi.
Les callbacks du cycle de vie de sérialisation ([OnSerializing] / [OnSerialized]) sont
invoqués sur le chemin XML, au même endroit et via le même appel généré que sur le chemin JSON :
les deux formats sont deux rendus d'une seule sérialisation, donc un callback qui prépare les
membres s'exécute pour les deux, une fois par écriture. OnSerializing s'exécute après l'ouverture
de l'élément mais avant que quoi que ce soit ne lise la valeur (membres projetés en attribut
compris), donc ses mutations sont ce que le document porte ; OnSerialized s'exécute juste avant
la fermeture de l'élément. Sur le dialecte ISerializable du profil de compatibilité, la paire
encadre l'appel à GetObjectData, là où le sérialiseur de référence les déclenche aussi. Il n'y a
pas de contrepartie OnDeserializing / OnDeserialized, puisque CrystalXml ne lit jamais.
Prérequis côté consommateur
Activer la sortie XML ne coûte à un container rien que la sortie JSON ne coûtait déjà. Le
générateur dans son ensemble exige que le projet consommateur compile en LangVersion 9 ou
plus (en dessous, il rejette avec SYSLIB1221, le même diagnostic et le même plancher que le
générateur de System.Text.Json, et n'émet rien du tout, JSON compris). Le code XML émis reste dans
ce plancher : les noms d'éléments et d'attributs mis en cache sont écrits comme des littéraux de
tableau byte[] plutôt que comme des littéraux de string UTF-8 "..."u8, ce qui aurait relevé
la barre à C# 11 pour les containers XML uniquement. Un projet à l'ancienne (.NET Framework est en
C# 7.3 par défaut) a donc exactement une chose à faire, et c'est la même chose qu'un container
JSON uniquement lui demande : mettre LangVersion à 9 ou plus.
Le chemin lite (netstandard2.0 / net472) est pris en charge. Le runtime de CrystalXml
se compile pour netstandard2.0, et le code XML généré compile et s'exécute sur le CLR
.NET Framework. Deux parties d'un container généré y sont conditionnelles, et aucune n'est du
XML :
- les proxies JSON
ReadOnly/Writablene sont pas émis, parce que leurs interfaces ont besoin de membres d'interface static abstract, que le CLR netfx ne peut pas prendre en charge (ils sont tout aussi absents en dessous de C# 11). Les convertisseurs, leTypeMapperet toute la surface XML sont émis normalement ; - les annotations de trimming
[DynamicallyAccessedMembers]sont supprimées quand l'attribut n'est pas visible pour le consommateur, ce qui ne compte que pour une publication trimming/AOT que le chemin lite ne fait pas.
La suite de certification XML s'exécute sur net472 comme sur .NET moderne, y compris les
fixtures qui comparent la sortie de l'emitter à un DataContractSerializer réel. Ces
fixtures passent octet pour octet sur les deux, donc les formats DCS netfx et moderne s'accordent
sur chaque famille que la suite couvre.