Le PetShopDNG 2.0, l'architecture multi-tiers en action  par Thomas GIL (thomas.gil@valtech.fr)

Introduction

En début d'année 2003, le PetShopDNG 1.0 a posé les bases d'un modèle d'application à plusieurs couches logiques, déployé sur 3 couches physiques (navigateur Web, serveur Web, base de données).

A sa sortie, nombreux sont ceux parmi vous qui ont regretté que cette application "de référence" repose partiellement sur un produit commercial, Evaluant DTM (même si ce produit est récemment devenu semi-libre, comme vous avez pu le lire sur DotNetGuru).

D'autre part, nous étions nous-mêmes restés un peu sur notre faim puisque le PetShop 1.0 ne tirait pas parti de .NET Remoting, empêchant par là même tout déploiement en 4 couches physiques.

Cet article accompagne la sortie de la nouvelle mouture de notre petite application : le PetShopDNG 2.0. Il a donc les objectifs suivants :

Les amateurs d'ASP.NET, quant à eux, seront probablement déçus : la couche de présentation du PetShopDNG n'a pas évolué depuis la version 1.0. La raison est simple : les choix que nous avions faits à l'époque (janvier 2003) n'ont pas de raison d'être remis en cause pour le moment (même si certains experts ont pu nous faire part de leur opinion concernant le chargement dynamique de contrôles utilisateurs). L'événement qui nous fera revenir sur cette position sera probablement la sortie du framework ASP.NET 2.0.

Bref rappel de l'architecture du PetShopDNG 1.0

[Les lecteurs qui ont encore bien en tête cette architecture sont évidemment invités à reprendre leur lecture à la section suivante]

Le PetShop 1.0 était une petite application dans laquelle nous avons tenté de montrer comment bâtir une architecture multi-couches souple et évolutive. En partant du poste utilisateur, nous pouvons résumer les responsabilités de la manière suivante :

Le diagramme suivant reprend ces éléments de manière un peu plus synthétique :

Figure A : Architecture logique du PetShopDNG 1.0

 

Abstract Factory : un investissement gagnant

Toute l'implémentation du PetShopDNG 2.0 repose sur le Design Pattern Abstract Factory, qui constitue une véritable charnière. En effet, cette nouvelle version propose 4 implémentations différentes des couches service, métier et accès aux données, que nous reprendrons sous les acronymes BLL (Business Logic Layer) et DAL (Data Access Layer) :

Là où la beauté du Pattern Abstract Factory est resplendissante, c'est que pour passer d'une implémentation à l'autre, il suffit de modifier le fichier de configuration Web.config ! En effet, dès la modification des informations de configuration, le PetShopDNG détecte quelle nouvelle implémentation il doit offrir derrière les interfaces des couches BLL et DAL; la couche de présentation ASP.NET, elle, n'y voit que du feu puisque sa vision se limite à celle des fabriques abstraites et des interfaces des couches de service et d'objets du domaine.

Pour vous faire une idée des informations que requiert le mécanisme de configuration dynamique (toujours basé sur la Reflexion .NET), voici le contenu du fichier Web.config :

 

