Tirer parti de la programmation déclarative pour créer des applications Web maintenables

Publié: 2022-03-11

Dans cet article, je montre comment l'adoption judicieuse de techniques de programmation de type déclaratif peut permettre aux équipes de créer des applications Web plus faciles à étendre et à maintenir.

"... la programmation déclarative est un paradigme de programmation qui exprime la logique d'un calcul sans décrire son flux de contrôle." —Remo H. Jansen, Programmation fonctionnelle pratique avec TypeScript

Comme la plupart des problèmes logiciels, décider d'utiliser des techniques de programmation déclaratives dans vos applications nécessite d'évaluer soigneusement les compromis. Consultez l'un de nos articles précédents pour une discussion approfondie à ce sujet.

Ici, l'accent est mis sur la manière dont les modèles de programmation déclarative peuvent être progressivement adoptés pour les applications nouvelles et existantes écrites en JavaScript, un langage qui prend en charge plusieurs paradigmes.

Tout d'abord, nous expliquons comment utiliser TypeScript à la fois en arrière et en avant pour rendre votre code plus expressif et résistant au changement. Nous explorons ensuite les machines à états finis (FSM) pour rationaliser le développement frontal et accroître l'implication des parties prenantes dans le processus de développement.

Les FSM ne sont pas une nouvelle technologie. Ils ont été découverts il y a près de 50 ans et sont populaires dans des secteurs tels que le traitement du signal, l'aéronautique et la finance, où l'exactitude des logiciels peut être critique. Ils sont également très bien adaptés aux problèmes de modélisation qui surviennent fréquemment dans le développement Web moderne, tels que la coordination de mises à jour d'état asynchrones complexes et d'animations.

Cet avantage découle des contraintes sur la façon dont l'état est géré. Une machine à états ne peut être que dans un seul état simultanément et a des états voisins limités vers lesquels elle peut passer en réponse à des événements externes (tels que des clics de souris ou des réponses de récupération). Le résultat est généralement un taux de défauts considérablement réduit. Cependant, les approches FSM peuvent être difficiles à mettre à l'échelle pour bien fonctionner dans de grandes applications. Les extensions récentes des FSM appelées statecharts permettent de visualiser des FSM complexes et de les adapter à des applications beaucoup plus vastes, ce qui est la saveur des machines à états finis sur lesquelles cet article se concentre. Pour notre démonstration, nous utiliserons la bibliothèque XState, qui est l'une des meilleures solutions pour les FSM et les statecharts en JavaScript.

Déclaratif sur le Back End avec Node.js

La programmation d'un back-end de serveur Web à l'aide d'approches déclaratives est un vaste sujet et peut généralement commencer par l'évaluation d'un langage de programmation fonctionnel côté serveur approprié. Au lieu de cela, supposons que vous lisez ceci à un moment où vous avez déjà choisi (ou envisagez) Node.js pour votre back-end.

Cette section détaille une approche de modélisation des entités sur le back-end qui présente les avantages suivants :

  • Amélioration de la lisibilité du code
  • Refactorisation plus sûre
  • Potentiel d'amélioration des performances grâce à la modélisation des types de garanties

Garanties de comportement grâce à la modélisation de type

Javascript

