Canalblog
Editer l'article Suivre ce blog Administration + Créer mon blog
Publicité
Fanfois et ses papillons
17 janvier 2012

Aspirateur (7 - finalisation du Model)

Pour se remettre du précédent billet, nous allons "finaliser" notre implémentation du service d'accès aux données pendant que la chose est encore fraîche dans nos têtes.

Pas de panique, si finaliser est entre guillemet ce n'est pas pour rien. Nous n'allons pas nous mettre à niveau avec ce que l'on peut obtenir avec Entity Framework. Non, nous allons simplement tendre vers un fonctionnement du fichier XML plus proche de ce qui a été présenté dans le précédent billet, mais pas réalisé.

L'idée est donc d'obtenir une couche ServicesXML qui permette de manipuler simultanément les sites web et les domaines.
Actuellement ce n'est pas le cas car chaque service spécialisé a son instance de XDocument. Ainsi si on ajoute un domaine via DomainsService, ce domaine n'est pas connu de WebSitesService.
Pour qu'il le soit, il faudrait écrire sur fichier après chaque modification du XML, et recharger tous les services depuis le fichier. Ce n'est pas possible, en plus serait idiot.

Le livre "imposant" les méthodes AppliquerLesChangements et AnnulerLesChangements, il nous faut un XDocument commun à tous les services.

La première idée pourrait être de rentre le XDocument de la classe ServiceBase statique. En fait cette modification ne changerait pas grand-chose. Chaque classe dérivée aurait sa propre version statique du XDocument et il n'y aurait pas de partage entre les classes dérivées.

En fait, comme cela a été dit dans le précédent billet, il nous faut un singleton pour notre XDocument, singleton qui sera partagé par tous les services implémentés dans la couche ServicesXML.

Il existe un nombre limité de méthodes d’implémentation d’un singleton. Compte tenu de la simplicité de la chose, c’est un peu normal. Personnellement je trouve que Implementing the Singleton Pattern in C# explique bien les différentes façons de faire un singleton en C#.

Cela dit, qu’elle méthode allons-nous choisir ?

Nous n’allons pas suivre la recommandation de l’article, pour une raison très simple. L’initialisation différée (dont au passage, nous n’avons pas vraiment besoin) est obtenue via une ruse qui utilise une particularité du compilateur C#. Il en résulte que le code n’est pas explicitement "lazy" et qu’un changement dans le compilateur changerait la donne. Nous sommes en .NET 4, donc nous pouvons faire les choses de manière explicite et plus sûre.

Nous ajoutons donc une nouvelle classe au répertoire Base de notre projet Aspirateur.ServicesXML, que nous nommons XDocumentSingleton. Le code est celui de la sixième proposition, seul le nom de la classe a été modifié :

using System;
 
namespace Aspirateur.ServicesXML
{
    public sealed class XDocumentSingleton
    {
        #region Partie singleton
        private static readonly Lazy<XDocumentSingleton> lazy =
            new Lazy<XDocumentSingleton>(() => new XDocumentSingleton());
 
        public static XDocumentSingleton Instance { get { return lazy.Value; } }
 
        private XDocumentSingleton()
        {
        }
        #endregion Partie singleton
    }
}

Nous pouvons maintenant ajouter notre XDocument, qui sera automatiquement partagé :

using System.Xml.Linq;
 
#region Partie XDocument
#region Membres
private XDocument _xdoc;
#endregion Membres
 
#region Propriétés
public XDocument XDoc
{
    get { return _xdoc; }
}
#endregion Propriétés
#endregion Partie XDocument

Avant de voir les modifications induites dans ServiceBase, on peut déjà dire qu'il est hautement souhaitable que le XDocument.Load soit interne au singleton. Dans le cas contraire le singleton ne sert à rien, le XDocument sera créé hors de son contrôle. On se dit alors que, pour équilibrer l'usage, il serait bon d'également internaliser le XDocument.Save. On ajoute alors :

using System.IO;
 
#region Partie XDocument
#region Membres
private string _fileName;
#endregion Membres
 
#region Propriétés
public string FileName
{
    get { return _fileName; }
    set { _fileName = value; }
}
#endregion Propriétés
 
#region Fonctions publiques
public void Load()
{
    _xdoc = XDocument.Load(_fileName);
}
 
public void LoadOrCreate()
{
    if (File.Exists(_fileName))
    {
        _xdoc = XDocument.Load(_fileName);
    }
    else
    {
        CreateXDoc(_fileName);
    }
}
 
public void Save()
{
    _xdoc.Save(_fileName);
}
#endregion Fonctions publiques
 
