Bài 2: FastAPI + ASGI + Async/Await¶
Tham khảo trong project:
admin/src/main.py,admin/src/routers/audio.py
1. WSGI vs ASGI — Tại sao chúng ta lại cần cơ chế Bất đồng bộ (Async)?¶
Để hiểu vì sao FastAPI lại nổi tiếng với tốc độ xé gió, hãy nhìn lại cách các framework đời cũ (như Flask, Django) hoạt động dưới mô hình WSGI:
Khách A gọi món → Mời Bồi Bàn 1 → Chạy vô bếp đợi món (Đứng chơi, không làm gì)
Khách B gọi món → Mời Bồi Bàn 2 → Chạy vô bếp đợi món (Đứng chơi)
Khách C gọi món → Mời Bồi Bàn 3 → Chạy vô bếp đợi món (Đứng chơi)
Ở đây, Bồi Bàn chính là các Thread. Mỗi khách truy cập vào web sẽ ngốn mất một bồi bàn. Khoảng thời gian bồi bàn đứng đợi bếp nấu ăn (tương đương với lúc CPU nhàn rỗi chờ Database trả dữ liệu, hoặc chờ đọc file từ ổ cứng), bồi bàn đó bị "đóng băng" hoàn toàn (Blocking). Nếu quán có 100 khách cùng lúc, bạn phải mướn 100 bồi bàn → Cực kỳ tốn RAM máy chủ.
Nhưng với ASGI (công nghệ lõi đứng sau FastAPI, Starlette), câu chuyện hoàn toàn khác:
Khách A gọi món → Bồi Bàn 1 ghi order → Quăng cho bếp nấu → Quay ra hỏi "Ai gọi món tiếp theo?"
Khách B gọi món → Bồi Bàn 1 ghi order → Quăng cho bếp nấu → Quay ra hỏi tiếp...
... Bếp nấu xong món A → Báo Bồi Bàn 1 → Bồi bàn mang ra cho Khách A.
Ở mô hình này, chỉ cần 1 Bồi bàn (1 Thread chạy Event Loop) là đủ sức xoay tua phục vụ hàng ngàn khách.
Khi đụng phải câu lệnh await, luồng code sẽ chủ động "buông tay", báo với hệ thống rằng: "Tôi đang chờ DB trả kết quả, anh cứ đi phục vụ người khác đi, khi nào DB xong thì hú tôi."
Ví dụ thực tế trong code dự án:
# admin/src/routers/audio.py
@router.get("")
async def list_audio(
q: str | None = None,
svc: AudioService = Depends(get_audio_service),
session: AsyncSession = Depends(get_session),
) -> dict[str, Any]:
# Gặp chữ await → Coroutine (luồng xử lý nhỏ) tạm ngưng (suspended).
# Event loop lập tức xoay sang xử lý các request khác
# trong lúc chờ SQLite cặm cụi chạy câu query.
return {"data": await svc.list_all(session, q=q)}
Giả sử lệnh list_all mất 50 mili-giây để chạy. Thay vì bắt Thread đứng im ru 50ms, Event loop sẽ lấp đầy 50ms đó để phục vụ hàng chục người dùng khác.
2. Dòng đời của App (FastAPI Lifespan)¶
Vòng đời của một cái web server giống hệt như quy trình mở/đóng cửa của một cửa tiệm:
# admin/src/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
# ── KHÂU MỞ CỬA (STARTUP): Chạy đúng 1 lần khi bật server ──
config.validate() # Kiểm tra cầu dao điện (Fail-fast check env)
Path(AUDIO_DIR).mkdir(parents=True, exist_ok=True)
await init_db() # Tạo database schema nếu chưa có
yield # ← TRONG GIỜ HÀNH CHÍNH: Phục vụ khách hàng (requests)
# ── KHÂU ĐÓNG CỬA (SHUTDOWN): Chạy khi tắt server ──
# (Dọn dẹp rác, ngắt kết nối DB một cách êm ái...)
app = FastAPI(title="Khí Công Dưỡng Sinh – Admin", lifespan=lifespan)
Sức mạnh của Fail-fast: Đoạn config.validate() sẽ quăng thẳng lỗi RuntimeError nếu lỡ bạn quên cài biến môi trường quan trọng. Server thà sập ngay lúc khởi động để dev nhìn thấy mà sửa ngay, còn hơn là chạy cho đã rồi lúc user đang upload file mới nổ lỗi không lưu được.
3. Quản lý Router (Chia nhỏ API)¶
Đừng bao giờ nhét cả trăm cái API vào một file main.py khổng lồ. Hãy chia nhỏ nó ra theo từng bộ phận (modules).
Định nghĩa các Router con ở file riêng¶
# admin/src/routers/audio.py
from fastapi import APIRouter
router = APIRouter()
stream_router = APIRouter() # Tách thêm 1 router riêng để gắn phân quyền riêng (nếu cần)
@router.get("") # Tương đương: GET /api/audio
async def list_audio(...): ...
@router.post("/{id}/stream-url") # Tương đương: POST /api/audio/{id}/stream-url
async def get_stream_url(...): ...
Ráp nối lại tại tổng hành dinh main.py¶
# admin/src/main.py
app.include_router(audio.router, prefix="/api/audio", tags=["audio"])
app.include_router(audio.stream_router, prefix="/api/audio", tags=["audio-stream"])
app.include_router(schedule.router, prefix="/api/schedule", tags=["schedule"])
Thành quả: Mỗi file Router bây giờ đóng vai trò như một viên gạch độc lập. Bạn muốn xây thêm phòng mới? Chỉ cần nặn 1 viên gạch mới rồi trát vữa dán vào main.py, hoàn toàn không cần đục phá cấu trúc cũ.
4. Xử lý lỗi tinh tế (Exception Handling)¶
Khi code nổ lỗi, bạn có muốn khách hàng nhìn thấy một tràng tiếng Anh kỹ thuật loằng ngoằng không? Chắc chắn là không. Hãy chặn và làm đẹp lỗi tại một điểm tập trung:
# admin/src/main.py — Nơi hội tụ của mọi lỗi lầm
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"error": exc.detail})
@app.exception_handler(ValueError)
async def value_error_handler(request: Request, exc: ValueError) -> JSONResponse:
# Biến lỗi ValueError của Python thành lỗi HTTP 400 (Bad Request) thân thiện
return JSONResponse(status_code=400, content={"error": str(exc)})
@app.exception_handler(RequestValidationError)
async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
return JSONResponse(status_code=422, content={"error": "Dữ liệu không hợp lệ, vui lòng kiểm tra lại!"})
Từ giờ trở đi, khi viết logic nghiệp vụ (Service), bạn chỉ cần quăng lỗi một cách cực kỳ tự nhiên và nhàn hạ:
# Văng lỗi rất gọn, không cần tự format JSON rườm rà
if used + new_size > MAX_STORAGE_BYTES:
raise HTTPException(507, "Máy chủ đã cạn dung lượng lưu trữ")
Đừng để mất dấu vết (Stack Trace) với lỗi 500¶
Ba cái hàm bắt lỗi ở trên là dành cho những lỗi bạn lường trước được. Nhưng lỡ hệ thống bị KeyError, IndexError, hay văng lỗi từ thư viện ngoài (Lỗi 500) thì sao?
Nhiều hệ thống Docker "nuốt" mất cái log này khiến dev mù tịt không biết đâu mà debug. Chặn ngay thảm họa đó bằng cách:
import logging
logger = logging.getLogger(__name__)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
# Tham số exc_info=True sẽ đính kèm nguyên bộ phả hệ dòng lỗi (Traceback) vào file log
logger.exception("Vỡ kế hoạch ở URL: %s %s", request.method, request.url.path)
# Chỉ trả về cho Frontend một câu nói lịch sự, TUYỆT ĐỐI không tiết lộ đường dẫn thư mục hay tên biến (bảo mật!)
return JSONResponse(
status_code=500,
content={"error": "Hệ thống đang gặp sự cố. Admin đã được thông báo."},
)
5. Chuẩn hóa hình dáng kết quả trả về (Response Format)¶
# Chuẩn mực của dự án: Luôn gói mọi kết quả vào trong field "data"
return {"data": await svc.list_all(session)}
return {"data": {"url": url, "expires": expires}}
# Nếu có thêm cảnh báo phụ thì kẹp thêm vào:
return {"data": result, "warnings": warnings}
(Ngoại lệ: Chức năng XÓA (Delete) thì trả thẳng mã HTTP 204 No Content và không cần body).
Gói vào "data" để làm gì? Có rảnh quá không?¶
Việc giữ nguyên một chuẩn (shape) duy nhất giúp Lập trình viên Frontend (hoặc chính bạn khi làm Front) sướng như tiên. Họ chỉ cần viết đúng 1 hàm lấy dữ liệu (fetch API) và xài lại ở hàng trăm chỗ khác nhau.
Nhìn thử bộ não kết nối API ở phía Client của project:
// admin/src/ui/src/modules/api.js
export class ApiError extends Error { ... }
export async function api(method, path, body = null) {
// Setup kết nối...
const res = await fetch(`/api${path}`, opts);
// Nếu trả mã 401 (Hết hạn đăng nhập) → Lặng lẽ đá user ra trang login
if (res.status === 401) {
window.location.href = "/";
throw new ApiError("Phiên đăng nhập hết hạn", 401);
}
// Nếu trả mã 204 (Xóa thành công) → Trả về rỗng, khỏi bóc tách
if (res.status === 204) return null;
const json = await res.json();
// Nếu nổ lỗi do mình gài ở backend → Quăng lỗi bằng đúng message mình viết
if (!res.ok) throw new ApiError(json.error ?? "Lỗi không xác định", res.status);
// Luôn trả thẳng tay `json.data` cho các module khác xài.
return json.data;
}
Thấy sức mạnh chưa?
- Server trả lỗi chuẩn
{"error": "..."}→ Client bóc đúng cái chữ đó ra in lên màn hình (Toast error). - Server trả kết quả chuẩn
{"data": ...}→ Client chọc thẳng vào lõijson.datalấy đồ xài luôn.
Nếu mỗi ông Backend dev viết API trả về một cục JSON dáng vẻ khác nhau, Frontend sẽ ngập ngụa trong các hàm kiểm tra if (json.items) rồi if (json.results)... Rất thảm họa và tốn thời gian.
Nguyên lý tổng quát chốt lại¶
| Kỹ năng vừa học | Thuộc Nguyên lý nào? (Bài 0) | Chuyển giao sang nơi khác |
|---|---|---|
Dùng hàm async để chờ thay vì bắt CPU đứng im |
Điều phối hợp tác (Cooperative scheduling) | Bạn có thể mang tư duy này sang Node.js, hay Go (Goroutine). Bản chất là y hệt nhau. |
Gọi config.validate() lúc khởi động |
Fail-Fast (Thất bại sớm) | Dù code Django, Express hay Spring Boot, luôn nhớ rào cấu hình ở bước khởi động. |
Gom code bắt lỗi ra chỗ riêng (exception_handler) |
SoC (Tách biệt mối quan tâm) | Code nghiệp vụ chỉ lo kiểm tra và la lên (raise error), việc format lỗi ra sao để thằng quản lý chung (Exception Handler) lo. |
Gói response vào {"data": ...} |
POLS (Ít gây bất ngờ nhất) | Client nhắm mắt cũng biết dữ liệu thực sự nằm ở đâu. Đây là tiêu chuẩn thiết kế REST API ở các công ty lớn. |
🚨 LƯU Ý CHÍ MẠNG: Async sinh ra là để xử lý các tác vụ chờ (I/O như đọc ghi DB, File). Nó không giúp máy bạn chạy nhanh hơn với các tác vụ ngốn CPU (như nén file zip, xử lý ảnh). Nếu bạn ném một tác vụ ngốn CPU vào một hàm async, cả server sẽ bị đứng hình. Phải quăng nó ra các thread phụ (Sẽ tìm hiểu sâu ở Bài 10).