Patrice Ferlet
Patrice Ferlet
Créateur de ce blog.
Publié le déc. 21, 2020 Temps de lecture: 7 min

Me suivre sur Mastodon

De Flask À Quart - pourquoi pas, surtout pour les Websockets

thumbnail for this post

Flask est certainement l’un des frameworks les plus connus dans le monde de Python, mais connaissez-vous Quart, compatible Flask, qui gère l’asychronisme ? Vous allez voir à quel point c’est bénéfique, notamment pour la gestion des WebSockets.

Flask a été pour moi l’une des plus grosses révélations quand je suis passé de PHP à Python, il y a déjà fort longtemps. C’est un framework facile à apprendre, à comprendre, léger, souple, qui permet d’écrire des API REST ou un site avec un rendu “server side”. Il intègre Jinja2 et vous avez un paquet de “plugins” pour gérer l’authentification, les CORS, et j’en passe. Bref, Flask, c’est le pied total. Jusqu’au moment où vous avez envie de faire des trucs “en parallèle”, par exemple gérer des ServerEvents ou des Websockets.

Oui, on peut le faire avec Flask, je ne dis pas le contraire. Mais Python propose depuis quelque temps une écriture native pour faire de l’asynchrone (des coroutines), je vous en ai parlé et je vous invite à vraiment vous pencher sur la question.

Et c’est là que je tombe sur Quart.

Oh non, encore un framework !

Ouais mais non. Je suis, moi aussi, peu enthousiaste à devoir passer d’un framework à l’autre. D’autant que la syntaxe de Flask est idéale pour moi. Mais justement, Quart utilise quasiment la même syntaxe. Je dis “quasiment” parce que finalement, la seule chose qui change c’est le fait de déclarer nos fonctions (nos routes) en coroutine.

Je vous fais un résumé simple.

# Avec Flask
from flask import Flask

app = Flask(__name__)

@app.route("/")
def index():
    return "Hello"

# Avec Quart
from quart import Quart

app = Quart(__name__)

@app.route("/")
async def index():
    return "Hello"

Voilà, à part le nom du framework et un “async” qui traine, rien de bien nouveau.

Alors, me direz-vous, ça change quoi ?

Et bien, de part sa nature asynchrone, Quart va vous soulager grandement pour gérer des tâches qui peuvent tourner en tâche de fond, comme pour les ServerSide Events, ou les websockets.

Les websockets

Flask, de base, nativement, ne peut pas gérer des websockets. Il vous faut un package supplémentaire (e.g. SocketIO).

Quart inclue un module de websocket, et il est… Comment dire… Très naturel à utiliser.

Alors, soyons précis, je le trouve idéal car il me permet de gérer la logique moi-même, chose que beaucoup n’aime pas. Je le comprends. J’ai un plaisirs fou à coder les logiques de base, alors que d’autres aiment utiliser les implémentations clef-en-main. C’est une question de goût et d’habitude.

Donc, à mon sens, la logique de Quart est plus adaptée à ma manière de travailler. Bref, comment ça marche ?

De la même manière que l’objet “request” dans Flask et Quart, il existe un objet websocket qui utilise le contexte. De ce fait, quart.websocket sera utilisé au sein d’une route pour:

  • reçevoir des messages avec websocket.receive() (asynchrone)
  • envoyer des messages avec websocket.send(data) (asynchrone)

Comme je vous le disais, il faut gérer la logique de base. En général, vous voulez envoyer des données à tout moment à vos clients connectés.

L’exemple donné dans la page de documentation dédiée au sujet est assez basique, et ne reflète pas forcément le besoin que vous aurez. Pour ma part, l’envoi et la réception d’info sont décorrélés. Donc, je fais comme ça :

  • je crée une route de connexion, par exemple sur “/ws
  • je crée une asyncio.Queue qui servira au serveur à envoyer des données à la connexion
  • je crée deux coroutines qui vont gére la réception et l’envoi

Vous allez voir c’est pas si compliqué :

import asyncio
import json
from datetime import datetime

from quart import Quart, copy_current_websocket_context, websocket

app = Quart(__name__)


async def manage(data, queue):
    # Ici, je gère les message reçus depuis le client.
    # Si le client m'envoie une clef "hello" je lui répond
    # par un "hello"
    # Pour envoyer au client, j'écris simplement dans une Queue.
    if data.get('hello'):
        await queue.put({"response": "Hello %s!" % data['hello']})
    else:
        await queue.put({"response": "What ?"})


async def ping(queue):
    # Là c'est un exemple de fonction qui peut tourner en tâche de fond
    # pour envoyer des données, de manière asynchrone, au client.
    while True:
        await asyncio.sleep(5)
        await queue.put({"response": "ping %s" % datetime.now()})


