fts5_fcgi, un moteur de recherche pour le small web🔗

Posted by Médéric Ribreux 🗓 In blog/ Code/

#dev #c

Introduction

Après avoir utilisé Tinysearch pendant quelques mois pour mon moteur de recherche de blog, j'ai remplacé tout ça par de la vieille techno et au final, ça marche bien mieux !

Pour faire simple, j'ai codé un programme FastCGI en C qui exploite les fonctions de moteur de recherche textuelle de SQLite pour en faire une API HTTP de recherche de texte qui renvoie une liste de résultats en JSON (exploitable en Javascript donc). Le tout, en un seul fichier C de 400 lignes avec commentaires !

Les problèmes de Tinysearch

Bon, sur le fond, Tinysearch est un filtre bloom. Déjà à la base, un filtre bloom a quelques défauts sur la recherche de texte (ce n'est pas un filtre parfait dans son mode de fonctionnement). Au final, j'ai trouvé que la recherche posait de vrais problèmes: souvent j'étais incapable de retrouver un article même en tapant un mot majeur qui se trouvait dedans.

Ensuite, le deuxième problème de Tinysearch, c'est qu'il est en Rust. Bon, Ok, Rust, c'est l'avenir: un truc qui dépasse le C, qui est secure par défaut, qui est compliqué mais que c'est pour notre bien (sans blague, pour avoir fait du Rust, ça à l'air bien intéressant comme proposition). Mais l'avenir, ça veut dire que tu as tout le temps des mises à jours de tout.

Comme Tinysearch est une compilation wasm d'un programme de gestion de filtre bloom, à chaque fois que je publiais un article, j'avais droit à la totale:

L'ensemble prend plus de temps que de parser l'intégralité de mon site web et le transformer de Markdown vers HTML avec un template ! Donc à chaque publication d'article, 80% du temps de publication consistait à mettre à jour Tinysearch ou ses dépendances.

Enfin, le dernier problème de tinysearch, c'est que la page de recherche embarque la totalité de la base de données de recherche. Ça fait quelques MB. Fondammentalement en 2023, ça pourrait être un détail. Mais je me dis que c'est quand même con d'avoir un moteur de recherche sur un site web géré par un serveur qui fait la requête intégralement côté client et qui oblige, en conséquence, à télécharger un erzatz de tout le texte du site web, même s'il est compressé.

Les débuts d'une solution

Quand je bossais sur gis2fg, je me suis mis à explorer un peu plus SQLite. Ça fait bien 10 ans que je travaille avec, mais à chaque fois que je m'y penche sérieusement sur une fonctionnalité de SQLite, je découvre un nouveau truc d'intérêt. C'est fou ! Mais c'est tellement génial.

Un jour, je trouve l'extension FTS5 et je tombe par terre. La promesse d'un moteur de recherche depuis une DB SQLite. Ça semble trop beau pour être vrai. Je fais un test: je lis la doc, créé une table dédiée, y injecte tout le contenu texte de mon site web et je fais une requête SQL de recherche.

Je tombe sur de bien meilleurs résultats que Tinysearch, out of the box! Aussi je me dis qu'on pourrait utiliser FTS5 à la place d'un filtre bloom. Pour autant, s'il faut charger une DB SQLite de tout le site web et faire du wasm de SQLite + requêtage FTS5, ça ne règle pas le problème n°3 de Tinysearch: charger plusieurs MB pour faire des requêtes sur le site web, sachant qu'il y a 1% de chances que l'utilisateur final fasse plus d'une seule recherche sur mon site web. Overkill!

Donc, me viens l'idée de rappatrier la recherche côté serveur. Mais, je ne veux pas d'un truc en PHP ou en Python. Ça serait faisable, mais je me dis que SQLite3 a une API C et que ça serait con de passer à côté. Alors, en C sous Apache, il y a CGI. Bon, c'est vieux. Ça marche, mais ça a beaucoup d'inconvénients. Vient alors mon souvenir de fastcgi et, en relisant les specs et le code de la lib, je me dis que je tiens mon bon candidat: faire un "composant serveur" de recherche de texte basé sur FTS5 qui crache du JSON en passant par fastcgi.

On reste dans du C intégral, un bon truc pour les perfs. Mais un truc dangereux, comme toutes les bêtes rapides et puissantes.

La solution: fts5_fcgi

Donc, j'ai codé un truc, ça s'appelle fts5_fcgi.

La doc de compilation est décrite ainsi que tout ce qu'il faut pour installer le truc sous Apache, dans le fichier README.md.

Ça fait 400 lignes de code avec:

Bien entendu, il y a probablement une vingtaine de faille de sécu (même si j'ai essayé de réduire le périmètre à mort), mais vous êtes prévenus.

Fonctionnement

Ce que j'ai appris de ce code

FastCGI c'est pas mal en fait. C'est une bibliothèque qui date un peu, mais qui est très simple. La meilleure doc de la bibliothèque consiste à lire le code de fcgiapp.h et fcgiapp.c. Avec vraiment peu de fonctions, on récupère facilement tout ce dont on a besoin pour créer un programme correct. Par contre, j'ai bien noté que la lib fastcgi ne gère pas du tout l'encodage pourcent et ça, c'est clairement dommage (même si ça ne coûte pas cher à implémenter).

J'ai utilisé fortement memccpy pour la manipulation des chaînes de caractères. D'abord parce que ça va devenir une fonction du standard C23. Ensuite parce que c'est une fonction très pratique et (à peu près, ça reste du C, hein) sécurisée. C'est en lisant cet article que je me suis dit de l'essayer. Et essayer memccpy, c'est l'adopter. Franchement je n'y vois que des avantages, notamment, ça permet de compacter beaucoup de code. Comme c'est une extension POSIX, c'est inclus dans l'option de compilation gnu17 de gcc.

Pour des questions de "sécurité", j'ai essayé de me passer d'allocation dynamique de mémoire. Dans fts5_fcgi, les chaînes de caractères sont toutes des tableaux de char et il n'y a pas de malloc. Ça se prêtait assez bien pour ce genre de programme: on a des entrées limitées en taille et une sortie volontairement réduite (ça ne sert à rien de sortir 3000 résultats sur une seule page web). Et puis, autant borner pour gérer les débordements.

Encore une fois, la gestion des strings en C, c'est toujours un sujet complexe. Par exemple, pendant le développement, j'ai bloqué pendant 2h sur un comportement inexplicable de l'application qui plantait au moment de créer un statement sur la base de données SQLite. Débugguer du fastcgi avec des chaînes de caractères, c'est assez compliqué, donc ça m'a pris beaucoup de temps pour me rendre compte que j'avais oublié tout simplement de faire un strdup sur des pointeurs de char pour stocker le chemin de la base de données, la requête et le nom du paramètre de recherche. Et pourtant, pendant le diagnostic, j'arrivais tout à fait à afficher le contenu des chaînes de caractères des paramètres. Undefined behavior ça veut bien dire ce que ça veut dire. Pour autant, c'était vraiment une erreur triviale. Je constate qu'en fait, quand on ne fait pas de C un peu tous les jours, on a tendance à oublier toutes ces précautions à prendre et on finit par se planter.

J'ai bien aimé lire le code de CURL sur l'encodage d'URL. Je m'en suis complètement inspiré pour gérer cette fonctionnalité (j'ai copié une partie du code, en l'adaptant et en limitant la gestion des cas particuliers) et en 30 minutes, j'avais un truc robuste: je ne pensais pas que ça irait aussi vite à coder.

Au passage dans ce projet, on veut pisser du JSON en sortie, donc il va falloir gérer la sortie dans ce format. J'aurais pu utiliser une lib dédiée. Mais, je me suis souvenu que fondammentalement JSON en fait, c'est du texte ! Donc, j'ai utilisé de quoi l'encoder de manière simple. Après tout, ce qu'on veut, c'est une liste de résultats qui pointent vers un article défini. Pas la peine d'implémenter un parser JSON complet pour ça, ou d'utiliser une lib dédiée, non ?

Futures évolutions ?

J'aimerai assez bien rendre le programme multithread et mesurer l'impact que ça a en termes de performances. Mais déjà, dans l'état, c'est ultra-rapide en monothread. Donc, pour mes besoins persos, ça va s'arrêter ici… Jusqu'à ce que je devienne compétent en multi-thread Posix.

Conclusions

Parfois, souvent en fait, la vieille techno informatique éprouvée depuis 20 ans, ça donne le meilleur résultat: le truc le plus rapide, le plus simple à mettre en place, le plus rapide à déployer et à utiliser au quotidien.

Ok, je suis certain que Rust c'est mieux. Pour en avoir déjà fait (et transpirer au passage), je vois qu'on peut avoir plutôt confiance dans le compilateur qui est vraiment implacable en ce qui concerne la gestion de la mémoire (fuckin' borrowin' comme je dis à chaque fois que j'ai oublié la place d'une variable dans le mécanisme d'emprunt du langage et que cargo me crache des insultes).

Mais pour autant, si on aime prendre des risques, finalement le C, ça reste le système le plus performant, de facto, y compris en 2023. FastCGI semble vraiment ultra intéressant car vieux, éprouvé et finalement vraiment rapide et simple à intégrer. Ça permet de pallier aux défauts de CGI qui oblige à lancer un exécutable (et tout le truc compliqué pour forker et passer des paramètres) à chaque requête.

Voilà, j'ai un moteur de recherche sur mon blog qui me satisfait. C'est la première fois en 15 ans (j'ai eu des trucs maisons, une redirection vers google ou duckduckgo et tinysearch) et j'en suis très content…