<?xml version="1.0" encoding="utf-8" ?>
<configuration>
    <!--
        Application-level settings include
        dsn : the database connection string with N-tier architecture.

        abstractFactoryClass : the abstract factory implementation
        that will instanciate classes that implement the BLL interfaces.
        abstractFactoryAssembly : the assembly in which this factory implementation
        is located.

        distributed : are presentation (asp.net) and business (BLL / DAL) layers distributed
        across a network ?
        protocol : if layers are distributed, they can use tcp (.NET remoting binary connected
        protocol) or http (WebServices, SOAP over HTTP) to communicate
        server : on which server does the BLL / DAL implementation run ?
        portNumber : on which server does the BLL / DAL implementation listen ?

    -->

    <!--
        Settings for DotNetGuru implementation
        with N-tier architecture
        and hand-made persistence framework

    -->
    <appSettings>
        <add key="dsn" value="server=localhost;database=PSDNG;UID=sa;PWD=" />

        <add key="abstractFactoryClass" value="PetShopDNG.BLL.DngImpl.DngAbstractFactory" />
        <add key="abstractFactoryAssembly" value="PetShopDNGCore_Dng" />

        <add key="distributed" value="true" />
        <add key="protocol" value="tcp" />
        <add key="server" value="localhost" />
        <add key="portNumber" value="9000" />
    </appSettings>

    <!--
        Settings for DotNetGuru implementation
        without N-tier architecture
        and hand-made persistenceframework
       <appSettings><br>       <addkey="dsn"value="server=localhost;database=PSDNG;UID=sa;PWD=" />

        <add key="abstractFactoryClass" value="PetShopDNG.BLL.DngImpl.DngAbstractFactory" />
        <add key="abstractFactoryAssembly" value="PetShopDNGCore_Dng" />

        <add key="distributed" value="false" />
        </appSettings>

    -->

    <!--
        Settings for Evaluant DTM implementation
        without N-tierarchitecture
       <appSettings><br>       <addkey="dsn"value="server=localhost;database=PSDNG;UID=sa;PWD=" />

        <add key="abstractFactoryClass" value="PetShopDNG.BLL.DtmImpl.DtmAbstractFactory" />
        <add key="abstractFactoryAssembly" value="PetShopDNGCore_Dtm" />

        <add key="distributed" value="false" />
        </appSettings>

    -->

    <!--
        Settings for Norpheme implementation
        without N-tierarchitecture
       <appSettings><br>       <addkey="dsn"value="server=localhost;database=PSDNG;UID=sa;PWD=" />

        <add key="abstractFactoryClass" value="PetShopDNG.BLL.NorImpl.NorAbstractFactory" />
        <add key="abstractFactoryAssembly" value="PetShopDNGCore_Norpheme" />

        <add key="distributed" value="false" />
        </appSettings>

    -->



    <!-- Standard ASP.NET web settings -->
    <system.web>
        <compilation defaultLanguage="c#" debug="true"/>
        <customErrors mode="Off"/>
        <authentication mode="None" />
        <trace enabled="false" requestLimit="10" pageOutput="true"
            traceMode="SortByTime" localOnly="true"/>
        <sessionState mode="InProc" stateConnectionString="tcpip=127.0.0.1:42424"
            sqlConnectionString="data source= 127.0.0.1;userid=sa;password="
            cookieless="false" timeout="20"/>
        <globalization requestEncoding="utf-8" responseEncoding="utf-8"/>
    </system.web>
</configuration>

Web.config

 

Résultat des courses, il faut se forcer à ne manipuler que des interfaces depuis la couche de présentation, passer systématiquement par l'intermédiaire de fabriques abstraites et implémenter les interfaces dans les couches d'implémentation BLL et DAL :

Couche de persistance faite maison

Comment implémenter une couche d'accès aux données à la fois simple, souple et efficace ? Voici les choix de conception que nous avons faits dans le PetShopDNG 2.0 :

[Remarque : le code des objets métier est un peu alourdi par le fait qu'il faille invoquer LoadObject(..) dans tous les accesseurs de propriétés. Dans un monde idéal, c'est-à-dire si le projet AspectDNG était arrivé à maturité assez tôt, nous aurions pu simplifier ce code en greffant l'invocation de LoadObject(..) à chaque début de corps d'accesseur, autre que ceux de la propriété Id. Ce n'est que partie remise.]

Le diagramme de classes correspondant est très simple :

Figure B : Classes correspondant à  l'implémentation "cousue main" de la couche d'accès aux données

 

Le code des objets métier est très simple. Mais il n'en va pas de même pour leur Factories. Elles ont en effet la lourde tâche de réaliser le mapping entre la représentation Objet en mémoire des informations et la représentation relationnelle en base de données.

La gestion des aspects techniques nécessite une attention particulière. Nous nous sommes rapidement rendu compte qu'il était dangereux de dupliquer du code d'ouverture / fermeture des connexions à la base de données, de même qu'il était subtil de propager systématiquement les transactions à travers toutes les méthodes de nos fabriques. Le risque, en effet, était de ne pas gérer ces ressources de manière uniforme à travers le projet. Il a donc fallu factoriser (sans mauvais jeu de mot) ce code technique.

Mais ce n'est pas tout : il est évident que les différentes fabriques (de Catégories, de Produits, d'Items...) allaient se ressembler énormément. Chacune devrait être capable d'exécuter des requêtes SQL d'insertion, modification, suppression dans la base de données, une requête de recherche par clé primaire, et éventuellement quelques recherches sur d'autres critères. Si l'on y réfléchit bien, les besoins sont assez simples pour une factory :

