Benjamin Geer

Créer une application hypermédia en Go

Benjamin Geer
Table des matières

Ceci est le deuxième article d’une série :

  1. Créer des applications hypermédia sécurisées pour un monde aux ressources limitées, qui présente la série
  2. l’article présent
  3. 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 :

  1. 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.
  2. 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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func InfoContactCount(loc *i18n.Localizer, count int) string {
  return loc.MustLocalize(&i18n.LocalizeConfig{
    DefaultMessage: &i18n.Message{
      ID:    "InfoContactCount",
      One:   "({{.Count}} total contact)",
      Other: "({{.Count}} total contacts)",
    },
    TemplateData: map[string]any{
      "Count": count,
    },
    PluralCount: count,
  })
}

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 :

1
SELECT id, familyname, given_name, phone, email FROM contact WHERE id = ?;

Nous obtiendrons une erreur lors de l’exécution de sqlc generate:

1
sql/queries.sql:1:1: sqlite3: SQL logic error: no such column: familyname

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 :

1
2
3
4
5
6
7
CREATE TABLE IF NOT EXISTS contact (
  id INTEGER PRIMARY KEY,
  family_name TEXT NOT NULL,
  given_name TEXT NOT NULL,
  phone TEXT,
  email TEXT UNIQUE NOT NULL
) STRICT;

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 :

1
2
CREATE INDEX IF NOT EXISTS idx_contact_sort
ON contact (family_name, given_name, email);

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 :

1
SELECT 1 FROM contact WHERE email = ?;

Pour que cette requête soit efficace, nous créons un index sur la colonne email :

1
CREATE INDEX IF NOT EXISTS idx_contact_email ON contact (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 :

1
2
3
4
5
6
7
8
CREATE VIRTUAL TABLE IF NOT EXISTS contact_fts USING fts5(
    family_name,
    given_name,
    phone UNINDEXED,
    email UNINDEXED,
    content = 'contact',
    content_rowid = 'id'
);

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:

1
2
3
4
5
6
7
type Contact struct {
  ID         int64
  FamilyName string
  GivenName  string
  Phone      string
  Email      string
}

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 :

1
2
3
4
5
6
func (db *Database) Get(
  ctx context.Context,
  id int64,
) (*model.Contact, error) {
  // ...
}

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 :

1
2
3
4
5
6
type ContactContent struct {
  FamilyName string `form:"family_name" binding:"required,name"`
  GivenName  string `form:"given_name" binding:"required,name"`
  Phone      string `form:"phone" binding:"omitempty,phone"`
  Email      string `form:"email" binding:"required,email"`
}

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 :

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 :

1
^[\p{L}\p{M}]+(?:(?:\p{P}\p{Zs}|[\p{P}\p{Zs}])[\p{L}\p{M}]+)*\p{P}?$

Donc, si vous vous appelez 曼玉-فاتن, tout va bien. Nous écrivons une fonction de validation qui utilise cette expression régulière :

1
2
3
4
var nameValidator validator.Func = func(fl validator.FieldLevel) bool {
  field := fl.Field().String()
  return len(field) <= 50 && NameRegex.MatchString(field)
}

Au démarrage de l’application, nous enregistrons la fonction auprès du package validator :

1
2
3
if validate, ok := binding.Validator.Engine().(*validator.Validate); ok {
  validate.RegisterValidation("name", nameValidator)
}

Gestion des erreurs #

Notre stratégie de gestion des erreurs fait la distinction entre les erreurs imputables au client et celles imputables au serveur :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type ClientError struct {
  Cause error
}

// ...

type NotFoundError struct{}

type ServerError struct {
  Cause error
}

type DatabaseError struct {
  Cause error
}

// ...

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-Language est 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 :

1
2
3
4
type AppState struct {
  Database   *db.Database
  I18NBundle *i18n.Bundle
}

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 :

1
2
3
4
5
engine := gin.Default()
engine.GET("/", app.index) // utilise Accept-Language
engine.GET("/:lang/contacts", app.contactsGet)
engine.GET("/:lang/contacts/:id", app.contactGet)
// ...

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
type LanguagePathParams struct {
  Lang string `uri:"lang" binding:"required,bcp47_strict_language_tag"`
}

type IDPathParams struct {
  ID int64 `uri:"id" binding:"required"`
}

type ContactPathParams struct {
  LanguagePathParams
  IDPathParams
}

func (state *AppState) contactGet(ctx *gin.Context) {
  var pathParams ContactPathParams

  if err := ctx.ShouldBindUri(&pathParams); err != nil {
    state.notFound(ctx)
    return
  }

  loc := localisation.GetLocalizer(state.I18NBundle, pathParams.Lang, ctx)

  contact, err := state.Database.Get(ctx, pathParams.ID)
  if err != nil {
    errorPage(ctx, pathParams.Lang, loc, err)
    return
  }

  urlsWithLangs, err := localisation.URLsWithLangs(ctx.Request.URL)
  if err != nil {
    errorPage(ctx, pathParams.Lang, loc, err)
    return
  }

  ctx.HTML(http.StatusOK, "",
    templates.Show(
      pathParams.Lang,
      loc,
      contact,
      urlsWithLangs,
    ),
  )
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
WITH vars AS (SELECT ? AS family_name, ? AS given_name, ? AS email)
SELECT id, family_name, given_name, phone, email
FROM contact
WHERE (family_name = (SELECT family_name FROM vars)
       AND given_name = (SELECT given_name FROM vars)
       AND email > (SELECT email FROM vars))
OR (family_name = (SELECT family_name FROM vars)
    AND given_name > (SELECT given_name FROM vars))
OR family_name > (SELECT family_name FROM vars)
ORDER BY family_name, given_name, email
LIMIT ?;

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func TestEditContactGet(t *testing.T) {
  server := InitServer(t, "test-contacts")

  recorder := httptest.NewRecorder()
  request, _ := http.NewRequest("GET", "/en/contacts/1/edit", nil)
  server.ServeHTTP(recorder, request)

  test.Equals(t, recorder.Code, http.StatusOK)
  doc, err := goquery.NewDocumentFromReader(recorder.Result().Body)
  if err != nil {
    t.Fatalf("Failed to parse HTTP response: %v", err)
  }

  familyNameLabel := doc.Find("label[for='family_name']").Text()
  test.Equals(t, familyNameLabel, "Family name")

  familyName, _ := doc.Find("#family_name").Attr("value")
  test.Equals(t, familyName, "Aaa")

  // ...
}

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 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
CREATE TABLE IF NOT EXISTS contact (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  family_name TEXT NOT NULL,
  given_name TEXT NOT NULL,
  phone TEXT,
  email TEXT UNIQUE NOT NULL,
  textsearch tsvector GENERATED ALWAYS AS (
    to_tsvector(
      'simple',
      COALESCE(family_name, '') || ' ' || COALESCE(given_name, '')
    )
  ) STORED
);

CREATE INDEX textsearch_idx ON contact USING GIN (textsearch);

La requête SQL qui trouve les lignes correspondantes utilise l’opérateur de recherche en texte intégral, @@ :

1
2
3
4
5
SELECT id, family_name, given_name, phone, email
FROM contact, to_tsquery($1) query
WHERE query @@ textsearch
ORDER BY ts_rank_cd(textsearch, query)
LIMIT $2;

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.


  1. Voir the N+1 selects problem et selecting all columns without thinking about it. ↩︎

Catégories :
Sujets :