Canalblog
Editer l'article Suivre ce blog Administration + Créer mon blog
Publicité
Fanfois et ses papillons
29 décembre 2011

Aspirateur (6 – sauvegarde des images, suite)

Pour avoir une version locale, presque, visuellement équivalente à la version en ligne, il nous faut au minimum résoudre le problème des ressources stockées sur un site autre que le site principal que nous aspirons. Le problème a déjà été expliqué, je n’y reviens donc pas, et vous invite à relire le billet Aspirateur (4 – sauvegarde des images), si nécessaire. Nous allons maintenant nous attaquer à la réalisation.

Nous savons que nous aurons besoin de demander des informations à l’utilisateur, mais plutôt que de se demander maintenant comment nous allons lui demander ce dont nous pourrions avoir besoin, nous allons valider concrètement ce dont nous avons besoin.
La méthode la plus simple de faire cela, consiste tout simplement à coder la partie manquante de la fonction ProcessUrl de notre classe UrlFileMatcher.

Pour ce code, nous créons une nouvelle classe qui contient les informations liées aux nouveaux domaines que nous rencontrons lors du traitement des pages aspirées. Nous allons la nommer DomainModele et l’ajouter au répertoire Models du projet Aspirateur.Model. Naturellement, elle hérite de ModeleBase.
Maintenant que doit contenir cette classe ?

  • le nom du domaine ;
  • allons-nous traiter ou pas les pages et/ou les ressources issues de ce domaine ?
  • où stockerons-nous les éléments que nous récupérerons ?

Cela donne le code suivant :

using System;
using System.ComponentModel.DataAnnotations;
using System.IO;
 
namespace Aspirateur.Model
{
    /// <summary>
    /// Données d'un domaine lié au site principal aspiré.
    /// </summary>
    public class DomainModele : ModeleBase
    {
        #region Constructeurs
        /// <summary>
        /// Il nous faut un constructeur par défaut, mais il ne devrait servir que pour la sérialisation.
        /// </summary>
        public DomainModele() : base() { }
 
        /// <summary>
        /// Le constructeur complet.
        /// </summary>
        /// <param name="url"></param>
        /// <param name="subdirectory"></param>
        /// <param name="name"></param>
        /// <param name="extension"></param>
        public DomainModele(Uri authority, string subdirectory, bool pages, bool resources)
            : base()
        {
            _authority = authority;
            _subdirectory = subdirectory;
            _virtualSubdirectory = _subdirectory.Replace('\\', '/');
            _pages = pages;
            _resources = resources;
        }
        #endregion
 
        #region Authority
        private Uri _authority = null;
 
        /// <summary>
        /// Le nom du domaine.
        /// </summary>
        [Required(ErrorMessage = "Il faut obligatoirement un nom de domaine.")]
        [RegularExpression(@"^http(s)?://([\w-]+\.)+[\w-]+/?$", ErrorMessage = "Nom de domaine non reconnue.")]
        public Uri Authority
        {
            get { return _authority; }
            set
            {
                if (_authority != value)
                {
                    RaisePropertyChanging<Uri>(() => Authority);
                    _authority = value;
                    RaisePropertyChanged<Uri>(() => Authority);
                }
            }
        }
        #endregion
 
        #region Subdirectory
        private string _subdirectory = null;
 
        /// <summary>
        /// Le sous-répertoire de base de la version locale de ce domaine, version fichier.
        /// </summary>
        [RegularExpression("^[^\\\\/:*\"<>|]+(?:(\\[^\\\\/:*\"<>|]+)*)\\?$", ErrorMessage = "Sous-répertoire non reconnu.")]
        public string Subdirectory
        {
            get { return _subdirectory; }
            set
            {
                if (_subdirectory != value)
                {
                    RaisePropertyChanging<string>(() => Subdirectory);
                    _subdirectory = value;
                    if (string.IsNullOrWhiteSpace(_subdirectory))
                    {
                        _subdirectory = string.Empty;
                    }
                    else
                    {
                        _subdirectory = _subdirectory.TrimEnd('\\') + '\\';
                    }
                    _virtualSubdirectory = _subdirectory.Replace('\\', '/');
                    RaisePropertyChanged<string>(() => Subdirectory);
                }
            }
        }
        #endregion
 
        #region VirtualSubdirectory
        private string _virtualSubdirectory = null;
 
        /// <summary>
        /// Le sous-répertoire de base de la version locale de ce domaine, version web.
        /// </summary>
        public string VirtualSubdirectory
        {
            get { return _virtualSubdirectory; }
        }
        #endregion
 
        #region Pages
        private bool _pages = false;
 
        /// <summary>
        /// Faut-il aspirer les pages de ce domaine ?
        /// </summary>
        public bool Pages
        {
            get { return _pages; }
            set
            {
                if (_pages != value)
                {
                    RaisePropertyChanging<bool>(() => Pages);
                    _pages = value;
                    RaisePropertyChanged<bool>(() => Pages);
                }
            }
        }
        #endregion
 
        #region Resources
        private bool _resources = false;
 
        /// <summary>
        /// Faut-il aspirer les ressources de ce domaine ?
        /// </summary>
        public bool Resources
        {
            get { return _resources; }
            set
            {
                if (_resources != value)
                {
                    RaisePropertyChanging<bool>(() => Resources);
                    _resources = value;
                    RaisePropertyChanged<bool>(() => Resources);
                }
            }
        }
        #endregion
    }
}

Il n’y a rien d’extraordinaire dans cette classe.
On peut éventuellement se demander pourquoi créer un Subdirectory et n’avoir pas repris l'idée du BaseDirectory présent dans la classe WebSiteModele ?
La raison est assez simple.
Nous souhaitons que le résultat de notre aspiration fournisse un site locale assez facile à manipuler, que ce soit pour regarder les pages ou pour les copier/déplacer.
Cela nous conduit à forcer l’utilisateur à tout regrouper dans un répertoire principal (celui qu’il a fourni initialement) et ces sous-répertoires. Cependant, pour éviter des collisions de noms, nous proposons à l’utilisateur de nous donner un nom de sous-répertoire que nous dédirons à ce domaine. Cela permet d'éviter d’utiliser le répertoire principal comme racine de ce nouveau domaine, tout en conservant la facilité de n'avoir à copier qu'un répertoire et tous ces sous-répertoires.
La présence de VirtualSubdirectory s’explique par la nécessité de disposer d’une version du nom du sous répertoire utilisable pour construire des URL locales qui soient relatives.

Nous ajoutons maintenant une liste de DomainModele à notre classe WebSiteModele. Pour retrouver facilement les domaines, nous les plaçons dans un dictionnaire. Cela conduit au code suivant (seuls les ajouts au code initial sont présentés) :

using System.Collections.Generic;
 
#region DomainList
private Dictionary<string, DomainModele> _domainList = new Dictionary<string,DomainModele>();
 
