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
                     
(typeof(PetShopDNG.BLL.DngImpl.TestController),
                    
remoteAddress + "TestCtrl");

                RemotingConfiguration.RegisterWellKnownClientType
                     
(typeof(PetShopDNG.BLL.DngImpl.DataMigrationController),
                    
remoteAddress + "DataMigrationCtrl");

                RemotingConfiguration.RegisterWellKnownClientType
                    
(typeof(PetShopDNG.DAL.DngImpl.Factories.RemoteFactory),
                    
remoteAddress + "RemoteFactory");
               }

            try{
               
authenticationCtrl = new AuthenticationController(); 
               
searchCtrl = new SearchController(); 
               
shoppingCtrl = new ShoppingController(); 
               
testCtrl = new TestController(); 
               
dataMigrationCtrl = new DataMigrationController();
               
remoteFactory = new RemoteFactory(); 
            }
           
catch(System.Exception ex){
               
throw new System.Exception("Problem in distribution settings", ex);
            }
      }

        public override IAuthenticationController AuthenticationController {
           
get{ return authenticationCtrl; }

        }

        public override ISearchController SearchController {
           
get{ return searchCtrl; }
        }

        public override IShoppingController ShoppingController {
           
get{ return shoppingCtrl; }
        }

       
public override ITestController TestController {
           
get{ return testCtrl; }
        }

        public override IDataMigrationController DataMigrationController {
        
    get{ return dataMigrationCtrl; }
        }

        public RemoteFactory RemoteFactory {
           
get{ return remoteFactory; }
        }

    }
}

DNGAbstractFactory.cs

 

Objets passés par valeur et lazy loading

Les paramètres et valeurs de retour des services offerts par les contrôleurs de cas d'utilisation doivent être soit des objets sérialisables, soit des références (des proxies) vers d'autres objets distribués. Pour éviter de multiplier les invocations de méthodes à distance, nous avons choisi de passer les objets métier par valeur plutôt que par référence distribuée. Donc tous les objets implémentant les interfaces de la couche DAL ont été rendus sérialisables.

Mais d'un autre côté, notre framework de persistance part du principe que l'état de ces objets est chargé à la demande (lazy loading) lors de la première lecture d'une propriété autre que l'identifiant de l'objet en question. Ce qui nous amène à un problème épineux : comment implémenter le chargement dynamique des objets à travers le réseau ?

Pour résoudre ce problème, on peut imaginer qu'une page ASPX ou un contrôle ASCX qui manipule un objet métier non encore chargé demande à une factory distribuée de lui renvoyer le même objet, mais complètement chargé. Cette approche est simple, efficace, mais impose à la couche de présentation d'être consciente de la stratégie de chargement des données par le framework de persistence ! Une véritable hérésie...

Au lieu de véhiculer à travers le réseau les objets métier eux-mêmes, on pourrait ne véhiculer que des structures qui représentent l'état des objets métier. Ainsi, un objet non encore chargé pourrait demander de lui-même à la factory distribuée de lui renvoyer les valeurs de chacun de ses champs, en fournissant en paramètre son identifiant. A la réception de la copie de son état, il ne ferait qu'affecter les valeurs reçues à ses propres attributs; ainsi les utilisateurs des objets métier n'y verraient que du feu, et ne seraient pas conscients de la mécanique interne du chargement à la demande des objets métier.

Mais ce choix de conception implique la nécessité de développer une nouvelle classe, ou une structure, pour chaque objet métier. Ce qui est assez astreignant (nous devons déjà maintenir les interfaces de la DAL et leurs implémentation). Ne serait-il pas possible de développer un composant suffisamment générique pour stocker n'importe quel état d'objet, et de faire en sorte de le créer côté serveur, et de l'utiliser côté client dans trop d'effort de développement ?

Nous avons fait le choix de sacrifier un peu les performances au profit de la maintenabilité du PetShopDNG 2.0. Au lieu de créer un objet pour porter l'état de chaque objet métier, nous utilisons une table d'association (Hashmap) qui fait le lien entre le nom et la valeur de chaque attribut d'objet métier. Il ne reste plus qu'à simplifier la programmation :

