Bottle : Un cache pour les requêtes

Bottle est un framework web écrit en Python. Nous allons voir comment ajouter un cache à certaines requêtes.

Partons de cet exemple inspiré de la description du projet sur GitHub :

app.py
 1import bottle
 2
 3
 4@bottle.route("/hello/<name>")
 5def index(name: str) -> str:
 6    return bottle.template("<b>Hello {{name}}</b>!", name=name)
 7
 8
 9if __name__ == "__main__":
10    bottle.run(host="localhost", port=8080)

Démarrons le serveur local :

python app.py

Et voyons que ça fonctionne :

$ curl “http://localhost:8080/hello/Mickaël”
<b>Hello Mickaël</b>

Le Cache

L’idée est la suivante : lorsqu’une requête est faite sur /hello/NAME, la réponse doit être enregistrée pour un usage ultérieur. La prochaine fois que ce même appel aura lieu, la version en cache sera servie directement.

Le cache en lui-même aura besoin de ces fonctions (c’est une façon de faire, à adapter selon le besoin) :

Imports & constante
from collections.abc import Callable
from pathlib import Path
from typing import Any

CACHE_DIR = Path(__file__).parent / "cache"
def get_from_cache(cache_key: str) -> str | None:
    """Retreive a response from a potential cache file."""
    from contextlib import suppress
    from zlib import decompress

    file = CACHE_DIR / f"{cache_key}.cache"
    with suppress(FileNotFoundError):
        return decompress(file.read_bytes()).decode()
    return None
def store_in_cache(cache_key: str, response: str, info: bool = True) -> None:
    """Store a HTTP response into a compressed cache file."""
    from zlib import compress

    if info:
        from datetime import UTC, datetime

        today = datetime.now(tz=UTC)
        response += f"<!-- Cached: {today} -->"

    file = CACHE_DIR / f"{cache_key}.cache"
    file.parent.mkdir(exist_ok=True, parents=True)
    file.write_bytes(compress(response.encode(), level=9))

Bien sûr, qui dit cache, dit invalidation de cache. Cette fonction sera utile donc :

def invalidate_caches() -> None:
    """Remove all cache files."""
    for file in CACHE_DIR.glob("*.cache"):
        file.unlink(missing_ok=True)

Et voici le code du cache, qui n’est autre qu’un décorateur :

def cache(func: Callable) -> Callable:
    """Decorator used to cache HTTP responses."""
    from functools import wraps

    @wraps(func)
    def wrapper(*args: Any, **kwargs: Any) -> str:
        # If Bottle is run in debug mode, then we do not use the cache
        if bottle.DEBUG:
            return func(*args, **kwargs)

        # The cache key is computed from the request path
        cache_key = small_hash(bottle.request.path.lower())
        if (response := get_from_cache(cache_key)) is None:
            response = func(*args, **kwargs)
            store_in_cache(cache_key, response)

        return response

    return wrapper

La clef du cache est déterminée suivant le chemin de la requête (ex : /hello/Mickaël) ; il est possible de prendre en compte plus de détails comme les paramètres passés à l’URL, entre autres. Aussi, si Bottle est en mode débogage, alors le cache est ignoré.

Avec cette information, un hash est généré via la fonction small_hash() que voici, inspirée de la version PHP smallHash() écrite pour Shaarli (idem, c’est un exemple et libre à chacun de tout chambouler) :

def php_crc32(value: str) -> str:
    """
    References:
    - https://www.php.net/manual/en/function.hash-file.php#104836
    - https://stackoverflow.com/a/50843127/636849

        >>> php_crc32("20111006_131924")
        'c991f6df'
        >>> php_crc32("liens.mohja.fr")
        '0c05b1a5'

    """
    crc = 0xFFFFFFFF
    for x in value.encode():
        crc ^= x << 24
        for _ in range(8):
            crc = (crc << 1) ^ 0x04C11DB7 if crc & 0x80000000 else crc << 1
    crc = ~crc
    crc &= 0xFFFFFFFF

    # Convert from big endian to little endian:
    crc = int.from_bytes(crc.to_bytes(4, "big"), byteorder="little")

    return hex(crc)[2:].rjust(8, "0")
def small_hash(value: str) -> str:
    """
    Returns the small hash of a string, using RFC 4648 base64url format
    http://sebsauvage.net/wiki/doku.php?id=php:shaarli

    Small hashes:
    - are unique (well, as unique as crc32, at last)
    - are always 6 characters long
    - only use the following characters: a-z A-Z 0-9 - _ @
    - are NOT cryptographically secure (they CAN be forged)

        >>> small_hash("20111006_131924")
        'yZH23w'

    """
    from base64 import b64encode

    return b64encode(bytes.fromhex(php_crc32(value)), altchars=b"-_").rstrip(b"=").decode()

Dernière étape, utiliser le décorateur :

app.py diff
3 @bottle.route("/hello/<name>")
4+@cache
5 def index(name: str) -> str:
6     return bottle.template("<b>Hello {{name}}</b>!", name=name)

Résultat

Le premier appel n’est pas en cache :

$ curl “http://localhost:8080/hello/Mickaël”
<b>Hello Mickaël</b>

Et les suivants le sont :

$ curl “http://localhost:8080/hello/Mickaël”
<b>Hello Mickaël</b>
<!-- Cached: 2023-10-17 07:08:41.510318+00:00 -->

📜 Historique

2024-01-27

Déplacement de l’article depuis le blog.

2023-10-17

Premier jet.