Formation Architecture Logicielle
Facteurs clés
- Évolutivité : suivre l’évolution des besoins au fil du temps.
- Testabilité : validation objective de la qualité du travail.
- Maintenabilité : l’essentiel du travail est de la maintenance.
- Productivité : utilisation de technologies tierces pertinentes pour permettre de se concentrer sur notre cœur de métier. Si le remplacement d’un composant par un autre à un coût élevé, c’est que nous avons de la dette technique. Il ne s’agit pas de suivre les modes, mais de rester concentré sur le choix pertinent.
- Sobriété : optimiser l’usage des ressources CPU, RAM, etc.
- Résilience : réduction des SLA au maximum (disponibilité haute).
- Scalabilité : supporter les ajustements de ressources technique en fonction de la charge. On peut aussi anticiper le recyclage des ressources utilisées uniquement de façon ponctuelle (cf. la naissance d’AWS).
Remarque : lien entre scalabilité et résilience, dans la mesure où on doit gérer la perte de ressources. Les programmes doivent donc être “stateless”, de sorte à ce qu’une tâche puisse être reprise par l’un ou l’autre serveur lorsqu’un autre tombe.
Langage commun
Modèle de domaine (entité métier), des services (rendu au client, à l’utilisateur), un espace de stockage et une interface client (cas d’usage) sont toujours commun. Un langage commun est nécessaire entre les différents protagonistes du projet, ou du produit développé: diagrammes, spécifications fonctionnelles et techniques, maquettes, etc.
L’implémentation de la solution se ferai au moyen de paradigmes objets, fonctionnels, ou déclaratif, etc. Et généralement une combinaison des trois.
Remarque : le déclaratif peut s’identifier avec les techniques de décoration, par exemple (cf. décorateurs python, souvent le @mon_décorateur). On “déclare” à l’attention de tel ou tel service (par exemple un serveur web) notre méthode ou fonction pour tel ou tel usage (par exemple, indiquer au serveur web que notre fonction devra être appelée pour telle ou telle point d’entrée).
Une application peut être définie par ses contraintes I/O ou CPU. Le but est de traiter cette contrainte pour éviter qu’elle ne soit bloquante. Typiquement, il s’agit d’optimiser l’usage des threads. Par exemple, il faudra déterminer si on génère un thread pour traiter un appel venant du réseau (par exemple la réponse d’un serveur) ou alors s’assurer qu’un thread est toujours utilisé en lui permettant de recevoir des appels lorsqu’il est inutilisé.
Attention aux “context switch” : le fait de changer de CPU au cours du traitement d’une tâche.
“Event loop” : la boucle tourne en permanence et accepte de nouvelles requêtes dès qu’elle est disponible, y compris le temps que la requête précédente soit traitée par un autre service (Client <=> Serveur Web <=> BDD. Le serveur web accepte une nouvelle requête dès que la précédente a été transmise à la BDD, et dès que cette BDD a fini, le serveur web reprend la réponse et la transmet au client). L’asynchrone permet d’optimiser l’usage du thread. Il est donc très utilisé, en Javascript, où le système ne dispose que d’un seul thread (dans le navigateur, ou avec Node JS).
Aujourd’hui, l’écosystème le plus courant consiste à utiliser un langage basé sur une abstraction (JVM, bytecode, etc.) permettant de coder sans avoir à dépendre de la plateforme où le code sera exécuté. L’abstraction gère les spécificité de la plateforme cible, et la mémoire.
Une application isomorphique est un programme utilisé pour l’ensemble des couches techniques. L’exemple type est Javascript : il peut être utilisé côté client et côté serveur. Plus simple à manipuler (cf. profils “fullstack”), cela implique de perdre l’optimisation acquise en adaptant la technologie à la couche métier (par exemple, Javascript étant peu typé, peut pénaliser la performance, côté serveur).
Remarque : les JVM .NET ou Java supportent de plus en plus de langages différents (par exemple, Kotlin, Scala, Clojure, etc.).
Découpage en couches - Séparation des responsabilités
Séparer la logique métier, du traitement, de la présentation, de la mise en forme, etc. en fonction des besoins du programme, et généralement parce qu’un programme atteint rapidement une taille critique où il faut structurer son code. On se base généralement sur les différentes couches métier (client, api, base de donnée). Il faut par contre limiter au maximum la séparation, chacune devant être justifiée de façon factuelle. Un bon découpage permet par exemple de réduire la complexité du code (conditions et boucles imbriquées), mesurer sa performance, etc.
Remarque : séparer le “service” du “business”, correspond à la séparation entre fonctionnalités du point de vue client, qui consiste généralement à exécuter un ensemble de tâches dans l’ordre adéquat, et la partie “métier” de l’entreprise, qui contient le code nécessaire au traitement de ces différentes tâches.
Lors du découpage de l’application en couche métier, nous avons tendance à forcer tout les appels à traverser les différentes couches implémentées. Cependant, nous pouvons avoir par exemple des appels nécessitant la lecture de données publiques (i.e. sans traitement de donnée, sans contrôle d’accès), pour lesquels il est possible de court-circuiter les couches service et business. On peut également avoir besoin d’un modèle de base de donnée (voir même un type) différent en fonction du type de requête. Un bon exemple est une requête de lecture utilisant du GraphQL pour chercher à récupérer les données supplémentaires, comme l’auteur, l’éditeur, d’un livre. Une requête d’édition se limitera peut-être plus souvent à un seul type de ressource (ou alors on change l’auteur ou l’éditeur, donc on change la référence correspondante dans les informations du livre).
Remarque : l’injection de dépendance peut se traduire (dans sa forme la plus simple) par une classe exposant des méthodes au moyen des quels l’appelant peut définir ses dépendances (par exemple monService.setBusiness(“toto”, totoBusiness)). Bien entendu, l’injection de dépendance sera implémentée de façon nettement plus élaborée, dans la pratique. On peut étendre encore le concept avec des patterns comme la Factory, le Proxy, etc. en fonction de la complexité du besoin.
Un bon moyen de savoir si nos choix de design patterns sont pertinents consiste à les confronter à quelques règles :
- Separation of Concern : clair est propre;
- Keep It Simple Stupide : facile à appréhender (exemple des petits outils spécialisés que l’on retrouve sur les systèmes compatible Unix);
- DRY : éviter de se répéter, typiquement par un abus de duplication du code;
- la faculté à utiliser des technologies tierces pour se focaliser sur le cœur de métier de notre projet, et pouvoir facilement remplacer ces technologies sans avoir à modifier des portions importantes de notre code.
Une bonne abstraction permet également de dégager un socle technique, pour ensuite le partager entre les projets. De la même façon, nous pouvons arriver à l’usage de bibliothèques ou frameworks dont le métier est justement de fournir un socle technique commun à nos projets, et donc nous permettre de nous concentrer sur notre métier. Par exemple, un framework peut fournir une base sur laquelle développer notre modèle de donnée sans avoir à se préoccuper de l’implémentation des accès en base de donnée (ou même l’architecture de cette base de donnée directement), tandis qu’un autre permettra de simplifier à l’extrême l’implémentation de la couche service, pour n’avoir qu’à renseigner les méthodes business et la façon de les utiliser, sans avoir à se préoccuper des détails techniques de la communication avec l’interface utilisateur.
Stateless : Un service peut maintenir une copie des données en cache, le temps de son traitement. L’idée étant que, si ce service, ou cette instance du service tombe, seul le cache, sa copie, est perdue, et pas la donnée elle-même.
Tester le programme
Cela consiste à comparer le comportement obtenu au comportement attendu, c’est-à-dire l’implémentation des spécifications. Ils permettent également de s’assurer qu’aucune régression n’est introduite par l’application de nouvelles fonctionnalités, ou de montée de version sur les dépendances.
Il est important que les tests soient écrit par une personne différente de celle qui a écrit le code (d’où l’idée d’une équipe “Q.A.” en charge des tests sur l’application).
Remarque : l’injection de dépendance facilite également l’écriture de tests, où nous surchargerons une classe injectée au moyen d’une variante de cette classe (un mock).
- Tests unitaires : sur une fonction donnée.
- Tests fonctionnels : sur un ensemble de fonctions.
- Tests d’intégration : en intégrant les composants externes au programme que nous testons.
- etc.
La donnée
SQL
Dans la mesure du possible, les clauses de condition (where), regroupement (group by), de classement (order by) et de jointure (join) doivent cibler des colonnes indexées en base de donnée.
Le SQL ne couvre pas tout les besoins. Il atteint ses limites pour gérer la charge : pour garantir les contraintes du SQL, chaque instance doit posséder toute la données. Il ne permet donc pas de jouer sur la répartition de la charge. À noter que les bases de donnée SQL offre l’option de “sharding”, mais cela va à l’encontre des possibilités offertes par les jointures, en particulier (perte de performance).
NoSQL : “Not only SQL”
Le NoSQL émerge pour gérer les nouveaux besoins du web, notamment la volumétrie (beaucoup plus de personnes produisent beaucoup plus de contenu) et la complexité de la donnée générée (une très grande variété de contenu, contrairement à avant, où il s’agissait d’articles au format bien défini).
Les bases de données NoSQL se répartissent grosso modo en quatre grandes familles :
- Clé / Valeur (exemple : Redis) -> pour des besoins très simples
- Colonnes (exemple: Cassandra)
- Documents (exemple : MongoDB, également Elasticsearch)
- Graph (exemple : Neo4j) -> adaptée pour raisonner en terme de relation.
Les données sont distribuées sur plusieurs nœuds, le schéma n’est pas forcément prédéfini, et la plupart n’ont pas de notion de relation (sauf les bases de donnée Graph, dont c’est la spécialité). Le No SQL apporte donc la résilience et l’élasticité manquant au SQL, au sacrifice des performances relationnel et au respect des normes ACID suivi par le SQL.
Faire du multi-paradigme
Dans la pratique, nous pouvons être intéressé par les avantages de multiples modèles de base de donnée.
Une solution est de mettre en place un système de réplication (par exemple, par indexation) des données maîtres dans une base de donnée spécialisée. L’exemple type, c’est une base de donnée relationnelle, avec une indexation dans une base Elasticsearch, pour les besoins de recherche. La stratégie variera suivant que les actions sont majoritairement de l’écriture, ou de la lecture de donnée et de l’importance des relations entre les données dans ces opérations.
Par exemple, si les écritures sont relativement rare, on s’orientera plutôt sur une mise à jour d’Elasticsearch à chaque fois qu’une entrée est ajoutée à la base de donnée relationnel.
Les problématiques de la donnée sont ainsi l’édition, et la recherche, sur la base des relations existantes (et leur nature) entre nos données.
Il faut donc prévoir une étude assez poussée des catégories de base de donnée, et les options qu’elles offrent (comme le “full text search” de certaines bases de donnée relationnelles). La plupart des fournisseurs de base de donnée cherchent maintenant à offrir des solutions permettant d’établir des ponts entre les différentes familles, de sorte à couvrir une grande variété de cas d’usage, et éviter aux entreprises l’obligation de supporter plusieurs base de donnée.
Remarque sur Elasticsearch : les index peuvent être créé à la volée (i.e. en saisissant un nom d’index lorsqu’on défini la nouvelle entrée), mais l’usage est de le faire avant (un PUT host:9200/mon-index) car on en profite généralement pour configurer cet index. Pour la culture : on donne de la tolérance à la recherche dans Elasticsearch en ajoutant un tilde “~” à la suite de la query. À noter que re-générer un index dans Elasticsearch a un coût élevé, on cherchera donc à éviter d’avoir à le faire trop souvent.
Remarque sur Docker : les images renseignées dans le champ “image” des docker-compose, ce sont des OCI (namespace + cgroups), ce qui correspond à un standard. À creuser, mais ce standard est normalement inter opérable entre Docker, Kubernetes, etc.
Théorème CAP
- Consistency (Cohérence)
- Availability (Diponibilité)
- Partition tolerance (Tolérance au partitionnement).
On ne peut garantir que deux des trois options. On privilégiera par exemple la cohérence des données sur la disponibilité, ou inversement.
Cohérence des données
OLTP : OnLine Transaction Processing.
Si on vise une cohérence forte, on annulera les modifications apportée par une requête donnée si elle échoue en cours d’exécution. La cohérence forte présente l’inconvénient d’une perte de temps potentiel, en cas d’erreur (ce qui est dommageable, sur un traitement pouvant facilement échouer).
À titre d’alternative, on peut définir un objectif métier irrévocable (par exemple, création du billet, après le paiement, en prenant le risque de ne pas avoir mis à jour le nombre de place disponible en cas d’erreur). Il s’agit d’une cohérence à terme : le serveur fera une nouvelle tentative de résolution des objectifs qui n’ont pas été atteint du premier coup. La cohérence à terme implique qu’on est sûr de parvenir à rétablir la cohérence (de nos données) dans un délai raisonnable, après traitement de la demande utilisateur.
Certaines bases de donnée garantissent également la cohérence des données en bloquant l’accès le temps de l’écriture. Cette cohérence est donc garantie au détriment de la performance, ou de la disponibilité, de la base de donnée.
Apparté sur la programmation réactive
La programmation réactive permet de décrire un ensemble d’actions à réaliser, et souscrire au résultat, de sorte à libérer le thread en attendant que la requête ait commencé à émettre un résultat. Cela permet de produire du code non bloquant, mais au prix d’une perte en lisibilité (notamment lorsqu’on doit réaliser un traitement complexe sur la donnée récupérée). La programmation réactive peut être intéressante pour la lecture de donnée dans une page web, typiquement (donc RxJS, et seulement lorsqu’on attend une réponse du navigateur), avec relativement peu de traitement des données reçues, avant affichage dans le navigateur.
Remarque : la programmation réactive consiste à faire en sorte que la liste de callback renvoi directement une souscription à laquelle notre client (l’appelant) souscrit, pour être informé, lorsque l’ensemble des calls ont aboutit. Si on fait foo(a).bar(b).baz(c), foo renvoi une souscription que bar récupère, de sorte que lorsque foo abouti, bar effectue son traitement, et de même pour baz. Tandis que la méthode qui a exécuté notre foo(a).bar(b).baz(c), eh bien elle attend elle-même que la souscription abouti pour en retourner le résultat. À un moment donné, il y aura toujours quelqu’un pour attendre le résultat. Après, on peut faire en sorte que la dernière souscription est envoyé au client via une websocket vers l’interface qui produira directement le rendu.
Piste de réflexion : si le backend supporte le GraphQL, on peut déjà filtrer la données à récupérer, les champs, etc. Du coup, le code réactif côté client se limitera à insérer la donnée dans un composant web adéquate. Cela implique d’ailleurs de prévoir un composant sur mesure qui prend en paramètre les données remontées par le call GraphQL. On arrive à un truc du genre : myConnextion.query(‘graphql-code’).then(data => new MyComponent(jsonData));
Beaucoup de langages fournissent, en remplacement des callbacks, un fonctionnement de type async/await. L’idée du await est de dire au thread “quand tu arrives sur cette ligne de code, tu peux aller travailler avec un autre client, le temps que cette ligne renvoi une réponse, et t’inquiètes, on aura toujours quelqu’un pour prendre la main lorsque cette réponse sera arrivée”. Async/await est plus agréable et naturel à utiliser que la programmation réactive.
À la récupération des données, on peut également mettre en place des solutions d’optimisation, comme le lazy loading, qui permet de récupérer certains détails à la demande (seulement au moment où le besoin s’en fait ressentir).
Persistence
La connexion sur les bases de donnée se fait généralement en TCP (Elastic est une exception notable, en HTTP). Certaines bases de donnée offre un mécanisme de session, permettant d’exécuter un lot de requête de façon optimisée.
Notion d’entité forte et d’entité faible : les premières peuvent exister indépendamment d’un contexte (par exemple un immeuble) et les secondes dépendent d’une identité faible (par exemple, un étage nécessite de connaître l’immeuble dans lequel il se trouve). D’une façon générale, une entité forte donnera une table, dans une base de donnée relationnelle, tandis qu’une identité faible sera plutôt une relation ou une entrée dans une table (une exception sera par exemple le compte d’un utilisateur : ce sera une table, mais il ne pourra pas pour autant exister sans être associé à un utilisateur).
Les données seront généralement représentées à l’aide d’un identifiant. Les identités faibles n’auront pas d’identifiant en base (du moins, pas d’identifiant propre : le compte aura une référence vers l’identifiant de l’utilisateur auquel appartient le compte).
Remarque : dans la pratique, on sera amené à fusionner les entrées propres à l’identité faible avec la table de l’identité forte associée, car une table dédiée n’a pas de raison d’être valable.
Monolithe et microservices
Une question importante est celle de la délimitation des contextes. Elle déterminera quel est la meilleure approche à suivre, dans de cadre du cœur de métier de l’entreprise, pour élaborer notre produit. Il est donc pertinent de partir de son domaine d’activité : on parle donc de “Domain Driver Design”, ou conception orientée domaine. Cette conception s’appuie donc sur l’expertise du client.
L’application pourra ensuite être construite sous la forme d’un monolithe. Celui-ci cherchera à couvrir le plus grand périmètre fonctionnel possible dans un seul produit. Celui-ci pourra toujours être découpé en plusieurs composants, mais avoir un monolithe signifie qu’il n’y aura pas d’appel externe et cela contraint la variété des technologies pouvant être employées. Il permet par contre de garantir une cohérence forte de l’ensemble du produit. Dans le monolithe, même si le produit fait l’objet d’un seul livrable, on peut quand même avoir un composant dédié à chaque fonctionnalité de notre produit. Dans l’absolu, on peut avoir des contextes délimités à l’intérieur d’un monolithe, allant même jusqu’à avoir plusieurs bases de données (ce qui est pourtant cité comme un avantage des micro services).
Remarque : les bases de donnée relationnelle offrent la possibilité de définir des vues, qui peuvent être spécifiques à chacune des fonctionnalités du produit, tout en ayant un seul schéma sous-jacent, avec son jeu de tables.
NB “culte du cargo” : c’est copier des particularités d’une technologie dans une autre, sans en comprendre les implications.
L’approche microservice cherchera à créer un service pour chaque contexte défini lors de l’analyse du besoin. Il visera à obtenir des composants indépendants, pouvant être déployés dans des environnements distincts (bases de donnée, machines, technologies, langage de programmation, etc.). Cependant, les microservices souffrent de difficultés pour maintenir la cohérence des données entre les différents services qui composent l’ensemble. De plus, l’ensemble peut être difficile à maintenir, dans le cas (par exemple) où un service clé tombe, et les erreurs peuvent être difficiles à analyser. Le microservice sera donc adapté si chaque contexte est réellement indépendant des autres, du moins suffisamment pour qu’aucune donnée critique ne soit partagée entre les différents services.
L’architecture microservice repose généralement sur un Message Broker (RMQ, etc. permettant un système de publication/souscription). Cela dépend du protocole de communication choisit entre les services. Quoi qu’il en soit, il faut que les microservices puissent communiquer entre eux, de façon plus ou moins indépendantes : un simple broker, c’est une totale indépendance, tandis qu’un intermédiaire plus lourd peu imposer du contrôle sur les données transitant entre les services (autorisation, garantir la cohérence des données, etc.).
Remarque, pour une idée originale d’architecture différente du microservice : https://www.touilleur-express.fr/2015/02/25/micro-services-ou-peon-architecture/. Il s’agit en quelque sorte d’une architecture intermédiaire, entre les modèles monolithique et microservice, où un composant central coordonne l’activité d’agents dédiés aux fonctions métiers.
Communication par messagerie
MOM “Message Oriented Middleware” : un message représente un événement dont il faut définir les propriétés. Il faut un système de publication / souscription permettant aux services d’émettre et recevoir ces messages.
- Broker : gestionnaire de la distribution des messages.
- Producer : envoi ses messages au broker.
- Consumer : celui qui va s’inscrire auprès du broker pour recevoir le message.
Deux technologies : AMQP (standard supporté, par exemple, par RabbitMQ) et Kafka (fondation Apache).
Techniquement, les consommateur des messages peut le faire de deux façon différentes : soit il va chercher les messages (pull, la connection est maintenue active), soit il va les recevoir (push, la connexion est établie à chaque fois qu’un message est envoyé).
Un message nécessite un mécanisme de routage, pour atteindre sa cible. Il faut déterminer si on veut les recevoir dans un ordre précis ou non, s’il faut un accusé de réception (et éventuellement un système pour retenter l’envoi) une priorisation, et éventuellement un mécanisme de stockage.
L’échange peut suivre différentes stratégies :
- direct : vers une seule queue,
- fanout : vers 1..n queus
- topic : envoi suivant un système de valorisation par une clé de routage.
L’ordre de livraison par défaut sera first in first out, mais cela ne garantie pas qu’ils seront reçus dans le même ordre. À noter que Kafka met en place un système de partition, basé sur l’échange de type topic, permettant de garantir que les messages à l’intérieur d’une partition seront consommé dans l’ordre où ils ont été émit.
Le broker se base sur la réception d’un accusé de réception (“ack” ou “nack”) pour déterminer si le message envoyé a été reçu ou non.
La notion d’accusé réception est plus complexe qu’il ne peut y paraître à première vue, car l’une des priorités d’un système de messagerie est de s’assurer que le message n’a été envoyé et traité qu’une seule fois. Certaines systèmes prévoient d’ailleurs une souplesse, en garantissant plutôt que le message a été traité “au moins” une fois.
Une des solutions les plus courantes, c’est de s’assurer que les messages envoyés sont “idem-potent”, ce qui garantie que, même s’il est traité plusieurs fois, il ne fera qu’une seule fois la modification côté consommateur du message. Kafka embarque directement un système de ce type pour garantir que le message n’a été reçu qu’une fois, sur la base d’un hash unique (dans la pratique, c’est plutôt un système de déduplication).
Remarque concernant l’idem-potence : une bonne pratique, d’une façon générale, sera d’envoyer comme message “redéfinie telle clé à telle valeur”, plutôt qu’un “incrémente la valeur”. Le premier est idem-potent, pas le second.
Les systèmes de messagerie supportent également un mécanisme de “commit”, similaire à ce qu’on trouve pour les bases de donnée relationnelles.
Un système de priorisation peut être rajouté par dessus le concept de “first in first out”. Il s’agira en effet d’attributer une priorité différente à un message donné pour des raisons métier. Ce peut être supporté via un attribut de priorité sur le message, ou par un système de partition dédiée.
Un AMQP classique ne stock pas les messages, partant de la philosophie qu’une queue vide est une queue rapide (i.e. on s’en débarrasse le plus vite possible pour faire de la place pour le message suivant). Kafka propose par contre un système de stockage des messages. L’idée sous-jacente, c’est qu’un souscripteur à une queue sera ainsi en mesure d’accéder à tout ou partie de l’historique des messages publiée avant sa souscription … ou sa “re” souscription (donc pour récupérer les messages qui auraient été publiés durant la perte de connexion).
Communication par API
Exposer un service nécessite une technologie de transport (HTTP, etc.), et une de sérialisation (XML, JSON, Protobuf).
Le service exposé doit être documenté de façon rigoureuse. Typiquement, en protobuf, on a des fichiers .proto dédié à ça. Ces fichiers peuvent être généré à partir du code (bottom-up), ou bien générer l’architecture du code à partir de la documentation du service (top-down). Dans ce dernier cas, il restera bien évidemment à produire le code métier du service.
Côté client, une interface, le proxy, se chargera de fournir une abstraction au protocole de communication, et se chargera de traduire la donnée en objet propre au service client.
RPC
Le principe RPC est le prolongement naturel du design pattern “façade” : l’idée est d’exposer au travers du réseau une procédure (“Remote Procedure”). L’idée de façade se traduit par l’écriture d’une classe dédiée portant les méthodes exposées vers le monde extérieur, et faisant office d’interface avec le code métier du service ainsi exposé.
Le gRPC, utilisant le format protobuf, s’appuie sur du binaire. L’avantage est la performance, mais l’inconvénient est le traitement des erreurs (dur de lire le binaire). gRPC s’appuie sur le protocole HTTP/2, ce qui permet une connexion bi-directionnel, avec par exemple la possibilité de faire du streaming de donnée (un “body” infini).
Le RPC implique également un couplage fort entre le client et le serveur (à moins de réussir une architecture, au niveau de la façade, permettant de mitiger cet inconvénient).
Remarque : lorsque le RPC est implémenté sur la base d’un serveur HTTP, on utilisera souvent le verbe POST.
REST
Le REST implique de communiquer sur la base des ressources, contrairement au RPC, qui communique sur la base des méthodes de manipulation de ces ressources. L’approche est donc radicalement différente, puisqu’il s’agit de définir en amont des ressources destinées à être exposées.
Pour bien se représenter la différence, il faut se dire qu’en RPC, l’endpoint sera un verbe d’action (“/find-book” par exemple), alors qu’en REST, ce sera un nom commun (“/book”).
La première étape pour mettre en place le REST est de choisir le protocole de base (généralement HTTP), puis la structure des ressources à exposer (leur modèle de donnée). Il faut ensuite bien maîtriser les verbes de sorte à fournir la réponse adéquate (un GET pour lire, POST pour éditer, PUT pour ajouter, DELETE pour supprimer, OPTIONS pour connaître les actions possibles sur la ressource, etc.).
Remarque POST : est le seul verbe à ne pas avoir une sémantique imposée, permettant de supporter tout les cas d’utilisation propre au métier. En principe, il est dédié aux actions qui sortent du CRUD classique. Si on s’impose de ne pas avoir de verbe dans l’uri, on se force à réfléchir en mode ressource. Par exemple, on utilisera plutôt une uri “/reservations” au lieu de “/books/1/reseve” (une réservation est une ressource, pas une action). Savoir si POST est idem potent ou non nécessite de bien définir la notion même d’idem potence dans le cadre de notre métier, et de réfléchir ensuite à la façon dont on implémente l’action associée au POST.
Remarque PUT : un PUT attend l’intégralité des ressources, plus la nouvelle, tandis que POST sous-entend de “modifier” cette liste de ressource, sans avoir à fournir à nouveau l’ensemble de la liste. Du coup, on ajoutera plutôt une ressource à la liste avec POST.
Une implémentation rigoureuse du REST implique également d’utiliser le bon code retour en fonction de l’action reçue et du résultat du traitement (par exemple, 201 pour created, 202 pour accepted, etc.).
Remarque : la norme REST est très dogmatique. Elle doit être appréciée de façon pragmatique, et plutôt viser une adaptation en fonction de nos besoin. Par exemple, le dogme dit que renvoyer “404 Not Found” signifie que le type de ressource /ma-ressource n’existe pas. Or, on considèrera plutôt que “404 Not Found” indique que la ressource demandée n’existe pas (i.e. la différence entre dire “on n’a pas de livre” et “on n’a pas le livre de Victor Hugo ‘Les Misérables’ “).
GraphQL est une alternative à REST, permettant de décrire la structure de donnée attendue par l’appelant. Il est par contre fortement recommandé de se baser sur une bonne abstraction, pour ce qui est de l’implémentation des modèles, car elle est ardue à implémenter manuellement (le coût pour la mise en place est élevé).
Websocket et STOMP
Les websockets permettent d’ouvrir un canal TCP entre client est serveur. La connexion doit être initiée par le client, les échanges seront ensuite bidirectionnels. L’idée est que plusieurs clients peuvent souscrire à une ressource, de sorte à ce que le serveur les informera tous lorsque l’un d’eux y apportera une modification. Le serveur reste donc garant de la cohérence des données qui transitent entre les clients (Peut-on imaginer une évolution vers du peer-to-peer où le serveur n’est plus que passe-plat, avec un système de cryptage et blockchain ?).
STOMP (Simple Text Oriented Messaging Protocol, http://stomp.github.io/) fournit une surcouche permettant de penser en terme de messages, de souscriptions et de destinations et non plus en terme de frame, afin de contrôler qui reçoit quel message.
Une idée pour garantir la cohérence des données, est que les instances de l’application, côté serveur, ne renvoi pas directement le résultat du traitement, mais va le transmettre à un broker “STOMP” qui se chargera ensuite d’informer toutes les instances de notre application qui remonterons à leur tour l’information aux clients. L’importance ici est de s’assurer que tout les clients sont informés en même temps.
La couche de présentation
Efficacité
Chaque action de l’utilisateur doit se traduire par un changement à l’écran : un feedback lui permettant de savoir qu’il a fait quelque chose. C’est le principe de base de l’ergonomie. Par exemple : réaction du bouton, celui-ci devient inactif, ou temporairement désactivé, une notification que l’action a bien été prise en compte et/ou a réussit, etc.
Il faut prendre en compte une interface optimisée, typiquement : images compressées et de résolution adéquate, le lazy loading, la mise en cache, etc.
Sécurité
- XSS : exécution de code malveillant (par exemple) dans le navigateur du client. On évitera au maximum de stocker de la donnée sensible chez le client, utiliser un checksum permettant de confirmer que le contenu du fichier chargé correspond à son hash, etc.
- Les formulaires : souvent associés aux ressources exposées au client. Les données doivent être validées avant d’être traitées.
- etc.
Server/Client Side Rendering
Le Server Side Rendering consiste à construire toute l’interface côté serveur, tandis que le Client Side Rendering consiste à ne renvoyer que la donnée nécessaire à la construction de l’interface, celle-ci disposant ensuite des moyens techniques pour procéder à cette construction. Dans le premier cas, le rendu est assumée par la même application que celle traitant les données. Dans le second cas, c’est une application dédiée au rendu qui s’en chargera. L’exemple type est celle de l’application web, avec un framework JS (React, etc.) qui interroge une API REST renvoyant du JSON, et construisant la page web dans le navigateur.
Cookies et Sessions
C’est ce qui permet de retrouver l’état d’une application. Le cookie, porteur d’une valeur unique, permet de retrouver la session utilisateur. L’objectif est qu’aucun script ne peu obtenir son contenu (typiquement, le cookie est marqué “Secure; HttpOnly; SameSite=strict” pour informer le navigateur qu’il ne doit pas autoriser les scripts qu’il exécute a lire ce cookie). Le principe est que le cookie est créé par le serveur, et le navigateur va le renvoyer à chaque requête au serveur. Ce dernier pourra ensuite s’assurer qu’il connaît bien ce cookie, et retrouver les informations qui lui sont associés.
La façon dont le serveur stock et utilise le cookie est importante, pour garantir l’expérience utilisateur. Par exemple, si l’instance associée à cette session tombe, si le cookie n’est pas répliqué ailleurs, le client perd sa connexion. Cela se traduit, pour un navigateur, par le client renvoyé vers le formulaire d’authentification, ce qui peut être assez désagréable).
Session et authentification
L’authentification peut se faire par jeton, mais dans le cas d’une application où la connexion est maintenue, il sera exposé aux failles XSS.
La méthode à la mode est celle du JWT, composé de trois blocs encodés en base 64 : entête, corps, et signature. L’entête défini la manière de lire le corps du token. Celui-ci contiendra la charge utile, c’est-à-dire l’information nécessaire pour identifier l’utilisateur auquel appartient cette session. La signature est générée à partir d’une clé secrète, de la charge utile, et du corps du token. Il permettra de valider la charge utile et confirmer que le jeton appartient bien à la personne le fournissant. La charge utile contiendra des informations, comme la date de début et fin de validité, et le type d’audience dont fait parti l’utilisateur. Le jeton est passé via l’entête “Authorization”, et normalement précédé du mot clé “Bearer”.
OAuth2 permet de déléguer la charge de génération de tokens valides. L’authentification se fera au moyen d’une clé secrète, à partir de laquelle nous pourrons ensuite authentifier les jetons. La clé secrète peut être connue uniquement d’un serveur OpenID, qui gèrera donc la signature des jetons.
Une idée plus poussée est que l’utilisateur dispose d’une clé publique, tandis que le serveur OpenID possède la clé privée. L’utilisateur signe avec sa clé publique, tandis que le serveur utilise la clé privée pour vérifier cette signature.
Remarque Hasher un mot de passe : argon, bcrypt, scrypt (pas md5, shaXXX qui ne sont pas fait pour ça). Ces méthodes utilisent un “salt” qui va complexifier artificiellement le mot de passe. L’idée est la suivante : lorsque l’utilisateur créée son mot de passe, bcrypt va générer une valeur composée d’un salt et du mot de passe hashé. Lorsque cet utilisateur va s’authentifier, il fournira son mot de passe. Le serveur va récupérer la donnée en base pour cet utilisateur, et bcrypt va extraire le salt du mot de passe et s’en servir pour encrypter le mot de passe fournit par l’utilisateur. Si le résultat obtenu correspond à ce qui se trouve en base, alors l’utilisateur est authentifié. C’est un peu le principe d’un chiffrage asymétrique.
Pour avoir un exemple sur OpenID Connect, voir KeyCloack.
Idée : lorsqu’un utilisateur s’authentifie sur la plateforme, créer un keypair de session, et renvoyer à l’utilisateur une clé publique. Ensuite, chaque action de l’utilisateur est accompagnée d’un jeton, signé avec cette clé publique. Pour persister une connexion, on peut fournir une clé et un point d’entrée dédiés au renouvellement de la clé publique. Une idée originale serait de regarder les algorithmes de minage de monnaie virtuelle pour l’appliquer au hashage de mot de passe.
MVC (Model View Controller) et MVVM (View ViewModel Model)
Ce sont des patterns cherchant à séparer l’étape de traitement de l’étape de rendu de la donnée, lorsque le rendu est réalisé côté serveur. Ces patterns possèdent des déclinaisons en fonction de la façon dont le rendu est fait (cf. Server/Client Side Rendering). Le MVVM est une version simplifiée du MVC, où les vues communiquent directement avec les modèles, qui exposent leurs propriétés et les actions possibles sur les ressources correspondantes.
Cette famille de pattern reste largement d’actualité, car même si le rendu est déporté côté client, l’architecture dans son ensemble reste la même.
Single Page Application
L’idée est de pousser à son paroxysme l’idée d’un rendu côté client. Le client va récupérer la donnée brute du serveur, et prendre en charge tout le traitement jugé comme sans risque, côté client (et pas seulement le traitement visant la mise en forme de la donnée). Ce genre d’architecture, côté client, est pertinent lorsqu’on développe une application client complexe, comme par exemple un jeu vidéo en ligne. Les SPA nécessitent généralement l’usage de frameworks JS tel que React ou Vue.js, dont l’architecture ressemble fortement à du MVVM.
NB : dans la pratique, des design patterns spécifiques ont vue le jour, pour le navigateur web (comme le système de composants), ainsi que des concepts spécifiques, comme les workers (pour les Progressive Web App).
Un client lourd sera développé sur la base d’un package.json définissant notamment une liste de “scripts”, qui seront des alias sur des commandes exécutables avec “npm run” (généralement “test”, “build”, ou “run”). Le projet comportera de base un index.html, style.css et un main.js (éventuellement un polyfills.js pour garantir que certaines fonctionnalités modernes soient bien disponibles, même sur des navigateurs plus anciens). On utilisera classiquement webpack pour construire le projet web à partir des fichiers javascript (avec potentiellement manipulation des fichiers html et css).
Livraison et déploiement
Grands principes
- Compilation : conversion vers un code machine compréhensible par la machine (éventuellement par le biais d’une machine virtuelle comme la JVM).
- Interprétation : code interprété “Just In Time” et exécuté à la volé.
Il s’agira donc soit de livrer un programme près à l’emploi, soit un programme accompagné de son environnement d’exécution. Il faudra également spécifier les conditions dans lesquels le programme peut être exécuté.
Pour simplifier les déploiements, un ensemble de règles, appelées “12 factors”, ont donc été définis.
- Codebase : un seul code source, et pas de code dupliqué (sinon on créée une lib externe).
- Dependencies : les dépendances doivent être fournit avec le programme.
- Configuration : fournit dans l’idéal par le biais de variables d’environnement, et le code doit exécuté doit être le même quelque soit l’environnement où le programme tourne (pas de code spécifique pour l’environnement de dev ou de prod, par exemple).
- Backing Service : les ressources externes doivent être interchangeable, et les remplacer doivent juste se résumer à changer une url.
- Build, Release, Run : chacune de ces phases doivent avoir un périmètre clair et bien délimité. Une fois buildé, le programme est livré à l’identique, quelque soit la plateforme cible, et son exécution ne nécessite plus d’éléments externes à son exécution qu’il faudrait construire.
- Processes : le projet doit fournir tout le nécessaire pour que le programme puisse être exécuté, sans que les Opérations doivent deviner les composants ou des éléments supplémentaires à fournir (exemple : rpm install mon-app, éventuellement définir la configuration, puis simplement systemctl start my-app).
- Port Binding : le programme est joignable sur un port, et il est capable de traiter n’importe quelle requête envoyée sur ce port.
- Concurrency : l’augmentation de charge doit être prise en compte, et ne nécessiter au maximum que l’augmentation du nombre d’instances de l’application.
- Disposabiliy : le programme doit supporter les signaux système de mise en pause, d’arrêt, de redémarrage, etc.
- Dev/Prod Parity : c’est l’objectif d’avoir un environnement de production (ou au moins de qualification) autant iso-prod que possible.
- Logs : l’application doit gérer les logs comme un flux d’événements, dont la destination sera configurée au moment d’opérer l’application.
- Admin Processes : les tâches d’administration doivent être gérés indépendamment de l’application elle-même (dans l’idéale, déployées indépendamment de l’application elle-même).
Les petites boîtes jetables
Une solution pour permettre aux opérations de ne pas avoir à se préoccuper de la solution technique mise en place par l’équipe de développement est d’utiliser une image OCI (comme Docker ou Kubernetes), à partir de laquelle ils démarrerons un container. Ce container nécessitera essentiellement de connaître un port d’écoute et la configuration peut être appliquée au moyen de variables d’environnement. Les containers sont également prévu pour avoir une espérance de vie très courte, d’être remplacée à chaque montée de version du service ou lorsqu’une instance tombe pour une raison X ou Y.
Une image OCI est composée de layers (ou couches). En général, la couche de base contiendra tout le nécessaire à l’exécution de l’application, et les couches supplémentaires concerneront l’application elle-même (dans l’idéal, on aura en tout 2 à 3 couches). Les images sont ensuite stockées sur un registry, à partir duquel il est possible de déployer 1..N containers, et ce vers n’importe quel environnement (développement, intégration, qualification, production). En général, on s’organise pour réaliser le build de l’application dans un container (i.e. c’est le cas dans Gitlab CI avec une configuration classique). Si on écrit un Dockerfile par nous-même, on peut définir deux “stages”, une de build, et une de run, cette dernière faisant des COPY avec l’option “–from=build” (à condition d’avoir informé Docker que la première stage a pour alias “build”).
La solution la plus courante aujourd’hui est d’utiliser Kubernetes, comme étant un orchestrateur d’images OCI, de containers, éventuellement de machines virtuelles (i.e. il pourra démarrer une machine virtuelle pour y déployer le container et tout ce qui va avec, comme le volume). En local, on peu tester avec “MiniKube”. Kubernetes se base sur un fichier yaml “descripteur” de déploiement. Ce fichier permettra d’indiquer notamment le nombre d’instances (pod) du service, les variables d’environnement, l’image à utiliser, etc.
Remarque : la configuration de service Kubernetes embarque un mécanisme permettant de déporter les variables d’environnement porteuse de donnée sensible en dehors du fichier de configuration.
Et sans boîte, ça donne les fonctions
Le serverless consiste à déployer des fonctions ultra légères, sollicitées uniquement à l’usage. Elles sont conçues pour être exécutées en très peu de temps.