Coin web de Frédéric Péters

Longues requĂŞtes avec aiohttp

2 août 2020, 09:59

Dans la note précédente j’écris avoir laissé aiohttp de côté, avoir plutôt tapé un mini-procotole maison parce que c’était très simple et évitait l’introduction d’une dépendance supplémentaire. Mais quand même, j’étais curieux de regarder aiohttp alors j’ai pris prétexte d’un autre développement pour regarder.

L’idée de base est simple, mon intégration continue fonctionne mais je fais parfois preuve d’impatience et je regarde les logs pour voir où ça en est. En pratique ça veut dire ssh vers le serveur et journalctl -u git-eobuilder.service -f. De là donc avoir une URL que je pourrais consulter pour avoir les logs, et tant qu’à faire qu’il n’y ait pas à recharger la page pour voir les logs continuer à arriver.

Vraiment tout bĂŞte et affaire sans doute rĂ©glĂ©e en cinq minutes avec un CGI façon :

#! /bin/sh
echo "Content-type: text/plain; charset=utf-8"
echo ""
journalctl -u git-eobuilder.service -f

(cela sans tester, il y a peut-être une astuce ou l’autre côté paramétrage nginx/fastcgi pour éviter le buffering)

Mais ce n’est pas cette vieille recette qui va me faire regarder aiohttp, c’est parti donc pour autre chose et l’hello world bidon ressemble Ă  :

from aiohttp import web

async def handle(request):
    return web.Response(text='hello world')

app = web.Application()
app.add_routes([web.get('/', handle)])
web.run_app(app)

Et dĂ©jĂ  ici j’aurais pu voir ce qui allait un peu coincer : j’ai l’impression qu’aiohttp ne sait pas très bien se positionner, fait client et serveur, et cĂ´tĂ© serveur intègre des fonctionnalitĂ©s relevant davantage d’un framework complet, comme le système de routage vu ici. Bon, c’est sans doute juste moi trompĂ© sur le nom, je m’attendais Ă  quelque chose de basique et minimal et je tombe sur autre chose, mais ça a des consĂ©quences pratiques sur la documentation, que je trouve pour la peine mal agencĂ©e, m’obligeant Ă  visiter le code source pour des bouts qui me semblent Ă©lĂ©mentaires. Dont le cas qui m’intĂ©resse, produire une rĂ©ponse longue, sans coupure, il y a juste mention Ă  un moment :

A request handler must be a coroutine that accepts a Request instance as its only argument and returns a StreamResponse derived (e.g. Response) instance

qui rĂ©vèle l’existance de cette classe StreamResponse qui doit ĂŞtre la rĂ©ponse qu’il me faut mais zĂ©ro exemple d’utilisation. Reste donc la description technique dĂ©taillĂ©e de la classe  et le code source.

Pareil pour un autre point que j’imagine basique, démarrer sur autre chose que le port par défaut, l’info ne se trouve pas, sauf pareil dans la référence (de run_app).

En passant je me trouve donc à regarder le code et confession, je trouve ça moche et je suis loin d’être prêt à accueillir du typage explicite en Python.

Passons, rĂ©ponse simple pour dĂ©marrer sur une interface et port particulier :

web.run_app(app, host='127.0.0.1', port=8123)

et exemple pour StreamResponse :

async def handle(request):
    response = web.StreamResponse(
        headers={'content-type': 'text/plain; charset=utf-8'})
    response.enable_chunked_encoding()
    await response.prepare(request)
    process = await asyncio.create_subprocess_exec(
        '/bin/journalctl', '-f',
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.STDOUT)
    while process.returncode is None:
        data = await process.stdout.readline()
        await response.write(data)
    return response

c’est-à-dire au final pas compliqué, retourner l’objet comme il vient et laisser la boucle asynchrone avant l’alimenter. (et ça repointe ce que je trouve très chouette avec asyncio, ça peut vraiment donner du code qui se lit linéairement)

Sauf que si jamais la requĂŞte HTTP est interrompue par le client, ça va ĂŞtre absorbĂ© silencieusement et le processus dĂ©marrĂ© va ĂŞtre laissĂ© intact et trainer, donc il y a Ă  encadrer ça :

async def handle(request):
    response = web.StreamResponse(
        headers={'content-type': 'text/plain; charset=utf-8'})
    response.enable_chunked_encoding()
    await response.prepare(request)
    process = await asyncio.create_subprocess_exec(
        '/bin/journalctl', '-f',
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.STDOUT)
    try:
        while process.returncode is None:
            data = await process.stdout.readline()
            await response.write(data)
    except asyncio.CancelledError:
        if process.returncode is None:
           process.terminate()
        raise
    return response

Autre chose, j’aurais aimĂ© faire ça depuis une classe pour porter un peu de contexte et taper ce code dans une mĂ©thode __call__, façon :

class Handler:
    async def __call__(self, request):
        response = web.StreamResponse(
            headers={'content-type': 'text/plain; charset=utf-8'})
        (...)

ça fonctionne mais cette construction est mal détectée et aiohttp affiche

DeprecationWarning: Bare functions are deprecated, use async ones

ce qui n’est pas joli.

De tout cela pour terminer j’ai quand mĂŞme tapĂ© mon code dans un dĂ©pĂ´t (tailerd) et dans l’élan ça gère davantage, genre ça se configure via un fichier :

[config]
command1 = /bin/journald -u whatever -f
command2 = /usr/bin/tail -f /var/log/whatever.log

Pour permettre qu’un accès Ă  /command1/ retourne le rĂ©sultat de la première commande, et /command2/ de la seconde; ou dans mon cas :

[config]
logs = /bin/journald -u  git-eobuilder.service -f

pour avoir une URL /logs/ qui contient la part de logs qui m’intéresse.

Mais je ne pense pas le projet particulièrement intéressant quand un CGI de quatre lignes fait le job.

Conclusion, aiohttp, bof, allez quand mĂŞme, it was ok, 2 Ă©toiles.