#region Helper
/// <summary>
/// Créer un fichier XML minimal.
/// </summary>
private void CreateXDoc(string fileName)
{
    XNamespace xsi = "http://www.w3.org/2001/XMLSchema-instance";
    XNamespace xsd = "http://www.w3.org/2001/XMLSchema";
    _xdoc = new XDocument(new XDeclaration("1.0", "utf-8", "yes"),
                         new XElement("Root", new XAttribute(XNamespace.Xmlns + "xsi", "http://www.w3.org/2001/XMLSchema-instance"),
                                              new XAttribute(XNamespace.Xmlns + "xsd", "http://www.w3.org/2001/XMLSchema")));
    _xdoc.Save(fileName);
}
#endregion Helper
#endregion Partie XDocument

Nous modifions en conséquence ServiceBase. Nous enlevons le membre_fileName et la fonction d'aide CreateXDoc. Le code modifié est le suivant :

#region Membres
private static XDocumentSingleton _singleton;
private static readonly string _elementName;
private static readonly string _collectionName;
#endregion Membres
 
#region Proprietes
protected XDocument XDoc
{
    get { return _singleton.XDoc; }
}
 
protected SynchronizationContext SynchronizationContext
{
    get { return SynchronizationContext.Current; }
}
#endregion Proprietes
 
#region Constructeurs / destructeur
static ServiceBase()
{
    _elementName = typeof(TGlobal).Name;
    _collectionName = _elementName + "s";
    _singleton = XDocumentSingleton.Instance;
    _singleton.FileName = "Config.xml";
    _singleton.LoadOrCreate();
}
 
protected ServiceBase()
{
}
 
public void Dispose()
{
    // Tout est en mémoire, rien à faire.
}
#endregion Constructeurs / destructeur
 
#region IServiceBase
public void AppliquerLesChangements()
{
    _singleton.Save();
}
 
public void AnnulerLesChangements()
{
    _singleton.LoadOrCreate();
}
#endregion IServiceBase

Les modifications restantes consistent à remplacer les _xdoc par des XDoc

Pour finaliser notre modification, nous devons modifier le modèle globale pour un site web ainsi que les conversions entre le modèle client et le modèle global :

using System;
 
namespace Aspirateur.ModelGlobal
{
    public interface IWebSite
    {
        ...
        /// <summary>
        /// La liste des domaines associés.
        /// </summary>
        SerializableDictionary<string, Guid> DomainList { get; set; }
    }
}
using System;
using System.ComponentModel;
using System.Xml.Serialization;
 
namespace Aspirateur.ModelGlobal
{
    public class WebSite : IWebSite
    {
        ...
        /// <summary>
        /// La liste des domaines associés.
        /// </summary>
        public SerializableDictionary<string, Guid> DomainList { get; set; }
    }
}
using System;
using System.Linq;
using Aspirateur.Model;
using Aspirateur.ModelGlobal;
 
namespace Aspirateur.ServicesXML.Mappers
{
    /// <summary>
    /// Classe de conversion des données "website".
    /// /// </summary>
    public static class WebSiteMapper
    {
        /// <summary>
        /// Convertit un site web du modèle global en site web du modèle client
        /// </summary>
        public static WebSiteModele ToWebSiteModele(this WebSite global)
        {
            WebSiteModele website = new WebSiteModele
            {
                Id = global.Id,
                BaseURL = global.BaseURL,
                BaseDirectory = global.BaseDirectory,
                DefaultName = global.DefaultName,
                DefaultExtension = global.DefaultExtension == 1 ? Extension.HTML : Extension.HTM
            };
            DomainsService ds = new DomainsService();
            foreach (var d in global.DomainList)
            {
                DomainModele dm = ds.ObtenirUn(d.Value);
                website.DomainList.Add(d.Key, dm);
            }
 
            return website;
        }
 
        /// <summary>
        /// Convertit un site web du modèle client en site web du modèle global.
        /// </summary>
        public static WebSite ToWebSite(this WebSiteModele client)
        {
            WebSite website = new WebSite
            {
                Id = client.Id,
                BaseURL = client.BaseURL,
                BaseDirectory = client.BaseDirectory,
                DefaultName = client.DefaultName,
                DefaultExtension = client.DefaultExtension == Extension.HTML ? 1 : 0
            };
            SerializableDictionary<string, Guid> domainList = new SerializableDictionary<string, Guid>();
            DomainsService ds = new DomainsService();
            foreach (var d in client.DomainList)
            {
                // On s'assure que les domaines seront également serialisés.
                DomainModele dm = ds.ObtenirUn(d.Value.Id);
                if (dm == null)
                {
                    ds.Creer(d.Value);
                }
                else
                {
                    ds.MettreAJour(d.Value);
                }
                domainList.Add(d.Key, d.Value.Id);
            }
            website.DomainList = domainList;
 
            return website;
        }
    }
}