Afin de simplifier (énormément) le code de chaque fabrique, nous avons donc décidé de développer une fabrique générique, qui ouvrirait les connexions, débuterait les transactions, etc... et délèguerait aux fabriques concrètes les responsabilités suivantes :

L'organisation des classes Factories et utilitaires (commande et transaction génériques) est donc la suivante. Pour l'instant, ne faites pas attention à la partie distribuée : nous y reviendrons dans une prochaine section.

Figure C : Les factories de l'implémentation "cousue main" de la couche d'accès aux données

 

Quelques précisions s'imposent. Tout d'abord, la GenericCommand : le principal intérêt de cette classe est de simplifier l'ajout de paramètres à une IdbCommand. Au lieu d'avoir à instancier un paramètre supplémentaire, à lui préciser son nom, son type et sa valeur, et à l'ajouter à la liste des paramètres de la commande, il suffit avec la GenericCommand d'utiliser la notation suivante :

maCommandeGenerique["nomParam"] = valeurParam;

 

Et d'autre part, il faudrait détailler le comportement de la GenericFactory pour bien se rendre compte de ce qui a été mis en facteur. Pour cela, rien ne vaut la lecture de son code source : le voici.

 

namespace PetShopDNG.DAL.DngImpl.Factories {
   
using System.Collections;
   
using System;
    using System.Data;
   
using PetShopDNG.DAL;

    public abstract class GenericFactory : MarshalByRefObject {
       
// SQL queries will be set in concrete factories constructors
       
protected string sqlStore;
       
protected string sqlUpdate;
       
protected string sqlDelete;
       
protected string sqlSelectById;
       
protected string sqlCount;

        // O/R mapping methods will be overridden in concrete factories
       
protected abstract IPersistent CreateBlankObject();
       
protected abstract void ObjectToCommandParameters(object obj, GenericCommand cmd);
       
protected abstract void DataReaderRowToObject(IDataReader reader, object obj, GenericTransaction tx);
       
// Technical internal services

        private IPersistent DataReaderRowToObject(IDataReader reader, GenericTransaction tx){ 
           
IPersistent obj = CreateBlankObject();
        
    DataReaderRowToObject(reader, obj, tx);
           
return obj;
        }

        private Random rndGen = new Random();
        private string GenerateId(){
             return Guid.NewGuid().ToString();
        }

        // Public methods

        public static GenericTransaction BeginTransaction(){
             return new GenericTransaction();
        }

        // Query methods
       
public virtual void Store(object obj, GenericTransaction tx){
             if (obj != null){
                  bool txWasNull = (tx == null);
                   if (txWasNull) tx = BeginTransaction();
                   GenericCommand cmd = new GenericCommand(tx);
                   cmd.CommandText = sqlStore;
                  IPersistent pobj = (IPersistent) obj;
                   pobj.Id = GenerateId();
                   ObjectToCommandParameters(pobj, cmd);

                int nbModified = cmd.ExecuteNonQuery();
                   if (txWasNull) tx.Commit();
            }
        }

        public virtual void Update(object obj, GenericTransaction tx){
             if (obj != null){
                  bool txWasNull = (tx == null || tx.IsEnded);
                  if (txWasNull) tx = BeginTransaction();
                  GenericCommand cmd = new GenericCommand(tx);
                  cmd.CommandText = sqlUpdate;
        
        ObjectToCommandParameters(obj, cmd); 
        
        int nbModified = cmd.ExecuteNonQuery();
        
        if (txWasNull) tx.Commit();
            }
        }

        public virtual void Delete(object obj, GenericTransaction tx){
                  if (obj != null){
                     bool txWasNull = (tx == null || tx.IsEnded);
                      if (txWasNull) tx = BeginTransaction();
                      IPersistent pobj = (IPersistent) obj;
                      
GenericCommand cmd = new GenericCommand(tx);
                     
cmd.CommandText = sqlDelete;
                     
cmd["@id"] = pobj.Id;
                     
int nbModified = cmd.ExecuteNonQuery();
                     
if (txWasNull) tx.Commit();
              }
        }

