Utilisation de SQLite (avec ORM) pour améliorer un cache pour GNU Mediagoblin

Remplacer l'utilisation de pickle par quelque-chose d'un peu moins pire.

Voici un petit article détaillant comment j'ai remplacé l'utilisation du module pickle de Python par un équivalent bien mieux fourni. Pour cela, on va utiliser SQLite, la base de données SQL passe partout.

Le système de cache est utilisé dans le plugin pelican-gmg que j'ai développé et que je maintiens pour ce même blog, afin d'avoir un système d'hébergement d'images fiable.

Première implémentation : un truc bien bourrin

Le système de cache permet de stocker localement les résultats d'appels à l'API REST de mon instance de GNU Mediagoblin (GMG). En effet, chaque appel — authentifié par OAuth v1 — met un certain temps avant de répondre. Il n'est pas envisageable de faire tous ces appels à l'API à chaque génération statique du blog, car le temps total de tous les appels peut atteindre plusieurs dizaines de secondes.

C'est donc pour cela qu'un système de cache est mise en place : pour chaque identifiant unique d'image (sous forme d'UUID), le résultat ne changera pas tant que l'on aura pas édité l'image dans la base de données de GMG.

On utilise pickle afin de pouvoir écrire directement dans un fichier le contenu du dictionnaire d'association UUID vers les informations complémentaires envoyées par l'API. Ce fichier sera au format binaire, et ne pourra être lu que par Python. Cela nous permet de pouvoir très rapidement sauvegarder ce dictionnaire sur disque sans se prendre la tête à convertir le dictionnaire en JSON par exemple — même si c'est plutôt simple à faire au final.

La première implémentation de ce système de cache est la suivante :

  • ouvrir un fichier de cache cache.bin ;
  • regarder si l'UUID est déjà présent dans le dictionnaire ;
  • demander à l'API les informations si l'UUID n'existe pas ;
  • réécrire entièrement le fichier cache.bin avec la nouvelle entrée ajoutée ;
  • retourner les informations correspondant à l'image demandée.

Voici le bout de code qui s'occupe de ça :

# Retrieve photos_mapping dict from cache pickle file if possible
try:
    with open(tmp_file, 'rb') as f:
        photos_mapping = pickle.load(f)
except (IOError, EOFError):
    photos_mapping = {}
else:
    # Get the difference of photos_ids and what have been cached
    cached_ids = set(photos_mapping.keys())
    photo_ids = list(set(photo_ids) - cached_ids)

# Fetch the images we have to fetch that have not been cached yet
if photo_ids:
    for public_id in photo_ids:
        photos_mapping[public_id] = fetch_image(generator.settings,
                                                public_id)

# Save the photos_mapping for cache for next generation
with open(tmp_file, 'wb') as f:
    pickle.dump(photos_mapping, f)

Cette implémentation est qualifiée de « bourrine » par mes propres soins, pour la raison suivante : à chaque article rencontré, le fichier sera lu depuis le disque, puis réécrit entièrement — même si les images étaient déjà présentes dans le cache ! Bien que sur une machine équipée d'un SSD cette implémentation soit totalement transparente, ce n'est pas une excuse pour utiliser un tel algorithme.

Je commence néanmoins à me rendre compte de plus en plus qu'une solution qui fonctionne avec une mauvaise implémentation est souvent suffisante. Cela est d'autant plus vrai dans le monde professionnel, là où les ressources sont limitées. Mais bon, on est tranquille à la maison donc on peut se permettre de se faire plaisir à faire quelque-chose d'un peu plus propre.

Pour le fun : remplacer pickle par JSON

Avant de penser à une solution un peu plus raisonnable, on peut tout de même se donner la peine d'écrire les quelques lignes qui vont enregistrer les données présentes dans le dictionnaire au format JSON, plutôt que d'utiliser pickle par facilité. Pour cela, le module json nous mâche le travail et le format du fichier est rapidement changé.

# Retrieve photos_mapping dict from cache file if possible
 try:
     with open(tmp_file, 'r') as f:
         photos_mapping = json.load(f)
 except (IOError, EOFError):
     photos_mapping = {}
 else:
     # Get the difference of photos_ids and what have been cached
     cached_ids = set(photos_mapping.keys())
     photo_ids = list(set(photo_ids) - cached_ids)

 # Fetch the images we have to fetch that have not been cached yet
 if photo_ids:
     for public_id in photo_ids:
         photos_mapping[public_id] = fetch_image(generator.settings,
                                                 public_id)

 # Save the photos_mapping for cache for next generation
 with open(tmp_file, 'w') as f:
     json.dump(photos_mapping, f, indent=4)

Cela n'apporte rien par rapport à l'implémentation avec pickle, si ce n'est que l'on peut s'amuser à consulter le contenu du cache avec un éditeur de texte, et éventuellement supprimer des entrées afin de forcer une mise à jour de celle-ci. Ce besoin arrive quand on fait une faute dans la description d'une image, qu'on la corrige dans GMG, et que l'on veut invalider cette entrée du cache sans tout supprimer.

