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

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 :
bien entendu décrire les nouveautés que vous découvrirez dans le code du PetShopDNG 2.0
montrer en quoi l'approche par interfaces et Abstract Factory que nous avions suivie dans le PetShopDNG 1.0 a été gagnante
expliquer les choix concernant l'implémentation de la couche d'accès aux données "faite maison" du nouveau PetShopDNG
expliquer également la conception de la couche de services distribués via .NET Remoting
enfin, mettre le doigt sur certaines rigidités des outils de mapping Objet/Relationnel qui nous ont contraint à un compromis difficilement acceptable dans une application non-académique
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.
[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 :
une page ASPX de "syndication de contenu" agrège un ensemble de contrôles ASCX, qui deviennent donc des portlets (chacun constitue en effet un élément du portail). Toutes les requêtes des clients ciblent cette page, qui a donc la responsabilité de gérer le contexte conversationnel entre le serveur et les clients.
chaque contrôle peut offrir des services (liste des catégories d'animaux à vendre, moteur de recherche) et donc déclencher des événements utilisateur.
les événements sont bien entendu traités dans les classes C# "CodeBehind" des contrôles, dans lesquelles on déclenche une activité, à l'issue de laquelle le système change d'état. Dès lors, la page de "syndication de contenu" fait en sorte d'afficher les portlets requises pour la présentation de ce nouvel état.
étant donné que la même action est susceptible d'être déclenchée de diverses manières à travers notre portail, les CodeBehind s'appuient en réalité sur une couche d'abstraction supplémentaire, les "WebCommands", qui sont de simples classes C# chargées de solliciter les contrôleurs de cas d'utilisation, et de gérer la navigation à travers notre portail.
les contrôleurs de cas d'utilisation sont des classes C# façades, responsables de l'interaction fine avec le modèle des objets métier. En pratique, les WebCommands ne manipulent que les interfaces des contrôleurs de cas d'utilisation : couplées à une AbstractFactory, ces interfaces permettent de limiter énormément le couplage entre les WebCommands et l'implémentation des contrôleurs. Nous reviendrons sur cet aspect dans la section suivante.
les objets métier, eux aussi, se cachent derrière leurs interfaces. Leur code est trivial : notre site est pauvre en logique, le plus complexe revenant certainement à gérer le caddie virtuel de l'utilisateur !
et enfin, la persistance des objets métier est réalisée par l'outil de mapping Objet/Relationnel DTM, de la société Evaluant. Malheureusement, comme DTM ne permet pas de générer des classes qui implémentent nos interfaces, nous avons été obligés de recourir à une technique d'adaptation, le décorateur, qui fait le lien entre nos interfaces métier et les classes générées par l'outil.
Le diagramme suivant reprend ces éléments de manière un peu plus synthétique :
|
|
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) :
En mode "3 couches physiques utilisant un outil de mapping Objet / Relationnel", nous avons complété l'implémentation basée sur DTM par une nouvelle version basée sur Norpheme (cette implémentation a été réalisée par Matthieu Guyonnet-Duluc, qui connaît le PetShopDNG encore mieux que son auteur ;-) ...)
Afin de satisfaire les personnes qui souhaitaient une implémentation "cousue main" de la couche de persistance, nous avons implémenté notre propre couche d'accès aux données (sur laquelle nous allons revenir plus en détail dans les sections suivantes)
Et enfin, grâce à .NET Remoting, il est dorénavant possible de déployer le PetShopDNG sur 4 couches physiques : la couche de services et d'accès aux données sont effectivement distribuables sur une autre machine que celle qui héberge la couche de présentation ASP.NET.
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" ?> |
| 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 :
constitue une charge de travail supplémentaire, qui peut paraître lourde
mais cette discipline est structurante, elle force à limiter le couplage entre deux couches successives (entre la couche de présentation ASP.NET d'un côté, et les couches BLL et DAL dans notre cas)
et nous voyons que la promesse de pouvoir changer d'implémentation sans aucune répercussion sur la couche utilisatrice (présentation ici) est tenue. Quelle souplesse ! Quel pattern élégant !
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 :
comme pour les autres implémentations (reposant respectivement sur Evaluant DTM et Norpheme), il existe une classe implémentant chaque interface métier du namespace PetShopDNG.DAL.
ces classes implémentent également deux interfaces techniques, IPersistent et IDistributable, qui les obligent à redéfinir certaines propritétés (Id, IsTotallyLoaded et IsDistributed).
il existe une petite dépendance entre ces classes de la DAL et leurs Factories (sur lesquelles nous allons revenir dans quelques lignes). En effet, nos objets peuvent n'être que partiellement chargés (ils ne connaissent alors que leur Id), auquel cas dès qu'une autre propriété que l'identifiant est lue ou modifiée, il faut terminer le chargement en mémoire de l'état de l'objet; cela se fait en rappelant la méthode LoadObject(..) de la fabrique adéquate.
[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 :
dans l'étape de lecture de la base de données (recherche par clé ou par critère), il s'agit de lancer une requête, d'instancier un objet métier par ligne renvoyée par la base de données, et d'affecter à chaque propriété de l'objet la valeur du champ correspondant en base
dans l'étape d'insertion, c'est l'inverse : il faut être capable de déclencher une commande (et donc une requête SQL) en ajoutant des paramètres correspondant aux propriétés de l'objet à stocker ou à modifier.
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 :
préciser le code des requêtes SQL à exécuter sur la base
instancier un objet du bon type, et le charger avec les champs contenus dans une ligne d'un DataReader (bref, faire l'adaptation Relationnel -> Objet)
ajouter les paramètres nécessaires à une Command pour insérer / mettre à jour un objet en base (adaptation Objet -> Relationnel)
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.
|
|
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){ public
override
void Update(object
obj,
GenericTransaction tx){ // Find methods
public Account
FindById(string
id,
GenericTransaction tx){ public
Account FindByLogin(string
login,
GenericTransaction 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.
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 :
DTM suppose qu'une clé primaire est de type VARCHAR en base de données (et la gestion des clés uniques par défaut passe par l'utilisation de GUID)
Norpheme, lui, ne gère tout simplement pas la génération de clés uniques : il se repose sur la base, et requiert donc un champ entier, plus précisément un ID IDENTITY, incrémenté par la base elle-même.
Dès lors, les choix étaient assez limités :
soit nous acceptions de créer deux bases de données différentes, dotées d'un schéma adapté aux contraintes des outils de mapping O/R
soit il fallait ajouter aux tables existantes des colonnes symétriques de gestion des clés primaires et étrangères : à la fois sous forme d'entiers et de chaînes.
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.
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 :
nous avons choisi de rendre la couche de services (la BLL) distribuée : chaque contrôleur de cas d'utilisation est un objet distribué via .NET Remoting, et hérite de ce fait de la classe MarshalByRefObject
et nous nous sommes refusés de rendre les objets métier eux-mêmes distribués, sans quoi l'utilisation du réseau serait devenue abusive, le nombre de proxies aurait énormément augmenté, et le cycle de vie des connexions réseau aurait été allongé. Au contraire, nous avons choisi de passer par un Design Pattern récurrent : le DTO (Data Transfer Object).
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
RemotingConfiguration.RegisterWellKnownClientType
RemotingConfiguration.RegisterWellKnownClientType
RemotingConfiguration.RegisterWellKnownClientType
RemotingConfiguration.RegisterWellKnownClientType
RemotingConfiguration.RegisterWellKnownClientType
try{
public
override IAuthenticationController
AuthenticationController { }
public
override ISearchController SearchController
{
public
override IShoppingController ShoppingController
{
public
override IDataMigrationController DataMigrationController
{
public RemoteFactory
RemoteFactory { } |
| DNGAbstractFactory.cs |
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 :
de la fabrique de Hashmaps côté serveur (qui porte la responsabilité de remplir une Hashmap avec les attributs de chaque type d'objet métier),
et de chaque objet métier (qui doit récupérer côté client la Hashmap renvoyée par la fabrique distribuée, et affecter à ses propres attributs les valeurs portées par les couples nom->valeur de cette Hashmap).
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[typeof(Account)]
= AccountFactory.Instance;
public GenericDTO
GetDtoForObject(object
o){
facto.LoadObject(o,
null); |
| RemoteFactory.cs |
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 !
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 :
|
|
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 :
|
|
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.
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 :
la couche BLL serait implémentée sous la forme de composants EJB Session (éventuellement orientés message pour le processus de migration des données)
la couche DAL pourrait être réalisée, selon les choix et les goûts de chacun, sous la forme de composants EJB Entité (dotés uniquement d'une interface locale, bien entendu) ou de simples objets Java reposant sur JDO pour assurer leur persistance.
la communication entre la couche de présentation et la couche de services pourrait se faire soit par le biais de WebServices (le risque de problèmes d'intégration est très faible, mais les performances attendues aussi), ou via .NET Remoting et un channel IIOP (le risque est plus élevé car les canaux IIOP ne sont pas matures, mais cette approche nous donnerait à coup sûr de bien meilleures performances).
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...).
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 :
risquée, car il est assez facile d'oublier d'invoquer la méthode Load(), surtout dans de nouvelles classes ou de nouvelles propriétés
lourde, le code correspondant étant désagréable à écrire et à maintenir.
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...
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 :
une autre implémentation des BLL et DAL sur un outil de mapping O/R Open Source, Norpheme,
une technique manuelle, simple (voire simpliste) et légère de gestion de la persistance reposant directement sur ADO.NET (Commands et DataReaders uniquement)
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