        public int Count(GenericTransaction tx){
        //...
        }

        public virtual void LoadObject(object obj, GenericTransaction tx){
              
bool txWasNull = (tx == null || tx.IsEnded);
              
if (txWasNull) tx = BeginTransaction();
              
IPersistent pobj = (IPersistent) obj;
             
GenericCommand cmd = new GenericCommand(tx);
             
cmd["@id"] = pobj.Id;
             
cmd.CommandText = sqlSelectById;
             
using(IDataReader reader = cmd.ExecuteReader()){
                 
if (reader.Read()){
                 
DataReaderRowToObject(reader, obj, tx);
                 
tx[pobj.Id] = pobj;
              }
             
reader.Close();    
              }

             if (txWasNull) tx.Commit();
             
if (pobj != null){
              pobj.IsTotallyLoaded = true;
              }
        }

        protected object FindUnique(string sqlString, GenericTransaction tx){
             
bool txWasNull = (tx == null || tx.IsEnded);
             
if (txWasNull) tx = BeginTransaction();
             
IPersistent result = null;
             
GenericCommand cmd = new GenericCommand(tx);
             
cmd.CommandText = sqlString;
             
using(IDataReader reader = cmd.ExecuteReader()){
                    
if (reader.Read()){
                     
result = DataReaderRowToObject(reader, tx);
                     
tx[result.Id] = result;
                  }
              }

            if (txWasNull) tx.Commit();
             
if (result != null){
                 
result.IsTotallyLoaded = true;
              }
             
return result;
        }
    }
}

GenericFactory.cs

 

Grâce à la mise en facteur de ces aspects techniques dans la GenericFactory, le code des fabriques concrètes est assez simple, jugez plutôt :

 

namespace PetShopDNG.DAL.DngImpl.Factories {
   
using System.Data;
   
using PetShopDNG.DAL;
   
public class AccountFactory : GenericFactory {
   
private static AccountFactory instance = new AccountFactory();
   
public static AccountFactory Instance { get{return instance;} }

   
private AccountFactory(){
       
sqlStore = @"INSERT INTO Account 
            (id, login, password, firstname, lastname, streetaddress, 
            postalcode, city, telephonenumber, email, iwantpettips, iwantmylist, 
            favoritelanguage, fk_creditcard, fk_favoritecategory) 
            VALUES 
            (@id, @login, @password, @firstname, @lastname, @streetaddress, 
            @postalcode, @city, @telephonenumber, @email, @iwantpettips, @iwantmylist, 
            @favoritelanguage, @fk_creditcard, @fk_favoritecategory)"
;

        sqlUpdate = @"UPDATE Account SET
            login = @login, password = @password, 
            firstname = @firstname, lastname = @lastname, 
            streetaddress = @streetaddress, postalcode = @postalcode, city = @city, 
            telephonenumber = @telephonenumber, email = @email, 
            iwantpettips = @iwantpettips, iwantmylist = @iwantmylist, 
            favoritelanguage = @favoritelanguage, fk_creditcard = @fk_creditcard, 
            fk_favoritecategory = @fk_favoritecategory 
            WHERE 
            id = @id"
;

        sqlDelete = "DELETE FROM Account WHERE id = @id";
       
sqlSelectById = "SELECT * FROM Account WHERE id = @id";
       
sqlCount = "SELECT COUNT(*) FROM Account";
    }

     // Redefine O/R mapping methods
    protected override IPersistent CreateBlankObject(){ return new Account(); }
    protected override void ObjectToCommandParameters(object obj, GenericCommand cmd){
            Account account = (Account) obj;
            cmd["@id"] = account.Id;
            cmd["@login"] = account.Login;
            cmd["@password"] = account.Password;
            cmd["@firstname"] = account.FirstName;
            cmd["@lastname"] = account.LastName;
            cmd["@streetaddress"] = account.StreetAddress;
            cmd["@postalcode"] = account.PostalCode;
            cmd["@city"] = account.City;
            cmd["@telephonenumber"] = account.TelephoneNumber;
            cmd["@email"] = account.EMail;
            cmd["@iwantpettips"] = account.IWantPetTips;
            cmd["@iwantmylist"] = account.IWantMyList;
            cmd["@favoritelanguage"] = account.FavoriteLanguage;
            cmd["@fk_creditcard"] = (account.CreditCard != null) ? account.CreditCard.Id : null;
           
cmd["@fk_favoritecategory"] = (account.FavoriteCategory != null) ? account.FavoriteCategory.Id : null;
        }