Si vous lancez cette version de l'aspirateur (en n'oubliant pas d'effacer l'ancien fichier de configuration), et que vous demandez l'aspiration avant de sauver la configuration, vous obtenez le fichier Config.xml suivant (avec vos GUID bien évidemment) :

<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<Root xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    <WebSites>
        <WebSite xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" Id="1779d0ed-7188-481f-a05a-74bfd32cf69f">
            <BaseURL>http://www.canalblog.com/</BaseURL>
            <BaseDirectory>D:\test\canalblog\</BaseDirectory>
            <DefaultName>canalblog</DefaultName>
            <DefaultExtension>0</DefaultExtension>
            <DomainList>
                <item>
                    <key>
                        <string>https://storage.canalblog.com</string>
                    </key>
                    <value>
                        <guid>f0c9ccb4-4f4b-4c02-bf53-3cd1e8ffc9e3</guid>
                    </value>
                </item>
            </DomainList>
        </WebSite>
    </WebSites>
    <Domains>
        <Domain xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" Id="f0c9ccb4-4f4b-4c02-bf53-3cd1e8ffc9e3">
            <Authority>https://storage.canalblog.com/</Authority>
            <Subdirectory>storage\</Subdirectory>
            <Pages>false</Pages>
            <Resources>true</Resources>
        </Domain>
    </Domains>
</Root>

On voit sur ce fichier, qu'il y a bien un nœud racine pour les sites web et un autre pour les domaines. Les sites web référencent bien les domaines par leur identifiant. La structure du fichier XML est donc bien celle annoncée dans le précédent billet.
Cela étant dit, peut-on considéré ce code comme bon ?
Clairement non.
Non car il expose directement le XDocument. Même si l'on peut admettre que le code qui le manipulera ne fera rien qui soit contraire au principe du singleton, chose à ne surtout pas faire dans du "vrai" code, il y a tout de même le problème du multithread. Ce code ne gère pas les accès concurrents. Dit autrement, il fait confiance au XDocument pour faire le travail.
La documentation de XDocument dit clairement, "Any public static (Shared in Visual Basic) members of this type are thread safe. Any instance members are not guaranteed to be thread safe". Cela signifie que c'est à nous de faire le travail, sauf éventuellement pour XDocument.Load.

En fait la chose est un peu plus compliquée. Ce que nous garantit Microsoft c'est que deux XDocument.Load ne s'exécuteront pas en même temps. Il reste un problème logique.
Nous avons placé dans le constructeur statique de ServiceBase un appel à LoadOrCreate. Il en résulte que ce code sera exécuté autant de fois qu'il y aura de classe dérivée de ServiceBase. Ajoutez un soupçon de chargement différé ("lazy loading") et vous pouvez avoir un constructeur statique qui est exécuté alors que le XDocument a déjà été modifié via une autre classe dérivée. Dans ce cas les problèmes commencent et le fait que XDocument.Load soit "thread safe" n'y change rien, même avec un seul thread, le problème existe.
Alors que faire ? Il nous faut un singleton pour que ça fonctionne, mais le singleton nous amène des problèmes logiques qui risquent de nuire à notre santé mentale.
C'est normal, et il n'y a pas de solution miracle. Dans le cadre du code du livre c'est Entity Framework qui gère la chose, ici c'est à nous de le faire. Nous allons commencer par une approche monothread. Dans ce cadre, le problème se limite aux constructeurs statiques dont la séquence d'appel peut poser problème. A ce niveau il y a au moins deux solutions simplicimes :

  • ajouter un paramètre à la fonction LoadOrCreate qui indique qu'il s'agit d'une initialisation ;
  • créer une fonction dédiée au cas de l'initialisation. On peut même ajouter une propriété qui serve à gérer l'initialisation.

Nous allons mettre en place la seconde solution. Nous ajoutons donc un membre privé de type booléen qui indique si "la chose" a été faite, et une nouvelle fonction que nous appelons de manière standard Initialize :

#region Membres
private bool _done = false;
#endregion Membres
 
#region Fonctions publiques
public void Initialize(string fileName)
{
    if (!_done)
    {
        FileName = fileName;
        LoadOrCreate();
        _done = true;
    }
}
#endregion Fonctions publiques

Si nous modifions le constructeur de ServiceBaseen conséquence, nous avons maintenant la garantie que la lecture du fichier XML ne sera effectuée qu'une seule fois :

static ServiceBase()
{
    _elementName = typeof(TGlobal).Name;
    _collectionName = _elementName + "s";
    _singleton = XDocumentSingleton.Instance;
    _singleton.Initialize("Config.xml");
}

Cet exemple montre que traiter correctement la réentrance n'est pas uniquement un problème de mutex ou autres sections critiques.