Une solution plus propre : SQLite à la rescousse

Bon, fini de jouer. Ouvrir et réécrire un fichier à chaque fois que le plugin trouve une image, ce n'est pas vraiment malin.

Trouver une alternative

On aimerai bien garder un objet à disposition durant toute la durée de vie du plugin, pour mutualiser les ressources. On ne peut cependant pas garder uniquement le dictionnaire en mémoire, car après chaque génération le cache sera perdu.

Il nous faut donc un objet qui persiste durant toute la durée de la génération, et qui nous permet également d'avoir une synchronisation sur disque. Une base relationnelle semble assez bien indiquée pour ce besoin. On peut l'interroger pour savoir si une entrée est en cache ou non, et celle-ci est automatiquement sauvegardée sur le disque. La connexion à la base de données peut être mutualisée entre tous les appels au plugin lors d'une même génération.

Si l'on considère SQLite, qui est utilisable directement en Python avec le module déjà présent dans la bibliothèque standard, alors on a une piste intéressante à explorer.

Un ORM pour aller plus vite

Bien que SQL soit un langage sympathique à utiliser, je n'ai pas vraiment envie de me relire la documentation pour retrouver comment faire des requêtes (bon ça ça va encore) ou encore créer une table qui va bien (aïe).

Vu que j'ai déjà joué à de nombreuses reprises avec Django, je suis assez fan de son ORM qui permet de faire plein d'opérations en combinant des fonctions Python, sans écrire une ligne de SQL, et surtout sans faire manuellement les conversions entre les types SQL et les types Python (partie qui me rebute le plus pour ne pas utiliser le module standard SQLite).

Mais on ne va pas tirer tout Django juste pour profiter de son ORM.

Heureusement, Peewee est un ORM qui s'inspire fortement de celui de Django, tout en étant indépendant de celui-ci. C'est donc un très bon candidat. On va pouvoir l'utiliser pour créer des objets qui soient facilement utilisables en Python sans écrire de SQL. Peewee supporte SQLite, c'est donc parfait, car ça va nous éviter de lancer une instance de Postgres juste pour stocker si peu de données — qui plus est des données de cache uniquement.

from peewee import *

db = SqliteDatabase('./gmg-cache.sqlite')

class BaseModel(Model):
    class Meta:
        database = db


class Image(BaseModel):
    public_id = CharField(unique=True, index=True)
    name = CharField(null=True)
    medium = CharField(null=True)
    content = CharField(null=True)
    url = CharField()


db.connect()
db.create_tables([Image])

Ce code permet de créer la classe Python qui va faire toute la magie nécessaire pour interagir avec la base SQLite.

Le code de vérification du cache devient le suivant :

# Create a set of all found IDs
missing_ids = set(matches)

# Get the difference of photos_ids and what have been cached
cached_ids = {i.public_id for i in Image.select(Image.public_id)}
missing_ids = list(set(missing_ids) - cached_ids)

# Fetch the images we have to fetch that have not been cached yet
for public_id in missing_ids:
    fetch_image_cached(generator.settings, public_id)

Et la mise en cache est directement effectuée dans fetch_image_cached.

def fetch_image_cached(settings, public_id):
    try:
        img = Image.get(Image.public_id == public_id)
    except Image.DoesNotExist:
        data = fetch_image(settings, public_id)
        img = Image.create(**data)

    return img

On remarque qu'il y a sans doute une petite inefficacité ici. En effet, on calcule d'abord la liste des éléments absents de la base, puis ensuite, dans la fonction de récupération, on regarde si l'élément est manquant.

Cela est dû au fait que l'ancien code qui va calculer la différence a été gardé, mais est maintenant inutile. On pourrait appeler fetch_image_cached pour toutes les images, étant donné que celle-ci est suffisament intelligente pour regarder si l'entrée existe ou non, auquel cas elle la récupère et la rajoute dans la base.

Conclusion

Voilà où le plugin en est ! Comme on l'a vu, ce n'est pas parfait, mais on partait de loin avec l'utilisation de pickle. Je prendrai peut-être le temps de virer la double requête SQL pour optimiser ça un peu plus. Mais bon, c'est déjà bien.

Ce petit refactoring m'a permis de refaire joujou avec un ORM, les temps où j'implémentais du web avec Django étant plutôt loin.

J'aurai également pu sortir de ma zone de confort, et essayer d'implémenter ça en utilisant un système dédié à la mise en cache, comme memcached ou Redis. Cependant, reste la question de la persistance sur le disque, ainsi que sur le temps de mise en place. Si il faut lancer des services en root avant de pouvoir générer le blog, on perd l'avantage du simple fichier que nous apporte SQLite et les précédentes implémentations. Je pense que l'utilisation de ces services peut être indiquée dans le cadre d'un site web par exemple, mais pour juste mettre en cache 100 entrées pour générer un blog, c'est le marteau pour écraser une mouche (ou un moustique, en cette période).

À la prochaine !

Une question ou remarque ? N'hésitez pas à me contacter en envoyant un mail à microjoe, suivi d'un arobase, puis encore microjoe et enfin un « point org ».