/// <summary>
/// La liste des domaines associés.
/// </summary>
public Dictionary<string, DomainModele> DomainList
{
    get { return _domainList; }
    set
    {
        if (_domainList != value)
        {
            RaisePropertyChanging<Dictionary<string, DomainModele>>(() => DomainList);
            _domainList = value;
            RaisePropertyChanged<Dictionary<string, DomainModele>>(() => DomainList);
        }
    }
}
#endregion

Revenons à la fonction ProcessUrl de la classe UrlFileMatcher et plus particulièrement à la section else du if (_leSite.BaseURL.IsBaseOf(absUrl)). Nous pouvons envisager pour ce else un squelette de la forme :

// Le domaine est-il connu ?
string authority = absUrl.GetLeftPart(UriPartial.Authority);
if (!_leSite.DomainList.ContainsKey(authority))
{
    // On demande à l’utilisateur ce que l'on doit faire.
    // Oui mais pour l'instant pas d'IHM.
    return false;
}
 
// Devons-nous traiter ?
DomainModele infos = _leSite.DomainList[authority];
if ((isPage && !infos.Pages) || (!isPage && !infos.Resources))
{
    // On ne doit pas traiter.
    return false;
}
 
// Construction de l'URL relative.
// Retour à la racine du site aspiré depuis la page courante.
Uri gotoRoot = _leSite.BaseVirtualDiretory.MakeRelativeUri(_currentUrl);
// Chemin relatif sur le domaine annexe de l'élément courant.
Uri gotoElement = infos.Authority.MakeRelativeUri(absFullUrl);
// On colle les deux morceaux...
 
// Construction du chemin complet local.
filePath = new Uri(_leSite.BaseDirectory, infos.Subdirectory);
filePath = new Uri(filePath, infos.Authority.MakeRelativeUri(absUrl));

Si vous avez lu attentivement le code, vous avez constaté qu'il manquait des morceaux :

  • Ne disposant pas d’une IHM dédié pour récupérer la volonté de l’utilisateur, la liste des domaines restera désespérément vide. Pour pouvoir tester nous allons donc la remplir artificiellement. Nous ajoutons à cet effet le code suivant :
    // On demande à l'utilisateur ce que l'on doit faire.
    // Pour test.
    if (authority == "https://storage.canalblog.com")
    {
        DomainModele pipo = new DomainModele(new Uri("https://storage.canalblog.com"), @"storage\", false, true);
        _leSite.DomainList.Add(authority, pipo);
    }
    else
        return false;
    
  • La classe Uri ne permet pas la concaténation de gotoRoot et gotoElement car gotoRoot est relative. Il nous faut donc coder nous-même cette opération. Nous pourrions ajouter la fonction dédiée à la classe Uri mais la logique voudrait alors que nous retournions une instance d’Uri. Cela pourrait conduire à la création/destruction de nombreuses instances intermédiaires d’Uri. Même si la performance n’est pas à proprement parler notre souci dans ces billets, nous allons éviter de saturer le ramasse-miettes. Nous ajoutons donc une méthode d’extension à la classe String. Par soucis de simplicité, la classe statique hôte est ajoutée, pour l'instant, au fichier source UrlFileMatcher.cs qui contient le code qui a besoin de cette fonctionnalité (seuls les ajouts au code initial sont présentés) :
    using System.Text;
     
    #region StringHelper
    /// <summary>
    /// Classe d'accueil pour les extensions de la classe String.
    /// </summary>
    public static class StringHelper
    {
        #region UriCombine
        /// <summary>
        /// Permet de combiner des URI, sous forme texte, même si la première n'est pas absolue.
        /// </summary>
        /// <param name="root">L'URI initiale.</param>
        /// <param name="separator">Le séparateur à utiliser dans l'URI.</param>
        /// <param name="elements">Les morceaux à ajouter.</param>
        /// <returns></returns>
        public static string UriCombine(this string root, char separator, params string[] elements)
        {
            if ((elements == null) || (elements.Length < 1))
                return root;
     
            string[] parts = new string[elements.Length + 1];
            int count = 0, i = 0;
            if (!string.IsNullOrWhiteSpace(root))
            {
                parts[count++] = root;
            }
            while (i < elements.Length)
            {
                if (!string.IsNullOrWhiteSpace(elements[i]))
                {
                    parts[count] = elements[i];
                    // A partir du second élément on enlève les séparateurs présents au début.
                    if (count == 0)
                    {
                        count++;
                    }
                    else
                    {
                        parts[count] = parts[count].TrimStart(separator);
                        if (!string.IsNullOrWhiteSpace(parts[count]))
                        {
                            count++;
                        }
                    }
                }
                i++;
            }
            // Aucun morceau non vide.
            if (count == 0)
            {
                return string.Empty;
            }
            // Un seul morceau non vide.
            else if (count == 1)
            {
                return parts[0];
            }
            // Un vrai tableau à traiter.
            // Sur tous les éléments sauf le dernier on enlève les séparateurs présents à la fin.
            for (i = 0; i < count - 1; i++)
            {
                parts[i] = parts[i].TrimEnd(separator);
            }
            // On concatène.
            StringBuilder path = new StringBuilder(parts[0]);
            for (i = 1; i < count; i++)
            {
                path.Append(separator);
                path.Append(parts[1]);
            }
            return path.ToString();
        }
        #endregion
    }
    #endregion
    
    Nous pouvons maintenant concaténer les deux morceaux :
    // Construction de l'URL relative.
    // Retour à la racine du site aspiré depuis la page courante.
    Uri gotoRoot = _leSite.BaseVirtualDiretory.MakeRelativeUri(_currentUrl);
    // Chemin relatif sur le domaine annexe de l'élément courant.
    Uri gotoElement = infos.Authority.MakeRelativeUri(absFullUrl);
    // Uri ne sait pas concaténer lorsque la première partie est une URL relative.
    // Du coup il faut passer par les concaténations de chaînes de caractères.
    relUrl = new Uri(gotoRoot.ToString().UriCombine('/', infos.VirtualSubdirectory, gotoElement.ToString()), UriKind.Relative);
    
  • La construction du chemin complet local est réalisée en deux étapes. La première, filePath = new Uri(_leSite.BaseDirectory, infos.Subdirectory);, peut sembler répétitive. En effet, pour chaque domaine connexe, elle fournira toujours le même résultat.
    Pourquoi ne pas avoir ajouté un membre à la classe DomainModele qui contienne cette information ?
    Cela nous aurait obligé à posséder dans DomainModele un lien vers l'instance courante de WebSiteModele, pour être en mesure de recalculer le chemin complet à chaque modification du sous-répertoire. Par ailleurs chaque instance de DomainModele devrait s'abonner aux notifications proposées par WebSiteModele, pour recalculer le chemin en cas de modification du répertoire de base dans WebSiteModele. Cela est un poil lourd, surtout si on envisage de traiter le cas de la désérialisation, et n'est pas mis en place ici. Evidemment, ceux qui cherchent les meilleures performances possibles, pourront ajouter le membre à DomainModele et gagner du temps à chaque passage dans cette branche du code.

Si nous lançons notre code sur "www.canalblog.com", nous pouvons constater que la récupération des images contenues dans "https://storage.canalblog.com" fonctionne.

Il ne nous reste plus qu'à demander son avis à l'utilisateur.
Facile à dire, et facile à réaliser, une simple petite boîte de dialogue modale et le tour est joué.
Certes, mais où mettons-nous le code de cette petite boîte de dialogue ?
Dans AspirateurUtil évidement, c'est AspirateurUtil qui en a besoin.
En fait, NON.
Indépendamment du fait que notre DLL AspirateurUtil n'a pas le bon type de projet, cela n'est pas forcément très heureux. Certes, nous ne sommes pas MVVM, mais tout de même.
Nous allons devoir faire un peu mieux et envisager de mettre cette vue, et oui, une boîte de dialogue est une vue, dans un endroit plus approprié. Avec les autres vues par exemple.
Cela nous conduit à une grande décision. MVVMisons nous complètement notre code ou pas ?
La tendance est au "oui". Problème, le billet risque d'atteindre une longueur record, même en s'appuyant sur le code de MVVM de la découverte à la maîtrise.
Dans ce billet nous allons donc préparer le terrain, en finissant notre couche Model qui n'est pas capable de sérialiser/désérialiser ces éléments.
En quoi cela nous aide-t-il pour notre problème ?
Nous pourrons modifier le fichier à la main et ainsi traiter tous les domaines connexes que nous voudrons. Evidemment, c'est moins souple qu'une IHM dédiée. Cela dit, c'est de toute façon nécessaire à terme, l'utilisateur ne souhaitant probablement pas devoir, à chaque récupération, dire ce qu'il compte faire pour chaque domaine rencontré.

Nous avons tout de même deux problèmes.

  • Premièrement, les données que nous avons placées dans le Model relèvent plus de la configuration que de données classiquement placées dans un Model. Ce problème existe depuis le début de notre programme, mais maintenant que nous désirons sauver nos données, il apparait que nous aimerions bien les mettre dans un fichier de configuration. Ce n'est pas exactement l'idée d'une couche Model classique et nous allons continuer comme s'il s'agissait d'une couche Model classique. Toutefois, nous n'allons pas pousser le bouchon jusqu'à demander la présence d'un SGBD sur le PC où notre programme sera installé. Pour stocker notre toute petite configuration, ce serait un peu exagéré. Nous allons utiliser un fichier au format XML. Fichier XML <=> fichier de configuration, nous ne serons pas si loin que ça.
  • Deuxième problème, le code de MVVM de la découverte à la maîtrise n'utilise pas les objets du modèle général au niveau de l'implémentation concrète du service d'accès aux données. En fait le projet MVVMReady.ModelGlobal est relativement décoratif et son code peu utilisable. Cela ne pose pas de problème à l'implémentation concrète du service d'accès aux données du livre, car elle utilise Entity Framework. La correspondance (mapping) est donc faite entre les objets de MVVMReady.Model et les objets créés dans Entity Framework. N'utilisant pas Entity Framework, nous ne pouvons pas nous passer de construire un vrai modèle globale.

Nous commençons par ajouter à notre solution, un projet de type "Bibliothèque de classes" que nous nommons Aspirateur.ModelGlobal. Nous renommons "Class1.cs" en "WebSite.cs" en acceptant la proposition faite par Visual Studio de renommer la classe) et ajoutons trois "classes", "IWebSite.cs", "Domain" et "IDomain". Bien que nous n'utilisions pas de SGBD, nous allons faire comme si et gérer un identifiant sur chaque élément. Cela ne pose pas de problème car nous avons laissé dans la classe ModeleBase l'identifiant que le code du livre possédait.
Nous avons toutefois un problème avec le type Uri. En théorie les classes d'Aspirateur.ModelGlobal doivent être sérialisables de manière standard, pour être échangeables avec n'importe qui. Cela implique une sérialisation XML ou SOAP. Nous avons déjà annoncé que nous utiliserons XML pour le format de nos fichiers. Dans ce contexte la classe Uri de .NET pose un problème et nous conduit à nous rabattre sur la valeur sûre String pour tout ce qui est sérilisation XML. Notons que grâce à l'astuce présentée dans la réponse principale de How to (xml) serialize a uri nous pouvons avoir les deux versions. Dans notre cas, proposer une version Uri n'est pas fondamental, car la couche présentation utilise les objets du model client, à savoir ceux de MVVMReady.Model. La mise en œuvre de l'astuce est plus faite pour nous faciliter les choses lors de la correspondance entre les objets globaux et ceux du Model qu'autre chose.
Le code des deux interfaces et des deux classes ne pose plus de problèmes particuliers :