@app.websocket('/ws')
async def ws():

    # Queue qui permet aux coroutines d'envoyer des data
    # au client.
    queue = asyncio.Queue()

    @copy_current_websocket_context
    async def recv():
        # Ici, on va attendre indéfiniment que le client
        # envoie des données. Quand on en a, on la traite
        # avec la fonction "manage".
        while True:
            message = await websocket.receive()
            await manage(json.loads(message), queue)

    @copy_current_websocket_context
    async def send():
        # Ici, on attend que des données entrent dans la queue,
        # quand on nous envoie des infos, on les envoie au client.
        while True:
            data = await queue.get()
            await websocket.send(json.dumps(data))

    # ici, je crée mes tâches concurrentes
    producer = asyncio.ensure_future(send())
    consumer = asyncio.ensure_future(recv())
    pinger = asyncio.ensure_future(ping(queue))

    # là, je vais attendre qu'un coroutine se viande, si c'est le cas
    # c'est que le client a coupé la connexion (ou l'a perdu)
    try:
        await asyncio.gather(producer, consumer, pinger)
    finally:
        producer.cancel()
        consumer.cancel()
        pinger.cancel()

Pour les deux coroutines qui utilisent le websocket, je dois utiliser copy_current_websocket_context pour ne pas perdre le contexte, c’est un “souci” commun dans ce genre d’application, mais ce décorateur fait ce qu’il faut pour ne pas avoir à trop se prendre la tête.

Si jamais vous voulez sortir les fonctions de la fonction mère, vous pouvez, mais dans la création de tâche, vous devrez utiliser le décorateur en tant que fonction, par exemple:

producer = asyncio.ensure_future(
    copy_current_websocket_context(producer)()
)

Et si vous avez un argument à passer, genre la queue, faut passer par functools.partial:

send_func = functools.partial(send, queue)
producer = asyncio.ensure_future(
    copy_current_websocket_context(send_func)()
)

Mais en règle générale, je préfère créer une toute petite fonction de dispatch, comme dans l’exemple que je vous ai donné.

Revenons au code de l’exemple ci-dessus, et voyez la fonction “ping” qui peut tourner en arrière-plann et qui envoie un message toutes les 5 secondes.

Le plus important reste les deux coroutines recv et send qui gèrent, en concurrence, l’envoi et la réception de données. La asyncio.Queue est très importante, mais aussi très pratique. Inutile de balader un objet de connexion de websocket, on va simplement stocker les messages dans une file d’attente qui part vers le client.

Coté JS, c’est pas compliqué :

let w = new WebSocket('ws://127.0.0.1:5000/ws');
w.onmessage = data => {
    console.log(data.data)
}
w.send(JSON.stringify({
    "hello": "John"
}))

Si vous avez besoin d’un “broadcast”, c’est-à-diree une fonction qui envoie à tous les clients, l’idée est de faire ceci :


# en haut, en globale
QUEUES = set()


# dans la fonction "ws()" qui gère la websocket:
queue = asyncio.Queue()
QUEUES.add(queue)

# et dans le finally en fin de fonciton, quand la connexion est perdue,
# on vire la queue de la liste:
    try:
        await asyncio.gather(producer, consumer, pinger)
    finally:
        producer.cancel()
        consumer.cancel()
        pinger.cancel()
        QUEUES.remove(queue)

Et donc, imaginons une fonction de broadcast :

async def broadcast(message):
    for qeue in QUEUES:
        try:
            await queue.put(json.dumps(message))
        except:
            pass

Voilà, tous les clients vont avoir le message.

Je passe à Quart ou je reste sur Flask ?

Ça dépend. Flask est éprouvé, bénéficie d’une large communauté, et il a des plugins d’une puissance extrême. C’est facile à gérer, surtout quand on pas l’habitude (ou l’envie) de bosser avec des tâches asynchrones.

Cela-dit, Quart est très proche de Flask (c’est un fork hein), et les développeurs n’entrent pas en conflit avec ceux de Flask, au contraire ils collaborent étroitement. Quart commence à se faire entendre, et il vous permet d’exploiter la puissance des coroutines Python. Côté Websocket et ServerSide events, c’est à mon avis la meilleure approche à avoir. Pas de thread à gérer, et vous avez la maitrise de la logique métier.

Je retrouve avec Quart ce que j’adore avec Go et les websocket en coroutines. C’est donc clairement un avis personnel lié à mes goûts.

Alors, à mon avis, la meilleure chose à faire de votre côté est de tester. Si vous n’aimez pas, restez sur Flask et les packages adéquats. Si vous aimez les coroutines et le fait de ne pas avoir à gérer des packages externes, peut-être allez-vous aimer Quart 🤓.

comments powered by Disqus