Pour cela, nous avons eu recours à la Reflection, qui rend triviale la production et l'extraction des données de la Hashmap. Pour vous faire une idée du comportement de cet objet générique, que nous avons nommé GenericDTO, jetons un oeil à son code source :

 

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

    [Serializable]
   
public class GenericDTO{
       
private IDictionary values;
       
public GenericDTO(object o){
           
Console.WriteLine("Lasy load object : " + o);
           
values = new Hashtable();
           
foreach (PropertyInfo pi in o.GetType().GetProperties()){
               
values[pi.Name] = pi.GetValue(o, null);
            }
        }
       
public void Fill(object o){
           
foreach (PropertyInfo pi in o.GetType().GetProperties()){
               
object val = values[pi.Name];
               
pi.SetValue(o, val, null);
            }
        }
    }
}

GenericDTO.cs

 

Et côté serveur, la fabrique distribuée génère automatiquement une instance de GenericDTO pour chaque objet métier qui en fait la demande (en fournissant son identifiant unique) :

 

namespace PetShopDNG.DAL.DngImpl.Factories {
    using System;
   
using System.Collections;
    public class RemoteFactory : MarshalByRefObject {
       
private static IDictionary factoriesByType;

        static RemoteFactory(){
           
factoriesByType = new Hashtable();

            factoriesByType[typeof(Account)] = AccountFactory.Instance;
            factoriesByType[typeof(CreditCard)] = CreditCardFactory.Instance;
           
factoriesByType[typeof(Category)] = CategoryFactory.Instance;
           
factoriesByType[typeof(Product)] = ProductFactory.Instance;
           
factoriesByType[typeof(Item)] = ItemFactory.Instance;
        }

        public GenericDTO GetDtoForObject(object o){
           
GenericFactory facto = (GenericFactory) factoriesByType[o.GetType()];

            facto.LoadObject(o, null);
           
return new GenericDTO(o);
        }
    }
}

RemoteFactory.cs

 

Simplicité, souplesse, mais quid des performances ?

Finalement, la couche de distribution du PetShopDNG 2.0 est assez simple et légère au niveau du développement : il nous a suffi de rendre les objets métier sérialisables, de faire hériter les contrôleurs de cas d'utilisation de MarshalByRefObject, et d'implémenter le GenericDTO ainsi que sa RemoteFactory (ces derniers n'étant rendus nécessaires que par le chargement dynamique de l'état des objets métier). Par contre, nous n'avons pas fait de mesures concernant les performances de cette architecture distribuée, que ce soit en termes de nombre d'invocations de méthodes à distance, ou en temps d'exécution. Il se peut que le mécanisme de chargement dynamique s'avère préjudiciable aux performances globales, auquel cas il suffira de renvoyer des graphes d'objets métier complètement chargés en guise de valeurs de retour des contrôleurs de cas d'utilisation. Cela véhiculerait certainement plus d'informations que nécessaire, mais diminuerait le nombre d'invocations de méthodes distantes... un compromis subtil qui mérite une analyse fine et un véritable banc d'essai. Avis aux amateurs !

Les différentes architectures offertes par le PetShopDNG 2.0

