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 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 noeud texte dans la terminologie XML). L'attribut XmlText permet d'obtenir ce résultat :

 
Sélectionnez
    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 :

 
Sélectionnez
<?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 :

 
Sélectionnez
    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 :

 
Sélectionnez
<?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 :

 
Sélectionnez

            XmlSerializerNamespaces ns = new XmlSerializerNamespaces();
            ns.Add("", "");
            ...
            xs.Serialize(wr, msg, ns);

Cela permet d'obtenir le résultat suivant :

 
Sélectionnez
<?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 noeuds 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 noeuds : par exemple, lorsqu'on modifie un document XML via la sérialisation XML, on ne veut pas supprimer les noeuds 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 noeuds 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 :

 
Sélectionnez
    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 :

 
Sélectionnez
<?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 noeuds Foo et Bar sont conservés. Il est bien sûr possible de modifier ces noeuds inconnus, des les supprimer, ou d'en ajouter.

I-D-2. Via les évènements de XmlSerializer

La classe XmlSerializer possède 4 é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 noeud 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 noeud inconnu est rencontré :

 
Sélectionnez
            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 noeuds 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 noeuds 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.

 
Sélectionnez
        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-1-a. 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 :

 
Sélectionnez
        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 :

 
Sélectionnez
    ...
    <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) :

 
Sélectionnez
    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 :

 
Sélectionnez
    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 :

 
Sélectionnez
    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 :

 
Sélectionnez
    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 :

 
Sélectionnez
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 :

 
Sélectionnez

            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 :

 
Sélectionnez
<?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>

Evidemment ç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 :

 
Sélectionnez
    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 :

 
Sélectionnez
    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; }
    }

A 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. Etant 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 :

 
Sélectionnez
    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égant 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.

 
Sélectionnez
    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 :

 
Sélectionnez
    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 :

 
Sélectionnez
 > 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 regé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 regénérer manuellement l'assembly de sérialisation après chaque compilation, il est possible de créer une tâche de post-compilation. Dans les propriétés du projet, allez dans l'onglet "Evè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) :

 
Sélectionnez
"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 :

 
Sélectionnez
  <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 :

 
Sélectionnez
    public class Foo
    {
        public Foo(string bar)
        {
            this.Bar = bar;
        }
        
        public string Bar { get; internal set; }
    }

A 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 :

 
Sélectionnez
    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) :

 
Sélectionnez
    [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 oeuvre 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é) :

 
Sélectionnez
  <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 :

 
Sélectionnez
 > 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 :

 
Sélectionnez
 > sn -tp public.key

Cette commande affiche le résultat suivant :

 
Sélectionnez

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 :

 
Sélectionnez
    [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 :

 
Sélectionnez
    [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 :

 
Sélectionnez
    DataContractSerializer dcs = new DataContractSerializer(typeof(Message));
    using (XmlWriter wr = XmlWriter.Create("message.xml"))
    {
        dcs.WriteObject(wr, message);
    }

Et pour le désérialiser :

 
Sélectionnez
    DataContractSerializer dcs = new DataContractSerializer(typeof(Message));
    using (XmlReader rd = XmlReader.Create("message.xml"))
    {
        Message message = dcs.ReadObject(wr) as Message;
    }

A 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.