Considérez la tâche consistant à rechercher un utilisateur donné via son adresse e-mail en JavaScript :

 function validateEmail(email) { if (typeof email !== "string") return false; return isWellFormedEmailAddress(email); } function lookupUser(validatedEmail) { // Assume a valid email is passed in. // Safe to pass this down to the database for a user lookup.. }

Cette fonction accepte une adresse e-mail sous forme de chaîne et renvoie l'utilisateur correspondant de la base de données lorsqu'il y a une correspondance.

L'hypothèse est que lookupUser() ne sera appelé qu'une fois la validation de base effectuée. C'est une hypothèse clé. Que se passe-t-il si plusieurs semaines plus tard, une refactorisation est effectuée et que cette hypothèse ne tient plus ? Croisons les doigts pour que les tests unitaires attrapent le bogue, ou nous pourrions envoyer du texte non filtré à la base de données !

TypeScript (première tentative)

Considérons un équivalent TypeScript de la fonction de validation :

 function validateEmail(email: string) { // No longer needed the type check (typeof email === "string"). return isWellFormedEmailAddress(email); }

Il s'agit d'une légère amélioration, le compilateur TypeScript nous ayant évité d'ajouter une étape de validation d'exécution supplémentaire.

Les garanties de sécurité qu'une frappe forte peut apporter n'ont pas encore vraiment été exploitées. Examinons cela.

TypeScript (deuxième tentative)

Améliorons la sécurité des types et interdisons le passage de chaînes non traitées en entrée à looukupUser :

 type ValidEmail = { value: string }; function validateEmail(input: string): Email | null { if (!isWellFormedEmailAddress(input)) return null; return { value: email }; } function lookupUser(email: ValidEmail): User { // No need to perform validation. Compiler has already ensured only valid emails have been passed in. return lookupUserInDatabase(email.value); }

C'est mieux, mais c'est encombrant. Toutes les utilisations de ValidEmail accèdent à l'adresse réelle via email.value . TypeScript utilise le typage structurel plutôt que le typage nominal employé par des langages tels que Java et C#.

Bien que puissant, cela signifie que tout autre type qui adhère à cette signature est considéré comme équivalent. Par exemple, le type de mot de passe suivant peut être passé à lookupUser() sans plainte du compilateur :

 type ValidPassword = { value: string }; const password = { value: "password" }; lookupUser(password); // No error.

TypeScript (troisième tentative)

Nous pouvons obtenir un typage nominal dans TypeScript en utilisant l'intersection :

 type ValidEmail = string & { _: "ValidEmail" }; function validateEmail(input: string): ValidEmail { // Perform email validation checks.. return input as ValidEmail; } type ValidPassword = string & { _: "ValidPassword" }; function validatePassword(input: string): ValidPassword { ... } lookupUser("[email protected]"); // Error: expected type ValidEmail. lookupUser(validatePassword("MyPassword"); // Error: expected type ValidEmail. lookupUser(validateEmail("[email protected]")); // Ok.

Nous avons maintenant atteint l'objectif selon lequel seules les chaînes d'e-mail validées peuvent être transmises à lookupUser() .

Conseil de pro : appliquez facilement ce modèle à l'aide du type d'assistance suivant :

 type Opaque<K, T> = T & { __TYPE__: K }; type Email = Opaque<"Email", string>; type Password = Opaque<"Password", string>; type UserId = Opaque<"UserId", number>;

Avantages

En tapant fortement les entités dans votre domaine, nous pouvons :

  1. Réduisez le nombre de vérifications à effectuer au moment de l'exécution, qui consomment de précieux cycles de processeur du serveur (bien qu'une très petite quantité, ceux-ci s'additionnent lorsque vous traitez des milliers de requêtes par minute).
  2. Conservez moins de tests de base en raison des garanties fournies par le compilateur TypeScript.
  3. Tirez parti de la refactorisation assistée par l'éditeur et le compilateur.
  4. Améliorez la lisibilité du code grâce à un meilleur rapport signal/bruit.

Les inconvénients

La modélisation de type s'accompagne de quelques compromis à prendre en compte :

  1. L'introduction de TypeScript complique généralement la chaîne d'outils, ce qui entraîne des temps d'exécution plus longs pour la construction et la suite de tests.
  2. Si votre objectif est de prototyper une fonctionnalité et de la mettre entre les mains des utilisateurs dès que possible, l'effort supplémentaire requis pour modéliser explicitement les types et les propager dans la base de code peut ne pas en valoir la peine.

Nous avons montré comment le code JavaScript existant sur le serveur ou la couche de validation back-end/front-end partagée peut être étendu avec des types pour améliorer la lisibilité du code et permettre une refactorisation plus sûre, des exigences importantes pour les équipes.

Interfaces utilisateur déclaratives

Les interfaces utilisateur développées à l'aide de techniques de programmation déclarative concentrent leurs efforts sur la description du « quoi » plutôt que du « comment ». Deux des trois principaux ingrédients de base du Web, CSS et HTML, sont des langages de programmation déclaratifs qui ont résisté à l'épreuve du temps et à plus d'un milliard de sites Web.

Les principaux langages du web
Les principaux langages qui alimentent le Web.

React a été mis en open source par Facebook en 2013 et a considérablement modifié le cours du développement frontal. Lorsque je l'ai utilisé pour la première fois, j'ai adoré la façon dont je pouvais déclarer l'interface graphique en fonction de l'état de l'application. Je pouvais désormais composer des interfaces utilisateur volumineuses et complexes à partir de blocs de construction plus petits sans m'occuper des détails compliqués de la manipulation du DOM et du suivi des parties de l'application qui devaient être mises à jour en réponse aux actions de l'utilisateur. Je pouvais largement ignorer l'aspect temporel lors de la définition de l'interface utilisateur et me concentrer sur la bonne transition de mon application d'un état à l'autre.

Évolution du JavaScript frontal de comment à quoi
Évolution du JavaScript frontal de comment à quoi .

Pour parvenir à un moyen plus simple de développer des interfaces utilisateur, React a inséré une couche d'abstraction entre le développeur et la machine/navigateur : le DOM virtuel .

D'autres cadres d'interface utilisateur Web modernes ont également comblé cette lacune, bien que de différentes manières. Par exemple, Vue utilise la réactivité fonctionnelle via des getters/setters JavaScript (Vue 2) ou des proxies (Vue 3). Svelte apporte de la réactivité grâce à une étape supplémentaire de compilation du code source (Svelte).

Ces exemples semblent démontrer un grand désir dans notre industrie de fournir aux développeurs des outils meilleurs et plus simples pour exprimer le comportement des applications par le biais d'approches déclaratives.

État et logique de l'application déclarative

Alors que la couche de présentation continue de tourner autour d'une certaine forme de HTML (par exemple, JSX dans React, les modèles basés sur HTML trouvés dans Vue, Angular et Svelte), je postule que le problème de la façon de modéliser l'état d'une application d'une manière qui est facilement compréhensible pour les autres développeurs et maintenable à mesure que l'application se développe n'est toujours pas résolu. Nous en voyons la preuve à travers une prolifération de bibliothèques et d'approches de gestion d'État qui se poursuit à ce jour.

La situation est compliquée par les attentes croissantes des applications Web modernes. Certains défis émergents que les approches modernes de gestion de l'État doivent soutenir :

  • Applications hors ligne utilisant des techniques avancées d'abonnement et de mise en cache
  • Code concis et réutilisation du code pour les exigences de taille de bundle toujours plus réduites
  • Demande d'expériences utilisateur de plus en plus sophistiquées grâce à des animations haute fidélité et des mises à jour en temps réel

(Ré)émergence des machines à états finis et des diagrammes d'états

Les machines à états finis ont été largement utilisées pour le développement de logiciels dans certains secteurs où la robustesse des applications est essentielle, comme l'aviation et la finance. Il gagne également en popularité pour le développement frontal d'applications Web grâce, par exemple, à l'excellente bibliothèque XState.

Wikipédia définit une machine à états finis comme suit :

Une machine abstraite qui peut être dans exactement un état parmi un nombre fini d'états à un instant donné. Le FSM peut passer d'un état à un autre en réponse à certaines entrées externes ; le passage d'un état à un autre s'appelle une transition. Un FSM est défini par une liste de ses états, son état initial et les conditions de chaque transition.

Et plus loin:

Un état est une description de l'état d'un système qui attend pour exécuter une transition.

Les FSM dans leur forme de base ne s'adaptent pas bien aux grands systèmes en raison du problème d'explosion d'état. Récemment, des diagrammes d'états UML ont été créés pour étendre les FSM avec hiérarchie et concurrence, qui permettent une large utilisation des FSM dans les applications commerciales.

Déclarez votre logique d'application

Tout d'abord, à quoi ressemble un FSM en tant que code ? Il existe plusieurs façons d'implémenter une machine à états finis en JavaScript.

  • Machine à états finis en tant qu'instruction switch

Voici une machine décrivant les états possibles dans lesquels peut se trouver un JavaScript, implémenté à l'aide d'une instruction switch :

 const initialState = { type: 'idle', error: undefined, result: undefined }; function transition(state = initialState, action) { switch (action) { case 'invoke': return { type: 'pending' }; case 'resolve': return { type: 'completed', result: action.value }; case 'error': return { type: 'completed', error: action.error ; default: return state; } }

Ce style de code sera familier aux développeurs qui ont utilisé la populaire bibliothèque de gestion d'état Redux.

  • Machine à états finis en tant qu'objet JavaScript

Voici la même machine implémentée en tant qu'objet JavaScript à l'aide de la bibliothèque JavaScript XState :

 const promiseMachine = Machine({ id: "promise", initial: "idle", context: { result: undefined, error: undefined, }, states: { idle: { on: { INVOKE: "pending", }, }, pending: { on: { RESOLVE: "success", REJECT: "failure", }, }, success: { type: "final", actions: assign({ result: (context, event) => event.data, }), }, failure: { type: "final", actions: assign({ error: (context, event) => event.data, }), }, }, });

Si la version XState est moins compacte, la représentation objet présente plusieurs avantages :

  1. La machine d'état elle-même est un simple JSON, qui peut être facilement conservé.
  2. Parce qu'elle est déclarative, la machine peut être visualisée.
  3. Si vous utilisez TypeScript, le compilateur vérifie que seules les transitions d'état valides sont effectuées.

XState prend en charge les diagrammes d'états et implémente la spécification SCXML, ce qui le rend adapté à une utilisation dans de très grandes applications.

Visualisation d'états d'une promesse :

Machine à états finis d'une promesse
Machine à états finis d'une promesse.

Meilleures pratiques XState

Voici quelques bonnes pratiques à appliquer lors de l'utilisation de XState pour aider à maintenir la maintenance des projets.

Séparez les effets secondaires de la logique

XState permet aux effets secondaires (qui incluent des activités telles que la journalisation ou les demandes d'API) d'être spécifiés indépendamment de la logique de la machine d'état.

Cela a les avantages suivants :

  1. Aide à la détection des erreurs logiques en gardant le code de la machine d'état aussi propre et simple que possible.
  2. Visualisez facilement la machine d'état sans avoir besoin de supprimer d'abord le passe-partout supplémentaire.
  3. Test plus facile de la machine d'état en injectant des services fictifs.
 const fetchUsersMachine = Machine({ id: "fetchUsers", initial: "idle", context: { users: undefined, error: undefined, nextPage: 0, }, states: { idle: { on: { FETCH: "fetching", }, }, fetching: { invoke: { src: (context) => fetch(`url/to/users?page=${context.nextPage}`).then((response) => response.json() ), onDone: { target: "success", actions: assign({ users: (context, event) => [...context.users, ...event.data], // Data holds the newly fetched users nextPage: (context) => context.nextPage + 1, }), }, onError: { target: "failure", error: (_, event) => event.data, // Data holds the error }, }, }, // success state.. // failure state.. }, });

Bien qu'il soit tentant d'écrire des machines d'état de cette manière pendant que vous faites encore fonctionner les choses, une meilleure séparation des problèmes est obtenue en passant les effets secondaires en option :

 const services = { getUsers: (context) => fetch( `url/to/users?page=${context.nextPage}` ).then((response) => response.json()) } const fetchUsersMachine = Machine({ ... states: { ... fetching: { invoke: { // Invoke the side effect at key: 'getUsers' in the supplied services object. src: 'getUsers', } on: { RESOLVE: "success", REJECT: "failure", }, }, ... }, // Supply the side effects to be executed on state transitions. { services } });

Cela permet également de tester facilement l'unité de la machine d'état, permettant de se moquer explicitement des récupérations de l'utilisateur :

 async function testFetchUsers() { return [{ name: "Peter", location: "New Zealand" }]; } const machine = fetchUsersMachine.withConfig({ services: { getUsers: (context) => testFetchUsers(), }, });

Fractionner les grosses machines

Il n'est pas toujours immédiatement évident de savoir comment structurer au mieux un domaine problématique dans une bonne hiérarchie de machines à états finis au début.

Conseil : Utilisez la hiérarchie de vos composants d'interface utilisateur pour guider ce processus. Voir la section suivante sur la façon de mapper les machines d'état aux composants de l'interface utilisateur.

L'un des principaux avantages de l'utilisation de machines d'état est de modéliser explicitement tous les états et les transitions entre les états dans vos applications afin que le comportement résultant soit clairement compris, ce qui rend les erreurs logiques ou les lacunes faciles à repérer.

Pour que cela fonctionne bien, les machines doivent être petites et concises. Heureusement, il est facile de composer hiérarchiquement des machines à états. Dans l'exemple canonique des diagrammes d'états d'un système de feux de signalisation, l'état « rouge » lui-même devient une machine d'état enfant. La machine "lumière" parente n'est pas consciente des états internes de "rouge" mais décide quand entrer "rouge" et quel est le comportement prévu à la sortie :

Exemple de feu tricolore utilisant des diagrammes d'états
Exemple de feu tricolore utilisant des statecharts.

1-1 Mappage des machines d'état aux composants d'interface utilisateur avec état

Prenons, par exemple, un site de commerce électronique fictif très simplifié qui a les vues React suivantes :

 <App> <SigninForm /> <RegistrationForm /> <Products /> <Cart /> <Admin> <Users /> <Products /> </Admin> </App>

Le processus de génération de machines d'état correspondant aux vues ci-dessus peut être familier pour ceux qui ont utilisé la bibliothèque de gestion d'état Redux :

  1. Le composant a-t-il un état qui doit être modélisé ? Par exemple, Admin/Products peut ne pas ; les récupérations paginées vers le serveur plus une solution de mise en cache (telle que SWR) peuvent suffire. D'autre part, les composants tels que SignInForm ou le panier contiennent généralement un état qui doit être géré, comme les données saisies dans les champs ou le contenu actuel du panier.
  2. Les techniques d'état locales (par exemple, setState() / useState() de React) sont-elles suffisantes pour capturer le problème ? Suivre si le modal popup du panier est actuellement ouvert ne nécessite guère l'utilisation d'une machine à états finis.
  3. La machine d'état résultante est-elle susceptible d'être trop complexe ? Si c'est le cas, divisez la machine en plusieurs machines plus petites, en identifiant les opportunités de créer des machines enfants pouvant être réutilisées ailleurs. Par exemple, les machines SignInForm et RegistrationForm peuvent invoquer des instances d'un textFieldMachine enfant pour modéliser la validation et l'état des champs d'adresse e-mail, de nom et de mot de passe de l'utilisateur.

Quand utiliser un modèle de machine à états finis

Bien que les diagrammes d'états et les FSM puissent résoudre avec élégance certains problèmes difficiles, le choix des meilleurs outils et approches à utiliser pour une application particulière dépend généralement de plusieurs facteurs.

Certaines situations où l'utilisation de machines à états finis brillent :

  • Votre application comprend un composant de saisie de données considérable où l'accessibilité ou la visibilité des champs est régie par des règles complexes : par exemple, la saisie de formulaires dans une application de réclamation d'assurance. Ici, les FSM aident à garantir que les règles métier sont mises en œuvre de manière robuste. De plus, les fonctions de visualisation des diagrammes d'état peuvent être utilisées pour aider à accroître la collaboration avec les parties prenantes non techniques et identifier les exigences commerciales détaillées dès le début du développement.
  • Pour mieux fonctionner sur des connexions plus lentes et offrir des expériences plus fidèles aux utilisateurs , les applications Web doivent gérer des flux de données asynchrones de plus en plus complexes. Les FSM modélisent explicitement tous les états dans lesquels une application peut se trouver, et des diagrammes d'états peuvent être visualisés pour aider à diagnostiquer et résoudre les problèmes de données asynchrones.
  • Applications qui nécessitent beaucoup d'animations sophistiquées basées sur l'état. Pour les animations complexes, les techniques de modélisation des animations sous forme de flux d'événements dans le temps avec RxJS sont populaires. Pour de nombreux scénarios, cela fonctionne bien, cependant, lorsqu'une animation riche est combinée à une série complexe d'états connus, les FSM fournissent des "points de repos" bien définis entre lesquels les animations circulent. Les FSM combinés à RxJS semblent être la combinaison parfaite pour aider à offrir la prochaine vague d'expériences utilisateur expressives et haute fidélité.
  • Applications clientes riches telles que l'édition photo ou vidéo, les outils de création de diagrammes ou les jeux où une grande partie de la logique métier réside côté client. Les FSM sont intrinsèquement découplés du cadre ou des bibliothèques d'interface utilisateur et sont des tests faciles à écrire pour permettre aux applications de haute qualité d'être itérées rapidement et expédiées en toute confiance.

Mises en garde sur les machines à états finis

  • L'approche générale, les meilleures pratiques et l'API pour les bibliothèques d'états tels que XState sont nouvelles pour la plupart des développeurs frontaux, qui nécessiteront un investissement en temps et en ressources pour devenir productifs, en particulier pour les équipes moins expérimentées.
  • Semblable à la mise en garde précédente, alors que la popularité de XState continue de croître et est bien documentée, les bibliothèques de gestion d'état existantes telles que Redux, MobX ou React Context ont d'énormes suivis qui fournissent une mine d'informations en ligne que XState ne correspond pas encore.
  • Pour les applications suivant un modèle CRUD plus simple, les techniques de gestion d'état existantes combinées à une bonne bibliothèque de mise en cache des ressources telles que SWR ou React Query suffiront. Ici, les contraintes supplémentaires fournies par les FSM, bien qu'incroyablement utiles dans les applications complexes, peuvent ralentir le développement.
  • L'outillage est moins mature que d'autres bibliothèques de gestion d'état, avec des travaux toujours en cours sur l'amélioration de la prise en charge de TypeScript et des extensions des outils de développement du navigateur.

Emballer

La popularité et l'adoption de la programmation déclarative dans la communauté du développement Web continuent d'augmenter.

Alors que le développement Web moderne continue de devenir plus complexe, les bibliothèques et les frameworks qui adoptent des approches de programmation déclarative apparaissent de plus en plus fréquemment. La raison semble claire : des approches plus simples et plus descriptives de l'écriture de logiciels doivent être créées.

L'utilisation de langages fortement typés tels que TypeScript permet de modéliser succinctement et explicitement les entités du domaine d'application, ce qui réduit le risque d'erreurs et la quantité de code de vérification sujet aux erreurs qui doit être manipulé. L'adoption de machines à états finis et de diagrammes d'états sur le front-end permet aux développeurs de déclarer la logique métier d'une application via des transitions d'état, permettant le développement d'outils de visualisation riches et augmentant les possibilités de collaboration étroite avec des non-développeurs.

Lorsque nous procédons ainsi, nous nous concentrons sur les rouages ​​du fonctionnement de l'application vers une vue de niveau supérieur qui nous permet de nous concentrer encore plus sur les besoins du client et de créer une valeur durable.