using System;
using System.Collections.Generic;
 
namespace Aspirateur.ModelGlobal
{
    public interface IWebSite
    {
        /// <summary>
        /// L'identifiant du site web.
        /// </summary>
        Guid Id { get; set; }
 
        /// <summary>
        /// L'URL de base à aspirer.
        /// </summary>
        Uri BaseURL { get; set; }
 
        /// <summary>
        /// Le répertoire de base de la version locale du site.
        /// </summary>
        Uri BaseDirectory { get; set; }
 
        /// <summary>
        /// Le nom par défaut donné aux fichiers sans nom.
        /// </summary>
        string DefaultName { get; set; }
 
        /// <summary>
        /// L'extension par défaut donnée aux fichiers HTML locaux.
        /// - 0 => HTM
        /// - 1 => HTML
        /// </summary>
        int DefaultExtension { get; set; }
 
        /// <summary>
        /// La liste des domaines associés.
        /// </summary>
        Dictionary<string, Guid> DomainList { get; set; }
    }
}
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Xml.Serialization;
 
namespace Aspirateur.ModelGlobal
{
    public class WebSite : IWebSite
    {
        /// <summary>
        /// L'identifiant du site web.
        /// </summary>
        [XmlAttribute()]
        public Guid Id { get; set; }
 
        /// <summary>
        /// L'URL de base à aspirer.
        /// </summary>
        [XmlIgnore]
        public Uri BaseURL { get; set; }
 
        [XmlElement("BaseURL")]
        [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)]
        public string BaseURLString
        {
            get { return BaseURL == null ? string.Empty : BaseURL.ToString(); }
            set { BaseURL = string.IsNullOrWhiteSpace(value) ? null : new Uri(value); }
        }
 
        /// <summary>
        /// Le répertoire de base de la version locale du site.
        /// </summary>
        [XmlIgnore]
        public Uri BaseDirectory { get; set; }
 