Pour résumer, le PetShopDNG 2.0 offre 4 options de configuration possibles en termes d'architecture technique. Les trois premières sont très similaires, elles ne varient que sur l'implémentation de la couche de persistance (sur DTM, sur Norpheme, et enfin l'implémentation faite maison). Le diagramme de ces architectures est donc celui du PetShopDNG 1.0 :

Figure D : Architecture à trois niveaux physiques

 

La quatrième option met en oeuvre .NET Remoting en distribuant les contrôleurs de cas d'utilisation et la fabrique de DTO distribuée :

Figure E : Architecture à quatre niveaux physiques

Que manque-t-il au PetShopDNG ?

Une nouvelle couche de présentation ?

Dans certaines situations, une architecture en mode "client léger" est inadaptée aux besoins des utilisateurs (saisie de masse, navigation asynchrone, chargement proactif d'informations d'aide à la saisie ou à la présentation...). Maintenant que le PetShopDNG intègre une couche de services distribuée par .NET Remoting, rien ne nous empêcherait de réimplémenter sa couche de présentation en mode "client riche", sous la forme d'une assembly basée sur les Windows Forms.

Ainsi, nous pourrions avoir des clients légers (accessibles de partout, sans déploiement, mais d'une interactivité limitée) et des clients plus lourds (nécessitant que le runtime .NET soit installé sur les postes utilisateur, mais dotés de fonctionnalités graphiques et ergonomiques avancées) qui utiliseraient exactement les mêmes services, distribués, et la même couche d'accès aux données cachée derrière notre couche de services.

Une preuve de faisabilité concernant l'intégration J2EE / .NET

Il est probable que dans les temps qui viennent, nous soyons amenés à rencontrer de plus en plus d'architectures hybrides mettant en oeuvre à la fois la platefome J2EE et .NET. Typiquement, il ne serait pas surprenant que l'on utilise .NET pour implémenter la couche de présentation (WindowsForms, ASP.NET) et que la couche de services, d'objets métiers et d'accès aux données soit réalisée à base de composants EJB par exemple.

Ce serait donc une belle validation de ce type d'architecture que de ré-implémenter les BLL et DAL sous forme de composants EJB. Bien sûr, cela ne change en rien l'architecture globale  :

D'aucuns diraient qu'on pourrait tout à fait inverser le schéma, et réaliser des couches de présentation Java (Swing, SWT, ou JSP/Servlets) qui dialogueraient avec une BLL écrite en C#. Techniquement, le problème est identique à l'option précédente. Mais cette situation nous semble moins probable et moins souhaitable vu l'état actuel des outils et des bibliothèques sur les plateformes J2EE et .NET.

En effet, il reste a priori (en Septembre 2003) plus simple de développer un site Web en ASP.NET qu'à base de JSP / Servlets (même en utilisant un framework de présentation type Struts). Et ce jusqu'à l'avènement des JSF (Java Server Faces).

Et de même, les serveurs d'applications EJB nous paraissent avoir encore une petite longueur d'avance face à l'offre de développement par composants métier de la plateforme .NET (du moins comparé à .NET Remoting, c'est évident; mais face aux Serviced Components, la partie est bien plus serrée...).

L'élimination de redondances, de code répétitif

Une critique que nous pourrions faire à l'implémentation "faite maison" de notre couche de persistance : dans nos objets métier, chaque accesseur (Get, Set) doit appeler la méthode Load() pour vérifier si l'objet que l'on manipule a été complètement chargé en mémoire ou s'il faut le charger à la demande. Cette approche est :

Malheureusement, l'approche Orientée Objet ne nous permet pas d'aller plus loin dans la mise en facteur de ce code. Héritage, polymorphisme et délégation sont ici inappropriés ou insuffisants. Par contre, il serait trivial d'implémenter un Aspect, et de greffer l'invocation de la méthode Load() au début de chaque Get et chaque Set d'objet métier, à l'exception de ceux de la propriété Id. Nous y penseront dans les versions suivantes du PetShopDNG... Qui sait, peut-être AspectDNG pourra-t-il répondre à ce besoin dans quelques temps...

Conclusion

Le PetShopDNG 2.0 est une nouvelle preuve de la souplesse offerte par les architectures multi-couches et les design patterns. Il met en évidence l'intérêt d'un couplage faible entre certaines couches (présentation et service/accès aux données en particulier) et se donne les moyens de limiter ce couplage par le biais de d'une abstract factory et d'interfaces abstraites.

Nous espérons que les lecteurs déçus par l'implémentation de la couche de persistance du PetShop 1.0 trouveront leur compte dans cette nouvelle mouture : nous avons désormais le choix entre :

Enfin, une nouvelle option de déploiement est apparue, consistant à délocaliser l'exécution des couches BLL/DAL sur un serveur dédié, sur lequel la couche de présentation se connecte via .NET Remoting.

Au fait, .NET Remoting permet de choisir le protocole et le format des messages échangés, n'est-ce pas ? Qui se lance pour tester la distribution de nos BLL/DAL via SOAP / HTTP ?...

 

Auteur : Thomas GIL

Copyright © Septembre 2003

 

Ressources

Téléchargez le fichier complet (Web et Couches BLL+DAL) : PetShopDNG-2.0.zip