        protected override void DataReaderRowToObject(IDataReader reader, object obj, GenericTransaction tx){
            Account account = (Account) obj;
            account.Id = reader["id"] as string;
            account.Login = reader["login"] as string;
           
account.Password = reader["password"] as string;
           
account.FirstName = reader["firstname"] as string;
           
account.LastName = reader["lastname"] as string;
            account.StreetAddress = reader["streetaddress"] as string;
            account.PostalCode = reader["postalcode"] as string;
            account.City = reader["city"] as string;
            account.TelephoneNumber = reader["telephonenumber"] as string;
            account.EMail = reader["email"] as string;
            account.IWantPetTips = (bool) reader["iwantpettips"];
            account.IWantMyList = (bool) reader["iwantmylist"];
            account.FavoriteLanguage = reader["favoritelanguage"] as string;
           
string creditCardId = reader["fk_creditcard"] as string;

            if (creditCardId != null) account.CreditCard = CreditCardFactory.Instance.FindById(creditCardId, tx);
           
string favoriteCategoryId = reader["fk_favoritecategory"] as string;
           
if (favoriteCategoryId != null) account.FavoriteCategory = CategoryFactory.Instance.FindById(favoriteCategoryId, tx);
        }

        // Override methods that modify database state to handle dependant objects
        public override void Store(object obj, GenericTransaction tx){
            CreditCardFactory.Instance.Store(((Account) obj).CreditCard, tx);

            base.Store(obj, tx);
        }

        public override void Delete(object obj, GenericTransaction tx){
            CreditCardFactory.Instance.Delete(((Account) obj).CreditCard, tx);
            base.Delete(obj, tx);
        }

        public override void Update(object obj, GenericTransaction tx){
            CreditCardFactory.Instance.Update(((Account) obj).CreditCard, tx);
             base.Update(obj, tx);
        }

        // Find methods

        public Account FindById(string id, GenericTransaction tx){
            Account result = null;
           
if (tx != null) result = tx[id] as Account;
           
return (result != null) ? result : new Account(id, tx);
        }

        public Account FindByLogin(string login, GenericTransaction tx){
            const string sql = "SELECT * FROM ACCOUNT WHERE login = '{0}'";
           
return (Account) FindUnique(string.Format(sql, login), tx);
        }
    }
}

 

AccountFactory.cs

 

Bien sûr, il est possible de rendre notre couche d'accès aux données encore plus générique, soit en utilisant la réflexion (comme Norpheme), soit en procédant par génération de code (comme DTM). Mais dans ce cas, la couche d'accès aux données devient un développement de framework, ce que nous nous sommes interdits dans le cadre de cette implémentation manuelle, ou une simple utilisation d'un framework de mapping Objet/Relationnel, ce qui est l'objectif des implémentations sur Norpheme et DTM.

Problème d'interopérabilité des outils de mapping O/R

Nous avons mentionné dans les sections précédentes que le pattern Abstract Factory masquait complètement l'implémentation des couches DAL et BLL. C'est exact, mais nous avons tout de même rencontré un problème de taille lors du passage d'une implémentation à l'autre de ces couches.

En effet, les outils de mapping Objet/Relationnel que nous avons utilisés pour réaliser les implémentations de la DAL (DTM et Norpheme) supposent tous deux que nos objets C# disposent d'un identifiant unique, accessible via une propriété, et qui correspond à une clé primaire dans la base de données. Jusque là, aucun problème. Mais ce qui est bien plus gênant, c'est que :

Dès lors, les choix étaient assez limités :