        [XmlElement("BaseDirectory")]
        [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)]
        public string BaseDirectoryString
        {
            // Suivant la façon dont la sérialisation est effectuée le résultat
            // dans le fichier XML et à l'affichage diffère :
            // - ToString() conduit à file///D:/test/canalblog/
            // - LocalPath conduit à D:\test\canalblog\
            //get { return BaseDirectory == null ? string.Empty : BaseDirectory.ToString(); }
            get { return BaseDirectory == null ? string.Empty : BaseDirectory.LocalPath; }
            set { BaseDirectory = string.IsNullOrWhiteSpace(value) ? null : new Uri(value); }
        }
 
        /// <summary>
        /// Le nom par défaut donné aux fichiers sans nom.
        /// </summary>
        public string DefaultName { get; set; }
 
        /// 
        /// L'extension par défaut donnée aux fichiers HTML locaux.
        ///  - 0 => HTM
        ///  - 1 => HTML
        /// 
        public int DefaultExtension { get; set; }
 
        /// <summary>
        /// La liste des domaines associés.
        /// </summary>
        public Dictionary<string, Guid> DomainList { get; set; }
    }
}
using System;
 
namespace Aspirateur.ModelGlobal
{
    public interface IDomain
    {
        /// <summary>
        /// L'identifiant du site web.
        /// </summary>
        Guid Id { get; set; }
 
        /// <summary>
        /// Le nom du domaine.
        /// </summary>
        Uri Authority { get; set; }
 
        /// <summary>
        /// Le sous-répertoire de base de la version locale de ce domaine, version fichier.
        /// </summary>
        string Subdirectory { get; set; }
 
        /// <summary>
        /// Faut-il aspirer les pages de ce domaine ?
        /// </summary>
        bool Pages { get; set; }
 
        /// <summary>
        /// Faut-il aspirer les ressources de ce domaine ?
        /// </summary>
        bool Resources { get; set; }
    }
}
using System;
using System.ComponentModel;
using System.Xml.Serialization;
 
namespace Aspirateur.ModelGlobal
{
    public class Domain : IDomain
    {
        /// <summary>
        /// L'identifiant du site web.
        /// </summary>
        [XmlAttribute()]
        public Guid Id { get; set; }
 
        /// <summary>
        /// Le nom du domaine.
        /// </summary>
        [XmlIgnore]
        public Uri Authority { get; set; }
 
        [XmlElement("Authority")]
        [Browsable(false), EditorBrowsable(EditorBrowsableState.Never)]
        public string AuthorityString
        {
            get { return Authority == null ? string.Empty : Authority.ToString(); }
            set { Authority = string.IsNullOrWhiteSpace(value) ? null : new Uri(value); }
        }
 
        /// <summary>
        /// Le sous-répertoire de base de la version locale de ce domaine, version fichier.
        /// </summary>
        public string Subdirectory { get; set; }
 
        /// <summary>
        /// Faut-il aspirer les pages de ce domaine ?
        /// </summary>
        public bool Pages { get; set; }
 
        /// <summary>
        /// Faut-il aspirer les ressources de ce domaine ?
        /// </summary>
        public bool Resources { get; set; }
    }
}

Bien que cela ne saute pas forcement au yeux, nous avons un problème avec notre membre DomainList. En effet un Dictionary ne se sérialise pas en XML. Cela peut paraître surprenant, en tout cas cela surprend beaucoup de monde, mais c'est comme ça. Nous allons donc devoir faire notre propre Dictionary sérialisable que nous nommerons comme tout le monde SerializableDictionary (il y en a plusieurs de proposés sur le web, j'ai personnellement choisi celui de XML Serializable Generic Dictionary). Nous ajoutons donc une nouvelle classe SerializableDictionary à notre projet Aspirateur.ModelGlobal :

using System;
using System.Collections.Generic;
using System.Xml.Serialization;
 
namespace Aspirateur.ModelGlobal
{
    [XmlRoot("dictionary")]
    public class SerializableDictionary<TKey, TValue> : Dictionary<TKey, TValue>, IXmlSerializable
    {
        #region IXmlSerializable Members
        public System.Xml.Schema.XmlSchema GetSchema()
        {
            return null;
        }
 
        public void ReadXml(System.Xml.XmlReader reader)
        {
            XmlSerializer keySerializer = new XmlSerializer(typeof(TKey));
            XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue));
 
            bool wasEmpty = reader.IsEmptyElement;
            reader.Read();
 
            if (wasEmpty)
                return;
 
            while (reader.NodeType != System.Xml.XmlNodeType.EndElement)
            {
                reader.ReadStartElement("item");
 
                reader.ReadStartElement("key");
                TKey key = (TKey)keySerializer.Deserialize(reader);
                reader.ReadEndElement();
 
                reader.ReadStartElement("value");
                TValue value = (TValue)valueSerializer.Deserialize(reader);
                reader.ReadEndElement();
 
                this.Add(key, value);
 
                reader.ReadEndElement();
                reader.MoveToContent();
            }
            reader.ReadEndElement();
        }
 
        public void WriteXml(System.Xml.XmlWriter writer)
        {
            XmlSerializer keySerializer = new XmlSerializer(typeof(TKey));
            XmlSerializer valueSerializer = new XmlSerializer(typeof(TValue));
 
            foreach (TKey key in this.Keys)
            {
                writer.WriteStartElement("item");
 
                writer.WriteStartElement("key");
                keySerializer.Serialize(writer, key);
                writer.WriteEndElement();
 
                writer.WriteStartElement("value");
                TValue value = this[key];
                valueSerializer.Serialize(writer, value);
                writer.WriteEndElement();
 
                writer.WriteEndElement();
            }
        }
        #endregion
    }
}

Nous modifions en conséquence la définition de DomainList qui devient SerializableDictionary<string, Guid> DomainList dans l'interface et dans la classe. Nous pouvons également enlever le using System.Collections.Generic;.

Il nous faut maintenant réaliser le service qui va concrètement sérialiser nos données. Nous ajoutons donc à notre solution, un nouveau projet de type "Bibliothèque de classes", que nous nommons Aspirateur.ServicesXML. Nous y référençons les projets Aspirateur.Model et Aspirateur.ModelGlobal.
La filiation avec MVVM de la découverte à la maîtrise ne s'arrête pas aux noms, nous reprenons également la structure.
Nous ajoutons donc au projet Aspirateur.Model un répertoire Services dans lequel nous ajoutons un sous-répertoire Criteres. Nous recopions (attention aux namespace) depuis le code du livre les fichiers IServiceBase.cs et AsyncResponse.cs que nous mettons dans Services. CritereBase.cs est lui recopié dans Criteres.
Nous allons maintenant créer nos propres éléments.
Pour manipuler les sites web, nous allons prévoir un critère qui puisse porter sur le nom du domaine. Nous ajoutons donc une classe CritereWebSite au répertoire Criteres :

