mercredi 21 mai 2014

Une application GWT de prise de commandes fonctionnant en mode hors-ligne

Le but de cette série d'articles (probablement deux) est de présenter une architecture type d'application Web supportant le mode hors-ligne avec le minimum de dégradation de fonctionnement possible.

C'est la suite logique de mon précédent article sur le développement d'applications web hors-ligne avec GWT, bien que le POC présenté ici utilise une architecture légèrement différente.

L'application présentée ici est disponible en open source sur github à cette adresse.

Présentation de l'application

Cette application pour un client fictif doit répondre aux besoins suivants :
  1. Présenter la liste des articles, des commandes et de leur contenus à l'utilisateur. Qu'il soit en ligne ou pas.
  2. Permettre à l'utilisateur de saisir de nouveaux articles et de nouvelles commandes, et modifier celles-ci. Qu'il soit en ligne ou pas.
  3. Chaque article en base a la possibilité d'être associé à deux fichiers : une image et une fiche technique. Ces fichiers doivent être consultables que l'utilisateur soit en ligne ou pas.
  4. Les transitions entre mode en ligne et mode hors-ligne doivent être transparentes pour l'utilisateur.

Structure des données

A des fins de simplification, nous aurons à gérer trois tables :
  • articles : stocke les données de chaque article du catalogue
  • orders : stocke les entêtes des commandes
  • order_items : stocke les postes de chaque commande
Ces tables existent dans la base de données serveur et dans la base de données du navigateur (SQL.js embarqué).

Côté serveur

J'utilise ici Hibernate. Rien de spécial, j'ai donc trois classes entités : Article, Order et OrderItem.

Côté client

Comme présenté dans mon précédent article, j'utilise SQL.js afin de gérer les données côté client. La persistence de la base de données est assurée par le local storage (stockage de la base de données sérialisée en JSON).
La structure de la base de données est identique à celle du serveur. Le fichier SQL de création de la base se trouve ici.

Simple non ?

Comme vous le constatez, le modèle de données est simplissime ! Cela nous permettra de nous focaliser sur les aspects plus techniques de la mise en oeuvre des principes d'une application hors-ligne.

Architecture, vue de loin

Avant de rentrer dans les détails, je voudrais vous présenter l'architecture à grosse maille de notre application.
Architecture de l'application
Côté serveur, nous avons trois composants importants:
  • ManifestServlet : cette servlet génère le fichier manifest demandé par le navigateur. On doit mettre en cache non-seulement les fichiers statiques de l'application (index.html, les fichier js, les fichiers css, etc), mais aussi les fichiers générés lors de la compilation GWT ainsi que les fichiers (images et fiches techniques) associés aux articles.
  • UploadServlet : cette servlet reçoit les requêtes POST du client, stocke les fichiers envoyés et les associe aux articles souhaités (en stockant le nom des fichiers dans les enregistrements de la base de données).
  • SyncServiceServlet : cette servlet permet le dialogue entre client et serveur afin de synchroniser les bases de données.
Côté client, nos données sont stockées dans une base de données SQLite, par dessus laquelle nous trouvons la classe DataAccess qui donne un accès indépendant de la base au reste de l'application, et sous forme de DTO.

Des deux côtés, nous trouverons les classes UpstreamSynchroManager et DownstreamSynchroManager qui géreront respectivement la synchronisation montante et la synchronisation descendante.

La particularité de cette architecture consiste dans le fait que la partie cliente fonctionne de façon autonome, avec sa propre base de données, celle-ci étant synchronisée avec celle du serveur par des composants indépendants.

En fait, la majeure partie de l'application cliente n'a pas conscience de la présence d'un serveur dans l'architecture. En effet, les accès aux données sont fait directement depuis la base SQLite locale. Ceci a deux effets :
  • L'accès aux données est synchrone pour le client. Ceci permet de simplifier énormément le code client (plus de callback à implémenter pour attendre les données)
  • La couche de synchronisation travaille directement sur la base de données et voit donc des modifications "atomiques" et non des appels de méthodes fonctionnels. Ceci peut rendre plus difficile la gestion de la sécurité et des droits (rien n'empêche un utilisateur de hacker l'application cliente en ajoutant des données dans la base locale). Il faudra donc bien réfléchir avant d'adopter cette architecture, en répondant à la question : "Puis-je implémenter un contrôle de droits d'accès au niveau de la couche de stockage plutôt qu'au niveau de la couche métier ?".

Gestion du Manifest pour l'Application Cache

Le fichier manifest est utilisé par le navigateur afin de recenser tous les fichiers qui doivent être stockés dans le cache applicatif (Application Cache). Tout fichier présent dans ce manifest sera téléchargé par le navigateur et rendu disponible à l'application quand le serveur sera injoignable.

