Tutoriel sur le développement d'une application Web GWT de prise de commandes fonctionnant en mode hors-ligne

Arnaud Tournier

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.

Cet article a été rédigé par Arnaud Tournier. L'article original (Une application GWT de prise de commandes fonctionnant en mode hors-ligne) peut être vu sur le blog/site de Arnaud Tournier.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum Commentez .

- Titre : Tutoriel sur le développement d'une application Web GWT de prise de commandes fonctionnant en mode hors-ligne

- Auteur : Arnaud Tournier

- Parution : 15 juillet 2014

- Mise à jour : 18 février 2020

- Licence : Copyright ® 2014 Arnaud Tournier. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Présentation de l'application

- II. Structure des données

- II-A. Côté serveur

- II-B. Côté client

- II-C. Simple, non ?

- III. Architecture, vue de loin

- IV. Gestion du manifest pour l'Application Cache

- V. Génération du manifest (fichiers statiques, dynamiques et issus de la compilation GWT)

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

- VI-A. SQL.js

- VI-B. Persistance de la base

- VI-C. Initialisation de la base

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

- VI-E. Exportation de la base pour sauvegarde ou consultation externe

- VII. Synchronisation

- VII-A. Synchronisation descendante

- VII-B. Synchronisation montante

- VII-C. Gestion des erreurs et des conflits

- VIII. Gestion des fichiers associés aux articles

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

- IX. Mode d'emploi de l'application

- X. Conclusion

I - Présentation de l'application

Cette application, pour un client fictif et qu'il soit en ligne ou non, doit répondre aux besoins suivants :

1. Présenter la liste des articles, des commandes et de leur contenu à l'utilisateur ;

2. Permettre à l'utilisateur de saisir de nouveaux articles et de nouvelles commandes, et modifier celles-ci ;

3. Chaque article en base a la possibilité d'être associé à deux fichiers : une image et une fiche technique. Ces fichiers doivent être consultables ;

4. Les transitions entre mode en ligne et mode hors-ligne doivent être transparentes pour l'utilisateur.

II - Structure des données

À des fins de simplification, nous aurons à gérer trois tables :

- articles : stocke les données de chaque article du catalogue ;

- orders : stocke les en-tê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é).

II-A - Côté serveur

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

II-B - 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 persistance 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.

II-C - 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 œuvre des principes d'une application hors-ligne.

III - Architecture, vue de loin

Avant de rentrer dans les détails, je voudrais vous présenter l'architecture à grosse maille de notre 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 fichiers 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 faits directement depuis la base SQLite locale. Cela a deux effets.

- L'accès aux données est synchrone pour le client. Cela 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. Cela peut rendre plus difficile la gestion de la sécurité et des droits (rien n'empêche un utilisateur de pirater 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 ? ».

IV - 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.

V - 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é 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() : parcourt 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 à transférer vers le serveur par les utilisateurs.

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.

VI - 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é.

VI-A - 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 à fait suffisant pour interroger 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).

VI-B - Persistance 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).

Cela 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.

VI-C - Initialisation de la base

Une fois cette persistance obtenue, 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 chaîne de caractères, 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.

VI-D - 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. Cela 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 :

1. JavaScriptObject jsoResult = sqlDb.execute( "select * from articles where code like '%xx%' group by price" );

2. SQLiteResult result = new SQLiteResult( jsoResult );

3. for( SQLiteResult.Row row : result ) {

4. int id = row.getInt( "id" );

5. String code = row.getString( "code" );

6. }

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. À cette fin, j'ai créé quelques classes permettant de faire ceci :

1. TableRecordSerializer<article> serializer = Serializer.getSerializer( "articles" );

2. JavaScriptObject jsoResult = sqlDb.execute( "select * from articles where code like '%xx%' group by price" );

3. SQLiteResult result = new SQLiteResult( jsoResult );

4. for( SQLiteResult.Row row : result ) {

5. Article article = serializer.rowToDto( row );

6. }

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

1. public List<article> getArticles() {

2. JavaScriptObject sqlResults = sqlDb.execute( "select * from articles" );

3. return deserializeRecords( sqlResults, "articles" );

4. }

6. private <T> List<T> deserializeRecords( JavaScriptObject sqlResults, String tableName ) {

7. TableRecordSerializer<t> recordSerializer = Serializer.getSerializer( tableName );

8. List<t> res = new ArrayList<t>();

9. SQLiteResult rows = new SQLiteResult( sqlResults );

10. for( SQLiteResult.Row row : rows )

11. res.add( recordSerializer.rowToDto( row ) );

12. return res;

13. }

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 !

VI-E - 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. Cela va nous permettre d'implémenter une fonction intéressante 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 :

1. // when the user clicks, we serialize the database content into

2. // a Base 64 encoded Data URI

3. // this leads the browser to show the "download file" dialog

5. // export the SQLite database into an integer array

6. JsArrayInteger data = DataAccess.get().exportDbData();

8. // convert it into a Base64 stream

9. byte[] bytes = new byte[data.length()];

10. for( int i = 0; i < bytes.length; i++ )

11. bytes[i] = (byte) data.get( i );

12. String encoded = new String( Base64Coder.encode( bytes ) );

14. // we change the element's attributes before the default event

15. // handling happens

16. // so that the browser shows a file download, although all

17. // happens locally in the browser.

18. // Please note that i'm not sure that this will support 5Mb

19. // files.

20. exportDb.getElement().setAttribute( "download", "OfflineDemo.db" );

21. exportDb.setHref( "data:application/octet-stream;charset=UTF-8;base64," + encoded );

22. 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 le manipuler…

VII - Synchronisation

Nous voilà donc rendus au problème de synchronisation : nous avons une base de données sur la couche serveur et une sur la couche cliente, 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).

VII-A - 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 pratique 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 serveur) :

- 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).

VII-B - 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 serveur.

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 mise à jour de la base de son côté.

VII-C - 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.

VIII - 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 question et de les stocker dans le Local Storage. Cependant, outre la complexité d'une telle technique, l'espace du Local Storage est limité à cinq 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 transfère 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 envoi vers le serveur, nous demandons simplement au navigateur de mettre à jour son cache d'application, ce qui va avoir pour effet de stocker le fichier nouvellement transféré 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.

VIII-A - 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 à fait raisonnable d'imaginer une meilleure fin. Les API 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é.

IX - 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 !

X - 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.

Cet article a été publié avec l'aimable autorisation de la société LTE Consulting.

Nous tenons à remercier jacques_jean pour sa relecture attentive de cet article et Mickaël Baron pour la mise au gabarit.