using System;
 
namespace Aspirateur.Model.Services
{
    public class CritereWebSite : CritereBase<CritereWebSite>
    {
        public string UrlSite { get; set; }
    }
}

Nous ajoutons maintenant une interface par élément manipulable dans notre projet. Cela donne une interface IWebSitesService et une interface IDomainsService :

using System;
 
namespace Aspirateur.Model.Services
{
    /// <summary>
    /// Service fournisseur de données relatives aux sites web aspirés.
    /// </summary>
    public interface IWebSitesService :
        IServiceBase<WebSiteModele, CritereWebSite>
    {
    }
}
using System;
 
namespace Aspirateur.Model.Services
{
    /// <summary>
    /// Service fournisseur de données relatives aux domaines liés.
    /// </summary>
    public interface IDomainsService :
        IServiceBase<DomainModele>
    {
    }
}

Nous sommes maintenant prêt pour l'implémentation concrète de notre service. Comme dans MVVM de la découverte à la maîtrise nous commençons par implémenter une classe de base qui servira pour nos différents services. Il se trouve que la sérialisation/désérialisation XML ne se passe pas complétement toute seule, même si le Framework .NET va faire quasiment tout le boulot grâce à la solution proposée dans How to read serialized XML into a XDocument by removing the BOM. Nous ajoutons donc une classe XmlHelpers, que nous plaçons dans le répertoire Base de notre projet Aspirateur.ServicesXML. Cette classe, et sa camarade StringHelpers, nous permettra de sérialiser/désérialiser nos données en une seule ligne :

using System;
using System.Text;
using System.Xml;
using System.Xml.Serialization;
using System.IO;
 
namespace Aspirateur.ServicesXML
{
    #region XmlHelpers
    public static class XmlHelpers
    {
        public static string RemoveUtf8ByteOrderMark(string xml)
        {
            string byteOrderMarkUtf8 = Encoding.UTF8.GetString(Encoding.UTF8.GetPreamble());
            if (xml.StartsWith(byteOrderMarkUtf8))
            {
                xml = xml.Remove(0, byteOrderMarkUtf8.Length);
            }
            return xml;
        }
 
        public static string SerializeObject<T>(object o)
        {
            MemoryStream ms = new MemoryStream();
            XmlSerializer xs = new XmlSerializer(typeof(T));
            XmlTextWriter xtw = new XmlTextWriter(ms, Encoding.UTF8);
            xs.Serialize(xtw, o);
            ms = (MemoryStream)xtw.BaseStream;
            return StringHelpers.UTF8ByteArrayToString(ms.ToArray());
        }
 
        public static T DeserializeObject<T>(string xml)
        {
            XmlSerializer xs = new XmlSerializer(typeof(T));
            MemoryStream ms = new MemoryStream(StringHelpers.StringToUTF8ByteArray(xml));
            XmlTextWriter xtw = new XmlTextWriter(ms, Encoding.UTF8);
            return (T)xs.Deserialize(ms);
        }
    }
    #endregion XmlHelpers
 
    #region StringHelpers
    public static class StringHelpers
    {
        public static String UTF8ByteArrayToString(Byte[] characters)
        {
            UTF8Encoding encoding = new UTF8Encoding(false);
            String constructedString = encoding.GetString(characters);
            return (constructedString);
        }
 
        public static Byte[] StringToUTF8ByteArray(String pXmlString)
        {
            UTF8Encoding encoding = new UTF8Encoding(false);
            Byte[] byteArray = encoding.GetBytes(pXmlString);
            return byteArray;
        }
    }
    #endregion StringHelpers
}

Maintenant que nous savons sérialiser en une seule ligne de code, nous pouvons écrire notre classe de base pour les services XML. Toutes les explications sur les fonctions présentes dans ce code, sont à chercher dans le livre MVVM de la découverte à la maîtrise :

using System;
using System.Collections.Generic;
using Aspirateur.Model.Services;
using Aspirateur.Model;
using System.Threading;
using System.Xml.Linq;
using System.IO;
using System.Linq;
 
namespace Aspirateur.ServicesXML
{
    public abstract class ServiceBase<TModelBase, TGlobal>
        : ServiceBase<TModelBase, TGlobal, CritereBase>
        where TModelBase : ModeleBase
        where TGlobal : class
    { }
 