Afin de pouvoir changer d'implémentation de manière très souple au niveau de notre Abstract Factory, et de limiter l'effort d'administration de la base, nous avons choisi la deuxième option. Chaque table dispose d'une clé entière, et d'une clé textuelle. Mais bien entendu, ces clés n'ont aucune corrélation, et ne sont donc pas synchronisées.

Pour résoudre ce problème, nous avons implémenté un nouveau cas d'utilisation : la "migration des données", qui uniformise les clés et les relations à travers nos tables. Ce choix nécessaire ne nous satisfait toutefois pas complètement, car cela signifie qu'à un instant donné, deux serveurs Web différents ne peuvent pas choisir d'utiliser l'un Norpheme et l'autre DTM simultanément ! Il existe donc bel et bien un couplage entre notre application et la couche d'accès aux données.

Bref, nous restons sur notre faim, et avons été assez déçus de cette contrainte imposée par les outils de mapping O/R : on serait en droit d'attendre de tels outils qu'ils proposent de gérer des clés de tous types, entiers, chaînes, clés agrégées correspondant à plusieurs colonnes d'une table... Et que tout cela soit configurable à souhait (typiquement, que le choix ne soit pas global à un projet, mais qu'il puisse être revu au cas par cas, objet par objet). Gageons que DTM, Evaluant et leurs concurrents intègrent ce besoin dans les mois qui viennent.

Couche de services, et pattern DTO

La version précédente du PetShopDNG ne tirait pas parti du framework .NET Remoting. D'un point de vue opérationnel, cela coupait court à tout espoir de distribuer sur des machines différentes la couche de présentation d'une part, et les couches BLL et DAL d'autre part. Et d'un point de vue didactique, cela rendait le PetShopDNG incomplet, puisqu'il n'offrait aucune "bonne pratique" concernant la distribution. Le PetShopDNG 2.0 comble cette lacune, en rendant la couche de services distribuée.

Nous avons donc dû faire des choix de conception, et en particulier choisir QUOI distribuer, et QUE passer par valeur entre les couches distribuées. Les réponses à ces deux questions sont venues de manière complètement naturelle, tant cet aspect a été étudié ces dernières années. Donc sans surprise :

Contrôleurs de cas d'utilisation distribués

Le framework .NET Remoting est très souple : il suffit de le configurer, et en particulier de préciser l'adresse des objets distribués, pour que l'opérateur new instancie un proxy et non l'objet lui-même côté client. Cela nous a aidés à faire en sorte que la couche de présentation ne sache pas si elle manipule des contrôleurs de cas d'utilisation locaux, ou simplement des proxies référençant des contrôleurs qui s'exécutent sur une autre machine. La configuration du framework se fait, bien entendu, dans la classe centrale du PetShopDNG : l'AbstractFactory, dont voici le code source

 

namespace PetShopDNG.BLL.DngImpl {
    using System.Runtime.Remoting;
    using PetShopDNG.BLL;
   
using PetShopDNG.DAL.DngImpl.Factories;

    public class DngAbstractFactory : AbstractFactory{
       
private IAuthenticationController authenticationCtrl;
       
private ISearchController searchCtrl;
       
private IShoppingController shoppingCtrl;
       
private ITestController testCtrl;
       
private IDataMigrationController dataMigrationCtrl;
       
private RemoteFactory remoteFactory;

        public DngAbstractFactory(){
           
if (AbstractFactory.IsDistributed){
            
    string remoteAddress = string.Format("{0}://{1}:{2}/",
            
        AbstractFactory.Protocol,
                    
AbstractFactory.Server,
                     
AbstractFactory.PortNumber);

                 RemotingConfiguration.RegisterWellKnownClientType
                     
(typeof(PetShopDNG.BLL.DngImpl.AuthenticationController), 
            
        remoteAddress + "AuthenticationCtrl");

                RemotingConfiguration.RegisterWellKnownClientType
                    
(typeof(PetShopDNG.BLL.DngImpl.SearchController),
                    
remoteAddress + "SearchCtrl");

                RemotingConfiguration.RegisterWellKnownClientType
                     
(typeof(PetShopDNG.BLL.DngImpl.ShoppingController),
                    
remoteAddress + "ShoppingCtrl");

                RemotingConfiguration.RegisterWellKnownClientType
                     
(