Créer une application hypermédia en Go
Table des matières
Ceci est le deuxième article d’une série :
- Créer des applications hypermédia sécurisées pour un monde aux ressources limitées, qui présente la série
- l’article présent
- Créer une application hypermédia en Rust
Aperçu de l’application #
L’application qui sert d’exemple dans Hypermedia Systems est une application CRUD qui gère une liste de contacts. Le livre montrer comment créer une application hypermédia qui offre une bonne interface utilisateur, en alternative aux applications monopages. Grâce à une minuscule bibliothèque JavaScript (htmx) et à quelques attributs déclaratifs sur les éléments HTML, toutes sortes d’interactions utilisateur peuvent récupérer des fragments de code HTML depuis le serveur et ne modifier que certaines parties de la page. Par exemple, quand on passe d’une page à une autre dans la liste des contacts ou qu’on supprime un contact, la liste affichée s’actualise sans que la page ne soit rechargée.
Réalisons cette application en Go, avec deux objectifs :
- La rendre plus réaliste en ajoutant une interface utilisateur internationalisée, une base de données relationnelle avec un index de recherche en texte intégral, une meilleure validation des entrées, une pagination par curseur et des tests d’interface utilisateur.
- La rendre facile à maintenir, en limitant au maximum le code répétitif et en tirant parti des vérifications effectuées lors de la compilation pour éviter les bugs.
Nous utiliserons dans un premier temps une base de données SQLite, puis passerons à PostgreSQL dans une étape ultérieure. Le code source des deux versions est disponible ici :
Bibliothèques #
Notre première tâche est de choisir quelques bibliothèques.
Templates HTML #
Notre interface utilisateur est basée sur des templates HTML. Il y a un template de mise en page qui fournit un en-tête et affiche des messages d’information (comme « Le contact a été ajouté ») en réponse aux actions de l’utilisateur·ice. D’autres templates, comme un formulaire permettant de modifier un contact, peuvent être imbriqués dans cette mise en page. Il y a aussi des templates qui génèrent des fragments de code HTML qui remplacent une ou plusieurs parties de la page affichée. Par exemple, quand on clique sur un lien de pagination, la liste des contacts s’actualise ainsi que les liens de pagination.
Il nous faudra donc un moteur de template. Dans la bibliothèque standard de Go il y a html/template, mais ce package ne propose pas de templates à typage sûr. Les moteurs de template à typage sûr se déclinent essentiellement en deux catégories : celle qui permet d’écrire quasiment du code HTML standard, et celle qui permet d’exprimer la structure d’un document HTML dans un langage de programmation. Je préfère la première catégorie, car plus la syntaxe des templates se rapproche du HTML, plus il sera facile pour un concepteur UX, ou toute personne maîtrisant HTML, de travailler sur ces templates sans avoir à connaître Go.
J’ai choisi templ, qui est facile à utiliser, compile les templates en Go
et est bien pris en charge par les environnements de développement. Pour l’utiliser, on écrit
des templates sous forme de fonctions dont le corps est en HTML, puis on exécute templ generate pour les compiler en fonctions Go. Nous pouvons appeler ces fonctions depuis les
implémentations de nos points de terminaison HTTP.
Internationalisation #
Nous souhaitons que notre application prenne en charge différentes langues. Dans cet exemple, j’ai réalisé la prise en charge de l’anglais et du français. Pour faciliter la maintenance, nous voulons éviter de créer une version différente de chaque template pour chaque langue, ou d’encombrer les templates avec les différentes traductions d’un même message. Il nous faut donc une bibliothèque permettant d’appeler une fonction depuis un template et d’obtenir la traduction d’un message dans la langue cible.
Un point important à prendre en compte est le choix des concepteurs de la bibliothèque quant au type d’identifiant qui représente un message. Certaines bibliothèques (comme gettext) utilisent le texte entier du message dans la langue par défaut comme identifiant de message. Les limites de cette approche sont clairement exposées dans le contract social de Project Fluent:
Tout d’abord, cela signifie que toute modification apportée à la chaîne source invalide toutes les traductions de cette chaîne. Cela alourdit considérablement la charge de travail des développeur·euses, qui doivent veiller à ne jamais modifier les messages dans la langue source, car cela entraînerait la nécessité de mettre à jour toutes les traductions.
D’autre part, cela complique l’utilisation de plusieurs messages qui ont la même chaîne source mais qui devraient être traduits différemment.
Je préfère donc les bibliothèques qui utilisent des identifiants de message uniques choisis par les développeur·euses.
Un autre critère important dans le choix d’une bibliothèque concerne la manière dont elle gère les pluriels. Alors que les pluriels sont relativement simples en français (il n’y a que deux catégories, le singulier et le pluriel), de nombreuses langues comportent davantage de catégories ainsi que des règles syntaxiques qui nécessitent d’adapter la phrase de manière plus complexe en fonction du nombre exprimé. Une bonne bibliothèque d’internationalisation doit implémenter les règles de pluriel CLDR d’Unicode, qui prennent en charge plus de 200 langues.
J’avais envie d’essayer Project Fluent, mais il semblerait que ce projet ait été abandonné. J’ai donc opté pour go-18n, qui offre les fonctionnalités dont on a besoin. Pour éviter l’encombrement du code, j’ai écrit une fonction comme celle-ci pour chaque message, en fournissant le texte en anglais, qui est la langue par défaut :
|
|
Nous pouvons appeler cette fonction directement depuis un template. Le programme en ligne de
commande goi18n extrait les messages de notre code source pour les enregistrer dans des
fichiers texte, où nous pouvons ensuite les traduire. Au moment de l’exécution, nous pouvons
accéder à la traduction d’un message via la Localizer correspondant à la langue cible. Nous
verrons ci-dessous comment obtenir celui qui convient.
Puisque nous internationalisons l’application, nous pouvons aussi améliorer la validité
interculturelle des noms de champs : au lieu de first_name (« prénom ») et last_name (« nom
de famille »), qui ne conviennent pas aux langues (comme le chinois) dans lesquelles le nom de
famille vient en premier, nous suivrons la recommandation de l’ontologie
FOAF, qui préconise given_name (« nom
personnel ») et family_name (« nom de famille »).
Accès à la base de données et migration de schéma #
La bibliothèque standard de Go fournit le package
database/sql, mais celui-ci vous oblige à réaliser vous-même
les conversions entre les résultats des requêtes et les structs Go. La maintenance de ces
conversions est fastidieuse, et il est facile de faire des erreurs qui ne seront pas détectées
lors de la compilation. Nous pourrions utiliser un mapping
object-relationnel (ORM), mais à mon
avis il vaut mieux écrire notre code SQL nous-mêmes. Les ORM peuvent générer des requêtes
inefficaces.1 Écrire notre propre code SQL nous permet de tirer pleinement parti des
fonctionnalités du SQL moderne et de ses possibilités d’optimisation
des requêtes, notamment en concevant conjointement les
requêtes et les index, et en affinant les requêtes en tenant compte des plans d’exécution
choisis par l’optimiseur de requêtes de la base de données. (Voir mon article Optimiser des
requêtes PostgreSQL avec un jeu de données ouvert pour un exemple.)
À mon avis, l’idéal est d’avoir une bibliothèque qui implémente la sûreté du typage pour les
requêtes SQL, de sorte que celles-ci soient vérifiées lors de la compilation et renvoient
automatiquement des structs Go. C’est exactement ce que fait le package
sqlc (voir son article de blog de
présentation pour une explication plus détaillée). Pour
utiliser sqlc, on écrit les requêtes SQL dans des fichiers texte. Quand on exécute sqlc generate en ligne de commande, l’outil se connecte à la base de données pour analyser les
requêtes, puis génère un package Go, dans lequel il y a une fonction à typage sûr correspondant
à chaque requête.
Par exemple, imaginons que nous ayons mal orthographié le nom d’une colonne (familyname au
lieu de family_name) dans une requête :
|
|
Nous obtiendrons une erreur lors de l’exécution de sqlc generate:
|
|
Nous allons aussi faire en sorte que sqlc utilise des instructions préparées pour prévenir les attaques par injection SQL.
Comme les schémas des bases de données évoluent, il nous faut aussi un outil qui gère les migrations de schéma. Parmi plusieurs bonnes options j’ai choisi goose.
Framework de serveur HTTP #
La bibliothèque standard de Go propose le module net/http pour
créer des serveurs HTTP, mais j’ai choisi le framework Gin, qui
implémente des conversions à typage sûr entre les requêtes et les structs Go, ainsi qu’une
validation flexible des entrées au moyen du package
validator. Et il est facile d’intégrer templ dans
Gin.
Tests d’interface utilisateur #
S’il s’agissait de créer une application monopage, notre serveur aurait une API JSON, et nous pourrions écrire des tests vérifiant qu’il renvoie les données correctes pour différentes requêtes. Mais notre application renvoie du code HTML. Nous allons donc écrire des tests analogues aux tests d’API JSON, en nous concentrant sur les fonctionnalités pratiques des réponses HTML. À l’aide du package goquery, nous analyserons ces réponses et utiliserons des sélecteurs CSS pour vérifier le contenu de certains éléments. Nous aurons ainsi la liberté de modifier considérablement la conception graphique des pages sans avoir à changer nos tests.
Conception de la base de données #
(Si vous avez déjà lu la version Rust de cet article, vous pouvez sauter cette section.)
Notre base de données est très simple. Il y a une seule table, contact :
|
|
Il y a toutefois quelques détails à prendre en compte.
J’ai défini la table avec l’option STRICT. Par défaut,
SQLite dispose d’un système de types très
flexible, ce que certaines personnes
apprécient. Je préfère obtenir des erreurs quand il y a un bug dans mon programme. Avec l’option
STRICT, SQLite effectue une vérification stricte des types lors de l’exécution, en plus des
vérifications effectuées par sqlc lors de la compilation.
Les colonnes pouvant prendre la valeur NULL ajoutent un peu de complexité à notre code. Par
conséquent, pour simplifier les choses dans cet exemple, nous n’autoriserons que la colonne
phone à prendre la valeur NULL.
Quand l’application affiche la liste des contacts, celle-ci devrait être triée selon un certain
ordre. Nous utiliserons ORDER BY family_name, given_name, email. Pour optimiser les
performances, nous ajoutons un index correspondant à cet ordre de tri :
|
|
Comme l’application exige que les adresses e-mail soient uniques, j’ai mis une contrainte
UNIQUE sur le champ email, et nous aurons besoin de faire une requête comme celle-ci :
|
|
Pour que cette requête soit efficace, nous créons un index sur la colonne email :
|
|
Nous souhaitons disposer d’un index de recherche en texte intégral nous permettant de trouver
les contacts dont le nom correspond à une chaîne de caractères. L’utilisateur pourra saisir les
premières lettres de given_name et/ou de family_name pour obtenir une liste des contacts
correspondants. Heureusement, SQLite propose un module de recherche en texte intégral, qui
s’appelle FTS5. Pour l’utiliser, il faut créer une table
virtuelle pour y stocker l’index :
|
|
Nous utiliserons des déclencheurs pour
que la table contact_fts reste synchronisée avec la table contact. Et voilà qui conclut
notre schéma de base de
données.
Architecture logicielle #
Étant donné la simplicité de cette application CRUD, une architecture
modèle-vue-contrôleur suffira.
Les vues sont des templates HTML et les contrôleurs sont des routes
HTTP. Le type principal du modèle est Contact:
|
|
La couche d’abstraction de base de données est tout simplement une struct qui contient le pool
de connexions et dispose de méthodes comme celle-ci :
|
|
Validation des entrées #
Que doit-il se passer quand l’utilisateur·ice envoie un formulaire pour créer un contact ?
L’application doit valider tous les champs, en ayant la possibilité de collecter plusieurs
erreurs de validation et de les signaler toutes à l’utilisateur·ice. Le formulaire comporte un
élément <span class="error"> sous chaque champ de saisie pour afficher ces erreurs. Gin nous
permet d’associer les champs du formulaire aux champs d’une struct, de désigner des fonctions
de validation pour chaque champ et de récupérer une liste d’erreurs de validation. Étant donné
qu’un contact qui n’a pas encore été enregistré n’a pas d’identifiant, créons une autre struct
à cet effet :
|
|
La balise form indique le champ du formulaire à associer à un champ donné de la struct, et
binding indique les fonctions de validation à exécuter pour le champ. Nous pouvons utiliser
les fonctions proposées par le package validator
ainsi que les nôtres. Ici, j’ai créé des fonctions appelées name, phone et email, qui
utilisent des expressions régulières Unicode. Comme il
s’agit d’une application internationalisée, nous aimerions prendre en charge :
- tous les caractères Unicode qui pourrait figurer dans le nom d’une personne
- n’importe quel numéro de téléphone utilisable dans n’importe quel pays
- l’internationalisation des adresses de courrier électronique
Par exemple, ma modeste tentative d’expression régulière pour les noms accepte des groupes de lettres et de caractères Unicode, séparés par des espaces et/ou des signes de ponctuation :
|
|
Donc, si vous vous appelez 曼玉-فاتن, tout va bien. Nous écrivons une fonction de validation qui utilise cette expression régulière :
|
|
Au démarrage de l’application, nous enregistrons la fonction auprès du package validator :
|
|
Gestion des erreurs #
Notre stratégie de gestion des erreurs fait la distinction entre les erreurs imputables au client et celles imputables au serveur :
|
|
Si l’erreur est imputable au client, nous allons simplement la signaler à l’utilisateur·ice, par
exemple en affichant « Page introuvable ». Si l’erreur est imputable au serveur (par exemple,
s’il s’agit d’une DatabaseError), nous afficherons « Erreur interne du serveur » et
enregistrerons les détails de l’erreur dans le journal.
Les routes et les langues #
Avant de définir les points de terminaison HTTP, il convient de déterminer comment l’application
saura quelle langue utiliser. L’en-tête de requête Accept-Language permet au navigateur de
fournir une liste, classée par ordre de préférence, des langues choisies par l’utilisateur·ice,
mais il faut aussi lui permettre de sélectionner une
langue
spécifiquement pour cette application :
Cet en-tête sert d’indication lorsque le serveur ne peut pas déterminer la langue du contenu cible autrement (par exemple, utiliser une URL spécifique qui dépend d’une décision explicite de l’utilisateur·ice). Le serveur ne doit jamais passer outre un choix explicite de langue de l’utilisateur·ice. Le contenu d’
Accept-Languageest souvent hors du contrôle de l’utilisateur·ice (par exemple lors d’un voyage). Un·e utilisateur·ice peut aussi vouloir visiter une page dans une langue différente de celle de l’interface utilisateur.
Ajoutons donc un sélecteur de langue dans l’en-tête de la page et insérons une étiquette de
langue BCP 47
au début du chemin d’accès de chaque point de terminaison. Pour le chemin d’accès racine, nous
pouvons utiliser l’en-tête Accept-Language et rediriger vers un chemin d’accès contenant une
étiquette de langue.
Avec le package go-i18n, l’application initialise un i18n.Bundle au démarrage. Celui-ci
contient les identifiants de messages et les traductions, et fera partie de l’état partagé de
l’application, comme la Database. Voici notre structure AppState :
|
|
Si nous implémentons les routes sous forme de méthodes dont le récepteur est *AppState, nous
pouvons configurer un routeur Gin de la façon suivante :
|
|
Gin peut également valider le paramètre :lang et l’associer à un champ de struct. J’ai écrit
une petite fonction appelée GetLocalizer, qui renvoie le Localizer correspondant à la langue
indiquée dans le chemin d’accès, le cas échéant, et qui, à défaut, utilise l’en-tête
Accept-Language.
Évitons de créer un de ces sites web agaçants dont le sélecteur de langue vous renvoie à la page
d’accueil. Écrivons plutôt une fonction, URLsWithLangs, qui prend le chemin d’accès de l’URL
actuel, l’adapte à chaque langue disponible et renvoie la liste des chemins obtenus. Dans
l’en-tête de la page, nous afficherons ces URL sous forme de liens.
Maintenant, si nous créons un template appelé Show qui prend un Contact en paramètre, notre
route contactGet sera à peu près ceci :
|
|
Pagination #
(Si vous avez déjà lu la version Rust de cet article, vous pouvez sauter cette section et celle d’après.)
La pagination dans Hypermedia Systems est basée sur des numéros de
page, ce
qui est inefficace (la base de données doit toujours récupérer toutes les pages précédant celle
que vous avez demandée) et peut produire des résultats inattendus si les données sont modifiées
pendant que vous parcourez les pages. Une meilleure méthode consiste à utiliser la pagination
par
curseurs.
Lors du renvoi d’une page de résultats, la couche de base de données renvoie éventuellement deux
curseurs, appelés prev et next, que la vue peut présenter sous forme de liens vers la page
précédente et la page suivante. Le lien « Page précédente » contient le curseur prev comme
paramètre before, et le lien « Page suivante » contient le curseur next comme paramètre
after. Quelques astuces sont nécessaires pour que cela fonctionne.
Notre curseur contiendra les valeurs des colonnes family_name, given_name et email, que
nous utilisons déjà pour trier les contacts. Comme il y a déjà un index sur ces trois colonnes,
dans cet ordre, la base de données peut récupérer efficacement une page de lignes situées avant
ou après le curseur.
Nous voulons éviter de renvoyer un curseur prev si nous avons atteint la première page, ou un
curseur next si nous avons atteint la dernière. La solution est la suivante : dans une requête
« page suivante », si la taille maximale d’une page est N, nous demandons N + 1 lignes. Si nous
les obtenons, nous savons qu’une page suivante existe. Nous renvoyons donc un curseur next
basé sur la dernière ligne de la page renvoyée. Si un curseur after nous a été transmis dans
la requête, nous pouvons supposer qu’il y a une page précédente. Le curseur prev correspondra
donc à la première ligne de la page renvoyée.
Nos requêtes SQL permettant d’obtenir une page de contacts doivent tenir compte du fait que les noms ne sont pas forcément uniques. Voici donc notre requête « page suivante » : (avec une expression de table commune pour ne pas avoir à transmettre les mêmes paramètres plusieurs fois) :
|
|
Pour les requêtes « page précédente », le principe est le même, mais dans l’ordre inverse.
Traitement des requêtes de recherche en texte intégral #
Il ne reste plus grand-chose à faire pour la recherche en texte intégral. Le point de
terminaison /:lang/contacts reçoit une requête composée d’un ou plusieurs mots ou préfixes de
mots. Au niveau de la couche de base de données, nous analysons cette requête pour l’adapter à
la syntaxe du module FTS5. Par exemple, G O'Mal devient "G"* + "O'Mal"* et permettra de
trouver un contact qui s’appelle Grace O’Malley. Comme nous renvoyons au maximum une page de
résultats de recherche en texte intégral, la route transmet des curseurs nil au template, qui
supprime les liens de pagination au moyen d’un remplacement hors
bande réalisé par htmx.
Tests d’intégration #
Les tests unitaires de cette application n’ont rien de remarquable (à moins que vous ne vous
appeliez 曼玉-فاتن), mais les tests d’intégration sont plus intéressants. J’en ai écrit deux
groupes : l’une pour la couche de base de données, et l’autre pour les points de terminaison
HTTP. Dans les deux cas, chaque test s’exécute avec une base de données SQLite fraîchement
initialisée avec une fixture. Comme mentionné plus haut, les tests des points de terminaison
HTTP analysent les réponses HTML et vérifient le contenu de certains éléments. Nous utilisons un
routeur Gin en mode test, et net/http/httptest
enregistre les réponses, que nous pouvons analyser à l’aide de
goquery. Par exemple, pour tester une requête GET
vers /:lang/contacts/:id/edit, le point de terminaison permettant de modifier un contact
existant, nous pouvons procéder ainsi :
|
|
Comme nous examinons uniquement le texte de libellés et de champs de formulaire à l’aide de sélecteurs CSS simples, ce genre de test a peu de chances d’être affecté par des modifications apportées à la mise en page.
PostgreSQL #
Nous n’avons pas à modifier grand-chose pour que notre application utilise PostgreSQL à la place de SQLite.
Recherche en texte intégral #
(Si vous avez déjà lu la version Rust de cet article, vous pouvez sauter cette section.)
Tout comme SQLite, Postgres intègre une fonctionnalité de recherche en texte
intégral, et celle-ci est encore plus
simple à configurer. Nous pouvons stocker les noms des contacts, transformés en valeurs
tsvector, dans une colonne
générée
de la table contact, avec un index GIN
pour accélérer les requêtes :
|
|
La requête SQL qui trouve les lignes correspondantes utilise l’opérateur de recherche en texte
intégral, @@ :
|
|
La syntaxe de la chaîne à transmettre à to_tsquery ne nécessite que quelques ajustements pour
fonctionner avec Postgres : si l’utilisateur·ice saisit G O'Mal, nous transmettons l’argument
'G':* & 'O''Mal':*.
Tests d’intégration avec Testcontainers #
L’exécution de nos tests d’intégration avec Postgres demande un peu plus de travail. Pour exécuter chaque test avec une base de données fraîchement initialisée, comme précédemment, nous pouvons utiliser Testcontainers et faire tourner Postgres dans un conteneur Docker éphémère. Nous exécuterons les tests d’intégration de manière synchrone, et il suffit donc de créer une seule base de données. Le module postgres de Testcontainers propose une fonctionnalité très utile à cet effet : nous pouvons initialiser la base de données avec nos migrations et notre fixture, puis enregistrer un instantané de la base de données. Une fois que chaque test est terminé, nous restaurons la base de données à partir de cet instantané, pour qu’elle soit prête pour le test suivant.
Conclusion #
En utilisant un système de validation déclaratif et des outils de génération déterministe de code dans un langage à typage statique, nous avons réduit au minimum le code répétitif et amélioré les chances que, si nous faisons une erreur, celle-ci soit détectée lors de la compilation plutôt qu’au moment de l’exécution. Cela devrait contribuer à faciliter la maintenance de notre code et à prévenir les bugs.
Passons maintenant à l’écriture de la même application en Rust.