Une couche supplémentaire dans le frontend ?
Dans la plupart des applications que j’ai vues bâties avec un framework comme Angular, React ou Vue, il n’y avait pas vraiment de couche supplémentaire dans l’architecture frontend autre que les composantes qui sont fournies dans ces frameworks. Puis, j’en suis venu à développer un prototype d’application avec Angular et je cherchais une façon rapide de tester. Et si on traitait nos services comme une base de données ?
Note : Cet article est purement expérimental. J’aimerais bien avoir votre opinion en commentaire 🙂
La problématique
Supposons que mon objectif soit de faire rapidement un prototype simple d’une application Angular. Pour pouvoir pleinement développer les fonctionnalités et faire une démo, j’aimerais qu’il y ait de la persistance au niveau des données que j’y saisie. J’ai vu quelques solutions originales, comme json-server et d’autres frameworks qui mockent les APIs directement. Il y a un avantage à cette méthode lorsqu’on construit du code de production parce que ça permet de définir les entrées / sorties que le frontend va avoir besoin en facilitant l’intégration avec les vraies APIs par la suite. Par contre, il y a un coût d’intégration, aussi minime soit-il.
Puis, je me suis rappelé un mandat au Ministry of Forests, Lands and Natural Resource de Colombie Britannique. L’application Angular devait pouvoir tomber en mode hors-ligne, puisqu’elle était utilisée dans des mines sous terre. C’était fait avec le localStorage…
Proposition
Dans une application traditionnelle à plusieurs couches, on a MV quelque chose pour le frontend, puis on a une couche de service, une couche d’accès aux données, puis le système de persistance des données (BD, fichiers, etc.). On peut retrouver parfois cette structure dans l’architecture des applications en services REST.
En considérant que les frontends d’aujourd’hui sont des applications complètes en soit comme c’est le cas dans cette article en utilisant le localStorage comme système de persistance, on pourrait alors imagine un backend au strict minimum, soit appliquer l’authentification et la validation des accès pour tous les appels qui nécessitent un accès aux données.
Mais Sylvain… ça ressemble à des microservices ça 😛 !
Ouaip! Mais on s’intéresse au frontend! J’ai vu quelques articles intéressants (ang) qui proposent une architecture clean (ang) au niveau d’Angular. Mais j’aimerais explorer une autre voie, un peu plus simple, en considérant le design pattern qu’est le repository.
Avantages
Le premier, c’est la rapidité d’implémentation. L’objectif étant d’avoir un type générique qui fera pour tous les différents types d’objet du domaine. Un deuxième est au niveau de l’évolutivité. En ayant une couche dédiée à l’accès aux données frontend, ça permet notamment de passer d’une implémentation à l’autre via l’injection de dépendances. De plus, en ayant une implémentation via le localStorage, par exemple, ça pourrait aussi faciliter l’écriture de tests unitaires frontend, considérant qu’il peut être réinitialisé assez facilement.
Inconvénients
Évidemment, il y en a. En ayant une implémentation générique, ça nous limite un peu dans la personnalisation des différents appels au backend. Tous les services doivent être identiques, soit :
- trouver tous les objets d’un type spécifique
- trouver un objet par son identifiant unique
- trouver tous les objets d’un type spécifique qui correspond à certains critères
- ajouter un objet
- mettre à jour un objet
- retirer un objet
Implémentation
localStorage
Dans un premier temps, on doit créer une interface qui va définir le contrat que toutes les implémentations du repository vont devoir respecter. Voici un exemple :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
export interface DataRepository<T> { findAll(collectionName: string): Promise<T[]>; findById(id: string, collectionName: string): Promise<T>; findWithCriteria(criteria: FindCriteria): Promise<T[]>; add(object: T, collectionName: string): Promise<void>; replace(object: T, collectionName: string): Promise<void>; remove(object: T, collectionName: string): Promise<void>; } export interface FindCriteria { collectionName: string; filter: any; } |
Ici, le collectionName va nous permettre de stocker les objets du même type dans une même entrée du localStorage. On pourrait aussi, dans le cas d’un accès à des API via HTTP, mettre ce nom de collection dans l’URL pour appeler le bon service (ex. : /api/clients/findAll). Puis, on crée une implémentation pour écrire les données dans le localStorage :
1 2 3 |
export class LocalStorageRepository<T> implements DataRepository<T> { } |
Ensuite on implémente les méthodes pour que ça compile. Commençons par la plus simple :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
findAll(collectionName: string): Promise<T[]> { return new Promise((resolve, reject) => { try { const serializedData = localStorage.getItem(collectionName); if (!serializedData) { resolve([]); } const data = JSON.parse(serializedData); resolve(data || []); } catch (err) { reject(err); } }); } |
Le principe étant d’obtenir les données depuis le localStorage au format sérialisé en chaîne de caractères, puis de tenter de les retourner. Sauf qu’on a un problème avec les propriétés des objets stockés… Si un type contient une ou plusieurs propriétés de type Date, elles ne seront pas désérialisées en objet Date, mais bien en chaîne de caractères. On pourrait remédier à la situation comme ceci :
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private deserializeDateProperties(objects: T[]) { if (!objects) return; objects.forEach(object => { if (!object) return; Object.keys(object).forEach(key => { if (!key) return; const keyValue = object[key]; if (typeof(keyValue) === 'string' && (keyValue as string).match(dateRegExp)) { object[key] = new Date(object[key]); } }); }) } |
Je vous laisser faire les autres méthodes 🙂 Vous devriez comprendre le principe.
http
Maintenant, on crée une deuxième implémentation pour notre backend. Celle-ci sera évidemment beaucoup plus simple, mais à titre comparatif, voici l’implémentation partielle :
1 2 3 |
findAll(collectionName: string): Promise<T[]> { return this.http.get<T[]>(`/api/${collectionName}/findAll`).toPromise(); } |
FindCriteria
L’implémentation ci-dessus est volontairement simple. Le challenge ici, c’est de créer un système de filtre commun. Heureusement, le type any de JavaScript nous permet de faciliter le filtre en passant par exemple :
1 2 3 4 5 6 |
this.clientRepository.findWithCriteria({ collectionName: 'clients', filter: { active: true } }); |
De cette façon, on a beaucoup de liberté, mais il faudra assurément appliquer une certaine validation à l’attribut filter s’il est envoyé au backend pour éviter une faille de sécurité.
Injection du service
Au niveau des providers du module Angular, il faut simplement lui en définir un comme ceci :
1 |
{provide: 'DataRepository', useClass: LocalStorageRepository} |
Ou encore, en se basant sur une variable d’environnement comme ceci :
1 2 3 4 |
{ provide: 'DataRepository', useClass: environment.production ? HttpStorageRepository : LocalStorageRepository } |
Puis, pour l’injection, il faut utiliser @Inject() et lui fournir le nom du provider créé plus haut :
1 |
@Inject('DataRepository') private repo: DataRepository<Project> |
Conclusion
Comme je l’expliquais en début d’article, tout ceci n’était qu’une expérimentation que j’ai fini par trouver assez agréable. Je suis curieux d’avoir votre opinion en commentaire 🙂 Faites-vous quelque chose qui ressemble à ça aussi dans vos équipes ou vous avez l’habitude de vous en tenir à ce qui est offert par le framework?
Commentaires
Laisser un commentaire