    public abstract class ServiceBase<TModelBase, TGlobal, TCritere>
        : IServiceBase<TModelBase, TCritere>, IDisposable
        where TModelBase : ModeleBase
        where TGlobal : class
        where TCritere : CritereBase<TCritere>, new()
    {
        #region Membres
        private XDocument _xdoc;
        private readonly string _fileName = String.Empty;
        private readonly string _elementName = String.Empty;
        private readonly string _collectionName = String.Empty;
        #endregion Membres
 
        #region Proprietes
        protected XDocument XDoc
        {
            get { return _xdoc; }
        }
 
        protected SynchronizationContext SynchronizationContext
        {
            get { return SynchronizationContext.Current; }
        }
        #endregion Proprietes
 
        #region Constructeurs / destructeur
        protected ServiceBase()
        {
            _fileName = "Config.xml";
            _elementName = typeof(TGlobal).Name;
            _collectionName = _elementName + "s";
 
            if (File.Exists(_fileName))
            {
                _xdoc = XDocument.Load(_fileName);
            }
            else
            {
                CreateXDoc();
            }
        }
 
        public void Dispose()
        {
            // Tout est en mémoire, rien à faire.
        }
        #endregion Constructeurs / destructeur
 
        #region Méthodes abstraites
        protected abstract TGlobal MapToGlobal(TModelBase modele);
        protected abstract TModelBase MapToModel(TGlobal entity);
        protected abstract IQueryable<TGlobal> ObtenirConcret(TCritere critere);
        protected virtual List<string> ValidationALaMiseAJour(TModelBase valeurPrecedente, TModelBase valeurNouvelle)
        { return new List<string>(); }
        protected virtual List<string> ValidationALaCreation(TModelBase valeur)
        { return new List<string>(); }
        protected virtual List<string> ValidationALaSuppression(TModelBase aSupprimer)
        { return new List<string>(); }
        #endregion Méthodes abstraites
 
        #region IServiceBase
        public virtual bool Creer(TModelBase aCreer)
        {
            // Vérification de la validité de l'objet.
            var validationErrors = ValidationALaCreation(aCreer);
            if (validationErrors.Count > 0)
            {
                aCreer.RemplacerLesErreursDeValidationGlobales(validationErrors);
                return false;
            }
            return AddElement(aCreer);
        }
 
        public virtual bool MettreAJour(TModelBase aMettreAJour)
        {
            // Obtention de l'élément dans le XML.
            List<string> errors = null;
            IEnumerable<XElement> oldXml = GetXElement(aMettreAJour.Id, true, ref errors);
            // Il ne doit pas y en avoir plus d'un.
            if (errors != null)
            {
                // Ce n'est pas normal.
                aMettreAJour.RemplacerLesErreursDeValidationGlobales(errors);
                return false;
            }
            // Désérialisation.
            TGlobal oldValue = XmlHelpers.DeserializeObject<TGlobal>(oldXml.First().ToString());
            // Vérification de la validité de l'objet.
            var validationErrors = ValidationALaMiseAJour(MapToModel(oldValue), aMettreAJour);
            if (validationErrors.Count > 0)
            {
                aMettreAJour.RemplacerLesErreursDeValidationGlobales(validationErrors);
                return false;
            }
            // Mise à jour effective.
            oldXml.Remove<XElement>();
            return AddElement(aMettreAJour);
        }
 
        public virtual bool Supprimer(TModelBase aSupprimer)
        {
            // Vérification de la validité de l'objet.
            var validationErrors = ValidationALaSuppression(aSupprimer);
            if (validationErrors.Count > 0)
            {
                aSupprimer.RemplacerLesErreursDeValidationGlobales(validationErrors);
                return false;
            }
            // Obtention de l'élément dans le XML.
            List<string> errors = null;
            IEnumerable<XElement> xElts = GetXElement(aSupprimer.Id, false, ref errors);
            // Il ne doit pas y en avoir plus d'un.
            if (errors != null)
            {
                // Ce n'est pas normal.
                aSupprimer.RemplacerLesErreursDeValidationGlobales(errors);
                return false;
            }
            // Suppression effective.
            xElts.Remove<XElement>();
            return true;
        }
 
        public virtual TModelBase ObtenirUn(Guid idAObtenir)
        {
            // Obtention de l'élément dans le XML.
            List<string> errors = null;
            IEnumerable<XElement> xElts = GetXElement(idAObtenir, false, ref errors);
            if (xElts.Count() == 0)
            {
                return null;
            }
            // Désérialisation du premier (normalement, le seul) élément.
            TGlobal global = XmlHelpers.DeserializeObject<TGlobal>(xElts.First().ToString());
            return MapToModel(global);
        }
 
        public virtual List<TModelBase> Obtenir(TCritere critere)
        {
            IQueryable<TGlobal> obtenirConcret = this.ObtenirConcret(critere);
            if (critere.TailleLimite > 0)
            {
                obtenirConcret = obtenirConcret.Take(critere.TailleLimite);
            }
            var liste = obtenirConcret.ToList().Select(MapToModel);

            return liste.ToList();
        }
 
        public virtual bool ObtenirAsync(TCritere critere, Action<AsyncResponse, List<TModelBase>> callback)
        {
            // Obtention du thread UI.
            var UISyncContext = SynchronizationContext.Current;
 
            // Définition de l'appel.
            WaitCallback waitCallBack =
                param =>
                {
                    AsyncResponse response = new AsyncResponse();
                    List<TModelBase> retour = default(List<TModelBase>);
                    try
                    {
                        // On obtient la liste des éléments.
                        retour = Obtenir((TCritere)param);
                    }
                    catch (Exception e)
                    {
                        response.HasError = true;
                        response.ErrorMessage = e.Message;
                    }
                    finally
                    {
                        // On execute le callback sur le Thread UI.
                        UISyncContext
                            .Post(cParam => callback(response, (List<TModelBase>)cParam)
                            , retour);
                    }
                };
 
            // Lancement effectif du traitement.
            return ThreadPool.QueueUserWorkItem(waitCallBack, critere);
        }
 
        public void AppliquerLesChangements()
        {
            _xdoc.Save(_fileName);
        }
 
        public void AnnulerLesChangements()
        {
            if (File.Exists(_fileName))
            {
                _xdoc = XDocument.Load(_fileName);
            }
            else
            {
                CreateXDoc();
            }
        }
        #endregion IServiceBase
 
        #region Helper
        /// <summary>
        /// Créer un fichier XML minimal.
        /// </summary>
        private void CreateXDoc()
        {
            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);
        }
 
        /// <summary>
        /// Retrouve un noeud à l'aide de son type et de son identifiant unique.
        /// </summary>
        /// <param name="id">L'identifiant unique.</param>
        /// <param name="required">true si l'élément doit être trouvé.</param>
        /// <param name="errors">La liste des erreurs.</param>
        /// <returns>Les noeuds correspondants (en théorie 0 ou 1 noeud).</returns>
        protected IEnumerable<XElement> GetXElement(Guid id, bool required, ref List<string> errors)
        {
            var xElts = from c in _xdoc.Descendants(_elementName)
                        where c.Attribute("Id").Value == id.ToString()
                        select c;
 
            // 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;
        }
 
        /// <summary>
        /// Retrouve tous les noeuds d'un type.
        /// </summary>
        /// <returns>Les noeuds correspondants.</returns>
        protected IEnumerable<XElement> GetAllXElements()
        {
            return from c in _xdoc.Descendants(_elementName)
                   select c;
        }
 
        /// <summary>
        /// Ajoute un élément dans le fichier XML
        /// </summary>
        /// <param name="model">L'élément à ajouter.</param>
        /// <returns>true si ajout ok, false si pb</returns>
        protected bool AddElement(TModelBase model)
        {
            try
            {
                TGlobal global = MapToGlobal(model);
                string xml = XmlHelpers.SerializeObject<TGlobal>(global);
                xml = XmlHelpers.RemoveUtf8ByteOrderMark(xml);
                XElement xel = XElement.Parse(xml);
                var coll = _xdoc.Root.Element(_collectionName);
                if (coll == null)
                {
                    _xdoc.Root.Add(new XElement(_collectionName));
                    coll = _xdoc.Root.Element(_collectionName);
                }
                coll.Add(xel);
                model.AEteModifie = false;
                return true;
            }
            catch
            {
                return false;
            }
        }
 
        /// <summary>
        /// Méthodes d'aide permettant d'implémenter facilement un appel asynchrone.
        /// </summary>
        protected bool ExecuteAsync<TParam, TResult>(TParam parametre,
            Func<TParam, TResult> traitementCouteux,
            Action<AsyncResponse, TResult> callback,
            SynchronizationContext contextCallback)
        {
            // Définition de l'appel.
            WaitCallback waitCallBack =
                param =>
                {
                    AsyncResponse response = new AsyncResponse();
                    TResult retour = default(TResult);
                    try
                    {
                        // On obtient la liste des éléments.
                        retour = traitementCouteux((TParam)param);
                    }
                    catch (Exception e)
                    {
                        response.HasError = true;
                        response.ErrorMessage = e.Message;
                    }
                    finally
                    {
                        // On exécute la callback sur le Thread UI.
                        contextCallback
                            .Post(cParam => callback(response, (TResult)cParam)
                            , retour);
                    }
                };
 
            // Lancement effectif du traitement.
            return ThreadPool.QueueUserWorkItem(waitCallBack, parametre);
        }
        #endregion Helper
    }
}