Le code du livre ne supporte pas le multithreading, la chose est dite explicitement dans le livre et la lecture du code de ServiceBase ne laisse aucun doute. Cependant nous allons rendre le code de notre singleton "thread safe". Evidemment, le code se trouvant au-dessus ne l'étant pas, l'ensemble ne le sera pas. Cela dit, s'il faut commencer quelque part c'est sur la couche la plus basse.

Nous ajoutons donc à notre singleton un membre pour pouvoir gérer les accès concurrents. Tant qu'à faire, nous allons essayer de le faire bien, et allons utiliser ReaderWriterLockSlim. Ce que nous apporte ReaderWriterLockSlim, c'est une gestion qui différentie les lectures des écritures. Pour faire simple, n lectures peuvent être simultanées, mais rien ne peut être fait en même temps qu'une écriture.
Reprenons simplement le code de notre initialisation pour voir ce que cela donne :

using System.Threading;
 
#region Partie singleton
private ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
#endregion Partie singleton
 
#region Fonctions publiques
public void Initialize(string fileName)
{
        if (!_done)
        {
            rwLock.EnterWriteLock();
            try
            {
                if (_done)
                {
                    return;
                }
                FileName = fileName;
                LoadOrCreate();
                _done = true;
            }
            finally
            {
                rwLock.ExitWriteLock();
            }
        }
}
#endregion Fonctions publiques

Première chose très (vraiment très) importante, il faut absolument utiliser des finally pour éviter qu'un verrou ne reste indéfiniment actif.
Deuxième chose, nous testons _done avant et après la demande d'accès. Nous testons avant pour ne pas attendre s'il n'y a rien à faire. Nous testons après pour le cas où quelqu'un d'autre aurait réalisé l'initialisation pendant que nous attendions.
Troisième chose, nous créons un verrou qui supporte la récursion car cela nous facilitera le travail plus tard. Si nous ne faisons pas cela et qu'une fonction A demande le verrou et appelle ensuite une fonction B qui fait la même chose, nous aurons une exception au niveau de la demande de verrou de fonction B.

Evidemment ce code seul est sans intérêt. A partir du moment où nous voulons gérer les accès concurrents, nous devons le faire sur tout. Si nous laissons un trou quelque part, c'est comme si nous n'avions rien fait.
Nous généralisons donc aux autres fonctions du singleton :

#region Fonctions publiques
public void Load()
{
    rwLock.EnterWriteLock();
    try
    {
        _xdoc = XDocument.Load(_fileName);
    }
    finally
    {
        rwLock.ExitWriteLock();
    }
}
 
public void LoadOrCreate()
{
    if (File.Exists(_fileName))
    {
        Load();
    }
    else
    {
        CreateXDoc(_fileName);
    }
}
 
public void Save()
{
    rwLock.EnterReadLock();
    try
    {
    _xdoc.Save(_fileName);
    }
    finally
    {
        rwLock.ExitReadLock();
    }
}
#endregion Fonctions publiques
 
#region Helper
private void CreateXDoc(string fileName)
{
    rwLock.EnterWriteLock();
    try
    {
        XNamespace xsi = "http://www.w3.org/2001/XMLSchema-instance";
        XNamespace xsd = "http://www.w3.org/2001/XMLSchema";
        _xdoc = new XDocument(new XDeclaration("1.0", "utf-8", "yes"),
                              new XElement("Root", new XAttribute(XNamespace.Xmlns + "xsi", "http://www.w3.org/2001/XMLSchema-instance"),
                                                   new XAttribute(XNamespace.Xmlns + "xsd", "http://www.w3.org/2001/XMLSchema")));
        _xdoc.Save(fileName);
    }
    finally
    {
        rwLock.ExitWriteLock();
    }
}
#endregion Helper

Le code essaie de minimiser les blocages. Dans cette optique, sauver le document XML sur un fichier est une opération longue, mais qui ne peut pas conduire à sa modification. Le verrou de lecture est donc suffisant.
Pour cette même raison, nous aimerions que le code de CreateXDoc change la nature du verrou demandé au moment de l'écriture sur fichier. Cela n'est pas possible car on ne peut pas passer d'un verrou d'écriture à un verrou de lecture. Une solution, interne à la fonction, serait de demander au départ un verrou de lecture que l'on puisse transformer (EnterUpgradeableReadLock), de le monter immédiatement au niveau écriture pour modifier le XDocument, et de le redescendre en lecture lors de l'écriture sur disque. Malheureusement l'appelant (Initialize) a déjà demandé un verrou en écriture (il ne peut pas faire autrement), donc cela est impossible.

