I. Introduction▲
Dans les architectures mettant en œuvre des clients RIA écrits en GWT, la communication client-serveur est une problématique récurrente. Dans une telle application où le développement du client et du serveur se font tous deux en Java, le domaine métier est implémenté une seule fois et partagé entre le client et le serveur. Les objets sont échangés au moyen de l'API GWT RPC, solution historique pour invoquer des services distants. Cette API met en place un mécanisme de sérialisation des données.
L'utilisation du framework Hibernate pour gérer la persistance des objets métier rend impossible le transfert de ces objets via le mécanisme de sérialisation de GWT. Nous allons voir dans quelle mesure cette limite est contraignante ainsi que les différentes manières de traiter cette problématique.
II. Problématique▲
GWT RPC permet d'effectuer des appels asynchrones du client vers le serveur. Tout objet transporté sur le réseau doit être sérialisable (les règles de sérialisation diffèrent un petit peu de l'API standard Java). On peut instinctivement penser qu'il nous suffit de respecter cette contrainte en rendant sérialisables tous les types impliqués dans les contrats d'interfaces pour se mettre à l'abri de toute erreur de sérialisation. C'est vrai tant que l'on conserve la maîtrise des types des objets que l'on souhaite sérialiser. Que se passe-t-il si les objets à envoyer côté client sont des entités chargées depuis une base de données à l'aide de l'ORM Hibernate ?
Hibernate, pour rendre les classes persistantes, les instrumente en modifiant le bytecode durant l'exécution de l'application au moyen de la bibliothèque Javassist. De cette manière, la définition des classes durant l'exécution est différente de la définition des classes issues du processus de compilation. Ce mécanisme bien qu'invisible du point de vue du développement rend impossible l'échange d'objets entre le client et le serveur au moyen du mécanisme de sérialisation.
Plusieurs solutions existent pour contourner ce problème. Dans la suite, je vous propose de comparer plusieurs méthodes, leurs mises en œuvre, leurs avantages et inconvénients.
III. Les différentes approches▲
J'ai choisi de traiter la problématique d'intégration de GWT avec le framework Hibernate. Cependant, cette problématique existe de manière identique avec d'autres ORM et d'une manière encore plus générale la problématique existe à partir du moment où les objets à envoyer côté serveur ne sont pas sérialisables.
Nous allons ici comparer trois approches permettant de traiter cette problématique :
- le pattern DTO (Data Transfert Object) ;
- le framework Gilead ;
- les RequestFactory.
Je vais vous présenter chacune des approches en mettant particulièrement l'accent sur les RequestFactory car il s'agit de la solution la plus jeune, la moins connue, et certainement la moins triviale en termes de mise en œuvre.
III-A. Le pattern DTO▲
Il s'agit probablement de la solution la plus souvent implémentée à ce jour dans les applications GWT/Hibernate. C'est également la plus facile à définir et à mettre en place. Ce pattern d'architecture consiste à définir de nouveaux objets (appelés objets de transfert) qui sont de simples POJO. Ils ne sont pas instrumentés, ne contiennent pas d'intelligence mais seulement les données qui doivent êtres échangées entre le client et le serveur. Il est donc très facile de les rendre sérialisables afin qu'ils deviennent éligibles au transfert via des appels de services RPC.
Souvent, les données présentent dans un DTO correspondant exactement aux données affichées à l'écran ou sur une partie de l'écran. Les DTO peuvent avoir la même structure que les objets persistés mais ce n'est pas nécessairement le cas. La représentation des objets en base de données n'est pas toujours adaptée à une utilisation côté client.
La création et l'alimentation des DTO sont en général faites de manière manuelle et restent à la charge du développeur. Cependant, il est possible d'utiliser des frameworks de mapping Objet/Objet qui permettent de définir au moyen d'un fichier de configuration XML des couples d'attributs qui doivent être mappés l'un sur l'autre. Le framework de ce type le plus connu est Dozer. Attention toutefois, dans certains cas l'usage de tels framework peut s'avérer être une mauvaise idée. Si par exemple, les DTO contiennent beaucoup de valeurs calculées ou si les types des attributs sont souvent différents entre les DTO et les objets persistés, la définition d'un tel mapping bien que toujours possible, peut être lourde à mettre en place et difficile à maintenir.
III-B. Le framework Gilead▲
La solution Gilead est présentée ici pour alimenter la discussion technique mais ce framework n'est pas une option d'avenir car il n'est plus maintenu. La dernière version (1.3.1), vieille d'un peu plus de deux ans, supporte les annotations JPA2, Hibernate 3.5 et GWT 2.0.
La solution consiste, contrairement au pattern DTO, à rendre sérialisables les entités persistantes sans définir de nouveaux objets. Pour cela, Gilead parcourt les graphes d'objets et remplace les proxys Hibernate non sérialisables par des références null et les implémentations spécifiques Hibernate (e.g. PersistentSet) par des implémentations issues de JavaSE.
La plus-value apportée par le framework est sa capacité à stocker (soit côté serveur, soit dans les objets eux-mêmes) les métadonnées nécessaires à la reconstruction des graphes d'objets Hibernate. De cette manière, un objet persisté envoyé puis modifié côté client pourra sans problème être rattaché à une session Hibernate à son retour côté serveur.
Sa mise en œuvre est plutôt simple. Elle se résume en trois points :
- les entités persistantes doivent hériter de la classe LightEntity ;
- les classes d'implémentation RPC doivent hériter de PersistentRemoteService ;
- le PersistentBeanManager de Gilead doit être initialisé.
Il n'y a que quelques lignes de code ou de configuration à écrire pour mettre en place le mécanisme qui s'exécute ensuite de manière transparente.
Comme annoncé en introduction, ce projet est au point mort. L'arrêt de la maintenance est intervenu à peu près au moment de l'arrivée d'une nouveauté dans GWT : Les RequestFactory.
III-C. Les RequestFactory▲
Les RequestFactory sont apparues dans la version 2.1 de GWT. Elles ont été introduites pour répondre à la problématique dont il est ici question.
Les RequestFactory vont nous permettre de faire transiter sur le réseau les données contenues dans des objets non sérialisables. Comme son nom l'indique, le but d'une RequestFactory est de créer des requêtes. Pour cela, une API plutôt minimaliste fournit une sorte de builder permettant de construire une requête HTTP qui sera plus tard envoyée en POST au serveur.
Le corps de cette requête est une chaîne de caractères au format JSON dans laquelle on trouve principalement les informations suivantes :
- les données d'identification du service à invoquer côté serveur ;
- les paramètres d'appel du service ;
- les noms des propriétés correspondant aux relations qui devront être transmises côté client.
Côté serveur, le traitement de cette requête va être délégué à une RequestFactoryServlet. Celle-ci se charge de parser la requête, d'effectuer l'appel de service, puis de sérialiser le résultat dans la réponse envoyée au client.
Par opposition au mécanisme RPC qui offre une vision service, les RequestFactory offrent une vision requête. Nous verrons d'ailleurs plus tard dans un exemple que l'envoi des requêtes devra être codé explicitement par le développeur, ce qui est totalement transparent dans le cas d'un appel RPC.
Il y a tout de même un point commun entre les deux approches, dans les deux cas les appels serveur sont faits de manière asynchrone. Là où en RPC on utilise un AsyncCallback pour traiter le retour de l'appel, on utilisera un Receiver avec les RequestFactory.
On lit souvent que les RequestFactory sont orientées ressources par opposition au mécanisme RPC qui est orienté services. Je ne serais pas si catégorique. Effectivement, initialement les RequestFactory étaient destinées à faciliter les opérations CRUD sur des entités persistantes dans le cadre d'une architecture orientée ressources. Cependant, il n'y aucun inconvénient à les utiliser pour effectuer tout type d'appel de service.
Passons maintenant à un exemple de mise en œuvre au travers d'un cas concret. L'exemple qui va suivre n'a pas vocation à être exhaustif mais simplement à mettre en avant les principales caractéristiques des RequestFactory.
Soit le domaine métier ci-dessous. Il s'agit d'un simple annuaire dans lequel une personne peut avoir plusieurs adresses.
Nous avons deux entités reliées par une relation qui sera déclarée Lazy. Il s'agit du cas le plus simple nous permettant de mettre en œuvre les RequestFactory de manière non triviale.
Ci-dessous le code des entités côté serveur (allégé des getters et setters ainsi que des méthodes equals et hashcode).
Interface PersonProxy
@ProxyFor
(
Person.class
)
public
interface
PersonProxy extends
EntityProxy {
public
int
getId
(
);
public
void
setId
(
int
id);
public
String getFirstname
(
);
public
void
setFirstname
(
String firstname);
public
String getLastname
(
);
public
void
setLastname
(
String lastname);
public
String getPhone
(
);
public
void
setPhone
(
String phone);
public
String getEmail
(
);
public
void
setEmail
(
String email);
public
Set<
AddressProxy>
getAddresses
(
);
public
void
setAddresses
(
Set<
AddressProxy>
addresses);
}
Interface AddressProxy
@ProxyFor
(
Address.class
)
public
interface
AddressProxy extends
EntityProxy {
public
int
getId
(
);
public
void
setId
(
int
id);
public
String getLine1
(
);
public
void
setLine1
(
String line1);
public
String getLine2
(
);
public
void
setLine2
(
String line2);
public
String getZipcode
(
);
public
void
setZipcode
(
String zipcode);
public
String getCity
(
);
public
void
setCity
(
String city);
public
String getState
(
);
public
void
setState
(
String state);
public
String getCountry
(
);
public
void
setCountry
(
String country);
}
Nous allons ensuite définir la fameuse RequestFactory. Elle est unique pour toute l'application. Il s'agit d'une interface qui étend RequestFactory et qui contient des méthodes renvoyant des stubs des interfaces de services. Dans notre cas, nous aurons une seule interface de service.
La RequestFactory de l'application
public
interface
AddressBookResquestFactory extends
RequestFactory {
PersonRequest personResquest
(
);
}
L'interface de service étend RequestContext. Elle est annotée de manière à définir la classe d'implémentation des services. Cette classe peut être l'entité elle-même. C'est la solution la plus simple, c'est celle que nous allons mettre en place ici. La classe d'implémentation peut aussi être une classe DAO dédiée en définissant un ServiceLocator. Ce point ne sera pas abordé ici.
L'interface va comporter deux services :
- pour lire un objet Person par son nom et prénom ;
- pour sauvegarder ou mettre à jour en base de données un objet Person.
Interface de services PersonRequest
@Service
(
Person.class
)
public
interface
PersonRequest extends
RequestContext {
Request<
PersonProxy>
read
(
String firstname, String lastname);
Request<
Void>
saveOrUpdate
(
PersonProxy person);
}
Comme vu précédemment, ces services sont implémentés directement dans l'entité Person.
public
static
Person read
(
String firstname, String lastname) {
// lecture de la personne sans ses adresses
}
public
static
void
saveOrUpdate
(
Person person) {
// Mise à jour de la personne
}
En réalité, l'entité n'implémente pas l'interface au sens strict, mais la classe d'implémentation doit être en phase avec l'interface. On constate que les méthodes de l'interface PersonRequest renvoient des objets de type Request paramétrés par le type de retour effectif des méthodes implémentées côté serveur.
N.B. Le plugin Eclipse de développement GWT détecte les incohérences entre les interfaces RequestContext et leurs implémentations.
Par ailleurs, les méthodes d'implémentation sont déclarées statiques car elles ne s'appliquent pas à une instance en particulier. Il est en revanche possible de définir des méthodes d'instances lorsque cela a du sens. Le cas typique est la définition d'opérations CRUD.
Côté serveur, il reste à déclarer dans le descripteur de déploiement de l'application Web, la servlet qui va traiter les requêtes.
<
servlet>
<
servlet-
name>
requestFactoryServlet</
servlet-
name>
<
servlet-
class
>
com.google.web.bindery.requestfactory.server.RequestFactoryServlet</
servlet-
class
>
</
servlet>
<
servlet-
mapping>
<
servlet-
name>
requestFactoryServlet</
servlet-
name>
<
url-
pattern>/
gwtRequest</
url-
pattern>
</
servlet-
mapping>
Afin que le client GWT puisse utiliser la RequestFactory, nous devons ajouter une ligne de déclaration dans son descripteur de module.
<
inherits name=
'com.google.web.bindery.requestfactory.RequestFactory'
/>
Le client peut maintenant instancier la RequestFactory et l'initialiser en lui passant en paramètre l'EventBus de l'application.
AddressBookResquestFactory requestFactory =
GWT.create
(
AddressBookResquestFactory.class
);
requestFactory.initialize
(
new
SimpleEventBus
(
));
III-C-1. Envoyer une requête de lecture▲
Créons maintenant une requête pour rechercher une personne par ses nom et prénom.
Request<
PersonProxy>
req =
requestFactory
.personResquest
(
)
.read
(
"Barack"
, "Obama"
);
La requête est exécutée de manière asynchrone en appelant la méthode fire(Receiver). Le traitement du retour de l'appel est à implémenter dans la méthode onSuccess.
req.fire
(
new
Receiver<
PersonProxy>(
) {
@Override
public
void
onSuccess
(
PersonProxy person) {
...
}
}
);
En cas d'échec, la méthode onFailure(ServerFailure) est appelée. Dans la classe abstraite Receiver, l'implémentation de cette méthode consiste à lever une RuntimeException.
Si on effectue un appel à person.getAddresses() dans la méthode onSuccess on s'aperçoit que l'on obtient null. C'est normal me direz-vous, étant donné que l'implémentation de la méthode read côté serveur ne charge pas les adresses. Mais en réalité, la raison est ailleurs.
Par défaut, la RequestFactoryServlet ne sérialise pas les propriétés correspondant aux relations si ce n'est pas explicitement spécifié dans la requête. Ce qui veut dire qu'ici, même si la relation entre les entités Person et Address était déclarée Eager cela ne changerait absolument rien aux données reçues côté client.
Pour cela, Il faut utiliser la méthode with(String...) de la classe Request pour indiquer les propriétés qui doivent être récupérées par le client.
Request<
PersonProxy>
req =
requestFactory
.personResquest
(
)
.read
(
"Barack"
, "Obama"
)
.with
(
"addresses"
);
Ce qui précède soulève au moins deux questions intéressantes :
- comment la RequestFactoryServlet va-t-elle exploiter cette nouvelle information ?
- à qui incombe la responsabilité de charger la collection d'adresses ?
En fait, ces deux questions sont intimement liées.
La réponse à la première question vient assez intuitivement. La RequestFactoryServlet va simplement invoquer, sur l'objet de type Person récupéré de l'appel de service, les getters correspondant aux propriétés qui doivent être sérialisées.
Nous savons déjà que la méthode read ne charge pas les adresses. Que se passe-t-il à ce stade lors du traitement de la requête par la servlet ? Si la session Hibernate est toujours active, la collection d'adresses sera chargée lors de l'appel du getter. Dans le cas contraire, nous serons inévitablement sanctionnés par une LazyInitializationException.
Évidemment, changer le mode de chargement de la collection d'adresses de Lazy à Eager rendrait notre scénario passant mais ce n'est pas ce que nous souhaitons ici. En gardant la relation en mode Lazy, ce problème peut être traité de deux manières :
- tout risque de provoquer une LazyInitializationException est écarté ;
- aucune donnée non désirée n'est chargée inutilement (tant que les relations sont en mode lazy).
III-C-2. Modifier des entités côté client▲
Si vous tentez d'invoquer un setter sur un EntityProxy issu d'un appel serveur, vous obtiendrez une exception du type IllegalStateException avec comme message The AutoBean has been frozen. Ce message n'est pas vraiment explicite. Ici, il faut comprendre que le proxy n'est pas éditable. Il doit préalablement être marqué comme tel.
PersonProxy person =
...
PersonRequest editRequest =
requestFactory.personResquest
(
);
person =
editRequest.edit
(
person);
Lorsqu'un proxy est rendu éditable, les proxy qui correspondent aux relations deviennent eux aussi éditables. Ensuite pour renvoyer côté serveur les données modifiées il faudra utiliser le même RequestContext que celui utilisé pour rendre le proxy éditable. Ici, nous envoyons une requête pour mettre à jour l'objet en base de données en appelant le service saveOrUpdate(Person).
editRequest.saveOrUpdate
(
person).fire
(
new
Receiver<
Void>(
) {
@Override
public
void
onSuccess
(
Void response) {
...
}
}
);
Un des avantages de la RequestFactory réside dans le fait que seules les données modifiées depuis le chargement de l'objet seront sérialisées dans la requête envoyée au serveur et non l'objet person dans sa totalité. Dans beaucoup de cas, cela permet de réduire la quantité de données qui transitent sur le réseau.
IV. Critique des différentes approches▲
Nous avons vu trois manières de traiter la problématique d'intégration GWT/Hibernate. Laquelle choisir dans le cadre de la réalisation de votre projet ? Cela dépend évidemment du besoin.
De tout ce qui précède, il est évident que la solution la plus flexible consiste à utiliser le pattern DTO. Mais bien que facile à mettre en place, le coût de réalisation est élevé. Beaucoup de codes répétitifs à écrire, à tester et à maintenir. Cette approche reste cependant la seule valable si la définition des entités persistantes n'est pas adaptée à une utilisation côté client.
La solution Gilead est attrayante. C'est celle dont le coût de mise en œuvre est le plus faible. Il n'est pas nécessaire de définir d'autres objets que les entités, et le framework se charge de tout pour les rendre sérialisable. Évidemment, il faut avoir la volonté d'utiliser les entités côté client. Malheureusement, Gilead n'étant plus maintenu, il n'est plus une solution à envisager.
Les RequestFactory sont aujourd'hui la seule approche standard GWT pour traiter cette problématique. Leur utilisation n'est pas triviale. En comparaison avec GWT RPC, il faut un peu plus de temps pour les appréhender, mais une fois maîtrisées leur utilisation n'est pas vraiment compliquée. Cette solution impose d'écrire des proxys côté client pour chacune des entités mais comme vu précédemment, le code de ces proxys peut être déduit du code des entités. Dans le cadre d'une démarche MDA par exemple, ces proxys pourraient sans aucun problème faire l'objet d'une génération de code. Comme pour Gilead, il faut souhaiter utiliser côté client des objets similaires aux entités définies côté serveur. L'utilisation du lazy loading par la RequestFactoryServlet permet de définir des services plus génériques. Un autre avantage mentionné précédemment est la réduction de la taille des données qui circulent sur le réseau.
V. Conclusion▲
Comme vu tout au long de cet article, la solution à choisir dépend clairement des besoins tant fonctionnels que techniques. Les DTO vont permettre de mettre en place des solutions plus spécialisées, tandis que les RequestFactory permettent de définir assez facilement des solutions plus génériques.
Ces deux approches ne sont pas nécessairement exclusives. Dans un système complexe, les besoins sont souvent variés. L'approche la plus productive est certainement de tirer le meilleur de l'une et l'autre des solutions selon les cas d'utilisation. Par exemple, pour une simple saisie de formulaire qui va solliciter des services CRUD, l'approche RequestFactory est clairement la plus adaptée alors que dans d'autres cas plus complexes l'approche DTO sera à privilégier.
Depuis l'apparition des RequestFactory en version 2.1, il y a eu d'importantes évolutions ainsi que de nombreuses corrections. De nouvelles corrections sont également planifiées dans la version 2.5 à venir. Il s'agit donc d'une technologie vivante qui continue de s'améliorer de version en version. Elle semble promise à un bel avenir et continuera certainement de faire parler d'elle.
L'application exemple commentée dans cet article est disponible sur github.
VI. Remerciements▲
Cet article a été publié avec l'aimable autorisation de la société ZenikaZenika, le billet original peut être trouvé sur le blog de ZenikaBlog de Zenika.
Nous tenons à remercier ClaudeLELOUP pour sa relecture attentive de cet article et Mickael Baron pour la mise au gabarit du billet original.