Génération du Manifest (fichiers statiques, dynamiques et issus de la compilation GWT)

Le manifest contiendra des entrées correspondant à des fichiers provenant de trois sources différentes :
  • les fichiers statiques de l'application. Ce sont les fichiers tels que index.html, les bibliothèques Javascript utilisées par l'application et aussi les css etc.
  • les fichiers javascript issus de la compilation GWT. Ceux-ci seront listés dans un fichier "offlinedemo-artifacts.lst" générés par le linker spécial que nous ajoutons à la compilation GWT (AppCacheLinker).
  • les fichiers référencés par les articles contenus dans la base de données.
Nous voyons donc que la liste des fichiers n'est pas connue à l'avance. Nous allons donc générer le manifest grâce à la servlet ManifestServlet. Cette servlet contient deux méthodes notoires : 
  • readFile() : lit le contenu d'un fichier ligne par ligne et ajoute chaque entrée dans le manifest
  • listFiles() : parcours récursivement le contenu d'un répertoire et ajoute chaque fichier trouvé au manifest.
Nous utilisons ces deux méthodes pour inclure dans le manifest les fichiers générés par la compilation GWT ainsi que tous les fichiers uploadés par les utilisateurs.

Note : l'implémentation actuelle du linker englobe toutes les permutations GWT. Il sera possible à des fins d'optimisation de ne faire stocker au navigateur que les fichiers liés à sa permutation GWT.

Gestion de la base de données locale avec SQL.js et le Local Storage

Nous allons maintenant voir comment mettre en place un SGBD dans le navigateur, en restant compatible avec tous les grands navigateurs du marché.

SQL.js

SQL.js est le résultat de la compilation de SQLite avec l'outil emscripten. C'est un port 100% compatible avec la version native. Au niveau des performances, on sera approximativement à 75% des performances de la version native, ce qui est remarquable et tout a fait suffisant pour requêter sur une base de données contenant quelques milliers d'enregistrements.

C'est la classe SQLite qui a le rôle de wrapper le script SQL.js vers GWT. Elle permet de créer une nouvelle base de données, d'en charger une existante à partir d'une représentation en tableau d'entiers d'un fichier SQLite, et d'effectuer toutes les requêtes SQL supportées par SQLite (donc à peu près tous les éléments du langage SQL).

Persistence de la base

Une chose que ne supporte pas le composant SQL.js est la persistance de la base. Et pour ce faire, nous allons nous appuyer sur le Local Storage (HTML5).

Ceci se fera en deux phases :
  • exporter la base de données sous forme d'un tableau d'entiers (SQLite.exportData()),
  • sauvegarder ce tableau sous forme d'une représentation JSON de ce tableau d'entiers (new JSONArray(data).toString()).
Le code faisant la persistance se trouve dans la classe DataAccess.

Initialisation de la base

Une fois persistée, il faudra ensuite charger la base de données au démarrage de l'application. Voici le diagramme de fonctionnement :


Si une sauvegarde de la base a déjà été effectuée, nous trouverons dans le Local Storage la chaine de caractère au format JSON représentant ce tableau d'entiers qui lui-même représente un fichier SQLite binaire.

