Requêtes HTTP asynchrones dans Python avec aiohttp et asyncio

March 25, 2021
Rédigé par
Sam Agnew
Twilion

Requêtes HTTP asynchrones dans Python avec aiohttp et asyncio

De plus en plus, le code asynchrone est devenu un pilier du développement Python. Comme il fait partie asyncio de la librairie standard et que de nombreux packages tiers fournissent des fonctionnalités compatibles avec lui, ce paradigme n'est pas prêt de disparaître.

Voyons comment utiliser la librairie aiohttp pour tirer parti de cette fonctionnalité et créer des requêtes HTTP asynchrones, soit l'un des cas d'utilisation les plus courants pour le code non bloquant.

Qu'est-ce qu'un code non bloquant ?

Vous pouvez entendre des termes tels que « asynchrone », « non bloquant » ou « simultané » et vous retrouver un peu confus quant à leur signification. Selon ce tutoriel beaucoup plus détaillé, deux des propriétés principales sont les suivantes :

  • Les routines asynchrones peuvent faire « pause » tout en attendant leur résultat final pour permettre à d'autres routines de s'exécuter en même temps.
  • Le code asynchrone, via le mécanisme ci-dessus, facilite l'exécution simultanée. Pour simplifier, le code asynchrone donne l'aspect de la simultanéité.

Le code asynchrone est donc un code qui peut se mettre en pause en attendant un résultat, afin de permettre à d'autres codes de s'exécuter entre-temps. Il ne « bloque » pas l'exécution d'un autre code et nous pouvons donc l'appeler code « non bloquant ».

Pour ce faire, la librairie asyncio offre de nombreux outils pour les développeurs Python pour cette tâche, et aiohttp propose une fonctionnalité encore plus spécifique pour travailler avec des requêtes HTTP. Les requêtes HTTP sont un exemple classique d'élément bien adapté à l'asynchrone, car elles impliquent l'attente d'une réponse d'un serveur, attente pendant laquelle il serait pratique et efficace d'exécuter d'autres codes.

Configurer

Assurez-vous de configurer votre environnement Python avant de commencer. Suivez ce guide dans la section virtualenv si vous avez besoin d'aide. Faire en sorte que tout fonctionne - en particulier en ce qui concerne les environnements virtuels - est important pour isoler vos dépendances si plusieurs projets s'exécutent sur la même machine. Vous aurez besoin d'au moins Python 3.7 ou une version ultérieure pour exécuter le code de ce post.

Maintenant que votre environnement est configuré, vous devez installer des librairies tierces. Nous allons utiliser aiohttp pour effectuer des requêtes asynchrones et la librairie de requêtes pour effectuer des requêtes HTTP synchrones régulières afin de les comparer ultérieurement. Installez les deux à l'aide de la commande suivante après avoir activé votre environnement virtuel :

pip install aiohttp-3.7.4.post0 requests==2.25.1

Ensuite, vous pouvez passer à l'écriture du code.

Effectuer une requête HTTP avec aiohttp

Commençons par effectuer une seule requête GET à l'aide d'aiohttp, afin de démontrer le fonctionnement des mots clés async et await. Nous allons utiliser l'API Pokemon comme exemple. Commençons donc par essayer d'obtenir les données associées à Mew, le légendaire 151e Pokémon.

Exécutez le code Python suivant et le nom « mew » devrait s'afficher sur le terminal :

import aiohttp
import asyncio


async def main():

    async with aiohttp.ClientSession() as session:

        pokemon_url = 'https://pokeapi.co/api/v2/pokemon/151'
        async with session.get(pokemon_url) as resp:
            pokemon = await resp.json()
            print(pokemon['name'])

asyncio.run(main())

Dans ce code, nous créons une coroutine appelée main, que nous utilisons avec la boucle d'événements asyncio. Ici, nous ouvrons une session du client aiohttp, un objet unique qui peut être utilisé pour un grand nombre de requêtes individuelles et, par défaut, peut établir des connexions avec jusqu'à 100 serveurs différents à la fois. Au cours de cette session, nous faisons une requête à l'API Pokemon, puis nous attendons une réponse.

Pour faire simple, ce mot clé async (asynchrone) indique à l'interpréteur Python que la coroutine que nous définissons doit être exécutée de manière asynchrone avec une boucle d'événements. Le mot clé await (attente) renvoie le contrôle à la boucle d'événements, en suspendant l'exécution de la coroutine environnante et en laissant la boucle d'événements exécuter d'autres opérations jusqu'à ce que le résultat « attendu » soit renvoyé.

Effectuer un grand nombre de requêtes

Effectuer une seule requête HTTP asynchrone est très utile, car nous pouvons laisser la boucle d'événements travailler sur d'autres tâches au lieu de bloquer l'intégralité du thread en attendant une réponse. Mais l'efficacité de cette fonctionnalité s'exprime réellement lorsque vous essayez de faire un plus grand nombre de requêtes. Faisons la démonstration en effectuant la même requête qu'auparavant, mais pour les 150 Pokémon originaux.

Prenons le code de requête précédent et plaçons-le dans une boucle, en mettant à jour les données de Pokemon demandées et en utilisant await pour chaque requête :

import aiohttp
import asyncio
import time

start_time = time.time()


