Cours Python · Module 6: Async et FastAPI
Base de données async - un accès rapide aux données
Dans cette leçon5
Bienvenue ! Ici Darwin, avec les bases de données asynchrones dans FastAPI !
Jusqu'ici, nous utilisions un dict en mémoire. C'est pratique pour démarrer, mais il y a un sérieux défaut : à chaque redémarrage du serveur, toutes les observations disparaissent, comme si quelqu'un arrachait les pages du carnet d'expédition. Nous allons maintenant brancher une vraie base de données PostgreSQL avec SQLAlchemy async !
Analogie safari : Une base de données async, c'est comme un système de suivi des animaux en temps réel - des milliers d'observations enregistrées en même temps sans blocage ! Un pisteur qui a envoyé un message radio ne reste pas planté à attendre la confirmation du camp. Pendant ce temps, il observe le troupeau suivant et récupère la réponse quand elle arrive.
Installation
Il nous faut deux paquets : la bibliothèque SQLAlchemy avec son extension asyncio, et un pilote qui parle à PostgreSQL sans bloquer la boucle d'événements :
1pip install sqlalchemy[asyncio] asyncpg- SQLAlchemy - ORM
- asyncpg - pilote PostgreSQL async
Un ORM (Object-Relational Mapper) traduit les classes Python en tables et les objets en lignes, donc au lieu d'écrire du SQL à la main, tu manipules des objets ordinaires. Dans le shell zsh, les crochets ont une signification spéciale, alors mets le nom entre guillemets : "sqlalchemy[asyncio]".
Configuration de SQLAlchemy async
On garde toute la configuration dans un seul fichier database.py. D'abord l'adresse de la base et le moteur (engine), l'objet qui gère les connexions. La fonction create_async_engine en crée la version asynchrone :
1# database.py
2from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
3from sqlalchemy.orm import DeclarativeBase
4
5DATABASE_URL = "postgresql+asyncpg://user:password@localhost/safari_db"
6
7# Moteur async
8engine = create_async_engine(DATABASE_URL, echo=True)Le préfixe postgresql+asyncpg dans l'adresse indique à SQLAlchemy quel pilote utiliser. L'option echo=True affiche chaque requête SQL dans la console - génial pour apprendre, mais désactive-la en production. Créer le moteur ne se connecte pas encore à la base, la connexion s'ouvre à la première requête.
Le moteur, c'est la ligne radio, mais pour travailler il nous faut une session - une conversation avec la base dans laquelle on regroupe des modifications et on les valide ensemble. async_sessionmaker est une fabrique de sessions :
1# Fabrique de sessions
2AsyncSessionLocal = async_sessionmaker(
3 engine,
4 class_=AsyncSession,
5 expire_on_commit=False
6)La classe AsyncSession est ici la valeur par défaut, tu peux donc omettre class_, même si l'écrire explicitement ne fait pas de mal. Plus important : expire_on_commit=False. Sans lui, après commit() les objets perdent leurs valeurs chargées, et les relire dans du code async finit en erreur, car SQLAlchemy voudrait recharger les données depuis la base de façon implicite.
Il reste la classe de base des modèles et une dépendance qui fournit une session à chaque requête :
1# Classe de base
2class Base(DeclarativeBase):
3 pass
4
5# Dépendance
6async def get_db():
7 async with AsyncSessionLocal() as session:
8 yield sessionDeclarativeBase est la classe dont héritent toutes les tables. La fonction get_db utilise yield, donc FastAPI passe la session à l'endpoint, et une fois la réponse envoyée, async with la ferme tout seul - même si l'endpoint a levé une exception.
Modèle SQLAlchemy
Un modèle décrit une table. Mapped[int] est l'annotation de type d'une colonne, et mapped_column fournit les détails : le type SQL, la clé primaire, la valeur par défaut :
1# models.py
2from sqlalchemy import Integer, String, Boolean
3from sqlalchemy.orm import Mapped, mapped_column
4from database import Base
5
6class SpeciesModel(Base):
7 __tablename__ = "species"
8
9 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
10 name: Mapped[str] = mapped_column(String(100), nullable=False)
11 scientific_name: Mapped[str] = mapped_column(String(150), nullable=False)
12 population: Mapped[int] = mapped_column(Integer, default=0)
13 endangered: Mapped[bool] = mapped_column(Boolean, default=False)Le nom de la table dans la base vient de __tablename__. Attention, ce n'est pas le modèle Pydantic de la leçon précédente : SQLAlchemy décrit comment les données sont rangées dans la base, Pydantic décrit leur forme dans les requêtes et les réponses. Dans un vrai projet, tu as donc les deux.
Opérations CRUD - async
CRUD désigne les quatre opérations de base : Create, Read, Update, Delete. On construit chaque requête avec la fonction select et on l'exécute avec await db.execute(...). Commençons par la lecture :
1# crud.py
2from sqlalchemy.ext.asyncio import AsyncSession
3from sqlalchemy import select
4from models import SpeciesModel
5from schemas import SpeciesCreate
6
7async def get_species(db: AsyncSession, species_id: int):
8 result = await db.execute(select(SpeciesModel).where(SpeciesModel.id == species_id))
9 return result.scalar_one_or_none()
10
11async def get_all_species(db: AsyncSession, skip: int = 0, limit: int = 10):
12 result = await db.execute(select(SpeciesModel).offset(skip).limit(limit))
13 return result.scalars().all()scalar_one_or_none() renvoie un objet, ou None si rien ne correspond, et scalars().all() renvoie une liste d'objets. Les paramètres offset et limit gèrent la pagination, pour ne pas télécharger toute la réserve d'un coup.
Passons à l'écriture. On transforme le modèle Pydantic en dict et on le déballe dans le constructeur du modèle SQLAlchemy :
1async def create_species(db: AsyncSession, species: SpeciesCreate):
2 db_species = SpeciesModel(**species.model_dump())
3 db.add(db_species)
4 await db.commit()
5 await db.refresh(db_species)
6 return db_speciesdb.add() ajoute seulement l'objet à la session - il n'arrive dans la base qu'au moment de await db.commit(). refresh charge les valeurs attribuées par la base, par exemple l'id généré. Dans les anciens tutoriels, tu verras species.dict(), mais dans Pydantic 2 le bon nom est model_dump().
La suppression réutilise la fonction de lecture que nous avons déjà :
1async def delete_species(db: AsyncSession, species_id: int):
2 species = await get_species(db, species_id)
3 if species:
4 await db.delete(species)
5 await db.commit()
6 return speciesDans AsyncSession, la méthode delete est une coroutine, elle a donc besoin de await, tout comme commit.
Endpoints FastAPI avec base async
Pour finir, on assemble tout dans les endpoints. Depends(get_db) est de l'injection de dépendances : FastAPI appelle lui-même get_db et passe la session dans le paramètre db :
1# main.py
2from fastapi import FastAPI, Depends, HTTPException
3from sqlalchemy.ext.asyncio import AsyncSession
4from database import get_db
5import crud
6import schemas
7
8app = FastAPI()
9
10@app.get("/species", response_model=list[schemas.Species])
11async def list_species(skip: int = 0, limit: int = 10, db: AsyncSession = Depends(get_db)):
12 species = await crud.get_all_species(db, skip=skip, limit=limit)
13 return speciesL'endpoint ne crée pas la session et ne la ferme pas - la dépendance s'en charge. Les deux autres endpoints suivent le même modèle :
1@app.get("/species/{species_id}", response_model=schemas.Species)
2async def get_species(species_id: int, db: AsyncSession = Depends(get_db)):
3 species = await crud.get_species(db, species_id)
4 if not species:
5 raise HTTPException(status_code=404, detail="Species not found")
6 return species
7
8@app.post("/species", response_model=schemas.Species)
9async def create_species(species: schemas.SpeciesCreate, db: AsyncSession = Depends(get_db)):
10 return await crud.create_species(db, species)Pour que response_model accepte un objet SQLAlchemy au lieu d'un dict, le modèle Pydantic doit avoir from_attributes=True, que tu connais depuis la leçon sur Pydantic. Une espèce absente devient un code 404 lisible.
Avantages d'une base de données async :
- Des milliers de requêtes en même temps
- Ne bloque pas la boucle d'événements
- Haute performance
Pour être honnête, l'async ne rend pas une requête isolée plus rapide. Le gain apparaît quand beaucoup de requêtes attendent la base en même temps : le serveur s'occupe des autres au lieu de rester les bras croisés. Mon conseil : quand tu écris des endpoints async def, utilise aussi un pilote asynchrone, car une requête bloquante ordinaire à l'intérieur de async def arrête toute la boucle d'événements.
Prochaine leçon : l'authentification JWT ! Depends va servir, car FastAPI injecte l'utilisateur connecté de la même manière.
Retiens : la base est le carnet d'expédition, une session est une entrée, et commit est la signature sans laquelle l'entrée ne compte pas.
Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Que permet SQLAlchemy avec l'async ?
2. Que représente AsyncSession dans SQLAlchemy ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri vertical
Ordonne un endpoint qui récupère des données de la base de données de manière asynchrone :
- Clique dans l'ordre
Ordonne la création d'un moteur SQLAlchemy async :
- Éditeur de code
Écris une fonction async create_species qui prend db (AsyncSession) et species_data, l'ajoute à la base de données et valide (commit).