Le code que nous venons d'ajouter s'occupe des lectures/écritures sur fichier. Il faut maintenant s'occuper des accès au contenu du XDocument. Mauvaise pioche pour nous, contrôler l'accès au XDocument lui-même ne suffit pas. Le code LINQ to XML présente deux particularités gênantes pour nous :
LINQ to XML pratique l'exécution différée (deferred Execution). Cela signifie que l'accès au XML n'est pas réalisé lors de l'exécution du code construisant la requête LINQ to XML, mais lors de l'exécution du code parcourant le résultat de la requête. Un exemple valant mieux qu'un long discourt, faites un tour sur LINQ Deferred Execution, le petit exemple explique tout. Ce n'est pas bon pour nous car cela signifie que lorsqu'une fonction retourne un IQueryable<T>, l'accès aux données n'a pas été réalisé et donc que le verrou ne devrait pas être relâché.
Autre problème, LINQ to XML peut passer à tout moment du mode lecture au mode écriture. Typiquement après avoir récupéré une liste d'éléments IQueryable<T>, le code peut décider, par exemple, de supprimer tous les résultats à l'aide de la méthode Remove.
Il en résulte que la seule façon de contrôler les accès au XML consiste à mettre tout le code de manipulation LINQ to XML dans notre singleton.
La bonne nouvelle, c'est que notre couche service ne fournit pas à son appelant des collections IQueryable<T> mais des List<T>. Il est donc techniquement possible de tout contrôler, et il va falloir le faire (décidement un billet court n'existera pas). Nous enlevons donc la propriété qui expose le XDocument de notre singleton et y descendons les fonctions suivantes :

using System.Collections.Generic;
using System.Linq;
 
public IEnumerable<XElement> GetXElement(string elementName, Guid id, bool required, ref List<string> errors)
{
    IEnumerable<XElement> xElts;
    rwLock.EnterReadLock();
    try
    {
        xElts = from c in _xdoc.Descendants(elementName)
                where c.Attribute("Id").Value == id.ToString()
                select c;
        // ToArray force une mise en cache du résultat.
        xElts = xElts.ToArray();
    }
    finally
    {
        rwLock.ExitReadLock();
    }
 
    // Il ne devrait y en avoir que 0 ou 1.
    int nb = xElts.Count();
    if (nb > 1)
    {
        // Ce n'est pas normal.
        errors = errors == null ? new List<string>() : errors;
        errors.Add("Il y a " + nb.ToString() + " élément(s) avec cet identifiants...");
    }
    else if (required && nb != 1)
    {
        // Il est indiqué que l'élément doit exister.
        errors = errors == null ? new List<string>() : errors;
        errors.Add("Identifiant inconnu...");
    }
    return xElts;
}
 
public IEnumerable<XElement> GetAllXElements(string elementName)
{
    IEnumerable<XElement> xElts;
    rwLock.EnterReadLock();
    try
    {
        xElts = from c in _xdoc.Descendants(elementName)
                select c;
        // ToArray force une mise en cache du résultat.
        return xElts.ToArray();
    }
    finally
    {
        rwLock.ExitReadLock();
    }
}
 
public bool AddElement(string collectionName, XElement xel)
{
    rwLock.EnterWriteLock();
    try
    {
        var coll = _xdoc.Root.Element(collectionName);
        if (coll == null)
        {
            _xdoc.Root.Add(new XElement(collectionName));
            coll = _xdoc.Root.Element(collectionName);
        }
        coll.Add(xel);
        return true;
    }
    catch
    {
        return false;
    }
    finally
    {
        rwLock.ExitWriteLock();
    }
}

Le code de ServiceBase est modifié en conséquence (seules les modifications sont présentées) :

#region Helper
protected IEnumerable<XElement> GetXElement(Guid id, bool required, ref List<string> errors)
{
    return _singleton.GetXElement(_elementName, id, required, ref errors);
}
 
protected IEnumerable<XElement> GetAllXElements()
{
    return _singleton.GetAllXElements(_elementName);
}
 
protected bool AddElement(TModelBase model)
{
    bool ret = false;
    try
    {
        TGlobal global = MapToGlobal(model);
        string xml = XmlHelpers.SerializeObject<TGlobal>(global);
        xml = XmlHelpers.RemoveUtf8ByteOrderMark(xml);
        XElement xel = XElement.Parse(xml);
        ret = _singleton.AddElement(_collectionName, xel);
        if (ret)
        {
            model.AEteModifie = false;
        }
        return ret;
    }
    catch
    {
        return false;
    }
}
#endregion Helper

