Introduction▲
Le premier tutoriel sur la sérialisation XMLLa sérialisation XML avec .NET avait abordé les bases de cette technique de sérialisation, ainsi que les principales méthodes permettant de personnaliser la façon dont les données sont sérialisées. Ce nouvel article a pour objectifs :
- d'une part, de détailler certains points qui n'avaient pas été abordés dans le premier ;
- d'autre part, de présenter diverses astuces pour mieux contrôler le schéma généré, améliorer les performances, ou encore contourner certaines limitations de la sérialisation XML.
Prérequis : cet article s'adresse à des personnes maîtrisant les bases du langage C# et de la sérialisation XML.
I. Contrôle du schéma XML▲
Les principaux attributs de contrôle de la sérialisation ont déjà été présentés dans le tutoriel précédent, je ne reviendrai donc pas dessus. Les sections suivantes présentent d'autres fonctionnalités utiles permettant d'affiner la personnalisation du schéma XML généré par la sérialisation.
I-A. Sérialiser un membre en tant que texte▲
En général, un champ ou une propriété d'un objet sérialisé est placé dans son propre élément ou attribut XML. Il est cependant possible de sérialiser un membre en tant que contenu direct de son objet parent (ce qu'on appelle un nœud texte dans la terminologie XML). L'attribut XmlText permet d'obtenir ce résultat :
public
class
Message
{
public
string
From {
get
;
set
;
}
public
string
To {
get
;
set
;
}
public
string
Subject {
get
;
set
;
}
public
DateTime Date {
get
;
set
;
}
[XmlText]
public
string
Body {
get
;
set
;
}
}
La sérialisation de cette classe produit un résultat de ce type :
<?xml version="1.0" encoding="utf-8"?>
<Message
xmlns
:
xsi
=
"http://www.w3.org/2001/XMLSchema-instance"
xmlns
:
xsd
=
"http://www.w3.org/2001/XMLSchema"
>
<From>
Bob</From>
<To>
Alice</To>
<Subject>
Hello world</Subject>
<Date>
2009-06-16T00:28:23.1867152+02:00</Date>
Hi Alice,
how are you ?
Bob</Message>
Notez que dans un cas comme celui-ci, cela n'améliore pas vraiment la lisibilité du XML obtenu… L'attribut XmlText est surtout intéressant dans les cas où il n'y a qu'une propriété à sérialiser, ou que les autres propriétés sont sérialisées en tant qu'attributs XML.
Pour des raisons évidentes, cet attribut ne peut être utilisé que sur un seul membre d'une classe. Dans le cas contraire, le XmlSerializer ne pourrait pas distinguer les différents membres sérialisés sous cette forme. Si cette règle n'est pas respectée, le constructeur de XmlSerializer lèvera une exception.
D'autre part, l'attribut XmlText ne peut être appliqué qu'à des membres de type primitif.
I-B. Sérialiser un membre en tant que section CDATA▲
En XML, une section CDATA est une section de texte littéral, qui ne sera pas interprété par le parser XML. Cela permet d'écrire dans un document XML du texte brut qui inclue des caractères habituellement considérés comme illégaux en XML. Cela n'est habituellement pas nécessaire, dans la mesure où la sérialisation XML se charge d'encoder les caractères illégaux sous forme d'entités XML, mais il peut être souhaitable de stocker les données sous forme brute si le document est susceptible d'être modifié manuellement.
Le .NET Framework ne fournit pas d'attribut spécifique pour sérialiser un membre dans une section CDATA, mais une astuce permet de contourner cette limitation : il suffit de créer une propriété de type XmlCDataSection, qui sera sérialisée en lieu et place de la propriété contenant les données :
public
class
Message
{
public
string
From {
get
;
set
;
}
public
string
To {
get
;
set
;
}
public
string
Subject {
get
;
set
;
}
public
DateTime Date {
get
;
set
;
}
[XmlIgnore]
public
string
Body {
get
;
set
;
}
private
static
readonly
XmlDocument _xmlDoc =
new
XmlDocument
(
);
[XmlElement(
"Body"
)]
public
XmlCDataSection BodyCData
{
get
{
return
_xmlDoc.
CreateCDataSection
(
Body);
}
set
{
Body =
value
.
Data;
}
}
}
- l'attribut XmlIgnore est appliqué à la propriété Body pour qu'elle ne soit pas prise en compte par la sérialisation ;
- l'attribut XmlElement est appliqué à la propriété BodyCData pour indiquer le nom voulu pour l'élément XML ;
- les accesseurs de BodyCData se chargent de la conversion entre les types string et XmlCDataSection ;
- notez le champ statique en lecture seule _xmlDoc : il est nécessaire pour pouvoir créer une instance de XmlCDataSection. Ce n'est bien sûr pas la seule façon de faire : on aurait aussi pu créer une classe utilitaire statique pour gérer ce genre de situation, de façon à éviter de multiplier les instances de XmlDocument.
Le XML généré est le suivant :
<?xml version="1.0" encoding="utf-8"?>
<Message
xmlns
:
xsi
=
"http://www.w3.org/2001/XMLSchema-instance"
xmlns
:
xsd
=
"http://www.w3.org/2001/XMLSchema"
>
<From>
Bob</From>
<To>
Alice</To>
<Subject>
Hello world</Subject>
<Date>
2009-06-16T00:48:43.175973+02:00</Date>
<Body>
<![CDATA[
Hi Alice,
how are you ?
Bob]] ></Body>
</Message>
I-C. Masquer les namespaces par défaut dans le XML généré▲
Vous aurez sans doute remarqué que tous les documents XML générés par la sérialisation XML comportent, dans l'élément racine, des définitions d'espaces de nom XML (préfixes xsi et xsd). Dans la plupart des cas, ces namespaces ne sont pas utilisés, il peut donc être souhaitable de les supprimer. Une astuce permet d'obtenir le résultat voulu : il faut passer en paramètre de la méthode Serialize un objet XmlSerializerNamespaces contenant un mapping de namespace vide :
XmlSerializerNamespaces ns =
new
XmlSerializerNamespaces
(
);
ns.
Add
(
""
,
""
);
...
xs.
Serialize
(
wr,
msg,
ns);
Cela permet d'obtenir le résultat suivant :
<?xml version="1.0" encoding="utf-8"?>
<Message>
<From>
Bob</From>
<To>
Alice</To>
<Subject>
Hello world</Subject>
<Date>
2009-06-16T01:17:37.9083948+02:00</Date>
<Body>
Hi Alice,
how are you ?
Bob</Body>
</Message>
Attention, les namespaces par défaut sont tout de même là pour une bonne raison et non seulement pour alourdir le XML généré… Ils sont notamment utilisés pour préciser le type effectif d'un élément. Par exemple, si vous avez une propriété Tag de type object, et que vous lui affectez une valeur de type int, la propriété sera sérialisée sous la forme suivante :
<Tag xsi:type="xsd:int">42</Tag>
Si vous supprimez les namespaces de l'élément racine, ils seront ajoutés sur chaque élément qui en a besoin, ce qui donnera ceci :
<Tag xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xsi:type="xsd:int">42</Tag>
Réfléchissez donc à deux fois avant de les supprimer…
I-D. Gérer les éléments et attributs inconnus▲
Le XML est, par définition, un langage "extensible" (eXtensible Markup Language). Il est donc possible qu'un schéma XML existant soit progressivement enrichi de nouveaux éléments et/ou attributs. Lors de la désérialisation d'un document XML, si des nœuds XML inconnus (ne correspondant pas à des membres de la classe) sont rencontrés, ils seront par défaut ignorés. Il peut cependant être utile de pouvoir récupérer et manipuler ces nœuds : par exemple, lorsqu'on modifie un document XML via la sérialisation XML, on ne veut pas supprimer les nœuds inconnus qui ont peut-être un sens pour une autre application, il faut donc les conserver pour pouvoir les réécrire dans le fichier après modification.
Il existe 2 méthodes principales pour gérer ces nœuds inconnus : via les attributs de contrôle de la sérialisation, ou via des évènements.
I-D-1. Via les attributs de contrôle▲
Les attributs XmlAnyElement et XmlAnyAttribute permettent de conserver, respectivement, les éléments et attributs XML inconnus du document désérialisé. Ils doivent être appliqués à des champs ou propriétés de type collection de XmlElement (resp. XmlAttribute). Si on reprend la classe Message des exemples précédents, on peut la modifier de la manière suivante :
public
class
Message
{
public
string
From {
get
;
set
;
}
public
string
To {
get
;
set
;
}
public
string
Subject {
get
;
set
;
}
public
DateTime Date {
get
;
set
;
}
public
string
Body {
get
;
set
;
}
[XmlAnyElement]
public
List<
XmlElement>
UnknownElements {
get
;
set
;
}
[XmlAnyAttribute]
public
List<
XmlAttribute>
UnknownAttributes {
get
;
set
;
}
}
Modifions maintenant notre fichier XML pour y ajouter des données sans rapport avec la classe Message :
<?xml version="1.0" encoding="utf-8"?>
<Message
Foo
=
"42"
>
<From>
Bob</From>
<To>
Alice</To>
<Subject>
Hello world</Subject>
<Date>
2009-06-16T01:58:21.6798792+02:00</Date>
<Body>
Hi Alice,
how are you ?
Bob</Body>
<Bar>
Coucou</Bar>
</Message>
L'attribut Foo et l'élément Bar ne correspondant à aucun membre de la classe Message, la désérialisation les place, respectivement, dans les collections UnknownAttributes et UnknownElements. Si on modifie les données et qu'on les réenregistre dans le fichier, les nœuds Foo et Bar sont conservés. Il est bien sûr possible de modifier ces nœuds inconnus, de les supprimer, ou d'en ajouter.
I-D-2. Via les évènements de XmlSerializer▲
La classe XmlSerializer possède quatre évènements :
- UnknownAttribute : se produit lorsqu'un attribut inconnu est rencontré lors de la désérialisation ;
- UnknownElement : se produit lorsqu'un élément inconnu est rencontré lors de la désérialisation ;
- UnknownNode : se produit lorsqu'un nœud inconnu (attribut ou élément) est rencontré lors de la désérialisation ;
- UnreferencedObject : utilisé lors de la désérialisation d'un flux SOAP, hors du cadre de cet article.
On peut gérer ces évènements pour réagir lorsqu'un nœud inconnu est rencontré :
XmlSerializer xs =
new
XmlSerializer
(
typeof
(
Message));
xs.
UnknownAttribute +=
new
XmlAttributeEventHandler
(
xs_UnknownAttribute);
xs.
UnknownElement +=
new
XmlElementEventHandler
(
xs_UnknownElement);
xs.
UnknownNode +=
new
XmlNodeEventHandler
(
xs_UnknownNode);
using
(
StreamReader rd =
new
StreamReader
(
"message.xml"
))
{
msg =
xs.
Deserialize
(
rd) as
Message;
}
...
void
xs_UnknownNode
(
object
sender,
XmlNodeEventArgs e)
{
Console.
WriteLine
(
"Noeud inconnu à la ligne {0}, position {1}, de type {2} : Nom = {3}, Texte = {4}"
,
e.
LineNumber,
e.
LinePosition,
e.
NodeType,
e.
Name,
e.
Text);
}
void
xs_UnknownElement
(
object
sender,
XmlElementEventArgs e)
{
Console.
WriteLine
(
"Element inconnu à la ligne {0}, position {1}, de type {2} : Nom = {3}, Texte = {4}"
,
e.
LineNumber,
e.
LinePosition,
"Element"
,
e.
Element.
Name,
e.
Element.
InnerText);
}
void
xs_UnknownAttribute
(
object
sender,
XmlAttributeEventArgs e)
{
Console.
WriteLine
(
"Attribut inconnu à la ligne {0}, position {1}, de type {2} : Nom = {3}, Texte = {4}"
,
e.
LineNumber,
e.
LinePosition,
"Attribute"
,
e.
Attr.
Name,
e.
Attr.
InnerText);
}
L'évènement UnknownNode se produit aussi bien pour un attribut que pour un élément, donc chaque attribut ou élément inconnu rencontré déclenche d'abord l'évènement UnknownNode, puis UnknownAttribute ou UnknownElement.
Les évènements UnknownAttribute et UnknownElement ne sont pas déclenchés si les attributs XmlAnyAttribute et XmlAnyElement sont appliqués sur un membre de la classe.
Il est également possible de passer à la méthode Deserialize un object XmlDeserializationEvents qui indique les handlers à appeler pour ces évènements.
Attention, ces évènements ne permettent que de traiter les nœuds inconnus lors de la désérialisation, pas de les réécrire lors de la sérialisation ! Cette méthode n'est donc pas adaptée si les nœuds inconnus doivent être conservés.
I-E. Contrôler dynamiquement si un membre doit être sérialisé▲
Il est possible de décider lors de l'exécution si un membre d'une classe doit être sérialisé ou non. Il existe pour cela deux techniques, non mentionnées dans la documentation officielle de Microsoft.
I-E-1. La méthode ShouldSerializeX▲
Pour indiquer au cas par cas si une propriété X doit être sérialisée, il est possible de créer une méthode ShouldSerializeX qui renvoie un bool. Cette méthode sera appelée lors de la sérialisation, et la propriété sera sérialisée ou non selon la valeur renvoyée.
public
bool
ShouldSerializeX
(
)
{
return
(
X >
0
);
}
Avec le code précédent, la propriété X ne sera sérialisée que si sa valeur est supérieure à 0.
I-E-2. La propriété XSpecified▲
Une approche similaire permet d'obtenir le même résultat. Elle consiste à créer une propriété XSpecified qui renvoie un bool :
public
bool
XSpecified
{
get
{
return
(
X >
0
);
}
}
II. Sérialiser un dictionnaire▲
Par défaut, XmlSerializer ne supporte pas la sérialisation d'une classe qui implémente IDictionary, limitation qui peut se révéler assez gênante… Pourtant, on imagine très bien ce que pourrait donner la sérialisation d'un dictionnaire : après tout, qu'est-ce qu'un dictionnaire sinon une collection de paires clé/valeur ? Cela pourrait se sérialiser par exemple sous cette forme :
...
<Directories>
<Directory>
<Key>
input</Key>
<Value>
D:\input</Value>
</Directory>
<Directory>
<Key>
output</Key>
<Value>
D:\output</Value>
</Directory>
<Directory>
<Key>
temp</Key>
<Value>
C:\Users\Tom\AppData\Local\Temp</Value>
</Directory>
</Directories>
...
Comme on l'a dit plus haut, un dictionnaire peut être vu comme une liste de paires clé/valeur. C'est donc comme cela qu'on va le présenter au XmlSerializer pour qu'il accepte de le sérialiser… Créons d'abord une classe XmlDictionaryEntry pour représenter une paire clé/valeur (la classe KeyValuePair ne peut pas convenir, car les propriétés Key et Value sont en lecture seule) :
public
class
XmlDictionaryEntry<
TKey,
TValue>
{
public
TKey Key {
get
;
set
;
}
public
TValue Value {
get
;
set
;
}
}
Nous allons ensuite créer une classe qui se présente comme une collection de XmlDictionaryEntry, mais qui utilise en fait un dictionnaire comme stockage interne :
public
class
XmlDictionaryEntryCollection<
TKey,
TValue>
:
ICollection<
XmlDictionaryEntry<
TKey,
TValue>>
{
public
XmlDictionaryEntryCollection
(
)
{
this
.
Dictionary =
new
Dictionary<
TKey,
TValue>(
);
}
public
XmlDictionaryEntryCollection
(
IDictionary<
TKey,
TValue>
dictionary)
{
if
(
dictionary ==
null
)
throw
new
ArgumentNullException
(
"dictionary"
);
this
.
Dictionary =
dictionary;
}
[XmlIgnore]
public
IDictionary<
TKey,
TValue>
Dictionary {
get
;
private
set
;
}
#region ICollection<XmlDictionaryEntry<TKey,TValue>> Members
public
void
Add
(
XmlDictionaryEntry<
TKey,
TValue>
item)
{
this
.
Dictionary.
Add
(
item.
Key,
item.
Value);
}
public
void
Clear
(
)
{
this
.
Dictionary.
Clear
(
);
}
public
bool
Contains
(
XmlDictionaryEntry<
TKey,
TValue>
item)
{
return
this
.
Dictionary.
ContainsKey
(
item.
Key);
}
public
void
CopyTo
(
XmlDictionaryEntry<
TKey,
TValue>[]
array,
int
arrayIndex)
{
int
index =
arrayIndex;
foreach
(
var
key in
this
.
Dictionary.
Keys)
{
if
(
index >=
array.
Length)
throw
new
ArgumentException
(
"The number of elements in the source collection is greater than "
+
"the available space from arrayIndex to the end of the destination array."
);
var
entry =
new
XmlDictionaryEntry<
TKey,
TValue>
{
Key =
key,
Value =
this
.
Dictionary[
key]
};
array[
index++]
=
entry;
}
}
public
int
Count
{
get
{
return
this
.
Dictionary.
Count;
}
}
public
bool
IsReadOnly
{
get
{
return
this
.
Dictionary.
IsReadOnly;
}
}
public
bool
Remove
(
XmlDictionaryEntry<
TKey,
TValue>
item)
{
return
this
.
Dictionary.
Remove
(
item.
Key);
}
#endregion
#region IEnumerable<XmlDictionaryEntry<TKey,TValue>> Members
public
IEnumerator<
XmlDictionaryEntry<
TKey,
TValue>>
GetEnumerator
(
)
{
foreach
(
var
key in
this
.
Dictionary.
Keys)
{
yield
return
new
XmlDictionaryEntry<
TKey,
TValue>
{
Key =
key,
Value =
this
.
Dictionary[
key]
};
}
}
#endregion
#region IEnumerable Members
IEnumerator IEnumerable.
GetEnumerator
(
)
{
return
this
.
GetEnumerator
(
);
}
#endregion
public
static
XmlDictionaryEntryCollection<
TKey,
TValue>
GetEntries
(
Dictionary<
TKey,
TValue>
dictionary)
{
if
(
dictionary ==
null
)
return
null
;
else
return
new
XmlDictionaryEntryCollection<
TKey,
TValue>(
dictionary);
}
}
Puisque cette classe est une simple collection, le XmlSerializer peut la sérialiser sans problème… Voyons maintenant comment l'utiliser ; par exemple, si nous avons la classe suivante :
public
class
Config
{
public
string
UserName {
get
;
set
;
}
public
Dictionary<
string
,
string
>
Directories {
get
;
set
;
}
}
En l'état, cette classe ne peut pas être sérialisée à cause du Dictionary. Nous allons donc masquer la propriété Directories au XmlSerializer, et lui présenter à la place une XmlDictionaryEntryCollection :
public
class
Config
{
public
string
UserName {
get
;
set
;
}
[XmlIgnore]
public
Dictionary<
string
,
string
>
Directories {
get
;
set
;
}
[XmlArray(
"Directories"
)]
[XmlArrayItem(
"Directory"
)]
public
XmlDictionaryEntryCollection<
string
,
string
>
DirectoriesXml
{
get
{
return
XmlDictionaryEntryCollection<
string
,
string
>.
GetEntries
(
this
.
Directories);
}
set
{
this
.
Directories =
new
Dictionary<
string
,
string
>(
value
.
Dictionary);
}
}
}
Avec ce code, la propriété Directories est bien sérialisée sous la forme indiquée plus haut. J'ai pris ici un exemple simple où la clé et la valeur sont de type string, mais cela fonctionne aussi avec des types complexes… pourvu qu'ils soient sérialisables bien sûr ! Notez que si l'on veut aussi sérialiser des types dérivés, il faut les déclarer avec l'attribut XmlInclude ; l'élément <Value> aura alors un attribut xsi:type="LeTypeDérivé" pour indiquer le type de l'objet.
Une autre approche possible pour résoudre ce problème est d'utiliser la classe SerializableDictionary développée par Paul Welter. Elle a l'inconvénient d'imposer le type de dictionnaire utilisé, mais ça ne devrait pas poser de problème dans la majorité des cas.
III. Sérialiser des graphes d'objets avec des références circulaires▲
III-A. Le problème▲
Une des limitations de la sérialisation XML est qu'elle ne sait pas gérer les références circulaires. Cela signifie que si un objet A référence un objet B, qui lui-même référence l'objet A, on ne peut pas sérialiser ces objets. Prenons l'exemple suivant pour mieux comprendre le problème :
public
class
Foo
{
public
string
Name {
get
;
set
;
}
public
Foo Parent {
get
;
set
;
}
public
Foo Child {
get
;
set
;
}
}
Cette classe Foo a une propriété Parent et une propriété Child, définissant une hiérarchie parent/enfant. Créons maintenant un objet A avec un enfant B, et essayons de sérialiser A :
Foo a =
new
Foo
{
Name =
"A"
,
Child =
new
Foo
{
Name =
"B"
}
};
a.
Child.
Parent =
a;
XmlSerializer xs =
new
XmlSerializer
(
typeof
(
Foo));
using
(
StringWriter wr =
new
StringWriter
(
))
{
xs.
Serialize
(
wr,
a);
Console.
WriteLine
(
wr.
ToString
(
));
}
Ce code lèvera une exception sur l'appel à Serialize, avec un message indiquant qu'une référence circulaire a été détectée. En effet, lorsque la propriété Parent de l'objet B doit être sérialisée, le XmlSerializer réalise que l'objet A référencé par cette propriété a déjà été sérialisé. Sans cette détection, on aurait obtenu une récursion infinie de ce type :
<?xml version="1.0" encoding="utf-16"?>
<Foo
xmlns
:
xsi
=
"http://www.w3.org/2001/XMLSchema-instance"
xmlns
:
xsd
=
"http://www.w3.org/2001/XMLSchema"
>
<Name>
A</Name>
<Child>
<Name>
B</Name>
<Parent>
<Name>
A</Name>
<Child>
<Name>
B</Name>
<Parent>
<Name>
A</Name>
<Child>
<Name>
B</Name>
<Parent>
<!-- et ainsi de suite... -->
</Parent>
</Child>
</Parent>
</Child>
</Parent>
</Child>
</Foo>
Évidemment ça se serait terminé par une belle StackOverflowException… Pour éviter cela, la référence circulaire est donc détectée avant d'en arriver là.
III-B. La solution▲
Pour régler le problème, la première chose qui vient à l'esprit est de ne pas sérialiser la propriété Parent : ainsi on ne sérialise que la relation descendante, et il n'y a donc plus de référence circulaire. Il suffit pour cela d'appliquer à la propriété Parent l'attribut XmlIgnore. Mais malheureusement, ce n'est pas suffisant : lors de la désérialisation, la propriété Parent n'est pas initialisée et vaut donc null, puisqu'on ne l'a pas sérialisée… mais on vient de voir qu'on ne peut pas la sérialiser, donc on tourne en rond !
Pour s'en sortir, la solution est en fait assez simple : il suffit d'ajouter dans l'accesseur set de la propriété Child un peu de logique pour initialiser Parent. On en profite d'ailleurs pour faire la même chose dans le set de la propriété Parent, afin de maintenir la cohérence entre les deux :
public
class
Foo
{
public
string
Name {
get
;
set
;
}
private
Foo _parent;
[XmlIgnore]
public
Foo Parent
{
get
{
return
_parent;
}
set
{
if
(
value
!=
_parent)
{
if
(
_parent !=
null
)
_parent.
Child =
null
;
_parent =
value
;
if
(
_parent !=
null
)
_parent.
Child =
this
;
}
}
}
private
Foo _child;
public
Foo Child
{
get
{
return
_child;
}
set
{
if
(
value
!=
_child)
{
if
(
_child !=
null
)
_child.
Parent =
null
;
_child =
value
;
if
(
_child !=
null
)
_child.
Parent =
this
;
}
}
}
}
La désérialisation fonctionne maintenant correctement, et la propriété Parent est renseignée quand l'enfant est affecté à la propriété Child.
III-C. Cas d'une collection d'enfants▲
Le cas présenté dans la section précédente était simple, mais assez peu réaliste… on a rarement une structure avec un parent qui a un seul enfant ! En général, on a plutôt une collection d'enfants, qui ont eux-mêmes une référence vers leur parent. Prenons donc un cas un peu plus concret : un programme de messagerie, dans lequel les messages sont organisés par dossier, et sauvegardés en XML. On a donc la structure d'objets suivante :
public
class
MessageRepository
{
public
MessageRepository
(
)
{
this
.
Folders =
new
List<
MessageFolder>(
);
}
public
List<
MessageFolder>
Folders {
get
;
set
;
}
}
public
class
MessageFolder
{
public
MessageFolder
(
)
{
this
.
Messages =
new
List<
Message>(
);
}
public
string
Name {
get
;
set
;
}
public
List<
Message>
Messages {
get
;
set
;
}
[XmlIgnore]
public
MessageRepository Repository {
get
;
set
;
}
}
public
class
Message
{
public
string
From {
get
;
set
;
}
public
string
To {
get
;
set
;
}
public
string
Subject {
get
;
set
;
}
public
DateTime Date {
get
;
set
;
}
public
string
Body {
get
;
set
;
}
[XmlIgnore]
public
MessageFolder Folder {
get
;
set
;
}
}
À première vue, on peut penser que la solution de l'exemple précédent est toujours valable ici… On place d'ailleurs l'attribut XmlIgnore sur la propriété qui référence l'objet parent. Mais il y a une subtilité… en effet, c'est lors de l'ajout d'un enfant à la collection qu'il faut affecter la valeur du parent, on ne peut donc pas le faire dans l'accesseur set comme précédemment. La désérialisation se contentera d'ajouter les messages à la collection MessageFolder.Messages avec la méthode Add, et n'affectera pas la propriété Message.Folder… on se retrouve à nouveau bloqué !
Puisque c'est la collection qui va devoir gérer l'affectation du parent à l'enfant, nous allons devoir créer notre propre collection. Étant donné qu'on est confronté au même problème à deux niveaux de la hiérarchie (MessageFolder.Repository et Message.Folder), autant chercher tout suite à factoriser la solution… Nous allons donc créer :
- une collection générique qui gère la relation parent/enfant, qu'on utilisera à la place de List<T> ;
- une interface générique que les enfants devront implémenter pour exposer une propriété Parent.
Le code de l'interface est assez simple :
public
interface
IChildItem<
P>
where
P :
class
{
P Parent {
get
;
set
;
}
}
Le paramètre de type P représente le type de l'objet parent. Remarquez la contrainte class sur le type du parent : en effet, le parent n'est pas forcément défini et doit donc pouvoir être null.
Voyons maintenant la collection : elle implémente l'interface IList<T> en déléguant l'implémentation à une List<T>, et en ajoutant la logique pour maintenir la cohérence de la relation parent/enfant. Elle conserve aussi une référence vers l'objet parent pour pouvoir l'affecter aux enfants.
public
class
ChildItemCollection<
P,
T>
:
IList<
T>
where
P :
class
where
T :
IChildItem<
P>
{
private
P _parent;
private
IList<
T>
_collection;
public
ChildItemCollection
(
P parent)
{
this
.
_parent =
parent;
this
.
_collection =
new
List<
T>(
);
}
public
ChildItemCollection
(
P parent,
IList<
T>
collection)
{
this
.
_parent =
parent;
this
.
_collection =
collection;
}
#region IList<T> Members
public
int
IndexOf
(
T item)
{
return
_collection.
IndexOf
(
item);
}
public
void
Insert
(
int
index,
T item)
{
_collection.
Insert
(
index,
item);
if
(
item !=
null
)
item.
Parent =
_parent;
}
public
void
RemoveAt
(
int
index)
{
T oldItem =
_collection[
index];
_collection.
RemoveAt
(
index);
if
(
oldItem !=
null
)
oldItem.
Parent =
null
;
}
public
T this
[
int
index]
{
get
{
return
_collection[
index];
}
set
{
T oldItem =
_collection[
index];
_collection[
index]
=
value
;
if
(
oldItem !=
null
)
oldItem.
Parent =
null
;
if
(
value
!=
null
)
value
.
Parent =
_parent;
}
}
#endregion
#region ICollection<T> Members
public
void
Add
(
T item)
{
_collection.
Add
(
item);
if
(
item !=
null
)
item.
Parent =
_parent;
}
public
void
Clear
(
)
{
List<
T>
oldItems =
new
List<
T>(
_collection);
_collection.
Clear
(
);
foreach
(
T item in
oldItems)
{
if
(
item !=
null
)
item.
Parent =
null
;
}
}
public
bool
Contains
(
T item)
{
return
_collection.
Contains
(
item);
}
public
void
CopyTo
(
T[]
array,
int
arrayIndex)
{
_collection.
CopyTo
(
array,
arrayIndex);
}
public
int
Count
{
get
{
return
_collection.
Count;
}
}
public
bool
IsReadOnly
{
get
{
return
_collection.
IsReadOnly;
}
}
public
bool
Remove
(
T item)
{
bool
b =
_collection.
Remove
(
item);
if
(
item !=
null
)
item.
Parent =
null
;
return
b;
}
#endregion
#region IEnumerable<T> Members
public
IEnumerator<
T>
GetEnumerator
(
)
{
return
_collection.
GetEnumerator
(
);
}
#endregion
#region IEnumerable Members
System.
Collections.
IEnumerator System.
Collections.
IEnumerable.
GetEnumerator
(
)
{
return
(
_collection as
System.
Collections.
IEnumerable).
GetEnumerator
(
);
}
#endregion
}
Le paramètre de type P représente là encore le type du parent, T étant le type des éléments de la collection. Par exemple, dans notre cas, la propriété MessageFolder.Messages sera de type ChildItemCollection<MessageFolder, Message>. Remarquez la contrainte de type sur T : les éléments de la collection doivent implémenter IChildItem<P> pour pouvoir accéder à leur parent.
Modifions donc nos classes pour intégrer ChildItemCollection et IChildItem :
public
class
MessageRepository
{
public
MessageRepository
(
)
{
this
.
Folders =
new
ChildItemCollection<
MessageRepository,
MessageFolder>(
this
);
}
public
ChildItemCollection<
MessageRepository,
MessageFolder>
Folders {
get
;
set
;
}
}
public
class
MessageFolder :
IChildItem<
MessageRepository>
{
public
MessageFolder
(
)
{
this
.
Messages =
new
ChildItemCollection<
MessageFolder,
Message>(
this
);
}
public
string
Name {
get
;
set
;
}
public
ChildItemCollection<
MessageFolder,
Message>
Messages {
get
;
set
;
}
private
MessageRepository _repository;
[XmlIgnore]
public
MessageRepository Repository
{
get
{
return
_repository;
}
set
{
if
(
value
!=
_repository)
{
if
(
_repository !=
null
)
_repository.
Folders.
Remove
(
this
);
_repository =
value
;
if
(
_repository !=
null
&&
!
_repository.
Folders.
Contains
(
this
))
_repository.
Folders.
Add
(
this
);
}
}
}
#region IChildItem<MessageRepository> Members
MessageRepository IChildItem<
MessageRepository>.
Parent
{
get
{
return
this
.
Repository;
}
set
{
this
.
Repository =
value
;
}
}
#endregion
}
public
class
Message :
IChildItem<
MessageFolder>
{
public
string
From {
get
;
set
;
}
public
string
To {
get
;
set
;
}
public
string
Subject {
get
;
set
;
}
public
DateTime Date {
get
;
set
;
}
public
string
Body {
get
;
set
;
}
private
MessageFolder _folder;
[XmlIgnore]
public
MessageFolder Folder
{
get
{
return
_folder;
}
set
{
if
(
value
!=
_folder)
{
if
(
_folder !=
null
)
_folder.
Messages.
Remove
(
this
);
_folder =
value
;
if
(
_folder !=
null
&&
!
_folder.
Messages.
Contains
(
this
))
_folder.
Messages.
Add
(
this
);
}
}
}
#region IChildItem<MessageFolder> Members
MessageFolder IChildItem<
MessageFolder>.
Parent
{
get
{
return
this
.
Folder;
}
set
{
this
.
Folder =
value
;
}
}
#endregion
}
On a donc effectué les modifications suivantes :
- Message implémente explicitement IChildItem<MessageFolder>.Parent, de façon à exposer la propriété Folder à la collection qui manipulera le message via l'interface ;
- la propriété Message.Folder a été "blindée" de façon à maintenir la cohérence de la relation si elle est affectée autrement que par la ChildItemCollection ;
- MessageFolder implémente (toujours explicitement) IChildItem<MessageRepository>.Parent, de façon à exposer la propriété Repository ;
- là encore, on a "blindé" la propriété Repository pour maintenir la cohérence ;
- la propriété MessageFolder.Messages est maintenant de type ChildItemCollection<MessageFolder, Message> ;
- la propriété MessageRepository.Folders est maintenant de type ChildItemCollection<MessageRepository, MessageFolder>.
Avec ce système, on peut maintenant sérialiser notre graphe d'objets sans s'inquiéter des références circulaires. Le parent des objets enfants n'est pas sérialisé, et ce n'est donc pas la désérialisation qui l'initialise, mais l'ajout à la collection.
Notez qu'un "pattern" similaire est utilisé dans diverses classes du .NET framework, pour maintenir la cohérence de la relation parent/enfant, par exemple la classe Control.ControlCollection dans Windows Forms. C'est ce qui fait qu'on n'a pas besoin de définir la propriété Parent d'un contrôle lorsqu'on l'ajoute à la collection Controls d'un contrôle parent, et inversement.
Notez aussi que ce système ne permet pas à un objet d'appartenir à plusieurs parents. Cependant il est bien sûr possible d'adapter la structure pour permettre ce scénario.
IV. Assemblies de sérialisation XML▲
IV-A. Fonctionnement interne de la sérialisation XML▲
On pourrait imaginer que la classe XmlSerializer parcourt par réflexion les membres de l'objet à sérialiser et les écrit au fur et à mesure, mais ce n'est pas ce qui se passe en réalité : cette approche serait très inefficace, car l'utilisation de la réflexion a un impact assez important sur les performances.
En pratique, quand on cherche à sérialiser un objet d'un type donné en créant une instance de XmlSerializer, le système cherche si une classe dédiée à la sérialisation de ce type existe : si ce n'est pas le cas, un assembly temporaire contenant le code nécessaire est généré dynamiquement en parcourant l'arborescence du type par réflexion. Par la suite, c'est cet assembly qui est utilisé à chaque fois qu'on souhaite sérialiser ce type. Ce mode de fonctionnement est la raison pour laquelle il y a souvent un temps de latence lorsqu'on crée une instance de XmlSerializer : c'est le temps nécessaire à la génération de l'assembly de sérialisation.
L'assembly généré contient des classes qui gèrent directement l'écriture et la lecture des éléments XML correspondant à la structure des objets à sérialiser, sans passer par la réflexion, d'où un gain de performance une fois que l'assembly de sérialisation est généré.
IV-B. Améliorer les performances de la sérialisation▲
Si vous devez sérialiser une structure d'objets complexe, la génération de l'assembly temporaire de sérialisation peut prendre un temps non négligeable, et donc ralentir le démarrage du programme. Pour pallier ce problème, il est possible de générer à l'avance l'assembly de sérialisation, et de le déployer avec l'application. L'outil permettant cela est le XML Serializer Generator Tool (sgen.exe), fourni avec le .NET framework.
Pour utiliser cet outil en ligne de commande, il suffit de lui indiquer l'assembly pour lequel on veut générer les sérialiseurs :
> sgen.exe /assembly:MyAssembly.dll
Cette commande génère un nouvel assembly nommé MyAssembly.XmlSerializers.dll, qu'il suffit de déployer avec l'application pour éviter la génération d'assemblies temporaires.
Différents switches permettent de modifier le comportement de cette commande. Il est notamment possible de spécifier les types pour lesquels on veut générer les sérialiseurs, ou encore de spécifier des options du compilateur C#. Je n'en détaillerai ici que quelques-uns (la liste complète est disponible ici) :
/assembly: (ou /a:) |
Indique l'assembly pour lequel les sérialiseurs doivent être générés |
/keep (ou /k) |
Permet de conserver les fichiers C# temporaires, afin de pouvoir examiner le code généré |
/force (ou /f) |
Force la régénération de l'assembly de sérialisation s'il existe déjà |
/type: (ou /t:) |
Permet de spécifier le ou les types pour lesquels les sérialiseurs doivent être générés |
Pour éviter de régénérer manuellement l'assembly de sérialisation après chaque compilation, il est possible de créer une tâche de postcompilation. Dans les propriétés du projet, allez dans l'onglet "Évènements de génération", et saisissez la commande suivante comme commande après génération (en modifiant éventuellement le chemin de l'exécutable selon votre environnement) :
"C:\Program Files\Microsoft SDKs\Windows\v6.0A\Bin\sgen.exe" /assembly:"$(TargetPath)" /force
La macro $(TargetPath) fait référence à l'assembly généré par le projet.
Il existe une autre manière de générer cet assembly de sérialisation, plus propre, car elle ne nécessite pas de spécifier de ligne de commande. Elle consiste à ajouter (manuellement) une tâche de compilation MSBuild au projet. Pour cela, déchargez le projet, éditez le fichier .csproj, et ajoutez dans la Target "AfterBuild" la tâche suivante :
<Target
Name
=
"AfterBuild"
>
<SGen
ShouldGenerateSerializer
=
"true"
BuildAssemblyName
=
"$(TargetName)$(TargetExt)"
BuildAssemblyPath
=
"$(TargetDir)"
UseProxyTypes
=
"false"
/>
</Target>
De cette façon, l'assembly de sérialisation sera systématiquement regénéré après chaque compilation du projet : il ne reste plus qu'à le déployer avec l'application.
IV-C. Sérialiser des membres internes▲
Le fait de générer explicitement l'assembly de sérialisation a un effet secondaire qui peut être bien pratique : puisqu'on connait l'identité de cet assembly, il est possible de le déclarer comme assembly "ami", grâce à l'attribut InternalsVisibleTo. Cet attribut indique que l'assembly spécifié peut accéder aux membres internes de l'assembly courant. Cela permet de sérialiser des membres marqués comme internal, ce qui n'est habituellement pas possible.
Par exemple, prenons la classe suivante :
public
class
Foo
{
public
Foo
(
string
bar)
{
this
.
Bar =
bar;
}
public
string
Bar {
get
;
internal
set
;
}
}
À première vue, cette classe n'est pas sérialisable en XML, car :
- elle n'a pas de constructeur par défaut ;
- le set de la propriété Bar est interne.
Si on souhaitait sérialiser cette classe, il faudrait normalement rendre le set public et ajouter un constructeur public par défaut… mais cela peut être contraire à l'architecture souhaitée : par exemple, si la classe est dans une librairie utilisée par d'autres programmes, on peut vouloir forcer le code appelant à utiliser le constructeur pour initialiser Bar, et l'empêcher de la modifier ensuite.
On va donc s'y prendre autrement… Pour commencer, ajoutons un constructeur par défaut interne à notre classe :
internal
Foo
(
)
{
}
Déclarons maintenant comme "ami" l'assembly de sérialisation. Si notre assembly s'appelle, par exemple, MyLibrary.dll, l'assembly de sérialisation s'appellera MyLibrary.XmlSerializers.dll. On ajoute donc dans le code (généralement dans le fichier AssemblyInfo.cs, mais ce n'est pas une obligation) :
[assembly: InternalsVisibleTo(
"MyLibrary.XmlSerializers"
)]
De cette façon l'assembly de sérialisation a accès au constructeur et au set de la propriété, et peut donc sérialiser et désérialiser cette classe sans problème.
Le titre de cette section est quelque peu inexact, dans la mesure où cette technique ne permet pas vraiment de sérialiser des membres internes : cela fonctionne en fait sur une propriété publique dont l'accesseur set est interne. Si la propriété elle-même était interne, elle serait tout bonnement ignorée, et l'outil Sgen ne génèrerait aucun code pour cette propriété. Cette technique permet donc de sérialiser des propriétés qui sont normalement en lecture seule pour les autres assemblies.
A l'heure où j'écris cet article, la technique décrite ci-dessus ne fonctionne pas avec Visual Studio 2010 beta 1 : l'outil Sgen ignore les propriétés qui ne sont pas entièrement publiques (en lecture et en écriture). Il s'agit d'un bug qui a été confirmé par Microsoft et sera corrigé dans la version finale.
IV-C-1. Cas particulier d'un assembly signé▲
Si l'assembly contenant les classes à sérialiser est signé, la technique décrite ci-dessus est aussi utilisable, mais la mise en œuvre est plus complexe… En effet, pour pouvoir utiliser l'attribut InternalsVisibleTo dans un assembly signé, l'assembly déclaré comme "ami" doit lui aussi être signé. Il faut donc signer également l'assembly de sérialisation (généralement avec la même clé, bien qu'on puisse en utiliser une autre…).
Voici la tâche MSBuild à utiliser pour signer l'assembly de sérialisation avec la même clé que l'assembly principal (cette tâche fonctionne également pour un assembly non signé) :
<Target
Name
=
"AfterBuild"
DependsOnTargets
=
"AssignTargetPaths;Compile;ResolveKeySource"
Inputs
=
"$(MSBuildAllProjects);@(IntermediateAssembly)"
Outputs
=
"$(OutputPath)$(_SGenDllName)"
>
<SGen
BuildAssemblyName
=
"$(TargetFileName)"
BuildAssemblyPath
=
"$(OutputPath)"
References
=
"@(ReferencePath)"
ShouldGenerateSerializer
=
"true"
UseProxyTypes
=
"false"
KeyContainer
=
"$(KeyContainerName)"
KeyFile
=
"$(KeyOriginatorFile)"
DelaySign
=
"$(DelaySign)"
ToolPath
=
"$(SGenToolPath)"
>
<Output
TaskParameter
=
"SerializationAssembly"
ItemName
=
"SerializationAssembly"
/>
</SGen>
</Target>
Il nous faut maintenant déclarer la clé publique de l'assembly de sérialisation dans l'attribut InternalsVisibleTo. Pour cela, on va utiliser le Strong Name Tool (sn.exe) pour extraire la clé publique du ficher de clé. En supposant que le fichier de clé s'appelle MyAssembly.snk, exécutons la commande suivante :
> sn -p MyAssembly.snk public.key
Cette commande extrait la clé publique et l'écrit, sous forme binaire, dans le fichier public.key. Pour l'obtenir sous forme hexadécimale, on utilise le même outil avec un autre paramètre :
> sn -tp public.key
Cette commande affiche le résultat suivant :
Microsoft (R) .NET Framework Strong Name Utility Version 3.5.21022.8
Copyright (c) Microsoft Corporation. All rights reserved.
Public key is
0024000004800000940000000602000000240000525341310004000001000100b393be1d1ac5dd
8b4b686a97803f414671b978cd10034df9cc82f3c975448d9cc2fbd2fe76f3be64c416f148329f
16061809d7fd9c0b30e043f979247840d3ed24eff5f28a8e2dfba44edd822b546fe24bb1bc00c2
8d9d7d3e1e3521df154f30518fbdbd2106e753ee39502fe6bcc0f84775aae3966253231d9d1f72
7c5293bb
Public key token is c5cd51bf2cc4ed49
La partie qui nous intéresse ici est la clé publique complète (public key), et non le jeton de clé publique (public key token). On utilise cette clé pour déclarer comme "ami" l'assembly de sérialisation de la façon suivante :
[assembly: InternalsVisibleTo(
"MyAssembly.XmlSerializers, PublicKey="
+
"0024000004800000940000000602000000240000525341310004000001000100b393be1d1ac5dd"
+
"8b4b686a97803f414671b978cd10034df9cc82f3c975448d9cc2fbd2fe76f3be64c416f148329f"
+
"16061809d7fd9c0b30e043f979247840d3ed24eff5f28a8e2dfba44edd822b546fe24bb1bc00c2"
+
"8d9d7d3e1e3521df154f30518fbdbd2106e753ee39502fe6bcc0f84775aae3966253231d9d1f72"
+
"7c5293bb"
)]
Si toutes ces étapes sont respectées, la compilation devrait se passer correctement, et l'assembly de sérialisation pourra accéder aux membres internes.
V. DataContractSerializer▲
Bien que l'article se soit jusqu'ici concentré sur l'utilisation de la classe XmlSerializer, sachez que ce n'est pas la seule possibilité offerte par le framework pour sérialiser des objets en XML… Depuis la version 3.0 et l'arrivée de WCF, une classe très similaire à XmlSerializer a fait son apparition : DataContractSerializer. C'est ce nouveau sérialiseur qui est employé par défaut pour les services WCF. Ce chapitre présentera succinctement la classe DataContractSerializer sans entrer dans les détails (il y aurait suffisamment à dire pour justifier un article dédié…).
Cette classe est de plus en plus utilisée à la place de XmlSerializer, en particulier pour les services WCF. Elle s'utilise de façon très similaire : création d'une instance de DataContractSerializer pour un type donné, utilisation d'attributs de contrôle…
Voyons à travers un exemple simple comment utiliser DataContractSerializer. Il faut tout d'abord ajouter au projet une référence vers l'assembly System.Runtime.Serialization. Reprenons notre classe Message du début de l'article, en lui ajoutant les attributs nécessaires :
[DataContract]
public
class
Message
{
[DataMember]
public
string
From {
get
;
set
;
}
[DataMember]
public
string
To {
get
;
set
;
}
[DataMember]
public
string
Subject {
get
;
set
;
}
[DataMember]
public
DateTime Date {
get
;
set
;
}
[DataMember]
public
string
Body {
get
;
set
;
}
}
Notez l'attribut DataContract appliqué à la classe, et les attributs DataMember appliqués à chaque propriété : contrairement à XmlSerializer, DataContractSerializer ne sérialise que les membres pour lesquels on l'a explicitement demandé.
Pour sérialiser un objet, on utilise le code suivant :
DataContractSerializer dcs =
new
DataContractSerializer
(
typeof
(
Message));
using
(
XmlWriter wr =
XmlWriter.
Create
(
"message.xml"
))
{
dcs.
WriteObject
(
wr,
message);
}
Et pour le désérialiser :
DataContractSerializer dcs =
new
DataContractSerializer
(
typeof
(
Message));
using
(
XmlReader rd =
XmlReader.
Create
(
"message.xml"
))
{
Message message =
dcs.
ReadObject
(
wr) as
Message;
}
À l'utilisation, il n'y a donc pas de différence fondamentale, en apparence, avec ce bon vieux XmlSerializer… Les différences existent pourtant, voici les principales :
- DataContractSerializer peut sérialiser des membres privés ;
- DataContractSerializer peut sérialiser des propriétés en lecture seule (mais pas les désérialiser) ;
- DataContractSerializer peut sérialiser des objets avec des références cycliques, pour peu qu'on ait spécifié la propriété IsReference dans l'attribut DataContract. Dans ce cas, l'élément XML correspondant à l'objet porte un attribut z:Id, qui permet de faire référence au même objet ailleurs dans le document XML généré, avec l'attribut z:Ref ;
- DataContractSerializer ne nécessite pas de constructeur public. En fait, le constructeur de l'objet désérialisé n'est jamais appelé : une instance non initialisée est créée à l'aide de la méthode FormatterServices.GetUninitializedObject. Il est cependant possible d'effectuer des traitements sur l'objet avant et après la désérialisation, en créant des méthodes auxquelles on applique les attributs OnDeserializing ou OnDeserialized.
Cette classe DataContractSerializer semble donc avoir de nombreux avantages sur la classe XmlSerializer… Cependant, si l'on doit respecter un schéma XML spécifique, elle sera probablement inadaptée, car elle offre beaucoup moins de contrôle sur le format du XML généré. XmlSerializer, au contraire, supporte la quasi-totalité de la norme XSD.
Conclusion▲
Ce tutoriel est terminé, j'espère qu'il vous aura appris quelques "trucs" utiles… Il avait pour but de compléter le premier article sur ce thème, mais le sujet est décidément trop vaste, et je n'ai pas encore couvert tous les aspects… J'espère néanmoins avoir dit l'essentiel. N'hésitez pas à me faire part de vos commentaires, et éventuellement de vos propres astuces !
Remerciements▲
Je tiens ici à remercier l'équipe de Developpez.com, et en particulier Skalp pour sa relecture attentive.