Si vous avez regardé attentivement ce code, vous avez du remarquer qu'il utilise une structure bien particulière pour les données qui sont stockées dans le fichier XML.
La racine des données s'appelle, sans grande originalité, Root.
Sous Root, un nœud sera créé pour chaque type de données sérialisé. Si par exemple nous sérialisons des Truc, un nœud Trucs (avec un s) sera créé et tous les Truc en seront fils. Cela permet d'accélérer les recherches d'élément, et surtout de limiter le bordel, si plusieurs types d'objet sont placés dans le fichier.
Ce code fonctionne mais n'est pas complètement opérationnel, il présente des lacunes/problèmes.
Premier problème, le nom du fichier XML est codé en dur, _fileName = "Config.xml";. Il faudrait envisager quelque chose de nettement plus souple. Nous y reviendrons probablement et ce n'est pas très grave pour l'instant.
Deuxième problème, nettement plus grave, le XDocument n'est pas un singleton. Il en résulte que chaque classe dérivée manipulera sa propre version du fichier XML. Cela est très fâcheux. Compte tenu de la façon dont nous allons coder l'implémentation de IWebSitesService, et utiliser notre service d'accès aux données, cela ne nous posera pas de problème. Cela en poserait un gros si nous voulions vraiment avoir des implémentations de IWebSitesService et de IDomainsService qui coopèrent, comme le laisse présager la structure XML qui vient d'être décrite. Pour obtenir quelque chose de réellement utilisable, il faut absolument faire de l'instance de XDocumentun singleton et gérer correctement les accès multithreads. A vous de jouer...

Cela étant dit, nous allons faire comme si de rien était, et poursuivre notre implémentation en suivant les principes du livre. Nous allons commencer par traiter les objets liés aux domaines connexes rencontrés lors de notre aspiration. Nous ajoutons donc deux nouveaux codes sources, DomainsService.cs et DomainsService.Validation.cs à notre projet Aspirateur.ServicesXML. Pour que le code fonctionne, il faut également réaliser le mapping entre la version présente dans Aspirateur.Model et celle de Aspirateur.ModelGlobal. Nous ajoutons donc un répertoire Mappers au projet Aspirateur.ServicesXML et y créons une classe DomainMapper.cs. Le code de tout ce petit monde ne présente pas de difficulté :

using System;
using Aspirateur.Model;
using Aspirateur.ModelGlobal;
 
namespace Aspirateur.ServicesXML.Mappers
{
    /// <summary>
    /// Classe de conversion des données "Domain".
    /// /// </summary>
    public static class DomainMapper
    {
        /// <summary>
        /// Convertit un domaine du modèle global en domaine du modèle client.
        /// </summary>
        public static DomainModele ToDomainModele(this Domain global)
        {
            DomainModele domain = new DomainModele
            {
                Id = global.Id,
                Authority = global.Authority,
                Subdirectory = global.Subdirectory,
                Pages = global.Pages,
                Resources = global.Resources
            };
 
            return domain;
        }
 
        /// <summary>
        /// Convertit un domaine du modèle client en domaine du modèle global.
        /// </summary>
        public static Domain ToDomain(this DomainModele client)
        {
            Domain domain = new Domain
            {
                Id = client.Id,
                Authority = client.Authority,
                Subdirectory = client.Subdirectory,
                Pages = client.Pages,
                Resources = client.Resources
            };
 
            return domain;
        }
    }
}
using System;
using Aspirateur.Model;
using Aspirateur.ModelGlobal;
using Aspirateur.Model.Services;
using Aspirateur.ServicesXML.Mappers;
using System.Collections.Generic;
using System.Linq;
 
namespace Aspirateur.ServicesXML
{
    public partial class DomainsService
        : ServiceBase<DomainModele, Domain>
        , IDomainsService
    {
        #region Méthodes spécifiques à IWebSitesService
        #endregion Méthodes spécifiques à IWebSitesService
 
        #region Méthodes surchargée de ServiceBase
        protected override Domain MapToGlobal(DomainModele modele)
        {
            var global = modele.ToDomain();
            return global;
        }
 
        protected override DomainModele MapToModel(Domain global)
        {
            var client = global.ToDomainModele();
            return client;
        }
 
        protected override IQueryable<Domain> ObtenirConcret(CritereBase critere)
        {
            List<Domain> domains = new List<Domain>();
            var xElts = GetAllXElements();
            foreach (var item in xElts)
            {
                // Désérialisation.
                Domain d = XmlHelpers.DeserializeObject<Domain>(item.ToString());
                // Ajout de l'élément.
                domains.Add(d);
            }
            return domains.AsQueryable();
        }
        #endregion Méthodes surchargée de ServiceBase
    }
}
using System;
using System.Collections.Generic;
using Aspirateur.Model;
using System.Xml.Linq;
using System.Linq;
 
namespace Aspirateur.ServicesXML
{
    public partial class DomainsService
    {
        protected override List<string> ValidationALaCreation(DomainModele domain)
        {
            var errors = new List<string>();
            IEnumerable<XElement> xElts = GetXElement(domain.Id, false, ref errors);
            if (xElts.Count() == 1)
            {
                errors.Add("Un domaine avec cet identifiant existe déjà...");
                return errors;
            }
 
            return errors;
        }
    }
}

La partie qui traite des sites web présente, elle, une particularité qui fait que notre code va fonctionner correctement.
Comme nous l'avons dit précédemment, notre classe de base ne permet pas de traiter simultanément deux types d'objets, car chacun sera placé dans son XDocument. Pour ne pas avoir à faire un singleton, nous allons tricher.
Au lieu d'avoir dans notre classe WebSite un dictionnaire contenant des Guid,  nous allons mettre un dictionnaire contenant des Domain (pensez à modifier IWebSite.cs et WebSite.cs).
Cela change tout. Nous n'aurons plus besoin dans le mapping d'aller à la pêche aux Domain en utilisant le service qui leur est dédié (en utilisant le Guid comme clé). Par contre notre service d'accès aux éléments de type Domain devient sans objet et totalement inutilisable.
Dans ces conditions les classes WebSiteMapper et WebSitesServices'écrivent de la manière suivante :

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
            };
            foreach (var d in global.DomainList)
            {
                website.DomainList.Add(d.Key, d.Value.ToDomainModele());
            }
 
            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, Domain> domainList = new SerializableDictionary<string, Domain>();
            foreach (var d in client.DomainList)
            {
                domainList.Add(d.Key, d.Value.ToDomain());
            }
            website.DomainList = domainList;
 
            return website;
        }
    }
}
using System;
using Aspirateur.Model;
using Aspirateur.ModelGlobal;
using Aspirateur.Model.Services;
using Aspirateur.ServicesXML.Mappers;
using System.Collections.Generic;
using System.Linq;
 