Avec ces modifications, il n'y a plus aucune manipulation directe de XDocument hors du singleton. Cependant il y a tout de même manipulation de son contenu via LINQ to XML. Par exemple le code de la fonction MettreAJour contient une ligne de code qui modifie directement le XML contenu dans le XDocument. Il s'agit de la ligne oldXml.Remove<XElement>();.
Le premier réflexe consiste à transformer cette ligne en un appel à une fonction de qui fasse effectivement la suppression. Cela conduit la fonction MettreAJour à demander un accès en lecture (via GetXElement), puis un accès en écriture (pour l'équivalent de oldXml.Remove<XElement>();) et enfin un accès en écriture (via AddElement).
Normalement, en lisant cette description, vous vous êtes dit que quelque chose clochait.
En effet si nous voulons vraiment prendre en compte les accès concurrent, il faudrait éviter que ce que nous avons récupéré lors de l'appel à GetXElement ait déjà disparu où ait été modifié avant nos deux autres appels concernant le XDocument. Dans le même ordre d'idée, deux appels c'est un de trop, il faut faire la suppression et l'ajout ensemble.
Alors comment faire ?
Ce problème montre simplement que ServiceBase n'est pas conçu pour le multithread. Nous n'allons pas nous attaquer à ce problème, nous allons simplement faire en sorte que notre singleton le soit. Du coup nous devons ajouter des fonctions "thread safe" qui permettent de modifier le contenu du XDocument. Si l'appelant ne fait pas les choses correctement, c'est un autre problème. Ce que nous allons garantir c'est que le singleton fait bien les choses, pour cela nous allons même modifier le code que nous venons d'écrire.

Nous allons fournir un code qui garantisse au mieux le résultat, même si cela va provoquer une perte de performances.

Typiquement, nous utilisons Descendants pour récupérer les éléments recherchés. Cela n'est pas super fiable, surtout dans une optique de réutilisation du code dans un autre projet. Si nous ajoutons un jour un élément dans un futur membre du model qui porte le même nom qu'un autre membre, Descendants le remontera. Lors de la désérialisation, explosion du code garantie et grosse prise de tête pour trouver pourquoi.
Pour éviter cela nous allons remplacer _xdoc.Descendants(elementName) par _xdoc.Root.Element(collectionName).Elements(elementName).
Certains doivent se demander pourquoi cela va nuire aux performances, ça devrait même être plus rapide.
Oui, mais non. Non, parce que ce nouveau code explose si le nœud collectionName n'existe pas. Descendants n'avait pas ce problème. Cela nous oblique à tester à chaque fois que le nœud collectionName existe et c'est là que nous allons perdre du temps.
Une autre solution serait de tenter de garantir que le nœud collectionName existe toujours dans le XDocument. Au départ ce n'est pas très compliqué, en modifiant Initialize. Ca le devient nettement plus lorsque l'on veut prendre en compte une réinitialisation en cours de route du XML. Le singleton doit alors notifier toutes les classes dérivées pour qu'elles ajoutent leur collectionName. Ce n'est pas non plus très compliqué (une simple callback dont l'adresse serait fournie à Initialize suffit, comme l'ordre d'exécution est sans importance .NET fait très bien la chose), mais c'est beaucoup plus sujet à erreur lors d'une modification future du code. La solution retenu est beaucoup plus robuste (il suffit d'utiliser la fonction dédiée, ce qui se fait tout seul en développement classique, i.e. avec un copier/coller) et pas si pénalisante en matière de temps d'exécution. Le code complet du singleton est donc :

using System;
using System.Xml.Linq;
using System.IO;
using System.Threading;
using System.Collections.Generic;
using System.Linq;
 
namespace Aspirateur.ServicesXML
{
    public sealed class XDocumentSingleton
    {
        #region Partie singleton
        private ReaderWriterLockSlim rwLock = new ReaderWriterLockSlim(LockRecursionPolicy.SupportsRecursion);
 
        private static readonly Lazy<XDocumentSingleton> lazy =
            new Lazy<XDocumentSingleton>(() => new XDocumentSingleton());

        public static XDocumentSingleton Instance { get { return lazy.Value; } }
 
        private XDocumentSingleton()
        {
        }
        #endregion Partie singleton
 
        #region Partie XDocument
        #region Membres
        private XDocument _xdoc;
        private string _fileName;
        private bool _done = false;
        #endregion Membres
 
        #region Propriétés
        public string FileName
        {
            get { return _fileName; }
            set { _fileName = value; }
        }
        #endregion Propriétés
 
        #region Fonctions publiques
        public void Initialize(string fileName)
        {
            if (!_done)
            {
                rwLock.EnterWriteLock();
                try
                {
                    if (_done)
                    {
                        return;
                    }
                    FileName = fileName;
                    LoadOrCreate();
                    _done = true;
                }
                finally
                {
                    rwLock.ExitWriteLock();
                }
            }
        }
 
        public void Load()
        {
            rwLock.EnterWriteLock();
            try
            {
                _xdoc = XDocument.Load(_fileName);
            }
            finally
            {
                rwLock.ExitWriteLock();
            }
        }
 
        public void LoadOrCreate()
        {
            if (File.Exists(_fileName))
            {
                Load();
            }
            else
            {
                CreateXDoc(_fileName);
            }
        }
 
        public void Save()
        {
            rwLock.EnterReadLock();
            try
            {
            _xdoc.Save(_fileName);
            }
            finally
            {
                rwLock.ExitReadLock();
            }
        }
 
        public IEnumerable<XElement> GetXElement(string collectionName, string elementName, Guid id, bool required, ref List<string> errors)
        {
            IEnumerable<XElement> xElts;
            rwLock.EnterUpgradeableReadLock();
            try
            {
                xElts = from c in GetCollection(collectionName).Elements(elementName)
                        where c.Attribute("Id").Value == id.ToString()
                        select c;
                xElts = xElts.ToArray();
            }
            finally
            {
                rwLock.ExitUpgradeableReadLock();
            }
 
            int nb = xElts.Count();
            if (nb > 1)
            {
                errors = errors == null ? new List<string>() : errors;
                errors.Add("Il y a " + nb.ToString() + " élément(s) avec cet identifiants...");
            }
            else if (required && nb != 1)
            {
                errors = errors == null ? new List<string>() : errors;
                errors.Add("Identifiant inconnu...");
            }
            return xElts;
        }
 
        public IEnumerable<XElement> GetAllXElements(string collectionName, string elementName)
        {
            IEnumerable<XElement> xElts;
            rwLock.EnterUpgradeableReadLock();
            try
            {
                xElts = from c in GetCollection(collectionName).Elements(elementName)
                        select c;
                return xElts.ToArray();
            }
            finally
            {
                rwLock.ExitUpgradeableReadLock();
            }
        }
 
        public bool AddElement(string collectionName, string elementName, Guid id, XElement xel)
        {
            rwLock.EnterWriteLock();
            try
            {
                var coll = GetCollection(collectionName);
                var xElts = from c in coll.Elements(elementName)
                        where c.Attribute("Id").Value == id.ToString()
                        select c;
                if (xElts.Count() != 0)
                {
                    return false;
                }
                coll.Add(xel);
                return true;
            }
            catch
            {
                return false;
            }
            finally
            {
                rwLock.ExitWriteLock();
            }
        }
 
        public bool ReplaceElement(string collectionName, string elementName, Guid id, XElement xel, bool manyOk = true)
        {
            rwLock.EnterWriteLock();
            try
            {
                var coll = GetCollection(collectionName);
                var xElts = from c in coll.Elements(elementName)
                            where c.Attribute("Id").Value == id.ToString()
                            select c;
                int nb = xElts.Count();
                if (manyOk ? nb == 0: nb != 1)
                {
                    return false;
                }
                xElts.Remove();
                coll.Add(xel);
                return true;
            }
            catch
            {
                return false;
            }
            finally
            {
                rwLock.ExitWriteLock();
            }
        }
 
        public bool AddOrReplaceElement(string collectionName, string elementName, Guid id, XElement xel)
        {
            rwLock.EnterWriteLock();
            try
            {
                var coll = GetCollection(collectionName);
                var xElts = from c in coll.Elements(elementName)
                            where c.Attribute("Id").Value == id.ToString()
                            select c;
                int nb = xElts.Count();
                if (nb > 1)
                {
                    return false;
                }
                if (nb == 1)
                {
                    xElts.Remove();
                }
                coll.Add(xel);
                return true;
            }
            catch
            {
                return false;
            }
            finally
            {
                rwLock.ExitWriteLock();
            }
        }
 
        public bool RemoveElement(string collectionName, string elementName, Guid id)
        {
            rwLock.EnterWriteLock();
            try
            {
                var xElts = from c in GetCollection(collectionName).Elements(elementName)
                            where c.Attribute("Id").Value == id.ToString()
                            select c;
                xElts.Remove();
                return true;
            }
            catch
            {
                return false;
            }
            finally
            {
                rwLock.ExitWriteLock();
            }
        }
        #endregion Fonctions publiques
 
        #region Helper
        private void CreateXDoc(string fileName)
        {
            rwLock.EnterWriteLock();
            try
            {
                XNamespace xsi = "http://www.w3.org/2001/XMLSchema-instance";
                XNamespace xsd = "http://www.w3.org/2001/XMLSchema";
                _xdoc = new XDocument(new XDeclaration("1.0", "utf-8", "yes"),
                                      new XElement("Root", new XAttribute(XNamespace.Xmlns + "xsi", "http://www.w3.org/2001/XMLSchema-instance"),
                                                           new XAttribute(XNamespace.Xmlns + "xsd", "http://www.w3.org/2001/XMLSchema")));
                _xdoc.Save(fileName);
            }
            finally
            {
                rwLock.ExitWriteLock();
            }
        }
 
        private XElement GetCollection(string collectionName)
        {
            XElement coll = _xdoc.Root.Element(collectionName);
            if (coll == null)
            {
                rwLock.EnterWriteLock();
                try
                {
                    coll = _xdoc.Root.Element(collectionName);
                    if (coll == null)
                    {
                        _xdoc.Root.Add(new XElement(collectionName));
                        coll = _xdoc.Root.Element(collectionName);
                    }
                }
                finally
                {
                    rwLock.ExitWriteLock();
                }
            }
            return coll;
        }
        #endregion Helper
        #endregion Partie XDocument
    }
}

On notera :

  • l'utilisation de ToArray() qui permet d'éviter le problème dit d'Halloween. Cela ne résout pas tous nos problèmes d'accès concurrents liés à l'exécution différée de LINQ to XML, car elle existe toujours, mais c'est probablement le mieux que l'on puisse faire ;
  • le singleton sait que les éléments devraient avoir des identifiants uniques. Il utilise cette caractéristique pour valider ce qu'il fait. Cela se voit très clairement avec la nouvelle version de AddElement ;
  • l'apparition d'AddOrReplaceElement. Cette fonction peut permettre une certaine souplesse au niveau de ServiceBase.

Justement, coté ServiceBase le code impacté est le suivant :

#region IServiceBase
public virtual bool MettreAJour(TModelBase aMettreAJour)
{
    List<string> errors = null;
    IEnumerable<XElement> oldXml = GetXElement(aMettreAJour.Id, true, ref errors);
    if (errors != null)
    {
        aMettreAJour.RemplacerLesErreursDeValidationGlobales(errors);
        return false;
    }
    TGlobal oldValue = XmlHelpers.DeserializeObject<TGlobal>(oldXml.First().ToString());
    var validationErrors = ValidationALaMiseAJour(MapToModel(oldValue), aMettreAJour);
    if (validationErrors.Count > 0)
    {
        aMettreAJour.RemplacerLesErreursDeValidationGlobales(validationErrors);
        return false;
    }
    return ReplaceElement(aMettreAJour);
}
 
public virtual bool Supprimer(TModelBase aSupprimer)
{
    var validationErrors = ValidationALaSuppression(aSupprimer);
    if (validationErrors.Count > 0)
    {
        aSupprimer.RemplacerLesErreursDeValidationGlobales(validationErrors);
        return false;
    }
    return RemoveElement(aSupprimer);
}
#endregion IServiceBase
 
#region Helper
protected IEnumerable<XElement> GetXElement(Guid id, bool required, ref List<string> errors)
{
    return _singleton.GetXElement(_collectionName, _elementName, id, required, ref errors);
}
 
protected IEnumerable<XElement> GetAllXElements()
{
    return _singleton.GetAllXElements(_collectionName, _elementName);
}
 
protected bool AddElement(TModelBase model)
{
    bool ret = false;
    try
    {
        TGlobal global = MapToGlobal(model);
        string xml = XmlHelpers.SerializeObject<TGlobal>(global);
        xml = XmlHelpers.RemoveUtf8ByteOrderMark(xml);
        XElement xel = XElement.Parse(xml);
        ret = _singleton.AddElement(_collectionName, _elementName, model.Id, xel);
        if (ret)
        {
            model.AEteModifie = false;
        }
        return ret;
    }
    catch
    {
        return false;
    }
}
 
protected bool ReplaceElement(TModelBase model)
{
    bool ret = false;
    try
    {
        TGlobal global = MapToGlobal(model);
        string xml = XmlHelpers.SerializeObject<TGlobal>(global);
        xml = XmlHelpers.RemoveUtf8ByteOrderMark(xml);
        XElement xel = XElement.Parse(xml);
        ret = _singleton.ReplaceElement(_collectionName, _elementName, model.Id, xel, true);
        if (ret)
        {
            model.AEteModifie = false;
        }
        return ret;
    }
    catch
    {
        return false;
    }
}
 
protected bool RemoveElement(TModelBase model)
{
    try
    {
        return _singleton.RemoveElement(_collectionName, _elementName, model.Id);
    }
    catch
    {
        return false;
    }
}
#endregion Helper

Si vous lancez cette nouvelle version de l'aspirateur, elle fonctionne, mais ne fait rien de plus que celle que nous avons déjà lancé dans ce billet. Sa couche Model est simplement plus robuste, suffisamment pour nos besoin, nous nous arrêtons donc là.

Si vous souhaitez récupérer le code c'est ici.

Dans le prochain billet, encore du MVVM. Le Model est fini, nous ferons la suite.

Publicité
Publicité
Commentaires
Fanfois et ses papillons
Publicité
Archives
Publicité