async def main():

    async with aiohttp.ClientSession() as session:

        for number in range(1, 151):
            pokemon_url = f'https://pokeapi.co/api/v2/pokemon/{number}'
            async with session.get(pokemon_url) as resp:
                pokemon = await resp.json()
                print(pokemon['name'])

asyncio.run(main())
print("--- %s seconds ---" % (time.time() - start_time))

Cette fois-ci, nous mesurons également le temps nécessaire à l'exécution de l'ensemble du processus. Si vous exécutez ce code dans votre shell Python, vous devriez voir quelque chose comme ce qui suit sur votre terminal :

Résultat de 150 appels d"API à l"aide d"aiohttp

Un délai de 8 secondes, c'est plutôt bien pour 150 requêtes, mais nous n'avons rien à quoi le comparer. Essayons d'effectuer la même opération de manière synchrone à l'aide de la librairie de requêtes.

Comparer la vitesse avec les requêtes synchrones

Les requêtes ont été conçues comme une librairie HTTP « pour les êtres humains ». Elle dispose d'une API très belle et simple. Je le recommande vivement pour tous les projets où la vitesse n'est pas forcément d'une importance capitale par rapport à la convivialité de développement et à la facilité du code à suivre.

Pour afficher les 150 premiers Pokémon comme précédemment, mais à l'aide de la librairie de requêtes, exécutez le code suivant :

import requests
import time

start_time = time.time()

for number in range(1, 151):
    url = f'https://pokeapi.co/api/v2/pokemon/{number}'
    resp = requests.get(url)
    pokemon = resp.json()
    print(pokemon['name'])

print("--- %s seconds ---" % (time.time() - start_time))

Vous devriez voir le même résultat avec une exécution différente :

Résultat de 150 appels d"API utilisant des requêtes

Avec près de 29 secondes, cette opération est beaucoup plus lente qu'avec le code précédent. Pour chaque requête consécutive, nous devons attendre la fin de l'étape précédente avant même de commencer le processus. Cela prend beaucoup plus de temps, car ce code attend que 150 requêtes se terminent de manière séquentielle.

Utiliser asyncio pour des performances améliorées

Un délai de 8 secondes contre 29 secondes représente donc un énorme bond en avant en matière de performance, mais nous pouvons encore mieux faire en utilisant les outils fournis par asyncio. Dans l'exemple d'origine, nous utilisons await après chaque requête HTTP, ce qui n'est pas tout à fait idéal. C'est tout de même plus rapide que l'exemple des requêtes, car nous exécutons tout dans des routines communes. Cependant, nous pouvons exécuter toutes ces requêtes « simultanément » en tant que tâches asyncio, puis vérifier les résultats à la fin à l'aide des asyncio.ensure_future et asyncio.gather.

Si le code qui effectue la requête est dissocié de sa propre fonction de coroutine, nous pouvons créer une liste de tâches composée de fonctions futures pour chaque requête. Nous pouvons ensuite décompresser cette liste vers un appel gather qui exécute les requêtes toutes ensemble. Lorsque nous utilisons la fonction await pour cet appel sur asyncio.gather, nous récupérons un itérable pour toutes les fonctions futures qui ont été passées, en conservant leur ordre dans la liste. De cette façon, nous n'attendons qu'une seule fois.

Pour voir ce qui se passe lorsque nous implémentons cette fonctionnalité, exécutez le code suivant :

import aiohttp
import asyncio
import time

start_time = time.time()


async def get_pokemon(session, url):
    async with session.get(url) as resp:
        pokemon = await resp.json()
        return pokemon['name']


async def main():

    async with aiohttp.ClientSession() as session:

        tasks = []
        for number in range(1, 151):
            url = f'https://pokeapi.co/api/v2/pokemon/{number}'
            tasks.append(asyncio.ensure_future(get_pokemon(session, url)))

        original_pokemon = await asyncio.gather(*tasks)
        for pokemon in original_pokemon:
            print(pokemon)

asyncio.run(main())
print("--- %s seconds ---" % (time.time() - start_time))

Notre temps de traitement est ainsi réduit à seulement 1,53 seconde pour 150 requêtes HTTP ! C'est une amélioration considérable par rapport à notre exemple initial avec async/await. Cet exemple n'est pas totalement non bloquant. Par conséquent, la durée totale d'exécution des 150 requêtes sera à peu près égale à la durée d'exécution de la requête la plus longue. Les chiffres exacts varient en fonction de votre connexion Internet.

Résultat de 150 appels d"API utilisant aiohttp et asyncio.gather

Réflexions finales

Comme vous pouvez le voir, l'utilisation de librairies comme aiohttp permet de repenser la façon dont vous effectuez des requêtes HTTP, mais aussi d'augmenter considérablement les performances de votre code et de vous faire gagner beaucoup de temps lorsque vous effectuez un grand nombre de requêtes. Par défaut, elles sont un peu plus denses que les librairies synchrones telles que les requêtes, mais c'est voulu, car les développeurs pensent d'abord aux performances.

Dans ce tutoriel, nous n'avons fait qu'effleurer la surface de ce que vous pouvez faire avec aiohttp et asyncio, mais j'espère que cela vous a permis de vous lancer un peu plus facilement dans le monde du Python asynchrone.

J'ai hâte de découvrir ce que vous construisez. N'hésitez pas à me contacter et à partager vos expériences ou à poser des questions.