namespace Aspirateur.ServicesXML
{
    public partial class WebSitesService : 
        ServiceBase<WebSiteModele, WebSite, CritereWebSite>,
        IWebSitesService
    {
        #region Méthodes spécifiques à IWebSitesService
        #endregion Méthodes spécifiques à IWebSitesService
 
        #region Méthodes surchargée de ServiceBase
        protected override WebSite MapToGlobal(WebSiteModele modele)
        {
            var global = modele.ToWebSite();
            return global;
        }
 
        protected override WebSiteModele MapToModel(WebSite global)
        {
            var client = global.ToWebSiteModele();
            return client;
        }
 
        protected override IQueryable<WebSite> ObtenirConcret(CritereWebSite critere)
        {
            List<WebSite> sites = new List<WebSite>();
            int max = critere.TailleLimite != 0 ? critere.TailleLimite : int.MaxValue;
            bool critere1 = !string.IsNullOrWhiteSpace(critere.UrlSite);
            var xElts = GetAllXElements();
            foreach (var item in xElts)
            {
                // Désérialisation.
                WebSite s = XmlHelpers.DeserializeObject<WebSite>(item.ToString());
 
                // Validation des critères.
                if (critere1)
                {
                    if (s.BaseURLString.Contains(critere.UrlSite))
                    {
                        // Ajout de l'élément.
                        sites.Add(s);
                    }
                }
                else
                {
                    // Pas de critère => ajout de l'élément.
                    sites.Add(s);
                }
                if (sites.Count == max)
                {
                    break;
                }
            }
            return sites.AsQueryable();
        }
        #endregion // Méthodes surchargée de ServiceBase
    }
}
using System;
using System.Collections.Generic;
using Aspirateur.Model;
using System.Xml.Linq;
using System.Linq;
 
namespace Aspirateur.ServicesXML
{
    public partial class WebSitesService
    {
        protected override List<string> ValidationALaCreation(WebSiteModele site)
        {
            var errors = new List<string>();
            IEnumerable<XElement> xElts = GetXElement(site.Id, false, ref errors);
            if (xElts.Count() == 1)
            {
                errors.Add("Un site web avec cet identifiant existe déjà...");
                return errors;
            }
 
            return errors;
        }
    }
}

Maintenant que nous avons un service de sérialisation opérationnel, nous allons pouvoir l'utiliser pour sauver, et charger, notre configuration. Pour cela nous allons modifier notre aspirateur pour le doter d'un menu Fichier. Ce menu ne contiendra que deux entrées opérationnelles (pour l'instant). Une pour sauver la configuration courante et une pour quitter le programme. Nous modifions donc le fichier MainWindow.xaml de notre projet Aspirateur (seul les modifications sont présentés) :

<Window.CommandBindings>
    <CommandBinding Command="ApplicationCommands.New" Executed="NewCmdExecuted" />
    <CommandBinding Command="ApplicationCommands.Open" Executed="OpenCmdExecuted" />
    <CommandBinding Command="ApplicationCommands.Save" Executed="SaveCmdExecuted" />
    <CommandBinding Command="ApplicationCommands.SaveAs" Executed="SaveAsCmdExecuted" />
</Window.CommandBindings>
<DockPanel LastChildFill="True">
    <Menu DockPanel.Dock="Top">
        <MenuItem Header='_Fichier'>
            <MenuItem Header="Nouveau..." Command="ApplicationCommands.New">
                <MenuItem.Icon>
                    <Image Source="Images\NewDocumentHS.png" />
                </MenuItem.Icon>
            </MenuItem>
            <MenuItem Header="Ouvrir..." Command="ApplicationCommands.Open">
                <MenuItem.Icon>
                    <Image Source="Images\OpenFile.png" />
                </MenuItem.Icon>
            </MenuItem>
            <MenuItem Header="Enregistrer" Command="ApplicationCommands.Save">
                <MenuItem.Icon>
                    <Image Source="Images\saveHS.png" />
                </MenuItem.Icon>
            </MenuItem>
            <MenuItem Header="Enregistrer sous..." Command="ApplicationCommands.SaveAs" />
            <Separator />
            <MenuItem Header="Quitter" InputGestureText="ALT+F4" Click="Quit_Click" />
        </MenuItem>
    </Menu>
    <Grid>
      ...
    </Grid>
</DockPanel>

Il faut ajouter au projet Aspirateur un répertoire Images et y mettre les trois images référencées dans le xaml. Les images que j’ai utilisées proviennent de Visual Studio et sont libres de droits (mais pas forcément les plus belles, et il en manque une).

Nous pouvons finir en modifiant le code de MainWindow.xaml.cs.
Déjà nous ajoutons deux références, une sur Aspirateur.ServicesXML et une sur Aspirateur.ModelGlobal. ainsi que le using Aspirateur.ServicesXML; de bon aloi. Nous pouvons maintenant ajouter un nouveau membre de type WebSitesService que nous initialisons et utilisons dans le constructeur. Nous l’utilisons également pour sauver notre configuration. Le nouveau code est le suivant :

using Aspirateur.Model.Services;
 
 
private WebSitesService _leService;
 
 
public MainWindow()
{
    InitializeComponent();
 
    // Initialisation du service.
    _leService = new WebSitesService();
    // Récupération du premier site web défini s'il y en a un.
    var liste = _leService.Obtenir(new CritereWebSite());
    if (liste.Count >= 1)
    {
        _leSite = liste[0];
    }
    else
    {
        _leSite = new WebSiteModele(new Uri("http://www.canalblog.com"), new Uri(@"D:\test\canalblog\"), "canalblog", Extension.HTM);
        _leService.Creer(_leSite);
    }
    // Initialisation du binding.
    DataContext = _leSite;
}
 
 
void SaveCmdExecuted(object target, ExecutedRoutedEventArgs e)
{
    _leService.MettreAJour(_leSite);
    _leService.AppliquerLesChangements();
}
 
private void Quit_Click(object sender, RoutedEventArgs e)
{
    Close();
}
 
void NewCmdExecuted(object target, ExecutedRoutedEventArgs e)
{
}
 
void OpenCmdExecuted(object target, ExecutedRoutedEventArgs e)
{
}
 
void SaveAsCmdExecuted(object target, ExecutedRoutedEventArgs e)
{
}

Si vous lancez le programme et cliquez sur le bouton "Aspire", pour initialiser un domaine lié, vous pouvez ensuite sauvegarder une configuration à l'aide du menu "Fichier"/"Enregistrer". Si vous modifiez le fichier ainsi obtenu, ce qui n'est pas très compliqué, vous pouvez ajouter d'autres domaines liés. Actuellement cela n'est pas très utile, car il n'y en a pas vraiment d'autres à traiter, mais c'est un progrès.

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

Révisez bien MVVM pour le prochain billet, ça pourrait servir.

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