Dans le cas contraire (la base n'a jamais été sauvegardée), nous créons une instance toute fraîche, puis nous exécutons les instructions DDL permettant la création des tables requises par notre application. Les instructions DDL se trouvent dans le fichier schema.sql, que nous chargeons par le biais du mécanisme GWT des ClientBundle.

Consultation et stockage des Entités, côté client (mini ORM)

Une fois la base de données chargée ou créée, nous souhaiterons évidemment effectuer des requêtes SQL. Ceci se fait avec la méthode execute( String statement ) de la classe SQLite. En retour, nous obtenons un objet JavaScriptObject contenant les résultats. Le format de cet objet étant un peu lourd à gérer à chaque requête, nous utiliserons la classe SQLiteResult pour exploiter les résultats d'une requête.

Ce qui donne en terme de code :

JavaScriptObject jsoResult = sqlDb.execute( "select * from articles where code like '%xx%' group by price" );
SQLiteResult result = new SQLiteResult( jsoResult );
for( SQLiteResult.Row row : result )
{
 int id = row.getInt( "id" );
 String code = row.getString( "code" );
}

Voici donc la façon d'obtenir les résultats dans leur version brute. Mais en général, le développeur aura besoin d'obtenir des objets DTO. A cette fin, j'ai créé quelques classes permettant de faire ceci :

TableRecordSerializer
serializer = Serializer.getSerializer( "articles" ); JavaScriptObject jsoResult = sqlDb.execute( "select * from articles where code like '%xx%' group by price" ); SQLiteResult result = new SQLiteResult( jsoResult ); for( SQLiteResult.Row row : result ) { Article article = serializer.rowToDto( row ); }

Ceci permet d'écrire facilement des méthodes d'accès aux données comme getArticles() de la classe DataAccess :

public List
getArticles() { JavaScriptObject sqlResults = sqlDb.execute( "select * from articles" ); return deserializeRecords( sqlResults, "articles" ); } private List deserializeRecords( JavaScriptObject sqlResults, String tableName ) { TableRecordSerializer recordSerializer = Serializer.getSerializer( tableName ); List res = new ArrayList(); SQLiteResult rows = new SQLiteResult( sqlResults ); for( SQLiteResult.Row row : rows ) res.add( recordSerializer.rowToDto( row ) ); return res; }

Une particularité importante que l'on remarque par rapport au modèle de programmation courant en GWT qui consiste à interroger le serveur pour obtenir les données est que dans notre cas les résultats arrivent sans attendre, à gauche de l'appel de la méthode.

Ce modèle se rapproche en fait du modèle de programmation utilisé côté serveur. Et cela sera l'objet du prochain article : la création d'une bibliothèque JPA pour GWT. Restez branchés si vous êtes intéressés !

Exportation de la base pour sauvegarde ou consultation externe

Comme expliqué précédemment, les données exportées par SQL.js sont compatibles au niveau binaire avec le format de fichier SQLite. Ceci va nous permettre d'implémenter une fonction interressante pour l'utilisateur : l'exportation de la base de données sous forme d'un fichier qu'il pourra exploiter ensuite avec un logiciel d'exploration SQLite ou tout simplement à des fins de sauvegarde.

Le code se trouve dans la classe MainView. En voici un extrait :

// when the user clicks, we serialize the database content into
// a Base 64 encoded Data URI
// this leads the browser to show the "download file" dialog

// export the SQLite database into an integer array
JsArrayInteger data = DataAccess.get().exportDbData();

// convert it into a Base64 stream
byte[] bytes = new byte[data.length()];
for( int i = 0; i < bytes.length; i++ )
 bytes[i] = (byte) data.get( i );
String encoded = new String( Base64Coder.encode( bytes ) );

// we change the element's attributes before the default event
// handling happens
// so that the browser shows a file download, although all
// happens locally in the browser.
// Please note that i'm not sure that this will support 5Mb
// files.
exportDb.getElement().setAttribute( "download", "OfflineDemo.db" );
exportDb.setHref( "data:application/octet-stream;charset=UTF-8;base64," + encoded );
exportDb.setTarget( "_blank" );

En phase de développement, cette fonctionnalité est également utile : elle vous permet de contrôler le contenu de la base de données et bien sûr de les manipuler...

Synchronisation

Nous voilà donc rendus au problème de synchronisation : nous avons une base de données sur le serveur et une sur le client, il va falloir les synchroniser.

Afin de simplifier la problématique, je découpe en deux sous-problèmes : faire remonter les informations récentes depuis le serveur vers le client (nous l'appellerons la synchronisation descendante), et envoyer les informations récentes du client vers le serveur (ça sera la synchronisation montante).

Synchronisation descendante

Celle-ci consiste à produire côté serveur les informations nécessaires au client afin de reconstituer une base de données cohérente avec le serveur. Le serveur doit donc savoir à quel "point"de synchronisation se trouve le client.

Comme il n'est pas praticable de stocker l'avancement de synchronisation de tous les clients sur le serveur, il va falloir se débrouiller autrement. Voici une solution possible (implémentée dans les classes DownstreamSynchroManager des packages client et server).

Le client demande au serveur les enregistrements créés, modifiés ou détruits dans sa base, à partir d'un "curseur" fourni par le serveur. Le serveur répond par l'ensemble des modifications récentes, ainsi qu'une information opaque pour le client représentant son nouveau curseur de synchronisation.

Ce mécanisme permet :
  • de ne pas stocker côté serveur l'état des clients et donc de supporter un nombre quasi-infini de clients,
  • d'effectuer côté client une synchronisation progressive (on peut définir la taille maximale de la réponse),
  • de repartir de zéro facilement (il suffit d'effacer le curseur de synchronisation côté client).

Synchronisation montante

L'autre problématique de synchronisation que nous allons rencontrer est d'envoyer au serveur les enregistrements créés, modifiés ou détruits par l'utilisateur côté client. Ceci est implémenté dans les classes UpstreamSynchroManager des packages client et server.

Ici la réponse est encore plus simple : nous installons dans la base de données SQLite des triggers permettant de garder traces de toutes les modifications effectuées par le client. Au moment où la synchronisation se produit, nous envoyons ces informations au serveur et celui-ci confirme ou pas la mises à jour de la base de son côté.

Gestion des erreurs et des conflits

Cette thématique sera abordée lors du prochain article. Pour l'instant considérons simplement qu'il n'y a pas de conflit et que les modifications du client ou du serveur sont toujours acceptées par le serveur et le client.

Gestion des fichiers associés aux articles

Dans le cahier des charges, nous avons émis le souhait de pouvoir associer des fichiers aux fiches article. Comment faire ?

Une première idée (naïve) consisterait à sérialiser en JSON le contenu des fichiers en questions et de les stocker dans le Local Storage. Cependant, outre la complexité d'une telle technique, l'espace du Local Storage est limité à 5 Mo et est déjà utilisé par la base de données.

Nous allons utiliser une autre technique : la manipulation de l'Application Cache.

Lorsque le client upload un fichier vers le serveur, le serveur associe le fichier ainsi créé à l'enregistrement de la table articles correspondant. Ce fichier va donc être inclus dans la prochaine version générée du manifest. Lorsque le client a terminé son upload, nous demandons simplement au navigateur de mettre à jour son cache d'application, ce qui va avoir pour effet de stocker le fichier nouvellement uploadé dans le cache du navigateur.

Un avantage notable est que le cache d'application n'a pas de limite de stockage prédéfinie et est réglable dans les options du navigateur. Un autre avantage est que nous pourrons désigner les fichiers par leur url, que l'on soit en ligne ou pas.

Upload (pas en mode hors-ligne, quoique !)

Par contre, il est évident que nous ne pourrons pas uploader de fichier vers le serveur lorsque celui-ci est indisponible. La fonctionnalité n'est donc tout simplement pas disponible dans ce mode.

Cependant il est tout a fait raisonnable d'imaginer une meilleure fin. Les apis HTML5 permettent d'accéder aux données des fichiers uploadés par l'utilisateur. Dans ce cas, on peut sans problème stocker de façon temporaire le contenu du fichier dans le Local Storage de façon à l'envoyer au serveur quand il sera de nouveau joignable.

Ce bout de code n'est pas implémenté...

Conclusion

Voilà, je vous invite à télécharger le projet sur github à cette adresse, afin de tester tout cela.

Cet article n'est pas rentré dans le fond des détails mais survole l'architecture. Avec le code disponible les deux se compléteront mutuellement. N'hésitez pas si vous avez des difficultés de compréhension à poster un commentaire, j'essaierais d'approfondir l'article si besoin.

Mode d'emploi de l'application

Le fichier readme explique comment compiler et lancer l'application.

Pour son utilisation, c'est très simple. Il y a les vues Articles, Orders et Order detail qui permettent d'éditer les différentes tables de la base.

Voici un descriptif des boutons :

  • la coche indique l'état de l'Application Cache. Dans cet état il est clean, mais l'application peut vous signifier qu'une nouvelle version du manifest est disponible et que vous pouvez recharger la page pour en profiter.
  • le signal permet de savoir si on est en mode en ligne ou hors-ligne. Lorsqu'il est vert, c'est en ligne. Gris pour hors-ligne.
  • le bouton de sauvegarde permet de sauvegarder en base de données locale les modifications que vous avez effectuées dans la vue actuelle. En effet, vos modifications ne sont pas sauvegardées tant que vous ne cliquez pas.
  • le bouton de rafraîchissement permet de rafraîchir la vue courante avec les dernières informations reçues du serveur. Lorsque la synchronisation met à jour les données locales, le bouton clignote en rouge, pour vous signifier que c'est le moment de rafraîchir (ici une automatisation est possible !).
  • le bouton poubelle efface toutes les données locales. Si vous rechargez ensuite la page, vous repartez de zéro.
  • le bouton download permet de récupérer une version binaire du fichier SQLite, à des fins de sauvegarde ou bien pour explorer la base de données locale avec un outil externe.
Vous pouvez vous amuser à éteindre et rallumer le serveur, avec plusieurs clients ouverts. Vous pouvez même utiliser votre téléphone ou votre tablette, c'est compatible !

Prochain article : JPA for GWT et détection et résolution des conflits !

A bientôt donc !

Bien entendu, si vous avez besoin de conseils spécialisés ou d'une intervention, vous pouvez visiter le site de mon entreprise : www.lteconsulting.fr, à bon entendeur !

Merci,
Arnaud