FastAPI 猫咖预约系统 API

FastAPI 猫咖预约系统 API

目录

  • [第01章 什么是 FastAPI](#第01章 什么是 FastAPI)
  • [第02章 FastAPI 核心特性](#第02章 FastAPI 核心特性)
  • [第03章 FastAPI 首个项目运行](#第03章 FastAPI 首个项目运行)
  • [第04章 基础路由与请求方式](#第04章 基础路由与请求方式)
  • [第05章 三大请求参数详解](#第05章 三大请求参数详解)
  • [第06章 参数校验](#第06章 参数校验)
  • [第07章 响应数据处理](#第07章 响应数据处理)
  • [第08章 静态文件与模板渲染](#第08章 静态文件与模板渲染)
  • [第09章 文件上传与下载](#第09章 文件上传与下载)
  • [第10章 异常处理与全局异常捕获](#第10章 异常处理与全局异常捕获)
  • [第11章 中间件与跨域](#第11章 中间件与跨域)
  • [第12章 依赖注入](#第12章 依赖注入)
  • [第13章 基础认证方式](#第13章 基础认证方式)
  • [第14章 JWT 令牌认证](#第14章 JWT 令牌认证)
  • [第15章 规范项目目录结构](#第15章 规范项目目录结构)
  • [第16章 数据库的联动开发](#第16章 数据库的联动开发)
  • [第17章 接口版本管理](#第17章 接口版本管理)
  • [第18章 同步和异步接口](#第18章 同步和异步接口)
  • [第19章 异步数据库与异步请求](#第19章 异步数据库与异步请求)
  • [第20章 高并发注意要点](#第20章 高并发注意要点)
  • [第21章 日志、测试、接口文档](#第21章 日志、测试、接口文档)
  • [第22章 项目部署与上线](#第22章 项目部署与上线)

第01章 什么是 FastAPI

〇、项目约定

本文教程全程使用统一的工程约定:

  • Python 版本: 3.14
  • 包管理 : uv(更快、更省心)
  • 代码字符集 : 所有 .py 文件首行 # -*- coding: utf-8 -*-(Windows 必备)
  • 端口规则 : 章节号 + 8800(ch038803ch218821)
  • 章节入口 : app/chXX_app.py
  • 集成入口 : app/main.py

讲师视角 : 学员第一次接触 FastAPI,只需要知道"它是什么、能干什么、为什么要学"。

不要在这里讲任何语法细节,后续章节会逐步展开。

一、教学目标

完成本章后,学员应当能够:

  1. 用一句话向别人解释 FastAPI 是什么。
  2. 说出 FastAPI 主要解决的三个问题。
  3. 明白 FastAPI 与传统 Web 框架(Flask、Django)在定位上的差异。

二、知识铺垫(零跳跃原则)

学习 FastAPI 之前,学员至少需要知道:

  • HTTP 是浏览器与服务器之间的"通信合同"。
  • 一个 URL(例如 https://cat-cafe.example/cats/1)会被服务器解析,然后返回数据。
  • 传统框架把"解析请求"和"返回数据"写得繁琐;FastAPI 让这两件事变得非常优雅。

不要在第01章解释: ASGI、装饰器、Pydantic、async/await ------ 这些在第03、第04、第06章才出现。

三、本质:FastAPI 是什么

FastAPI 是用 Python 编写的、用于构建 Web API 的现代框架。

它的"Web API"特指:接收 HTTP 请求,返回 JSON 数据的程序。

一个最朴素的例子:

复制代码
浏览器  --(GET /hello)-->  FastAPI 服务  --(JSON {"msg":"hi"})-->  浏览器

FastAPI 在中间做的事情:

  1. 解析请求路径、查询参数、请求体(JSON)。
  2. 校验数据是否符合预期(类型、长度、范围)。
  3. 执行你写的处理函数。
  4. 序列化返回值为 JSON,并附带状态码、文档。

四、本教程的业务场景:猫咖预约系统

为了避免抽象,本教程全程使用一个具体场景:

一家猫咖(Cat Cafe),顾客可以:

  • 浏览在店猫咪(GET /cats)
  • 预约某只猫咪的陪玩时段(POST /bookings)
  • 上传猫咪照片(POST /cats/{id}/photo)
  • 通过 JWT 登录,管理自己的预约

这一场景不涉及任何学生、学校、考试、课程,符合"零跳跃"中"生活化"的选材原则。

五、为什么选 FastAPI

维度 传统框架 FastAPI
文档 手动写 自动生成 Swagger UI
数据校验 手写 if 声明式
性能 一般 接近 Node.js / Go
异步 复杂 原生 async/await
类型提示 可选 强制

六、教学互动建议

  • 让学员打开浏览器访问 https://fastapi.tiangolo.com 浏览首页。
  • 让学员用一句话描述猫咖预约系统需要哪些 API(目标:把"业务理解"前置)。

七、过渡到下一章

"我们已经知道 FastAPI 是什么了,下一章我们看看它有哪些核心特性 ------ 就像买相机前先看参数列表。"


第02章 FastAPI 核心特性

〇、项目约定

讲师视角: 不写代码,纯概念铺垫。把 FastAPI 的"卖点"讲清楚,让学员带着期待进入第03章"第一个项目"。

一、教学目标

学员将了解 FastAPI 七个核心特性,并能说出每个特性解决的问题。

二、零跳跃铺路

上一章我们说 FastAPI "接收请求,返回 JSON"。

学员可能会有疑问:别的框架也能做到,FastAPI 凭什么?

本章回答这个问题。

三、特性清单(教学顺序按"用户感知"排列)

特性 1:基于 OpenAPI 的自动文档

是什么 : FastAPI 在启动服务时,会自动扫描你写的所有路由,生成一份 openapi.json,并通过 /docs 提供 Swagger UI。

解决的问题: 传统框架需要写两套东西 ------ 给程序员的代码,给前端同事的 API 文档。FastAPI 只写一份代码。

第03章我们会亲手看到 /docs 的样子

特性 2:Pydantic 数据校验

是什么 : 你用 Python 的类型注解 + 一个 BaseModel 子类声明字段,FastAPI 自动完成校验、转换、文档。

解决的问题 : 传统写法需要反复 if not isinstance(...)raise 400

铺垫: 详情留到第06章。

特性 3:类型提示驱动的开发

是什么 : 函数参数的类型注解不仅是"给 IDE 看",FastAPI 会真的用它做参数解析。

解决的问题: 让 Python 的类型系统第一次在 Web 框架里"显灵"。

特性 4:原生异步支持

是什么 : FastAPI 是 ASGI 应用,可以直接用 async def 定义处理函数。

解决的问题: 高并发场景下(同时有很多人预约猫咪)不会因 I/O 等待卡住。

铺垫: 异步的概念在第18章展开。

特性 5:依赖注入(Dependency Injection)

是什么: 把"取数据库连接 / 取当前登录用户"这种重复操作抽成可复用的依赖。

解决的问题: 避免每个接口都写一堆样板代码。

铺垫: 第12章专章讲解。

特性 6:依赖 Starlette(成熟底层)

是什么 : FastAPI 不重复造轮子,Web 部分由 Starlette 提供。

这意味着: WebSocket、后台任务、中间件等都是成熟的。

特性 7:依赖 Pydantic(成熟底层)

是什么: 数据校验部分由 Pydantic v2 提供,这是 Python 生态最成熟的库之一。

四、教学互动

让学员 FastAPI 的核心特性,然后用这个清单对比。

常见答案:async自动文档类型提示 ------ 与正确清单高度重合,即可建立信心。

五、过渡到下一章

"七个特性记住了,但没有跑过一行代码。下一章我们 5 分钟内跑起第一个 FastAPI 项目。"


第03章 FastAPI 首个项目运行

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch03_app.py,默认端口为 8803 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch03    # 默认 8803
uv run python run_chapter.py ch03 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角 : 这是学员第一次写代码 的章节。一切从最简单开始,目标:让服务跑起来,看到 JSON 返回

一、教学目标

  1. 安装 FastAPI 与 uvicorn。
  2. 写出最小可运行的 FastAPI 程序。
  3. 访问 //docs/openapi.json 三个端点。
  4. 理解 run_chapter.py 启动入口与 uvicorn 的关系。

二、零跳跃铺路

上一章讲了很多"特性",本章落地第一个:自动文档。学员将亲眼看到 Swagger UI。

三、环境准备

bash 复制代码
# 在项目根目录
uv add fastapi uvicorn[standard]

uv 的好处 : 无需手动激活虚拟环境,uv run 自动用 .venv

四、最小项目文件

本章入口文件 app/ch03_app.py,完整代码如下:

python 复制代码
# -*- coding: utf-8 -*-
"""第03章:基础根路径应用。"""
from typing import Any

from fastapi import FastAPI

app = FastAPI(title="猫咖预约系统 API", version="0.1.0",
              description="第03章:首个可运行 FastAPI 项目。")


@app.get("/")
async def root() -> dict[str, Any]:
    return {"message": "欢迎来到猫咖预约系统", "cats_online": 3}


@app.get("/healthz")
async def healthz() -> dict[str, Any]:
    return {"status": "ok"}

逐行解读:

  1. from fastapi import FastAPI ------ FastAPI 类就是"应用对象",所有路由都挂载到它身上。
  2. app = FastAPI(title=..., version=..., description=...) ------ 创建实例;title/version/description 都会出现在 Swagger UI 顶部。
  3. @app.get("/") ------ 装饰器,把下面的函数绑定到 "GET /" 这个 URL;装饰器是 Python 的语法糖,第04章会展开。
  4. async def root() -> dict[str, Any] ------ async def 表示异步处理函数(即使函数体里没有 await 也能写);返回类型标注 dict[str, Any] 让 IDE 与 FastAPI 都知道返回结构,Any 来自 typing
  5. 直接 return {...} ------ FastAPI 自动序列化为 JSON,HTTP 状态码默认 200。
  6. /healthz ------ 健康检查端点,本项目所有章节都暴露它,供 K8s/Supervisor 探活。

启动入口 :本项目统一用 run_chapter.py 启动单章(见下节),它内部用 importlib.import_module 加载对应模块的 app 并交给 uvicorn。因此 ch03_app.py 不需要 if __name__ == "__main__" 块。

五、启动项目

bash 复制代码
uv run python run_chapter.py ch03    # 默认 8803

启动成功后会看到:

复制代码
[run_chapter] starting ch03 on port 8803
INFO:     Uvicorn running on http://127.0.0.1:8803 (Press CTRL+C to quit)

六、四个必须访问的端点

URL 用途
http://127.0.0.1:8803/ 你写的 root() 函数返回的 JSON
http://127.0.0.1:8803/docs Swagger UI ------ 本章最重要的惊喜
http://127.0.0.1:8803/redoc 另一种风格的 API 文档
http://127.0.0.1:8803/openapi.json 机器可读的 API 契约

互动 : 让学员在 /docs 上点击 "Try it out" 按钮,亲身体验"零额外代码就有 UI"。

七、常见问题(Q&A)

  • Q: 端口被占用?
    A: 用 --port 指定其他端口,例如 uv run python run_chapter.py ch03 --port 9000
  • Q: 修改代码没生效?
    A: run_chapter.py 不带 --reload;开发期想热重载可改用 uv run uvicorn app.ch03_app:app --reload --port 8803。Windows 下中文路径也可能让 watchfiles 失效,改用纯英文路径。
  • Q: uvicorn: command not found?
    A: 用 uv run uvicorn ... 而不是 uvicorn ...

八、本章小结

  • FastAPI 应用 = 一个 FastAPI() 实例 + N 个被装饰器修饰的函数。
  • 启动 = uvicorn 模块路径:变量名
  • 自动文档 = /docs,不写一行额外代码就有。
  • 本项目约定 :所有章节都同时暴露 / 业务根 与 /healthz 健康检查端点,后者用于 K8s/Supervisor 探活。

九、过渡到下一章

"一个根路径太单调。下一章我们让 API 真正丰富起来 ------ 加更多路由、用上不同的 HTTP 方法。"


第04章 基础路由与请求方式

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch04_app.py,默认端口为 8804 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch04    # 默认 8804
uv run python run_chapter.py ch04 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 第03章只有一个根路径。本章把"路由"和"HTTP 方法"两个概念同时落地,让 API 变得像真正在用的样子。

一、教学目标

  1. 理解"路由 = URL + HTTP 方法"。
  2. 掌握 GET、POST、PUT、DELETE 四种方法在 REST 设计中的含义。
  3. @app.get / post / put / delete 实现"猫咪"模块的增删改查。
  4. 不要在本章引入数据库,先用内存列表 ------ 第16章才替换。

二、零跳跃铺路

我们已经在第03章用过 @app.get("/"),本章系统化。

三、HTTP 方法速记

方法 语义 典型用途
GET 读取 查看猫咪列表
POST 创建 新增一只猫咪
PUT 完整更新 修改猫咪信息
PATCH 部分更新 只改名字
DELETE 删除 下架一只猫

REST 不是"必须遵守的法律",但这是行业 90% 项目的做法

四、代码文件

本章路由文件 app/routers/ch04_routes.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第04章实战代码:基础路由与请求方式。

不引入数据库,用内存 list 模拟,便于学员聚焦在 HTTP 方法与路由上。
"""
# 1. 导入 FastAPI 类与 APIRouter
#    APIRouter 用于把一组相关路由"打包",便于按模块拆分
from typing import Any

from fastapi import APIRouter

# 2. 创建路由器实例
#    prefix="/cats" 让下面所有路径都自动以 /cats 开头
#    tags=["猫咪"] 会让 Swagger UI 把这些接口归到"猫咪"分组下
router = APIRouter(prefix="/cats", tags=["猫咪"])

# 3. 内存里的"假数据库"
#    生产环境我们会换成 SQLModel;现在先用一个 list 让逻辑跑通
CATS_DB: list[dict[str, Any]] = [
    {"id": 1, "name": "奶糖", "breed": "英短", "age_months": 6},
    {"id": 2, "name": "年糕", "breed": "美短", "age_months": 12},
]

# 4. 自增 ID 的小工具
_NEXT_ID = 3


def _next_id() -> int:
    # 5. 声明 global ------ 让函数内部能修改模块级变量
    global _NEXT_ID
    _NEXT_ID += 1
    return _NEXT_ID


# 6. GET /cats ------ 列表接口
@router.get("/")
async def list_cats() -> list[dict[str, Any]]:
    return CATS_DB


# 7. GET /cats/{cat_id} ------ 详情接口
@router.get("/{cat_id}")
async def get_cat(cat_id: int) -> dict[str, Any] | None:
    # 8. 用 next + 生成器表达式查找
    cat = next((c for c in CATS_DB if c["id"] == cat_id), None)
    if cat is None:
        return None
    return cat


# 9. POST /cats/ ------ 创建接口
@router.post("/")
async def create_cat(body: dict[str, Any]) -> dict[str, Any]:
    new_cat = {
        "id": _next_id(),
        "name": body.get("name", "无名猫"),
        "breed": body.get("breed", "田园"),
        "age_months": body.get("age_months", 0),
    }
    CATS_DB.append(new_cat)
    return new_cat


# 10. PUT /cats/{cat_id} ------ 完整更新
@router.put("/{cat_id}")
async def replace_cat(cat_id: int, body: dict[str, Any]) -> dict[str, Any] | None:
    for idx, cat in enumerate(CATS_DB):
        if cat["id"] == cat_id:
            CATS_DB[idx] = {
                "id": cat_id,
                "name": body.get("name", cat["name"]),
                "breed": body.get("breed", cat["breed"]),
                "age_months": body.get("age_months", cat["age_months"]),
            }
            return CATS_DB[idx]
    return None


# 11. DELETE /cats/{cat_id} ------ 删除
@router.delete("/{cat_id}")
async def delete_cat(cat_id: int) -> dict[str, Any] | None:
    for cat in CATS_DB:
        if cat["id"] == cat_id:
            CATS_DB.remove(cat)
            return {"deleted": cat_id}
    return None

关键点速记:

  • from typing import Any + list[dict[str, Any]] ------ 类型标注加固,让 IDE 与 FastAPI 都能精确推断返回结构。
  • _next_id()global 而非 nonlocal ------ 因为 _NEXT_ID模块级 变量,不是外层函数变量;nonlocal 只适用于嵌套函数场景。
  • body: dict[str, Any] ------ FastAPI 会自动从 JSON body 取;实际项目我们会用 Pydantic 模型(第06章)。
  • 找不到时返回 None,FastAPI 会序列化为 JSON null;第10章会把它换成"抛 404 异常"。
  • POST 的惯例是返回 201,本章先用默认 200;第07章会展开 status_code 的所有玩法。

五、章节入口挂载

本章入口文件 app/ch04_app.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第04章:基础路由应用。"""
from typing import Any

from fastapi import FastAPI
from app.routers import ch04_routes

app = FastAPI(title="猫咖预约系统 API - 第04章")
app.include_router(ch04_routes.router)


@app.get("/")
async def root() -> dict[str, Any]:
    return {"chapter": "ch04", "msg": "Hello"}


@app.get("/healthz")
async def healthz() -> dict[str, Any]:
    return {"status": "ok"}

app.include_router(ch04_routes.router) 把路由器里的所有路由挂载到 app

六、运行与测试

bash 复制代码
uv run python run_chapter.py ch04    # 默认 8804

打开 /docs,在 "猫咪" 分组下会看到 5 个接口。

按顺序尝试:

  1. GET /cats/ ------ 看到 2 只猫。
  2. POST /cats/ ------ body {"name": "豆沙", "breed": "布偶", "age_months": 8}
  3. GET /cats/{cat_id} ------ 用返回的 id 查询。
  4. PUT /cats/{cat_id} ------ 改名。
  5. DELETE /cats/{cat_id} ------ 删除。

七、概念辨析

  • prefix 与路径 : @router.get("/{cat_id}") + prefix="/cats" = 完整路径 /cats/{cat_id}
  • 斜杠一致性 : GET 与 POST 都用 "/" 结尾;FastAPI 默认会把 /cats 重定向到 /cats/
  • tags: 仅影响 Swagger UI 分组,不影响 URL。

八、过渡到下一章

"现在我们能让 API 接受请求了,但参数怎么传?URL 里?Body 里?Header 里? 下一章系统讲 ------ 三大请求参数。"


第05章 三大请求参数详解

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch05_app.py,默认端口为 8805 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch05    # 默认 8805
uv run python run_chapter.py ch05 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 学员第一次正式面对"参数到底放在请求的哪里"。本章把路径参数、查询参数、请求体讲清楚。

一、教学目标

  1. 区分 Path(路径参数)Query(查询参数)Body(请求体)
  2. 用 FastAPI 标注参数类型,体验"类型即校验"。
  3. 在 Swagger UI 上看到每种参数的 UI 位置。

二、零跳跃铺路

第04章我们用了 {cat_id}(路径参数)和 body: dict(请求体),但没讲"为什么这么写"。

本章补上。

三、HTTP 请求的解剖

复制代码
GET /cats/3?include=owner HTTP/1.1       ← 路径 + 查询参数
Host: cat-cafe.example
Authorization: Bearer eyJhbGc...         ← Header(第13章)
Content-Type: application/json           ← Header
                                        ← 空行
{ "comment": "想约奶糖下午时段" }        ← Body(只有 POST/PUT/PATCH 才有)

三种参数在猫咖场景的对应:

参数类型 出现位置 猫咖例子
Path URL 路径段 /cats/3 中的 3
Query ? 之后 /cats?breed=英短&max_age=12
Body 请求正文 POST /bookings 的 JSON

四、代码文件

本章路由文件 app/routers/ch05_params.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第05章实战代码:三大请求参数(Path / Query / Body)。"""
# 1. FastAPI 提供 Path、Query、Body 三个"修饰器对象"
from typing import Any

from fastapi import APIRouter, Path, Query, Body

router = APIRouter(prefix="/cats", tags=["参数演示"])

# 2. 内存数据
CATS_DB: list[dict[str, Any]] = [
    {"id": 1, "name": "奶糖", "breed": "英短", "age_months": 6, "tags": ["安静", "亲人"]},
    {"id": 2, "name": "年糕", "breed": "美短", "age_months": 24, "tags": ["活泼"]},
    {"id": 3, "name": "豆沙", "breed": "布偶", "age_months": 8, "tags": ["粘人", "话痨"]},
]


# 3. 同时使用 路径参数 + 查询参数
#    GET /cats/3?include=owner,booking
@router.get("/{cat_id}")
async def get_cat_with_filter(
    # 4. Path(...) 是路径参数
    cat_id: int = Path(..., ge=1, description="猫咪 id,必须 >= 1"),
    # 5. Query(...) 是查询参数
    include: str | None = Query(None, max_length=20, description="要包含的额外字段,逗号分隔"),
) -> dict[str, Any] | None:
    cat = next((c for c in CATS_DB if c["id"] == cat_id), None)
    if cat is None:
        return None

    if include:
        extras = {}
        for field in include.split(","):
            if field == "owner":
                extras["owner"] = {"id": 99, "name": "神秘店长"}
            elif field == "booking":
                extras["next_booking"] = {"time": "2026-07-21T15:00"}
        return {**cat, **extras}
    return cat


# 8. 列表筛选接口:用多个查询参数
#    GET /cats/?breed=英短&max_age=12
@router.get("/")
async def filter_cats(
    breed: str | None = Query(None, description="按品种精确匹配"),
    max_age: int = Query(999, ge=0, description="最大月龄"),
    limit: int = Query(10, ge=1, le=100, description="返回数量上限"),
) -> list[dict[str, Any]]:
    result = [c for c in CATS_DB if (breed is None or c["breed"] == breed) and c["age_months"] <= max_age]
    return result[:limit]


# 14. POST + 请求体(Body)
@router.post("/{cat_id}/slots")
async def create_slot(
    cat_id: int = Path(..., ge=1),
    slot: dict[str, Any] = Body(..., embed=True, examples=[
        {"start": "2026-07-22T14:00", "duration_minutes": 30}
    ]),
) -> dict[str, Any]:
    return {"cat_id": cat_id, "slot": slot, "status": "reserved"}

参数三件套速记:

  • Path(..., ge=1, description=...) ------ 路径参数;ge=1 表示大于等于 1,description 会出现在 Swagger UI。
  • Query(None, max_length=20, ...) ------ 查询参数;默认值 None 表示"不传也可以",max_length 是字符串最大长度。
  • Body(..., embed=True, examples=[...]) ------ 请求体;embed=True 让 body 在 Swagger UI 上以"独立对象"形式展示。
  • str | None 是 Pydantic v2 推荐的可选类型写法;dict[str, Any] 让请求体类型更精确(Any 来自 typing)。

五、章节入口挂载

本章入口文件 app/ch05_app.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第05章:三大请求参数应用。"""
from typing import Any

from fastapi import FastAPI
from app.routers import ch05_params

app = FastAPI(title="猫咖预约系统 API - 第05章")
app.include_router(ch05_params.router)


@app.get("/")
async def root() -> dict[str, Any]:
    return {"chapter": "ch05"}


@app.get("/healthz")
async def healthz() -> dict[str, Any]:
    return {"status": "ok"}

六、Swagger UI 上的对照

参数类型 Swagger UI 位置
Path URL 路径段,以"{}"标记
Query "Query Params" 表格
Body "Request body" 区域,JSON 示例

让学员先看 UI,再回到代码 ------ 这种"视觉先于代码"的教学顺序,适合新手。

七、教学陷阱

  • 学员常把"必填"和"有默认值"混淆。规则:没默认值 = 必填;有默认值 = 可选
  • 学员常问 Body(...) 和直接写 dict 有什么区别 ------ 功能上没区别,只是 UI 更清晰。第06章我们会用 Pydantic 模型,届时差异才显现。

八、过渡到下一章

"现在我们已经能把参数传进来,但有没有传对呢? 下一章让 FastAPI 自动帮我们做精细校验。"


第06章 参数校验

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch06_app.py,默认端口为 8806 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch06    # 默认 8806
uv run python run_chapter.py ch06 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角 : 第05章的 Query(None, max_length=20) 已经"埋下"了校验的种子。本章把它发扬光大 ------ 用 Pydantic 模型做"完整的、声明式的校验"。

一、教学目标

  1. 理解 Pydantic BaseModel 是 FastAPI 推荐的请求体声明方式。
  2. 掌握常用字段约束(Fieldconintconlist)。
  3. 体验"校验失败时自动返回 422"。
  4. 用嵌套模型表达"陪玩时段"包含多个"猫咪"。

二、零跳跃铺路

我们已经在函数签名里写过 cat_id: int = Path(..., ge=1) ------ 这是"单字段校验"。

当字段多到 3 个以上时,用模型更整洁

三、Pydantic 模型基础

python 复制代码
# -*- coding: utf-8 -*-
from pydantic import BaseModel, Field
  • BaseModel 是所有模型的父类。
  • Field(...) 给单个字段附加元信息(描述、范围、示例)。
  • 校验失败的默认错误码是 422 Unprocessable Entity

四、代码文件

本章模型文件 app/schemas/cat.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第06章:猫咪的 Pydantic 模型(请求/响应分离)。"""
# 1. Pydantic v2 推荐使用 BaseModel + Field
from pydantic import BaseModel, Field


# 2. 创建猫咪的请求体模型
class CatCreate(BaseModel):
    # 3. Field 的第一个位置参数是默认值;... 表示"必填"
    name: str = Field(..., min_length=1, max_length=20, description="猫咪名字")
    # 4. breed 是必填字符串
    breed: str = Field(..., description="品种")
    # 5. ge=0, le=240 表示 0 ≤ age_months ≤ 240
    age_months: int = Field(..., ge=0, le=240, description="月龄")
    # 6. tags 是可选字段,默认空列表
    tags: list[str] = Field(default_factory=list, description="性格标签")


# 7. 响应模型:返回给客户端时使用的结构
class CatOut(BaseModel):
    id: int
    name: str
    breed: str
    age_months: int
    tags: list[str]

请求/响应分离 :CatCreate 是客户端提交的请求体,CatOut 是返回给客户端的响应结构。两者分离后,可以隐藏某些内部字段(如数据来源),response_model 也会据此过滤。

五、路由文件

本章路由文件 app/routers/ch06_validate.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第06章实战代码:Pydantic 参数校验。"""
# 1. 导入 FastAPI 与 Pydantic 模型
from typing import Any

from fastapi import APIRouter, status
from app.schemas.cat import CatCreate, CatOut

router = APIRouter(prefix="/cats_v2", tags=["参数校验"])

# 2. 内存数据库(沿用前面的设定)
DB: list[dict[str, Any]] = []
NEXT_ID = 1


def _new_id() -> int:
    global NEXT_ID
    cur = NEXT_ID
    NEXT_ID += 1
    return cur


# 3. POST /cats_v2/ ------ 创建猫咪(用 Pydantic 模型)
@router.post("/", response_model=CatOut, status_code=status.HTTP_201_CREATED)
async def create_cat(payload: CatCreate) -> dict[str, Any]:
    # 4. payload 是 CatCreate 实例;model_dump() 是 Pydantic v2 推荐方式
    record = payload.model_dump()
    record["id"] = _new_id()
    DB.append(record)
    return record


# 6. 列表接口
@router.get("/", response_model=list[CatOut])
async def list_cats() -> list[dict[str, Any]]:
    return DB


# 8. 详情接口
@router.get("/{cat_id}", response_model=CatOut)
async def get_cat(cat_id: int) -> dict[str, Any] | None:
    return next((c for c in DB if c["id"] == cat_id), None)

response_model 的作用 :即使函数返回的 dict 多带了字段,FastAPI 也会按 CatOut 再次校验并过滤多余字段。response_model 是第07章的重点,这里先简单使用。model_dump() 是 Pydantic v2 的推荐方式(v1 的 .dict() 已废弃)。

六、章节入口挂载

本章入口文件 app/ch06_app.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第06章:Pydantic 参数校验应用。"""
from typing import Any

from fastapi import FastAPI
from app.routers import ch06_validate

app = FastAPI(title="猫咖预约系统 API - 第06章")
app.include_router(ch06_validate.router)


@app.get("/")
async def root() -> dict[str, Any]:
    return {"chapter": "ch06"}


@app.get("/healthz")
async def healthz() -> dict[str, Any]:
    return {"status": "ok"}

七、运行并观察错误

试着发一个故意错误的请求:

json 复制代码
{ "name": "", "breed": "英短", "age_months": 9999 }

返回:

json 复制代码
{
  "detail": [
    { "loc": ["body", "name"], "msg": "String should have at least 1 character" },
    { "loc": ["body", "age_months"], "msg": "Input should be less than or equal to 240" }
  ]
}

教学点: 学员第一次体会到"客户端写错字段也能被精准定位"。

八、嵌套模型(选讲)

如果要表达"陪玩时段包含多个猫咪",可以这样:

python 复制代码
# -*- coding: utf-8 -*-
from pydantic import BaseModel, Field

class SlotCreate(BaseModel):
    start_time: str = Field(..., description="ISO8601 时间")
    duration_minutes: int = Field(..., ge=15, le=180)
    cat_ids: list[int] = Field(..., min_length=1, description="至少包含一只猫")

选讲内容,不必当堂写代码。

九、教学陷阱

  • 学员把 Pydantic Field 和 SQLAlchemy Column 搞混 ------ 强调"Pydantic 校验请求/响应,SQLAlchemy 映射数据库"。
  • 学员常问"中文名字能传吗?" ------ 可以,Pydantic 对 unicode 一视同仁。

十、过渡到下一章

"现在我们做的请求是'创建一只猫',返回 'CatOut'。但响应体还能玩出花:状态码、Cookie、自定义 Header、甚至直接返回 HTML。下一章系统讲。"


第07章 响应数据处理

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch07_app.py,默认端口为 8807 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch07    # 默认 8807
uv run python run_chapter.py ch07 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角 : 前面所有章节默认 return 什么就是什么。本章让学员掌握"如何精细控制"响应 ------ 状态码、Header、Cookie、Response 对象。

一、教学目标

  1. status_code 设定 HTTP 状态码。
  2. response_model 声明返回结构,并能利用 exclude 隐藏字段。
  3. JSONResponseResponse 直接控制响应对象。
  4. 设置响应 Header 与 Cookie。

二、零跳跃铺路

第04章 POST 返回的是默认值 200;第06章用 status_code=201 创建。

本章把"响应"这件事彻底讲清楚。

三、HTTP 响应解剖

复制代码
HTTP/1.1 201 Created                  ← 状态行
Content-Type: application/json       ← Header
Set-Cookie: session=abc; HttpOnly     ← Header
Date: Tue, 21 Jul 2026 ...           ← Header
                                     ← 空行
{"id":1,"name":"奶糖",...}           ← Body

四、代码文件

本章路由文件 app/routers/ch07_response.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第07章实战代码:响应数据处理(状态码 / Header / Cookie)。"""
# 1. Response 与 JSONResponse 用于自定义响应
from typing import Any

from fastapi import APIRouter, Response, status
from fastapi.responses import JSONResponse, PlainTextResponse

router = APIRouter(prefix="/response_demo", tags=["响应处理"])


# 2. response_model 是声明"应该返回什么结构"
@router.get(
    "/cats/{cat_id}",
    status_code=status.HTTP_200_OK,
    response_model=dict[str, Any],
)
async def get_cat_detail(cat_id: int) -> dict[str, Any]:
    cat = {"id": cat_id, "name": "奶糖", "internal_secret": "不要泄露"}
    return {k: v for k, v in cat.items() if k != "internal_secret"}


# 6. 直接返回 JSONResponse
@router.get("/raw")
async def raw_json() -> JSONResponse:
    resp = JSONResponse(
        content={"ok": True},
        status_code=status.HTTP_200_OK,
        headers={"X-Cat-Cafe": "meow"},
    )
    # 通过 set_cookie 设置,补齐 httponly/samesite 安全属性,避免 headers 直写绕过
    resp.set_cookie(key="session", value="demo", httponly=True, samesite="lax")
    return resp


# 8. 纯文本响应
@router.get("/ping", response_class=PlainTextResponse)
async def ping() -> str:
    return "pong"


# 10. 用 Response 对象直接修改响应
@router.post("/login")
async def fake_login(response: Response) -> dict[str, Any]:
    response.set_cookie(
        key="session",
        value="fake-token",
        httponly=True,
        samesite="lax",
    )
    return {"logged_in": True}

安全要点(Cookie) :本项目所有 set_cookie 都带上 httponly=True(阻止 JS 读取,缓解 XSS)与 samesite="lax"(CSRF 的现代防御)。/raw 接口不再Set-Cookie 直接写进 headers 字典 ------ 那样会绕过 httponly/samesite 属性;正确做法是先用 JSONResponse 构造响应,再调用 resp.set_cookie(...) 补齐安全属性。response_model=dict[str, Any] 同样做了类型标注加固(Any 来自 typing)。

五、response_model 的进阶用法

python 复制代码
# -*- coding: utf-8 -*-
from pydantic import BaseModel

class CatOut(BaseModel):
    id: int
    name: str

@router.get("/cat_clean", response_model=CatOut)
async def cat_clean():
    # 即使函数返回的 dict 多带了字段,response_model 也会把多余的过滤掉
    return {"id": 1, "name": "奶糖", "extra": "不要返回"}

教学点 : response_model 是"对外契约",与"内部模型"分家。

六、章节入口挂载

本章入口文件 app/ch07_app.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第07章:响应处理应用。"""
from typing import Any

from fastapi import FastAPI
from app.routers import ch07_response

app = FastAPI(title="猫咖预约系统 API - 第07章")
app.include_router(ch07_response.router)


@app.get("/")
async def root() -> dict[str, Any]:
    return {"chapter": "ch07"}


@app.get("/healthz")
async def healthz() -> dict[str, Any]:
    return {"status": "ok"}

七、运行测试

URL 期望
/response_demo/cats/1 返回 {"id":1,"name":"奶糖"},secret 被过滤
/response_demo/raw 响应头包含 X-Cat-Cafe
/response_demo/ping 返回纯文本 pong
POST /response_demo/login 浏览器会看到 Cookie

八、常见问题

  • Q: 状态码用数字还是常量?
    A: 推荐 status.HTTP_xxx,IDE 可跳转查看含义。
  • Q: response_model 与返回类型注解有什么区别?
    A: 返回类型注解用于类型检查,不影响运行时 ;response_model 真的会校验与过滤。

九、过渡到下一章

"API 现在能返回 JSON、文本、Cookie;但有些页面我们想返回 HTML ------ 模板渲染。下一章让 FastAPI 既能写 API,也能生成网页。"


第08章 静态文件与模板渲染

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch08_app.py,默认端口为 8808 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch08    # 默认 8808
uv run python run_chapter.py ch08 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 把"API-only"扩展成"API + 简单网页"。本章引入 Jinja2 模板与静态资源挂载。

一、教学目标

  1. StaticFiles 挂载静态资源(CSS、JS、图片)。
  2. 用 Jinja2 模板渲染"猫咪列表"页面。
  3. 理解 TemplateResponseRequest 注入。

二、零跳跃铺路

第07章我们用 PlainTextResponse 返回纯文本;但要写"像样的网页"还是模板更顺手。

三、目录结构

复制代码
app/
├── main.py
├── templates/
│   └── cats.html
└── static/
    └── style.css

四、代码文件

本章模板文件 app/templates/cats.html:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>猫咖可约猫咪</title>
  <link rel="stylesheet" href="/static/style.css">
</head>
<body>
  <h1>🐱 今日可约猫咪</h1>
  <ul>
    {% for cat in cats %}
      <li>
        <strong>{{ cat.name }}</strong>
        ------ {{ cat.breed }}, {{ cat.age_months }} 月龄
        {% if cat.tags %}
          (性格: {{ cat.tags|join(', ') }})
        {% endif %}
      </li>
    {% else %}
      <li>暂无猫咪,请稍后再来 ☕</li>
    {% endfor %}
  </ul>
</body>
</html>

模板里用 Jinja2 的 {% for cat in cats %} 遍历变量,{``{ cat.name }} 输出字段;{% else %} 是列表为空时的分支。/static/style.css 引用由 StaticFiles 挂载提供。

本章样式文件 app/static/style.css:

css 复制代码
/* 极简样式,只为让页面不"裸奔" */
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  max-width: 720px;
  margin: 2rem auto;
  padding: 0 1rem;
  color: #333;
}
h1 {
  color: #ff8c42;
}
li {
  padding: 0.4rem 0;
  border-bottom: 1px dashed #ccc;
}

五、路由文件

本章路由文件 app/routers/ch08_static_tpl.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第08章实战代码:静态文件 + Jinja2 模板渲染。"""
# 1. 模板与请求
from pathlib import Path

from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates

# 2. 模板目录(基于本文件位置,避免相对路径在不同 CWD 出错)
BASE_DIR = Path(__file__).resolve().parents[2]
TEMPLATES_DIR = BASE_DIR / "app" / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))

router = APIRouter(tags=["模板与静态"])

# 3. 模拟数据
CATS = [
    {"id": 1, "name": "奶糖", "breed": "英短", "age_months": 6, "tags": ["安静", "亲人"]},
    {"id": 2, "name": "年糕", "breed": "美短", "age_months": 24, "tags": ["活泼"]},
]


# 4. 渲染模板的接口
@router.get("/cats_page", response_class=HTMLResponse)
async def cats_page(request: Request) -> HTMLResponse:
    # TemplateResponse(request, name, context) ------ Starlette 1.0+ 必须传 request
    return templates.TemplateResponse(
        request=request,
        name="cats.html",
        context={"cats": CATS},
    )

路径加固 :BASE_DIR = Path(__file__).resolve().parents[2] 基于当前文件位置向上回溯到项目根,再拼接 app/templates。这样无论进程工作目录(CWD)在哪里,模板路径都不会出错 ------ 比早期版本用相对路径字符串 "app/templates" 更稳健。

六、章节入口挂载

本章入口文件 app/ch08_app.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第08章:静态文件 + 模板应用。"""
from pathlib import Path

from typing import Any

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

from app.routers import ch08_static_tpl

BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"

app = FastAPI(title="猫咖预约系统 API - 第08章")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(ch08_static_tpl.router)


@app.get("/healthz")
async def healthz() -> dict[str, Any]:
    return {"status": "ok"}

app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") 把磁盘上的 app/static 目录挂载到 URL 前缀 /static;STATIC_DIR 同样基于 __file__ 计算,避免 CWD 差异。

七、运行与访问

bash 复制代码
uv run python run_chapter.py ch08    # 默认 8808
URL 期望
http://127.0.0.1:8808/cats_page 渲染后的 HTML 页面
http://127.0.0.1:8808/static/style.css 返回 CSS 文件

八、教学陷阱

  • 模板与静态目录都用 Path(__file__).resolve() 推导,不再依赖进程工作目录 。早期写法 Jinja2Templates(directory="app/templates") 是相对 CWD 的,Windows 下从资源管理器双击启动 vs 从终端启动,工作目录可能不同 ------ 现已用绝对路径加固。
  • TemplateResponse 在 FastAPI 0.106+ / Starlette 1.0+ 强制要求 request 参数,这是为了支持 url_for

九、过渡到下一章

"静态页面我们搞定了,但客人还要'上传自己的猫咪照片' ------ 下一章处理文件上传与下载。"


第09章 文件上传与下载

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch09_app.py,默认端口为 8809 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch09    # 默认 8809
uv run python run_chapter.py ch09 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 第08章讲了静态资源,本章讲"动态"文件 ------ 用户上传猫咪照片、用户下载预约回执。

一、教学目标

  1. UploadFileFile 实现上传。
  2. 校验文件类型与大小。
  3. FileResponseStreamingResponse 实现下载。

二、零跳跃铺路

我们已经在第08章用 StaticFiles 托管了"我们自己的"资源。

本章处理"客人上传的"资源。

三、代码文件

本章路由文件 app/routers/ch09_files.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第09章实战代码:文件上传与下载。"""
# 1. UploadFile / File / HTTPException
import os
import uuid
from pathlib import Path
from typing import Any

from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse

router = APIRouter(prefix="/files", tags=["文件"])

# 2. 上传目录:基于本文件位置,避免 CWD 差异
BASE_DIR = Path(__file__).resolve().parents[2]
UPLOAD_DIR = BASE_DIR / "static" / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)

# 3. 允许的图片 MIME 类型
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}


# 4. 上传猫咪照片
@router.post("/upload/{cat_id}")
async def upload_cat_photo(
    cat_id: int,
    photo: UploadFile = File(..., description="猫咪照片,支持 jpg/png/webp"),
) -> dict[str, Any]:
    # 校验 MIME 类型
    if photo.content_type not in ALLOWED_TYPES:
        raise HTTPException(status_code=400, detail=f"不支持的文件类型: {photo.content_type}")

    # 用 cat_id + 原始后缀构造文件名(后缀经 splitext 提取,不含路径分量)
    ext = os.path.splitext(photo.filename or "")[1] or ".jpg"
    save_path = UPLOAD_DIR / f"cat_{cat_id}{ext}"

    # 分块写入,避免一次性 read() 占用大量内存
    with save_path.open("wb") as f:
        while chunk := await photo.read(1024 * 64):  # 64KB 每块
            f.write(chunk)

    # 返回访问 URL(静态资源在 main.py / ch08_app 挂载到 /static)
    return {"saved_as": str(save_path), "url": f"/static/uploads/cat_{cat_id}{ext}"}


# 5. 多文件上传
@router.post("/upload_multi/{cat_id}")
async def upload_multi(
    cat_id: int,
    photos: list[UploadFile] = File(..., description="多张猫咪照片"),
) -> dict[str, Any]:
    saved: list[str] = []
    for p in photos:
        if p.content_type not in ALLOWED_TYPES:
            continue
        # 安全:仅取基名,避免 filename 含路径造成穿越;加 UUID 防覆盖
        safe_name = Path(p.filename or "x").name
        ext = os.path.splitext(safe_name)[1] or ".jpg"
        path = UPLOAD_DIR / f"cat_{cat_id}_{uuid.uuid4().hex}{ext}"
        with path.open("wb") as f:
            f.write(await p.read())
        saved.append(str(path))
    return {"saved": saved, "count": len(saved)}


# 6. 下载文件 ------ 用 FileResponse
@router.get("/download/{filename}")
async def download_file(filename: str) -> FileResponse:
    # 安全过滤:防止路径穿越
    safe_name = Path(filename).name
    file_path = UPLOAD_DIR / safe_name
    if not file_path.is_file():
        raise HTTPException(status_code=404, detail="文件不存在")
    return FileResponse(
        path=file_path,
        filename=safe_name,
        media_type="application/octet-stream",
    )


# 7. 流式下载 ------ 用 StreamingResponse
@router.get("/stream/booking_receipt/{booking_id}")
async def stream_receipt(booking_id: int) -> StreamingResponse:
    async def receipt_iter():
        yield f"=== 猫咖预约回执 #{booking_id} ===\n".encode("utf-8")
        yield "预约时间: 2026-07-21 14:00\n".encode("utf-8")
        yield "猫咪: 奶糖\n".encode("utf-8")
        yield "感谢您选择本猫咖!\n".encode("utf-8")

    return StreamingResponse(receipt_iter(), media_type="text/plain")

安全加固要点(本章重点):

  • 上传目录基于 __file__ 推导 :BASE_DIR = Path(__file__).resolve().parents[2] + UPLOAD_DIR = BASE_DIR / "static" / "uploads",不依赖 CWD。
  • 多文件上传防穿越 :safe_name = Path(p.filename or "x").name 只取基名,丢弃客户端传入的 ../ 等路径分量;再用 uuid.uuid4().hex 生成唯一文件名,防止同名覆盖。
  • 下载防穿越 :safe_name = Path(filename).name 过滤 ../,拼接后用 file_path.is_file() 校验存在性。
  • 类型标注加固 :所有接口都加了返回类型(dict[str, Any] / FileResponse / StreamingResponse),Any 来自 typing
  • 编码显式化 :流式响应的 .encode("utf-8") 显式指定编码,避免 Windows 默认 GBK 导致中文乱码。

四、章节入口挂载

本章入口文件 app/ch09_app.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第09章:文件上传下载应用。"""
from pathlib import Path

from typing import Any

from fastapi import FastAPI

from app.routers import ch09_files

# 上传目录:基于本文件位置,避免 CWD 差异
BASE_DIR = Path(__file__).resolve().parent
UPLOAD_DIR = BASE_DIR / "static" / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)

app = FastAPI(title="猫咖预约系统 API - 第09章")
app.include_router(ch09_files.router)


@app.get("/healthz")
async def healthz() -> dict[str, Any]:
    return {"status": "ok"}

五、curl 测试上传

bash 复制代码
curl -X POST "http://127.0.0.1:8809/files/upload/1" \
  -F "photo=@./test.jpg"

六、安全提示

  • 路径穿越(已修复) :下载接口 download/{filename} 与多文件上传都通过 Path(name).name 过滤 ../,确保文件只能落到 UPLOAD_DIR 内。早期版本"仅作演示"的写法已被替换为生产可用的安全写法。
  • 大小限制: 上传巨大文件会撑爆磁盘。生产环境应在反向代理层做大小限制。
  • 病毒扫描: 用户上传文件请做安全扫描。
  • MIME 白名单 : 代码用 ALLOWED_TYPES 限定 jpeg/png/webp,但 MIME 可被伪造,生产建议结合文件头魔数校验。

七、教学陷阱

  • 学员常把 File(...)Form(...) 搞混 ------ File 用于文件,Form 用于普通文本字段。
  • UploadFile.read()异步 的;忘记 await 会得到 coroutine 对象。
  • Path("a/b.jpg").name 返回 "b.jpg" ------ 这是防路径穿越的关键,等价于 os.path.basename 但跨平台更统一。

八、过渡到下一章

"上传下载都好了,但程序出错怎么办? 下一章系统讲异常处理 ------ 让错误信息对用户友好、对开发者清晰。"


第10章 异常处理与全局异常捕获

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch10_app.py,默认端口为 8810 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch10    # 默认 8810
uv run python run_chapter.py ch10 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角 : 前面章节我们已经"偷偷"用过 HTTPException;本章把它讲透,并引入自定义异常与全局处理器。

一、教学目标

  1. HTTPException 主动返回错误。
  2. 区分 4xx(客户端错误)与 5xx(服务端错误)。
  3. 自定义异常类全局异常处理器
  4. 统一错误响应格式。

二、零跳跃铺路

HTTP 状态码 4xx 表示"客户端请求有问题",5xx 表示"服务端自己崩了"。

学员需要建立这个心智模型。

三、常见 HTTP 状态码速记

含义 猫咖例子
200 OK 正常返回
201 Created 创建预约成功
400 Bad Request 请求体格式错
401 Unauthorized 未登录
403 Forbidden 没权限
404 Not Found 猫咪不存在
409 Conflict 重复预约
422 Unprocessable Entity 参数校验失败(Pydantic 默认)
500 Internal Server Error 代码 bug

四、统一异常类(app/core/errors.py)

本项目把"业务可控异常"统一为 BusinessError,把错误码常量集中在 ErrCode,避免路由里散落 404 等魔法数字:

python 复制代码
# -*- coding: utf-8 -*-
"""统一错误码 + 业务异常。

教学目标:让所有路由抛同一种异常,由全局 handler 转 JSON。
"""


class BusinessError(Exception):
    """业务可控异常。"""

    def __init__(self, code: str, message: str, status_code: int = 400) -> None:
        self.code = code
        self.message = message
        self.status_code = status_code
        super().__init__(message)


class ErrCode:
    """错误码常量:避免路由里散落 HTTP 数字。

    每个属性是 (code, status_code) 二元组,供 BusinessError 使用。
    """

    NOT_FOUND = ("NOT_FOUND", 404)
    CONFLICT = ("CONFLICT", 409)
    INVALID = ("INVALID", 400)
    UNAUTHORIZED = ("UNAUTHORIZED", 401)
    FORBIDDEN = ("FORBIDDEN", 403)
    INTERNAL = ("INTERNAL", 500)

注意 :ErrCode 是普通 class(不再是 @dataclass(frozen=True)),每个属性是 (code, status_code) 二元组。BusinessError(code, message, status_code) 是项目级"统一错误",由全局 handler 转 JSON。

五、路由文件

本章路由文件 app/routers/ch10_exceptions.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第10章实战代码:异常处理与全局异常捕获。

注意:全局异常处理器统一在 lifespan 注册,不再 import 时挂载。
"""
# 1. 导入异常相关
from typing import Any

from fastapi import APIRouter, FastAPI, HTTPException, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field

from app.core.errors import BusinessError

router = APIRouter(prefix="/errors_demo", tags=["异常处理"])

# 2. 模拟数据库
CATS_DB = {1: {"name": "奶糖"}, 2: {"name": "年糕"}}


# 3. 普通接口:猫咪不存在时返回 404
@router.get("/cats/{cat_id}")
async def get_cat(cat_id: int) -> dict[str, Any]:
    cat = CATS_DB.get(cat_id)
    if cat is None:
        raise HTTPException(status_code=404, detail=f"猫咪 #{cat_id} 不存在")
    return cat


# 4. Pydantic 模型 + 业务异常触发
class BookIn(BaseModel):
    cat_id: int = Field(ge=1)
    slot: str = Field(min_length=1, max_length=20)


# 5. 触发自定义业务异常的接口
@router.post("/book")
async def book_cat(payload: BookIn) -> dict[str, Any]:
    if payload.cat_id not in CATS_DB:
        # 业务异常会被全局 handler 包装为统一格式
        raise BusinessError("CAT_NOT_FOUND", f"猫咪 #{payload.cat_id} 不存在", 404)
    if payload.slot == "14:00":
        raise BusinessError("SLOT_CONFLICT", f"时段 {payload.slot} 已被预约", 409)
    return {"booked": payload.cat_id, "slot": payload.slot}


# 6. 兜底异常:用于演示 fallback
@router.get("/boom")
async def boom():
    raise RuntimeError("故意触发未捕获异常")


# 7. 全局异常处理器(由 ch10_app 的 lifespan 调用)
def register_exception_handlers(app: FastAPI) -> None:
    @app.exception_handler(BusinessError)
    async def business_handler(request: Request, exc: BusinessError):
        return JSONResponse(
            status_code=exc.status_code,
            content={"code": exc.code, "message": exc.message, "path": str(request.url.path)},
        )

    @app.exception_handler(RequestValidationError)
    async def validation_handler(request: Request, exc: RequestValidationError):
        return JSONResponse(
            status_code=422,
            content={
                "code": "VALIDATION_ERROR",
                "message": "请求参数校验失败",
                "errors": [
                    {"loc": list(e["loc"]), "msg": e["msg"], "type": e["type"]}
                    for e in exc.errors()
                ],
            },
        )

    @app.exception_handler(Exception)
    async def fallback_handler(request: Request, exc: Exception):
        return JSONResponse(
            status_code=500,
            content={"code": "INTERNAL_ERROR", "message": "服务器内部错误,请稍后再试"},
        )

架构变化要点:

  • 异常处理器不再用 @router.app.exception_handler 内联挂载,而是抽成 register_exception_handlers(app: FastAPI) 函数,由 ch10_app 在创建 app 后同步调用(也可在 lifespan 中调用)。
  • 路由里抛 BusinessError("CAT_NOT_FOUND", ..., 404) 而不是 HTTPException,统一走全局 handler 格式化为 {code, message, path}
  • RequestValidationError 也被单独捕获,把 Pydantic 422 错误也统一成相同结构,前端无需写两套解析。
  • 预约接口改为 POST /book + Pydantic BookIn 请求体(cat_idslot),不再用 query 参数。
  • 所有函数加了返回类型标注(dict[str, Any]),Any 来自 typing

六、章节入口挂载

本章入口文件 app/ch10_app.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第10章:异常处理应用。

注意:异常处理器在 app 创建时同步注册,这样 TestClient 与生产 uvicorn 都能立即生效。
"""
from typing import Any

from fastapi import FastAPI

from app.routers import ch10_exceptions

app = FastAPI(title="猫咖预约系统 API - 第10章")
# 注册全局异常处理器
ch10_exceptions.register_exception_handlers(app)
app.include_router(ch10_exceptions.router)


@app.get("/healthz")
async def healthz() -> dict[str, Any]:
    return {"status": "ok"}

register_exception_handlers(app) 必须在 include_router 之前或之后调用均可(异常处理器是 app 级别的),但必须在 app 启动前注册。同步注册保证 TestClient(app) 也能立即触发处理器。

七、运行测试

bash 复制代码
curl http://127.0.0.1:8810/errors_demo/cats/999
# {"detail":"猫咪 #999 不存在"}

curl -X POST "http://127.0.0.1:8810/errors_demo/book" \
  -H "Content-Type: application/json" \
  -d '{"cat_id": 1, "slot": "14:00"}'
# {"code":"SLOT_CONFLICT","message":"时段 14:00 已被预约","path":"/errors_demo/book"}

curl -X POST "http://127.0.0.1:8810/errors_demo/book" \
  -H "Content-Type: application/json" \
  -d '{"cat_id": 0, "slot": ""}'
# {"code":"VALIDATION_ERROR","message":"请求参数校验失败","errors":[...]}

curl http://127.0.0.1:8810/errors_demo/boom
# {"code":"INTERNAL_ERROR","message":"服务器内部错误,请稍后再试"}

八、教学陷阱

  • 学员常把"5xx 是 bug,4xx 是客户端问题"颠倒。
  • 学员常在兜底处理器里 return str(exc) ------ 会泄露堆栈

九、本章要点(更新版)

  • 统一异常类 :本项目用 app.core.errors.BusinessError(code, message, status_code) 替代分散的 HTTPException(...)
  • 错误码常量 :集中在 ErrCode 类(普通 class,非 dataclass),避免路由里散落 404 等魔法数字。
  • 统一响应格式 :{code, message, path} 三段式;RequestValidationError 也被统一为相同结构,前端无需写两套解析。
  • 同步注册 :全局处理器在 app 创建时立即注册,不再 import 时副作用,也不放到 lifespan 中(lifespan 启动后再注册异常 handler 在新版 starlette 上仍可,但同步注册最直观)。
  • 测试要点 :tests/test_ch10_exceptions.py 6 个用例覆盖 404/409/422/500 + 成功路径。

十、过渡到下一章

"异常处理是程序'安全网';但每次请求都要经过一些公共逻辑 ------ 比如'记录每个接口耗时'、'检查跨域请求'。下一章讲中间件。"


第11章 中间件与跨域

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch11_app.py,默认端口为 8811 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch11    # 默认 8811
uv run python run_chapter.py ch11 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角 : 中间件是"包裹所有请求"的洋葱层。本章讲两种典型场景:耗时统计CORS

一、教学目标

  1. @app.middleware("http") 写自定义中间件。
  2. CORSMiddleware 解决浏览器跨域问题。
  3. 理解"中间件 = 请求前后都能插手"。

二、零跳跃铺路

我们在第10章做了"统一错误响应";但有些处理(打日志、加 Header)每个接口都需要

把它们写在中间件里,而不是每个路由都写一遍。

三、中间件洋葱模型

复制代码
     请求 ──► A ──► B ──► 路由处理 ──► B ──► A ──► 响应

中间件 A 包中间件 B,中间件 B 包路由处理。

四、代码文件

本章路由文件 app/routers/ch11_middleware.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第11章实战代码:中间件与跨域。

注意:日志与中间件都不在 import 时挂载,统一在 lifespan 中调用。
"""
# 1. 中间件相关导入
import logging
import time
from typing import Any

from fastapi import APIRouter, FastAPI, Request

logger = logging.getLogger("cat-cafe")

router = APIRouter(tags=["中间件"])


# 2. 装饰器写法的中间件定义(暴露给 main.py / ch11_app 在 lifespan 中注册)
def register_decorator_middleware(app: FastAPI) -> None:
    @app.middleware("http")
    async def add_timing_header(request: Request, call_next):
        start = time.perf_counter()
        response = await call_next(request)
        elapsed_ms = (time.perf_counter() - start) * 1000
        response.headers["X-Process-Time-ms"] = f"{elapsed_ms:.2f}"
        return response


# 3. 一个简单的接口
@router.get("/ping2")
async def ping2() -> dict[str, Any]:
    return {"msg": "pong"}

架构变化要点:

  • 中间件不再在 import 时通过 timing_decorator_middleware() 自调用挂载,而是抽成 register_decorator_middleware(app: FastAPI) 函数,由 ch11_app 在创建 app 后同步调用。
  • @app.middleware("http")(挂在 app 上),而不是 @router.middleware(router 没有 middleware 属性)。
  • 移除了 BaseHTTPMiddleware 类写法,只保留更简洁的装饰器写法;configure_cors 也移到 ch11_app.py 中,路由文件只管路由与中间件注册函数。
  • logging.basicConfig 不再在路由 import 时调用(避免副作用),改由 ch11_app.py 在启动时配置。
  • ping2 加了返回类型 -> dict[str, Any](Any 来自 typing)。

五、章节入口挂载

本章入口文件 app/ch11_app.py,用工厂函数 create_app() 集中初始化:

python 复制代码
# -*- coding: utf-8 -*-
"""第11章:中间件与跨域应用。"""
import logging

from typing import Any

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from app.routers import ch11_middleware

# 仅在 dev 环境配置 INFO 日志,生产应交给外部 logging 配置
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")


def create_app() -> FastAPI:
    """工厂函数:集中所有初始化,方便测试场景。"""
    app = FastAPI(title="猫咖预约系统 API - 第11章")
    # CORS:教学环境开放;生产请把 allow_origins 改为具体域名或用 allow_origin_regex
    app.add_middleware(
        CORSMiddleware,
        allow_origins=["*"],
        allow_credentials=False,  # 与 allow_origins=["*"] 兼容,避免浏览器拒绝
        allow_methods=["*"],
        allow_headers=["*"],
    )
    # 装饰器中间件必须在 app 创建后、router 注册前同步挂载,
    # 否则 FastAPI 会在 lifespan 启动后拒绝 add_middleware
    ch11_middleware.register_decorator_middleware(app)
    app.include_router(ch11_middleware.router)

    @app.get("/healthz")
    async def healthz() -> dict[str, Any]:
        return {"status": "ok"}

    return app


app = create_app()

CORS 安全要点 :allow_origins=["*"] 必须配 allow_credentials=False ------ 浏览器安全策略规定,带通配符来源时禁止携带 Cookie;否则响应会被拒绝。生产环境请改为具体域名(如 ["https://web.cat-cafe.example"])或用 allow_origin_regex,此时才可安全地设 allow_credentials=True

六、运行与验证

bash 复制代码
uv run python run_chapter.py ch11    # 默认 8811
curl -i http://127.0.0.1:8811/ping2
# 响应头应包含 X-Process-Time-ms: xx.xx

跨域测试:由于 allow_origins=["*"],任意前端域名都能调用。打开浏览器控制台,在任意域下 fetch("http://127.0.0.1:8811/ping2") 都应成功(但不会携带 Cookie,因为 allow_credentials=False)。

七、教学陷阱

  • 学员常把 add_middleware 写成 app.add_middleware(...) 后忘了 import。
  • 中间件执行顺序: 后注册的"在最外层";洋葱模型可视化最能讲清楚。

八、本章要点(更新版)

  • @app.middleware("http") 装饰器BaseHTTPMiddleware 类等价,但装饰器更简洁。
  • CORS 配置 :allow_origins=["*"] 必须配 allow_credentials=False,否则浏览器因安全策略拒绝响应。生产请改为具体域名或用 allow_origin_regex
  • 注册时机 :add_middleware 必须在 app 创建后、启动前调用。本项目用 ch11_app.create_app() 工厂函数集中初始化,避免 import 时副作用,同时保证 pytest 直接 TestClient(app) 即可触发中间件。
  • 测试要点 :tests/test_ch11_middleware.py 验证 X-Process-Time-ms 响应头与 CORS 预检。

九、过渡到下一章

"中间件做的是'横切关注点';但'取数据库连接 / 取当前用户'这种'纵深复用'该怎么写? 下一章讲依赖注入。"


第12章 依赖注入

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch12_app.py,默认端口为 8812 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch12    # 默认 8812
uv run python run_chapter.py ch12 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 第12章是 FastAPI 的"杀手锏"之一。讲师必须把"依赖"讲成"工厂函数",让学员直观理解。

一、教学目标

  1. 理解 Depends 的作用:把"获取某种资源"的代码抽出来复用。
  2. 写出函数依赖与类依赖。
  3. 用依赖实现"分页参数解析"与"模拟当前用户"。

二、零跳跃铺路

第10章我们手动从路径里取参数,第11章在中间件加 Header。

本章把"重复劳动"封装成"依赖",让函数签名保持干净。

三、依赖的本质

依赖 = 一个返回值的可调用对象

python 复制代码
# -*- coding: utf-8 -*-
def get_db():
    db = Session()
    try:
        yield db
    finally:
        db.close()

@app.get("/cats")
def list_cats(db: Session = Depends(get_db)):
    ...

Depends(get_db) 让 FastAPI 在调用 list_cats 前自动执行 get_db() 并把返回值传进来。

四、代码文件

新建 app/routers/ch12_dependency.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第12章实战代码:依赖注入(Depends)。"""
# 1. 依赖注入相关
#    HTTPException 复用第10章的概念
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException

router = APIRouter(prefix="/di_demo", tags=["依赖注入"])

# 2. 模拟数据库
FAKE_BOOKINGS = [
    {"id": 1, "cat_id": 1, "user": "alice"},
    {"id": 2, "cat_id": 2, "user": "bob"},
]


# 3. 函数依赖:分页参数
#    把 skip / limit 这种"业务常用参数"封装为依赖,避免每个接口重复写
class Pagination:
    def __init__(self, skip: int = 0, limit: int = 10):
        self.skip = skip
        self.limit = limit


def pagination(skip: int = 0, limit: int = 10) -> Pagination:
    # 5. 简单校验:limit 不能超过 100
    if limit > 100:
        raise HTTPException(status_code=400, detail="limit 不能超过 100")
    return Pagination(skip=skip, limit=limit)


# 6. Annotated[int, Depends(...)] 是 FastAPI 推荐的现代写法
#    它把"默认值来自依赖"这件事写进类型系统,IDE 友好
@router.get("/bookings")
async def list_bookings(page: Annotated[Pagination, Depends(pagination)]) -> list[dict[str, Any]]:
    # 7. 像普通属性一样使用
    return FAKE_BOOKINGS[page.skip : page.skip + page.limit]


# 8. 类依赖:模拟"当前用户"
#    __init__ 参数会被 FastAPI 自动识别(类似函数依赖)
class CurrentUser:
    def __init__(self, user_id: int = 1, name: str = "guest"):
        self.user_id = user_id
        self.name = name


# 9. 用类作为依赖
#    FastAPI 会把 user_id 与 name 当成 Query 参数自动注入
@router.get("/me")
async def whoami(user: Annotated[CurrentUser, Depends()]) -> dict[str, Any]:
    return {"id": user.user_id, "name": user.name}


# 10. 带 yield 的依赖:模拟"打开/关闭数据库连接"
#     yield 之前的代码相当于"前置",yield 之后是"后置"
def get_db_session():
    # 11. 模拟"打开连接"
    print("打开数据库连接")
    session = {"connected": True}
    try:
        # 12. yield 是关键:把 session 传给路由函数
        yield session
    finally:
        # 13. 即使路由报错,也会执行 ------ 这是依赖的"自动清理"
        print("关闭数据库连接")
        session["connected"] = False


@router.get("/dbcheck")
async def dbcheck(db: Annotated[dict[str, Any], Depends(get_db_session)]) -> dict[str, Any]:
    return {"connected": db["connected"]}

五、app/main.py

python 复制代码
# -*- coding: utf-8 -*-
from app.routers import ch12_dependency
app.include_router(ch12_dependency.router)

六、运行测试

bash 复制代码
curl "http://127.0.0.1:8812/di_demo/bookings?skip=1&limit=1"
curl "http://127.0.0.1:8812/di_demo/me?user_id=42&name=alice"
curl "http://127.0.0.1:8812/di_demo/dbcheck"

访问 /dbcheck 时观察控制台:

复制代码
打开数据库连接
关闭数据库连接

即使路由报错,关闭日志也会出现 ------ 这是 yield 依赖的核心卖点。

七、依赖 vs 中间件

维度 中间件 依赖
作用范围 所有路由 单个或多个路由
拿得到 handler 函数? 是(因为是参数)
适合 横切关注点(日志、CORS) 资源获取(数据库、用户)

八、教学陷阱

  • 学员常把依赖返回值当成"全局变量";事实上每次请求都会重新执行依赖函数
  • 带 yield 的依赖一旦 yield 之后写了 return,FastAPI 会报警告。

九、过渡到下一章

"现在每个接口都能拿到'当前用户'了,但我们一直在假装登录。下一章讲真正的认证 ------ 先讲最朴素的基础认证方式。"


第13章 基础认证方式

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch13_app.py,默认端口为 8813 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch13    # 默认 8813
uv run python run_chapter.py ch13 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 本章从"HTTP Basic Auth"与"表单登录"两种最朴素的方式开始,作为下一章 JWT 的铺垫。

一、教学目标

  1. 理解 HTTP Basic Auth 的工作原理(Base64 编码的用户名密码)。
  2. 用 FastAPI 实现 Basic Auth。
  3. 实现"表单登录"返回 token 的简单机制。
  4. 强调: Basic Auth 不安全,仅作教学演示。

二、零跳跃铺路

第12章我们已经能在依赖里"假装拿到当前用户"了。本章让这个"假装"变成真的。

三、HTTP Basic Auth 原理

复制代码
请求头:
Authorization: Basic Y2F0Om1lb3c=

解码后: cat:meow

服务器解码后拿用户名密码去校验。

注意 : 这是明文等价(只是 Base64 编码,不是加密),必须配合 HTTPS 使用。

四、代码文件

新建 app/routers/ch13_basic_auth.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第13章实战代码:基础认证方式(Basic + Form)。"""
# 1. 导入安全工具
#    secrets 用于"安全随机数",防止时序攻击
#    HTTPBasic 是 FastAPI 的 Basic Auth 安全方案
#    HTTPBasicCredentials 是解出来的凭据对象
#    bcrypt 用于密码哈希,杜绝明文存储
import secrets
from typing import Any
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, status, Header, Form
from fastapi.security import HTTPBasic, HTTPBasicCredentials

router = APIRouter(prefix="/auth_demo", tags=["基础认证"])

# 2. HTTPBasic 安全方案
#    auto_error=True 让 FastAPI 在缺少 Authorization 时自动返回 401
security = HTTPBasic()


def _hash(p: str) -> str:
    """bcrypt 限制 72 字节,先截断再哈希。"""
    return bcrypt.hashpw(p.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8")


def _verify(password: str, hashed: str) -> bool:
    return bcrypt.checkpw(password.encode("utf-8")[:72], hashed.encode("utf-8"))


# 3. 模拟用户表(口令用 bcrypt 哈希存储,杜绝明文)
USERS: dict[str, str] = {
    "alice": _hash("wonderland"),
    "bob": _hash("builder"),
}

# 已签发 token -> 用户名(演示用内存存储;生产应放 Redis)
_ISSUED_TOKENS: dict[str, str] = {}


# 4. 校验 Basic 凭据
def authenticate(creds: HTTPBasicCredentials = Depends(security)) -> str:
    # 5. 按用户名查表校验,修复此前硬编码只校验 alice 导致 bob 永远登录失败的 bug
    expected = USERS.get(creds.username)
    if expected is None or not _verify(creds.password, expected):
        # 6. WWW-Authenticate 头让浏览器弹出登录框
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或密码错误",
            headers={"WWW-Authenticate": "Basic"},
        )
    return creds.username


# 7. 受保护接口:必须通过 Basic Auth 才能访问
@router.get("/whoami")
async def whoami(username: str = Depends(authenticate)) -> dict[str, Any]:
    return {"login_user": username}


# 9. 表单登录:校验口令后签发随机 token 并登记
#    这是下一章 JWT 的雏形
@router.post("/login")
async def login(username: str = Form(...), password: str = Form(...)) -> dict[str, Any]:
    # 10. 校验账号(bcrypt 哈希比对)
    expected = USERS.get(username)
    if expected is None or not _verify(password, expected):
        raise HTTPException(status_code=401, detail="登录失败")
    # 11. 真实场景会签发 JWT;这里仅返回一个随机 token 并登记
    token = secrets.token_urlsafe(32)
    _ISSUED_TOKENS[token] = username
    return {"access_token": token, "token_type": "bearer"}


# 12. 用 Header 取 token:必须校验是否为登录签发的 token
@router.get("/profile")
async def profile(authorization: str | None = Header(None)) -> dict[str, Any]:
    # 13. 客户端传: Authorization: Bearer <token>
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="缺少 Bearer token")
    token = authorization.removeprefix("Bearer ").strip()
    # 14. 校验 token 是否为本服务签发(防止客户端随意伪造)
    username = _ISSUED_TOKENS.get(token)
    if username is None:
        raise HTTPException(status_code=401, detail="无效或未签发的 token")
    return {"user": username}

五、app/main.py

python 复制代码
# -*- coding: utf-8 -*-
from app.routers import ch13_basic_auth
app.include_router(ch13_basic_auth.router)

六、运行测试

bash 复制代码
# Basic Auth
curl -u alice:wonderland http://127.0.0.1:8813/auth_demo/whoami

# 错误密码
curl -u alice:wrong http://127.0.0.1:8813/auth_demo/whoami
# 401

# 表单登录(返回 access_token)
curl -X POST http://127.0.0.1:8813/auth_demo/login \
  -d "username=alice&password=wonderland"
# {"access_token":"<随机 32 字节 token>","token_type":"bearer"}

# 用本服务签发的 token 访问
curl http://127.0.0.1:8813/auth_demo/profile \
  -H "Authorization: Bearer <上一步返回的 token>"

# 用伪造的 token 访问 ------ 401
curl http://127.0.0.1:8813/auth_demo/profile \
  -H "Authorization: Bearer fake-token-not-issued"

七、安全警告

风险 缓解
明文存密码 用 bcrypt/argon2 哈希(本章已用 bcrypt)
Base64 不是加密 强制 HTTPS
Token 无过期 exp 字段(第14章 JWT)
Token 可被伪造 本章用 _ISSUED_TOKENS 内存登记;生产放 Redis 并校验签发
暴力破解 加限流(第20章)

本章安全升级说明 :相比"明文存密码 + == 比较"的简化版本,本章代码已升级为:

  • 密码用 bcrypt 哈希存储(_hash / _verify);
  • 表单登录签发随机 token 后登记到 _ISSUED_TOKENS,/profile 必须校验 token 是否为本服务签发;
  • 修复了早期"硬编码只校验 alice"导致 bob 永远登录失败的 bug,改为按用户名查表校验。

八、教学陷阱

  • 学员常把 == 用于密码比较 ------ bcrypt 自身就是恒定时间比较,不需要再手动 compare_digest
  • 学员常忘记校验"token 是否为本服务签发" ------ 仅检查非空等于没校验。
  • 学员常问"前端怎么传 Basic Auth" ------ 浏览器原生弹框,前端可以用 fetch + Authorization Header。

九、过渡到下一章

"基础认证不够,我们要 token、带过期时间、能存用户信息 ------ 下一章上 JWT。"


第14章 JWT 令牌认证

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch14_app.py,默认端口为 8814 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch14    # 默认 8814
uv run python run_chapter.py ch14 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角 : 第13章我们用了"Bearer + 随机 token"。本章升级到 JWT(JSON Web Token) ------ 现代 API 的事实标准。

一、教学目标

  1. 理解 JWT 的三段式结构(Header.Payload.Signature)。
  2. pyjwt 签发与校验 token。
  3. 在 FastAPI 依赖里"取当前用户"。
  4. 配置过期时间、签发者、受众。

二、零跳跃铺路

JWT 解决了"token 里能塞什么"和"如何防伪造"两个问题。

  • 塞什么: Header + Payload(自定义字段)。
  • 防伪造: Signature(用密钥签)。

三、JWT 结构

复制代码
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.SflKxw...
└─────Header─────┘ └────────Payload────────┘ └─Signature─┘

三段分别是 base64url 编码:

  • Header: 算法与类型(可解码看)。
  • Payload : 业务数据(sub, exp, iat ...)。不加密,别放敏感数据!
  • Signature : HMACSHA256(header + "." + payload, secret),防篡改。

四、代码文件

新建 app/core/security.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第14章核心安全模块:JWT 签发与校验。

注意:生产环境必须把 JWT_SECRET 注入环境变量;未设置时本模块会生成
临时密钥并告警,该密钥重启后失效,仅供本地开发。
"""
# 1. 时间相关
import os
import secrets
import logging
from datetime import datetime, timedelta, timezone
from typing import Any

import jwt

logger = logging.getLogger(__name__)

# 2. 配置:从环境变量读取;未设置时生成随机密钥并告警(避免源码固化签名密钥)
SECRET_KEY = os.getenv("JWT_SECRET")
if not SECRET_KEY:
    SECRET_KEY = secrets.token_urlsafe(32)
    logger.warning("JWT_SECRET 未设置,已生成临时签名密钥(重启后失效,生产环境务必设置 JWT_SECRET)")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60


# 3. 创建 token
def create_access_token(sub: str, extra: dict | None = None) -> str:
    # 4. payload 是"要塞进 token 的字段"
    #    sub (subject) 是 JWT 标准字段,通常放用户 id
    now = datetime.now(timezone.utc)
    payload = {
        "sub": sub,
        # 5. iat (issued at):签发时间
        "iat": now,
        # 6. exp (expiration):过期时间
        "exp": now + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES),
    }
    if extra:
        # 7. 自定义业务字段(角色、昵称等)
        payload.update(extra)
    # 8. jwt.encode 返回字符串
    return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)


# 9. 校验 token
def decode_token(token: str) -> dict[str, Any]:
    # 10. 校验失败会抛 jwt.PyJWTError;调用方需要捕获
    return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])

安全升级说明:与早期版本相比,本模块做了三项加固:

  • 密钥从环境变量读取 :os.getenv("JWT_SECRET"),杜绝源码固化签名密钥;
  • 未设置时生成临时密钥并告警 :secrets.token_urlsafe(32) + logger.warning,重启后失效,仅供本地开发;
  • decode_token 返回 dict[str, Any]:类型标注完整,便于 IDE 推断 payload 字段。

生产部署请通过 -e JWT_SECRET=... 注入足够长的随机密钥(见第22章)。

新建 app/routers/ch14_jwt.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第14章实战代码:JWT 认证。"""
# 1. 导入
#    OAuth2PasswordBearer 是 FastAPI 提供的"Swagger UI 登录按钮"工具
#    tokenUrl 是登录接口路径;Swagger 会在 /docs 提供获取 token 的表单
from typing import Annotated, Any
import bcrypt
from fastapi import APIRouter, Depends, HTTPException, Form
from fastapi.security import OAuth2PasswordBearer
import jwt as pyjwt

from app.core.security import create_access_token, decode_token

router = APIRouter(prefix="/jwt_demo", tags=["JWT 认证"])

# 2. Swagger UI "Authorize" 按钮的入口
#    auto_error=True 让 FastAPI 在缺 token 时直接返回 401
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/jwt_demo/login")


def _hash(p: str) -> str:
    return bcrypt.hashpw(p.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8")


def _verify(password: str, hashed: str) -> bool:
    return bcrypt.checkpw(password.encode("utf-8")[:72], hashed.encode("utf-8"))


# 3. 用户表(口令用 bcrypt 哈希,杜绝明文存储)
USERS: dict[str, str] = {
    "alice": _hash("wonderland"),
    "bob": _hash("builder"),
}


# 4. 登录接口
@router.post("/login")
async def login(username: str = Form(...), password: str = Form(...)) -> dict[str, Any]:
    # 5. 校验账号(bcrypt 哈希比对)
    expected = USERS.get(username)
    if expected is None or not _verify(password, expected):
        raise HTTPException(status_code=401, detail="登录失败")
    # 6. 签发 token;sub 是用户标识
    token = create_access_token(sub=username, extra={"role": "customer"})
    return {"access_token": token, "token_type": "bearer"}


# 7. 受保护接口:必须传 Bearer token
@router.get("/me")
async def me(token: Annotated[str, Depends(oauth2_scheme)]) -> dict[str, Any]:
    # 8. 解码 token
    try:
        payload = decode_token(token)
    except pyjwt.ExpiredSignatureError:
        # 9. 区分"过期"与"无效",便于前端提示
        raise HTTPException(status_code=401, detail="token 已过期")
    except pyjwt.PyJWTError:
        raise HTTPException(status_code=401, detail="无效 token")
    return {"user": payload.get("sub"), "role": payload.get("role")}

依赖说明 :本章使用 pyjwt 而非 python-jose(后者维护停滞);密码哈希使用 bcrypt 而非 passlib

安装方式见第22章:uv add bcrypt pyjwt

五、app/main.py

python 复制代码
# -*- coding: utf-8 -*-
from app.routers import ch14_jwt
app.include_router(ch14_jwt.router)

六、Swagger UI 的"魔法"

打开 /docs,你会看到右上角有一个 "Authorize" 按钮:

  1. 点击 → 输入 username/password。
  2. Swagger 自动调用 /jwt_demo/login 获取 token。
  3. 之后所有受保护接口的请求会自动带上 Authorization: Bearer ...

这是 OAuth2PasswordBearer 带来的"零成本文档与测试联动"。

七、运行测试

bash 复制代码
# 登录
TOKEN=$(curl -s -X POST http://127.0.0.1:8814/jwt_demo/login \
  -d "username=alice&password=wonderland" | python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")

# 访问受保护接口
curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8814/jwt_demo/me

八、安全注意事项

  • 密钥必须保密 : JWT_SECRET 必须从环境变量注入,不能进 git;本模块在未设置时会生成临时密钥并告警,重启后失效。
  • HTTPS: JWT 不加密,中间人能看到 Payload。
  • 密码哈希 : 本章用 bcrypt 哈希存储口令,不再明文存储(与第13章保持一致)。
  • Refresh Token: 本章只讲 access token;真实场景应配合 refresh token(本教程不展开)。
  • 吊销: JWT 一旦签发,在过期前都有效;敏感场景需要"黑名单"机制。

九、教学陷阱

  • 学员常把 decode 当成"不抛异常";事实上 PyJWT 会抛。
  • 学员常把 exp 写成不带时区的 datetime ------ PyJWT 会警告。

十、过渡到下一章

"现在认证有了,但所有路由都堆在 main.py 里。下一章我们规范项目目录,把认证、数据库、业务路由各就各位。"


第15章 规范项目目录结构

〇、项目约定

本章是对前面所有章节的代码进行目录重构,不引入独立路由。

最终整合入口是 app/main.py,启动方式:

bash 复制代码
uv run uvicorn app.main:app --reload

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角 : 第14章我们仍然把所有代码塞在 routers/。本章讲解"为什么分层"以及"如何分层"。

一、教学目标

  1. 理解分层的目的:职责单一
  2. 掌握标准 FastAPI 项目骨架。
  3. 把前面章节的代码重构成规范目录。

二、零跳跃铺路

第03章的 main.py 只够"一个文件项目"。

当业务变多(用户、猫咪、预约、订单...),"把所有代码放在一个文件"会让新人入职第一天就想跑路。

三、标准分层

复制代码
app/
├── main.py                # 入口;只做"装配"
├── core/                  # 基础设施
│   ├── errors.py          # 统一错误码 ErrCode + BusinessError
│   └── security.py        # JWT(从环境变量读 JWT_SECRET)
├── db/                    # 数据库连接、Session 工厂
│   ├── ch16_session.py    # 同步 SQLModel Session(第16章)
│   └── ch19_async_session.py  # 异步 AsyncSession(第19章)
├── models/                # ORM 模型(SQLModel)
│   ├── cat_db.py          # 第16章同步表 + CatCreate/CatOut
│   └── cat_db_async.py    # 第19章异步表(独立表名避免冲突)
├── schemas/               # Pydantic 模型(请求/响应)
│   └── cat.py             # 第06章教学用 CatCreate/CatOut
├── routers/               # 路由(按章节切分)
│   ├── ch04_routes.py
│   ├── ...
│   └── ch21_log.py
└── static/                # 静态资源与上传目录

分层原则:

  • router : 接收请求、调用 service、返回响应。不写业务
  • service : 业务逻辑,不知道 HTTP 存在(本教学项目简化省略)。
  • model: 数据库表。
  • schema: 入参/出参。
  • core: 框架无关的工具(错误码、JWT、密码哈希)。

四、改造 main.py

下面是本教学项目实际使用的 app/main.py(完整入口,集成第03-22章):

python 复制代码
# -*- coding: utf-8 -*-
"""第03-22章实战项目:猫咖预约系统 API 完整可运行入口。

- 第03章: 最小可运行实例 + Swagger UI
- 第15章及之后: 把所有章节路由汇总,便于一次性体验

注意:
  - 全局异常处理器与中间件统一在 lifespan 中注册,不再 import 时挂载。
  - 静态目录与上传目录基于 BASE_DIR 计算,避免相对路径在不同 CWD 出错。
"""
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded

from app.routers import (
    ch04_routes,
    ch05_params,
    ch06_validate,
    ch07_response,
    ch08_static_tpl,
    ch09_files,
    ch10_exceptions,
    ch11_middleware,
    ch12_dependency,
    ch13_basic_auth,
    ch14_jwt,
    ch17_version,
    ch18_sync_async,
    ch20_concurrency,
    ch21_log,
)

BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"


@asynccontextmanager
async def lifespan(app: FastAPI):
    # 启动时注册全局异常处理器(第10章);异常处理器不需要 add_middleware,可在 lifespan 注册
    ch10_exceptions.register_exception_handlers(app)
    yield


# 1. 创建 FastAPI 应用实例
app = FastAPI(
    title="猫咖预约系统 API",
    version="1.0.0",
    description="🐱 教学用 API,跟着 22 章节逐步丰富。",
    openapi_tags=[
        {"name": "猫咪", "description": "第04-06章:猫咪的增删改查与校验"},
        # ... 其余 tag 见源码
    ],
    lifespan=lifespan,
)

# 2. 挂载静态资源目录(第08章)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")

# 3. 跨域(第11章);allow_credentials 必须为 False 才能与 "*" 共存
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=False,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 4. 装饰器中间件必须在 app 创建后、router 注册前同步挂载,
#    不能放到 lifespan 中(lifespan 启动后 add_middleware 会抛 RuntimeError)
ch11_middleware.register_decorator_middleware(app)

# 5. 慢速限流(第20章)
app.state.limiter = ch20_concurrency.limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

# 6. 注册所有章节路由
app.include_router(ch04_routes.router)
# ... 其余 include_router 见源码
app.include_router(ch21_log.router)


# 7. 根路由(第03章)
@app.get("/", tags=["猫咪"])
async def root():
    return {
        "message": "欢迎来到猫咖预约系统",
        "chapters": "01-22 全章节覆盖",
        "docs": "/docs",
    }


# 8. 健康检查(第22章)
@app.get("/healthz", tags=["猫咪"])
async def health():
    return {"status": "ok"}

第16、19章会把数据库部分实现(各自有独立 ch16_app.py / ch19_app.py 入口)。

五、改造路由器

app/routers/ch16_db.py(从第06章迁移,使用 SQLModel 同步 Session):

python 复制代码
# -*- coding: utf-8 -*-
"""第16章数据库 CRUD 路由。

注意:建表由 ch16_app 的 lifespan 调用,不在 import 时执行。
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select

from app.db.ch16_session import get_session
from app.models.cat_db import CatCreate, CatDB, CatOut

router = APIRouter(prefix="/cats_db", tags=["第16章数据库"])


@router.post("/", response_model=CatOut, status_code=201)
async def create_cat(payload: CatCreate, session: Session = Depends(get_session)):
    cat = CatDB(**payload.model_dump())
    session.add(cat)
    session.commit()
    session.refresh(cat)
    return cat


@router.get("/", response_model=list[CatOut])
async def list_cats(session: Session = Depends(get_session)):
    cats = session.exec(select(CatDB)).all()
    return cats


@router.get("/{cat_id}", response_model=CatOut)
async def get_cat(cat_id: int, session: Session = Depends(get_session)):
    cat = session.get(CatDB, cat_id)
    if not cat:
        raise HTTPException(status_code=404, detail="猫咪不存在")
    return cat

教学点 : 路由器只看得到 HTTPSessionSchema,看不到 SQL

六、跨路由复用 ------ 当前用户依赖

参考第14章的写法,可把"取当前用户"提取为公共依赖(本教学项目直接在各章内联,生产项目建议提取):

python 复制代码
# -*- coding: utf-8 -*-
from typing import Annotated, Any
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
import jwt as pyjwt

from app.core.security import decode_token

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/jwt_demo/login")


async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> dict[str, Any]:
    try:
        payload = decode_token(token)
    except pyjwt.PyJWTError:
        raise HTTPException(status_code=401, detail="无效 token")
    return payload

任何路由只要 user: dict[str, Any] = Depends(get_current_user) 就能拿到解码后的 payload(含 subrole 等)。

七、教学陷阱

  • 不要"为了分层而分层": 小项目保留单文件也可以。
  • router 里写数据库: 短期省事,长期是"技术债"。

八、过渡到下一章

"目录结构清楚了,但我们的数据库还是内存 list。下一章接 SQLModel,把数据真的存进 SQLite。"


第16章 数据库的联动开发

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch16_app.py,默认端口为 8816 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch16    # 默认 8816
uv run python run_chapter.py ch16 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角 : 第15章已经把项目分层了。本章把"内存 list"换成 SQLModel + SQLite,并实现真实 CRUD。

一、教学目标

  1. 用 SQLModel 定义 ORM 模型(同时是 Pydantic 模型)。
  2. 配置数据库引擎、Session 工厂、依赖。
  3. 实现"用户-猫咪-预约"三表的关联查询。
  4. 体验"表结构改动 → 重启即建表"的便利。

二、零跳跃铺路

我们之前所有"数据库"都是 Python list/dict,重启就丢。

真实项目必须用数据库。SQLModel 把 Pydantic 与 SQLAlchemy 合并,最贴合 FastAPI。

三、SQLModel 与 Pydantic 的关系

SQLModel = SQLAlchemy(ORM) + Pydantic(校验)。

一个类既是数据库表,也是 API 模型。简化样板代码。

四、代码文件

4.1 数据库 Session

新建 app/db/ch16_session.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第16章数据库 Session(SQLModel + SQLite)。

注意:建表在 lifespan 中执行,不在 import 时。
"""
from collections.abc import Iterator

from sqlmodel import Session, SQLModel, create_engine

# 教学用 SQLite;生产请换 Postgres/MySQL 并设置连接池参数
DATABASE_URL = "sqlite:///./ch16_test.db"
engine = create_engine(
    DATABASE_URL,
    echo=False,
    connect_args={"check_same_thread": False},
)


def init_db() -> None:
    """创建所有表。由 lifespan 调用,不在 import 时执行。"""
    SQLModel.metadata.create_all(engine)


def get_session() -> Iterator[Session]:
    """FastAPI 依赖:每个请求一个 Session。"""
    with Session(engine) as session:
        yield session

同步 vs 异步 :本章用同步 SQLModel Session(基于 sqlite:/// 协议)。

get_session() 返回类型标注为 Iterator[Session](来自 collections.abc),让 IDE 与类型检查器知道这是生成器依赖。

异步版本见第19章(AsyncIterator[AsyncSession])。

4.2 模型

新建 app/models/cat_db.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第16章数据库模型(SQLModel 同步版,SQLite)。"""
from pydantic import BaseModel
from sqlmodel import SQLModel, Field


class CatDB(SQLModel, table=True):
    """数据库表 Cat。"""
    __tablename__ = "cats_ch16"
    id: int | None = Field(default=None, primary_key=True)
    name: str
    breed: str
    age_months: int


class CatCreate(BaseModel):
    """请求模型。"""
    name: str
    breed: str
    age_months: int


class CatOut(BaseModel):
    """响应模型。"""
    id: int
    name: str
    breed: str
    age_months: int

教学点 :CatDB 是数据库表(table=True),CatCreate/CatOut 是 Pydantic 模型,职责分离。

__tablename__ = "cats_ch16" 显式指定表名,避免与第19章异步表冲突。

4.3 路由

新建 app/routers/ch16_db.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第16章数据库 CRUD 路由。

注意:建表由 ch16_app 的 lifespan 调用,不在 import 时执行。
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select

from app.db.ch16_session import get_session
from app.models.cat_db import CatCreate, CatDB, CatOut

router = APIRouter(prefix="/cats_db", tags=["第16章数据库"])


@router.post("/", response_model=CatOut, status_code=201)
async def create_cat(payload: CatCreate, session: Session = Depends(get_session)):
    # 1. CatDB(**payload.model_dump()) 用字典展开构造 ORM 实例
    cat = CatDB(**payload.model_dump())
    session.add(cat)
    # 2. commit 真正写库
    session.commit()
    # 3. refresh 让对象的 id 等自增字段被填充
    session.refresh(cat)
    return cat


@router.get("/", response_model=list[CatOut])
async def list_cats(session: Session = Depends(get_session)):
    # 4. select(CatDB) 等价于 SELECT * FROM cats_ch16
    cats = session.exec(select(CatDB)).all()
    return cats


@router.get("/{cat_id}", response_model=CatOut)
async def get_cat(cat_id: int, session: Session = Depends(get_session)):
    cat = session.get(CatDB, cat_id)
    if not cat:
        raise HTTPException(status_code=404, detail="猫咪不存在")
    return cat

同步 SQLModel 用 session.exec(select(...)) (SQLModel 自带);异步 AsyncSessionawait session.execute(select(...))(见第19章)。

4.4 启动钩子

新建 app/ch16_app.py(本章独立入口):

python 复制代码
# -*- coding: utf-8 -*-
"""第16章应用。"""
from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.db.ch16_session import init_db
from app.routers import ch16_db


@asynccontextmanager
async def lifespan(app: FastAPI):
    # 启动时建表,不在 import 时执行
    init_db()
    yield


app = FastAPI(title="第16章数据库联动", lifespan=lifespan)
app.include_router(ch16_db.router)


@app.get("/healthz")
async def healthz():
    return {"status": "ok"}

lifespan 同步建表 :init_db() 是同步函数(调用 SQLModel.metadata.create_all(engine)),在异步 lifespan 里直接调用即可(SQLite 建表很快,不会阻塞事件循环)。

第19章会用 await init_db() + run_sync 的异步版本。

五、运行与验证

bash 复制代码
uv run python run_chapter.py ch16
bash 复制代码
# 创建一只猫
curl -X POST http://127.0.0.1:8816/cats_db/ -H "Content-Type: application/json" \
  -d '{"name":"奶糖","breed":"英短","age_months":6}'

# 列出所有猫
curl http://127.0.0.1:8816/cats_db/

# 查看 1 号猫
curl http://127.0.0.1:8816/cats_db/1

六、教学陷阱

  • on_event 已废弃 : FastAPI 0.110+ 推荐 lifespan
  • 忘记 session.commit() (同步)或 await session.commit()(异步):数据只在内存。
  • 忘记 refresh: 自增 id 还是 None。
  • 同步/异步 API 混用 :本章用同步 session.exec(select(...))(SQLModel 扩展);第19章异步 AsyncSession 必须用 await session.execute(select(...)),且 selectsqlalchemy 导入而非 sqlmodel

七、本章要点(更新版)

  • lifespan 同步建表 :不再用 @app.on_event("startup")(已废弃),统一在 lifespan 里调 SQLModel.metadata.create_all(engine)。注意同步 SQLModel 用 create_all(engine),异步 SQLAlchemy 用 await conn.run_sync(Base.metadata.create_all)
  • Session 依赖:每个请求一个 Session,yield 后自动关闭。
  • SQLite + 多线程 :必须 connect_args={"check_same_thread": False} 才能在 FastAPI 线程池里跑。
  • 测试要点 :tests/test_ch16_db.pywith TestClient(app) as client 触发 lifespan,验证 CRUD 三件套。

八、过渡到下一章

"数据库能用了,但 API 一旦发布,字段就不能随便改了 ------ 怎么让新老客户端共存? 下一章讲接口版本管理。"


第17章 接口版本管理

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch17_app.py,默认端口为 8817 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch17    # 默认 8817
uv run python run_chapter.py ch17 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 学员第一次面对"API 上线后怎么改字段"的真实问题。本章给出 3 种主流方案。

一、教学目标

  1. 理解 API 版本管理的必要性。
  2. 掌握三种实现方式:
    • URL 前缀 (/v1/cats)
    • Header (X-API-Version: 1)
    • 请求参数 (?version=1)
  3. 在 FastAPI 中实现 URL 前缀版本(最主流)。

二、零跳跃铺路

第16章我们已经能稳定提供 /cats/ 接口。

如果明天产品说"猫咪字段加一个 weight 属性",直接改响应会让老客户端报错 ------ 这就是"破坏性变更"。

三、三种方案对比

方案 优点 缺点
URL 前缀 /v1/cats 直观、缓存友好 URL 变长
Header X-API-Version URL 干净 不直观、调试麻烦
查询参数 灵活 不利于网关路由

本教程用 URL 前缀方案,这是 GitHub、Stripe、Twilio 等主流选择。

四、代码文件

本教程把 v1、v2、default 拆到独立子模块,与 fastapi_code 风格保持一致。

新建 app/routers/ch17_v1.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第17章 v1 路由 ------ 老接口。"""
from fastapi import APIRouter

router = APIRouter(prefix="/v1/cats", tags=["v1 猫咪"])

_V1_DB = [
    {"id": 1, "name": "奶糖", "breed": "英短"},
    {"id": 2, "name": "年糕", "breed": "美短"},
]


@router.get("/")
async def list_v1():
    return _V1_DB


@router.get("/{cat_id}")
async def get_v1(cat_id: int):
    return next((c for c in _V1_DB if c["id"] == cat_id), None)


# 别名供 ch17_version.default 复用
get_cat_v1 = get_v1

新建 app/routers/ch17_v2.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第17章 v2 路由 ------ 新接口(增加了 weight 字段)。"""
from fastapi import APIRouter

router = APIRouter(prefix="/v2/cats", tags=["v2 猫咪"])

_V2_DB = [
    {"id": 1, "name": "奶糖", "breed": "英短", "weight_kg": 4.2},
    {"id": 2, "name": "年糕", "breed": "美短", "weight_kg": 5.5},
]


@router.get("/")
async def list_v2():
    return _V2_DB


@router.get("/{cat_id}")
async def get_v2(cat_id: int):
    return next((c for c in _V2_DB if c["id"] == cat_id), None)

新建 app/routers/ch17_version.py(汇总 + 默认版本别名):

python 复制代码
# -*- coding: utf-8 -*-
"""第17章实战代码:接口版本管理(URL 前缀方案)。

把 v1/v2/default 三个 router 拆到独立子模块,与 fastapi_code 风格保持一致。
"""
from fastapi import APIRouter

from app.routers.ch17_v1 import router as v1
from app.routers.ch17_v2 import router as v2

# 默认版本别名:不带版本号时指向 v1(向后兼容老用户)
default = APIRouter(prefix="/cats", tags=["v1 猫咪(默认)"])


@default.get("/{cat_id}")
async def default_get(cat_id: int):
    """透传到 v1 路由,保持向后兼容。"""
    from app.routers.ch17_v1 import get_cat_v1
    return await get_cat_v1(cat_id)

__all__ = ["v1", "v2", "default"]

拆分理由 :v1 与 v2 的数据、字段、淘汰节奏都不同,放在独立文件里"版本边界"才清晰;default 单独文件负责"不带版本号时透传到 v1"的兼容策略。

五、app/main.py

python 复制代码
# -*- coding: utf-8 -*-
from app.routers import ch17_version

# 1. 三种挂载方式同时存在,便于客户端选择
app.include_router(ch17_version.v1)
app.include_router(ch17_version.v2)
app.include_router(ch17_version.default)

六、运行测试

URL 返回
/v1/cats/ [{"id":1,"name":"奶糖","breed":"英短"},{"id":2,"name":"年糕","breed":"美短"}]
/v2/cats/ [{"id":1,"name":"奶糖","breed":"英短","weight_kg":4.2},{"id":2,...,"weight_kg":5.5}]
/cats/1 等同于 v1 的 get_v1(1)

七、版本弃用策略

python 复制代码
# -*- coding: utf-8 -*-
@v1.get("/", deprecated=True)  # Swagger UI 上会有划线
async def v1_list():
    ...

也可以加 X-API-Deprecated: true 响应头。

八、教学陷阱

  • 学员常把所有版本都写在同一个 router 里 ------ 一旦混在一起,版本边界就糊了
  • 学员常问"数据库要不要分版本" ------ 不要,版本只切 API。

九、过渡到下一章

"版本解决了'怎么改';但当用户量变大,我们发现同步代码会卡住请求 ------ 下一章讲同步/异步接口的选择。"


第18章 同步和异步接口

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch18_app.py,默认端口为 8818 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch18    # 默认 8818
uv run python run_chapter.py ch18 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角 : 学员第一次真正面对 defasync def 的选择。本章讲清"什么时候用哪个"。

一、教学目标

  1. 理解同步/异步在 I/O 密集场景下的差异。
  2. 区分"CPU 密集"与"I/O 密集"。
  3. 在 FastAPI 中混用 defasync def
  4. time.sleepasyncio.sleep 直观对比。

二、零跳跃铺路

我们之前一直写 async def,但为什么可以写 def?

写错了会不会报错?性能差多少?本章回答。

三、心智模型

场景 推荐写法 原因
调用 requests.get def 同步库会阻塞事件循环
调用 httpx.AsyncClient async def 异步库会让出事件循环
纯计算(排序、哈希) def GIL 让异步没收益,run_in_executor 更好
调用 asyncio.sleep async def 否则阻塞整个服务

四、代码文件

新建 app/routers/ch18_sync_async.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第18章实战代码:同步与异步接口。"""
# 1. 时间与异步
#    time.sleep 会真的阻塞当前线程
#    asyncio.sleep 是异步等待,期间事件循环可以处理别的请求
import asyncio
import time
import hashlib
from fastapi import APIRouter, BackgroundTasks

router = APIRouter(prefix="/io_demo", tags=["同步/异步"])


# 2. 同步接口:time.sleep 阻塞
@router.get("/sync_sleep/{seconds}")
def sync_sleep(seconds: int):
    # 3. def 声明同步函数;FastAPI 会把它放到线程池执行
    time.sleep(seconds)  # 阻塞,但 FastAPI 把它放到线程池
    return {"slept": seconds, "type": "sync"}


# 4. 异步接口:asyncio.sleep 让出
@router.get("/async_sleep/{seconds}")
async def async_sleep(seconds: int):
    # 5. async def;asyncio.sleep 不阻塞事件循环
    await asyncio.sleep(seconds)
    return {"slept": seconds, "type": "async"}


# 6. CPU 密集任务(同步即可,FastAPI 调度到线程池)
@router.get("/hash/{text}")
def hash_text(text: str):
    # 7. 同步 + 复杂计算 ------ 用 def 即可
    #    FastAPI 自动放到线程池,不会阻塞事件循环
    h = hashlib.sha256(text.encode()).hexdigest()
    return {"hash": h}


# 8. 后台任务:不阻塞响应
@router.post("/send_welcome_email/{user_id}")
async def send_welcome(user_id: int, bg: BackgroundTasks):
    # 9. BackgroundTasks 由 FastAPI 提供
    # 10. 把任务加到 bg;路由返回后才会执行
    bg.add_task(_send_email_slow, user_id)
    # 11. 立即返回,用户不等邮件发送
    return {"queued": True}


async def _send_email_slow(user_id: int):
    # 12. 假装发邮件(3 秒)
    await asyncio.sleep(3)
    print(f"已向用户 {user_id} 发送欢迎邮件")

五、app/main.py

python 复制代码
# -*- coding: utf-8 -*-
from app.routers import ch18_sync_async
app.include_router(ch18_sync_async.router)

六、运行测试

并发测试:同时发起 3 个 2 秒的"异步睡"请求,总耗时 ~2 秒;

同时发起 3 个 2 秒的"同步睡"请求,总耗时也接近 2 秒(因为 FastAPI 把同步函数丢到线程池)。

这是 FastAPI 优秀的地方 ------ 你不用纠结"是否必须 async"。

七、教学陷阱

  • 学员常把同步阻塞代码(例如 time.sleep + requests)写在 async def 里,这会让整个服务卡死
  • 学员常以为"全部 async 性能最好" ------ CPU 密集反而会更慢。

八、过渡到下一章

"异步 I/O 处理网络请求很爽;但数据库也要异步驱动才不卡 ------ 下一章把 SQLModel 切到 aiosqlite。"


第19章 异步数据库与异步请求

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch19_app.py,默认端口为 8819 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch19    # 默认 8819
uv run python run_chapter.py ch19 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 第18章讲了同步/异步函数的选择。本章聚焦"如何用异步数据库驱动",让数据库 I/O 不再阻塞事件循环。

一、教学目标

  1. 把第16章的同步 SQLModel 改造为异步。
  2. aiosqlite(教学)+ 切到 asyncpg(生产)的能力。
  3. 掌握异步 Session 的正确用法。

二、零跳跃铺路

我们已经在第16章用 AsyncSession,但只用了 SQLite。

本章把概念系统化,并演示"如何切到生产级异步驱动"。

三、同步 vs 异步驱动

驱动 协议 适用
sqlite 阻塞 教学、本地测试
aiosqlite 异步 教学 + 小项目
asyncpg 异步 生产 PostgreSQL
aiomysql 异步 生产 MySQL

四、代码文件

新建 app/db/ch19_async_session.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第19章异步数据库 Session。

注意:建表在 lifespan 中执行,不在 import 时。
"""
from collections.abc import AsyncIterator

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlmodel import SQLModel

from app.models.cat_db import CatDB  # noqa: F401 - 触发表注册
from app.models.cat_db_async import CatAsync  # noqa: F401 - 触发表注册

DATABASE_URL = "sqlite+aiosqlite:///./ch19_test.db"
engine = create_async_engine(DATABASE_URL, echo=False)


async def init_db() -> None:
    """异步建表,由 lifespan 调用。"""
    async with engine.begin() as conn:
        await conn.run_sync(SQLModel.metadata.create_all)


async def get_session() -> AsyncIterator[AsyncSession]:
    """FastAPI 依赖:每个请求一个 AsyncSession。"""
    async with AsyncSession(engine, expire_on_commit=False) as session:
        yield session

关键点:

  • get_session() 返回类型标注为 AsyncIterator[AsyncSession](来自 collections.abc),与第16章同步版的 Iterator[Session] 对应。
  • 顶部 from app.models.cat_db import CatDBfrom app.models.cat_db_async import CatAsync 是"触发表注册"------只有 import 了模型类,SQLModel 的 metadata 才会知道这两张表的存在,create_all 才会建表。# noqa: F401 告诉 linter 这是有意为之的"未使用导入"。

4.1 异步模型

新建 app/models/cat_db_async.py(用单独的表避免和 ch16 冲突):

python 复制代码
# -*- coding: utf-8 -*-
"""第19章异步数据库模型(用单独的表避免和 ch16 冲突)。"""
from sqlmodel import SQLModel, Field


class CatAsync(SQLModel, table=True):
    """异步数据库表。"""
    __tablename__ = "cats_ch19"
    id: int | None = Field(default=None, primary_key=True)
    name: str
    breed: str
    age_months: int

__tablename__ = "cats_ch19" 与第16章的 "cats_ch16" 分开,两章可以共用同一个 SQLModel.metadata 而不互相干扰。

五、路由

新建 app/routers/ch19_async_db.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第19章异步数据库路由。"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.ch19_async_session import get_session
from app.models.cat_db import CatCreate, CatOut
from app.models.cat_db_async import CatAsync

router = APIRouter(prefix="/cats_async", tags=["第19章异步数据库"])


@router.post("/", response_model=CatOut, status_code=201)
async def create_cat(payload: CatCreate, session: AsyncSession = Depends(get_session)):
    cat = CatAsync(name=payload.name, breed=payload.breed, age_months=payload.age_months)
    session.add(cat)
    await session.commit()
    await session.refresh(cat)
    return cat


@router.get("/", response_model=list[CatOut])
async def list_cats(session: AsyncSession = Depends(get_session)):
    # SQLAlchemy 2.x 用 session.execute + scalars()
    result = await session.execute(select(CatAsync))
    return list(result.scalars().all())


@router.get("/{cat_id}", response_model=CatOut)
async def get_cat(cat_id: int, session: AsyncSession = Depends(get_session)):
    cat = await session.get(CatAsync, cat_id)
    if not cat:
        raise HTTPException(status_code=404, detail="猫咪不存在")
    return cat

同步 vs 异步查询 API:

  • 同步 SQLModel Session:session.exec(select(CatDB)).all()(SQLModel 扩展)
  • 异步 AsyncSession:(await session.execute(select(CatAsync))).scalars().all()(SQLAlchemy 2.x 原生)

注意 select 的导入也不同:同步用 from sqlmodel import select,异步用 from sqlalchemy import select(本教程统一用 SQLAlchemy 原生以避免歧义)。

六、app/ch19_app.py 的 lifespan

python 复制代码
# -*- coding: utf-8 -*-
"""第19章应用。"""
from contextlib import asynccontextmanager

from fastapi import FastAPI

from app.db.ch19_async_session import init_db
from app.routers import ch19_async_db


@asynccontextmanager
async def lifespan(app: FastAPI):
    # 启动时异步建表
    await init_db()
    yield


app = FastAPI(title="第19章异步数据库", lifespan=lifespan)
app.include_router(ch19_async_db.router)


@app.get("/healthz")
async def healthz():
    return {"status": "ok"}

七、迁移到生产 PG(选讲)

python 复制代码
# -*- coding: utf-8 -*-
# settings.database_url = "postgresql+asyncpg://user:pass@host/db"
# 其他代码不变 ------ SQLAlchemy 抽象了驱动差异

八、教学陷阱

  • 学员常在 async def 里调用同步 SQLAlchemy ------ 会阻塞事件循环
  • 学员常把 session.scalarsession.execute().scalar_one() 混用 ------ 推荐前者,简洁。
  • 学员常在异步 Session 上调用 session.exec() ------ SQLAlchemy 2.x 的 AsyncSession 没有此方法,要用 await session.execute(select(...))。SQLModel 0.0.x 同步 Session 仍可用 session.exec(本教程 ch16 用到)。

九、过渡到下一章

"异步解决的是'单个请求里的 I/O 等待';但当请求数爆炸(比如猫咖促销),我们还要从架构层防崩。下一章讲高并发。"


第20章 高并发注意要点

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch20_app.py,默认端口为 8820 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch20    # 默认 8820
uv run python run_chapter.py ch20 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 第18、19章讲了"异步能不能让单个请求更快"。本章讲"当请求数爆炸,如何不让服务挂掉"。

一、教学目标

  1. 理解"高并发"的本质:有限资源面对海量请求。
  2. 掌握四种实战策略:
    • 异步 + 连接池
    • 限流(Rate Limit)
    • 缓存(Redis/内存)
    • 后台任务(队列)
  3. slowapi 给 FastAPI 加限流(演示)。

二、零跳跃铺路

学员可能没经历过"服务被压垮",但应理解"水库泄洪"的比喻:

  • 入口闸门 = 限流
  • 水库容量 = 内存 + 连接池
  • 多条泄洪道 = 异步 I/O

三、代码文件

新建 app/routers/ch20_concurrency.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第20章实战代码:高并发(限流 + 缓存 + 锁)。"""
# 1. slowapi 限流
#    slowapi 是 FastAPI 生态的限流库;本章用内存 limiter,生产可换 Redis
import asyncio
import time
from fastapi import APIRouter, Request
from slowapi import Limiter
from slowapi.util import get_remote_address

router = APIRouter(prefix="/concurrency", tags=["高并发"])

# 2. limiter 单例;按客户端 IP 限流
limiter = Limiter(key_func=get_remote_address)


# 3. 限流接口:每个 IP 每分钟最多 5 次
@router.get("/limited")
# 4. slowapi 的装饰器;5/period;period 默认是秒
@limiter.limit("5/minute")
async def limited(request: Request):
    return {"ok": True}


# 6. 极简缓存(dict + TTL)
_CACHE: dict[str, tuple[float, object]] = {}
CACHE_TTL = 5.0


def cache_get(key: str) -> object | None:
    # 7. 检查时间戳
    if key in _CACHE:
        ts, val = _CACHE[key]
        if time.time() - ts < CACHE_TTL:
            return val
        # 8. 过期删除
        _CACHE.pop(key, None)
    return None


def cache_set(key: str, val: object) -> None:
    _CACHE[key] = (time.time(), val)


# 9. 用缓存的猫咪列表接口
@router.get("/cats_cached")
async def cats_cached():
    cached = cache_get("cats_list")
    if cached is not None:
        return {"data": cached, "from_cache": True}
    # 10. 假装从 DB 查;实际项目查 SQLModel
    await asyncio.sleep(0.5)
    data = [{"id": 1, "name": "奶糖"}]
    cache_set("cats_list", data)
    return {"data": data, "from_cache": False}


# 11. asyncio.Lock 保护临界区
_LOCK = asyncio.Lock()
_COUNTER = 0


@router.post("/counter")
async def increment_counter():
    # 12. async with 保证同一时刻只有一个协程进入
    async with _LOCK:
        global _COUNTER
        _COUNTER += 1
        # 13. 假装写库
        await asyncio.sleep(0.1)
        current = _COUNTER
    return {"counter": current}


# 14. 严格的限流:1/秒
@router.get("/strict_limited")
@limiter.limit("1/second")
async def strict_limited(request: Request):
    return {"msg": "ok"}

连接池配置(参考):生产 SQLAlchemy 异步引擎通常这样配:

python 复制代码
# engine = create_async_engine(
#     DATABASE_URL,
#     pool_size=20,           # 最多 20 个连接
#     max_overflow=10,        # 紧急时再扩 10 个
#     pool_timeout=30,        # 等不到连接 30 秒就报错
#     pool_recycle=1800,      # 30 分钟回收一次,防止数据库端断开
# )

本章教学代码不展开,放在注释里供学员参考。

四、安装依赖

bash 复制代码
uv add slowapi

五、app/ch20_app.py 接入 limiter

python 复制代码
# -*- coding: utf-8 -*-
"""第20章:高并发应用。"""
from fastapi import FastAPI
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded

from app.routers import ch20_concurrency

app = FastAPI(title="猫咖预约系统 API - 第20章")
app.state.limiter = ch20_concurrency.limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)

app.include_router(ch20_concurrency.router)


@app.get("/healthz")
async def healthz():
    return {"status": "ok"}

六、运行测试

bash 复制代码
# 1 秒内多次请求 /strict_limited
for i in {1..5}; do curl http://127.0.0.1:8820/concurrency/strict_limited; done
# 第 2 次起返回 429

七、高并发清单(运维视角)

  1. 限流: 网关层 / 应用层(slowapi)。
  2. 连接池: 避免每次请求新建数据库连接。
  3. 缓存: 把"读多写少"的数据塞到 Redis。
  4. 异步: 让 I/O 不阻塞。
  5. 队列: 把耗时任务(发邮件)丢到 Celery / RQ。
  6. 熔断: 下游故障时快速失败,不要拖垮上游。
  7. 监控: Prometheus + Grafana(第21章提及)。

八、教学陷阱

  • 学员常把"限流"等同于"安全" ------ 限流只能防雪崩,真正的安全靠认证授权。
  • 学员常问"限流值怎么定" ------ 经验值 + 压测。

九、过渡到下一章

"服务抗住了压力,但出问题时我们怎么知道? 下一章讲日志、测试、文档 ------ 让运维和协作都顺畅。"


第21章 日志、测试、接口文档

〇、项目约定

本章所有示例代码对应的原始文件位于 app/ch21_app.py,默认端口为 8821 (8800 + 章节号)。

启动方式:

bash 复制代码
uv run python run_chapter.py ch21    # 默认 8821
uv run python run_chapter.py ch21 --port 9000   # 自定义端口

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 本章是"工程化"章节。三个主题看似无关,实际都是"让项目能被团队维护"。

一、教学目标

  1. 配置结构化日志(logging.config.dictConfig)。
  2. pytest + httpx.AsyncClient 写集成测试。
  3. 自定义 OpenAPI 文档(标题、tag 描述、示例)。

二、零跳跃铺路

第20章我们让服务"抗压";本章让服务"可观测 + 可验证 + 可交接"。

三、日志

新建 app/routers/ch21_log.py:

python 复制代码
# -*- coding: utf-8 -*-
"""第21章实战代码:结构化日志。"""
# 1. 导入 logging
import logging
from fastapi import APIRouter

router = APIRouter(prefix="/logging_demo", tags=["日志"])

# 2. 业务 logger
logger = logging.getLogger("cat_cafe")


# 3. 触发日志的接口
@router.get("/info")
async def log_info():
    # 4. 写入 INFO 级别日志
    logger.info("info 级别日志示例")
    return {"logged": "info"}


@router.get("/warn/{code}")
async def log_warn(code: int):
    # 5. WARNING 级别日志
    logger.warning("warn 级别日志 code=%d", code)
    return {"logged": "warn", "code": code}


@router.get("/error")
async def log_error():
    # 6. ERROR 级别日志
    logger.error("error 级别日志示例")
    return {"logged": "error"}

教学补充:结构化日志配置(生产推荐)

本教学项目为简化起见,直接用 logging.getLogger("cat_cafe") 走 root logger 输出到控制台。

生产项目建议用 logging.config.dictConfig 统一配置,示例如下:

python 复制代码
# -*- coding: utf-8 -*-
# app/core/logging_config.py(教学补充,本项目未实际启用)
import logging.config

LOGGING_CONFIG = {
 "version": 1,
 "disable_existing_loggers": False,
 "formatters": {
     "default": {
         "format": "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
     },
     "json": {
         # 生产环境推荐 JSON,便于 ELK 收集
         "()": "pythonjsonlogger.jsonlogger.JsonFormatter",
         "format": "%(asctime)s %(levelname)s %(name)s %(message)s",
     },
 },
 "handlers": {
     "console": {
         "class": "logging.StreamHandler",
         "formatter": "default",
     },
 },
 "loggers": {
     "uvicorn.access": {"level": "INFO", "handlers": ["console"], "propagate": False},
     "cat_cafe": {"level": "INFO", "handlers": ["console"], "propagate": False},
 },
 "root": {"level": "INFO", "handlers": ["console"]},
}


def setup_logging():
 logging.config.dictConfig(LOGGING_CONFIG)

然后在 app/main.py 启动时调用 setup_logging() 即可。

四、测试

本教学项目所有章节测试统一用 TestClient(基于 httpx 的同步客户端),无需 pytest-asyncio

例如 tests/test_ch21_log.py:

python 复制代码
# -*- coding: utf-8 -*-
"""ch21:日志记录。"""
from fastapi.testclient import TestClient

from app.ch21_app import app

client = TestClient(app)


def test_info_endpoint():
    r = client.get("/logging_demo/info")
    assert r.status_code == 200
    assert r.json() == {"logged": "info"}


def test_warn_endpoint():
    r = client.get("/logging_demo/warn/42")
    assert r.status_code == 200
    assert r.json() == {"logged": "warn", "code": 42}


def test_error_endpoint():
    r = client.get("/logging_demo/error")
    assert r.status_code == 200
    assert r.json() == {"logged": "error"}

测试约定:

  • 每章测试文件 tests/test_chXX_*.py 对应章节入口 app/chXX_app.py;
  • TestClient(app) 同步触发,不需要 async def 测试函数;
  • tests/conftest.py 把项目根目录加入 sys.path,并抑制 slowapi/httpx 的弃用告警。

运行:

bash 复制代码
uv run pytest tests -v

五、文档

app/main.py 已通过 openapi_tags 给每个分组加描述(实际代码片段):

python 复制代码
# -*- coding: utf-8 -*-
# 1. 创建 FastAPI 应用实例
app = FastAPI(
    title="猫咖预约系统 API",
    version="1.0.0",
    description="🐱 教学用 API,跟着 22 章节逐步丰富。",
    # 2. openapi_tags 给分组加描述,Swagger UI 会按 tag 分组显示
    openapi_tags=[
        {"name": "猫咪", "description": "第04-06章:猫咪的增删改查与校验"},
        {"name": "参数演示", "description": "第05章:三大请求参数"},
        {"name": "参数校验", "description": "第06章:Pydantic 模型校验"},
        {"name": "响应处理", "description": "第07章:响应控制"},
        {"name": "模板与静态", "description": "第08章:Jinja2 模板 + 静态文件"},
        {"name": "文件", "description": "第09章:上传与下载"},
        {"name": "异常处理", "description": "第10章:HTTPException 与自定义异常"},
        {"name": "中间件", "description": "第11章:中间件与 CORS"},
        {"name": "依赖注入", "description": "第12章:Depends"},
        {"name": "基础认证", "description": "第13章:HTTP Basic"},
        {"name": "JWT 认证", "description": "第14章:JWT"},
        {"name": "v1 猫咪", "description": "第17章:v1 接口"},
        {"name": "v2 猫咪", "description": "第17章:v2 接口"},
        {"name": "v1 猫咪(默认)", "description": "第17章:不带版本号时透传到 v1"},
        {"name": "同步/异步", "description": "第18章:sync vs async"},
        {"name": "高并发", "description": "第20章:限流/缓存"},
        {"name": "日志", "description": "第21章:结构化日志"},
    ],
    lifespan=lifespan,
)

教学补充 :在路由上还可以加更详细的元信息(summarydescriptionresponse_description),

Swagger UI 会把这些渲染成接口卡片。本教学项目为简化起见未在每个路由上展开,学员可自行尝试:

python 复制代码
@router.post(
 "/",
 summary="创建猫咪",  # Swagger UI 显示的小标题
 description="""提交猫咪的基本信息,系统会返回带 id 的对象。""",
 response_description="新创建的猫咪对象",
)
async def create_cat(...):
 ...

六、教学陷阱

  • 学员常把 print 当日志 ------ print 不可关、不可分流、不可加时间戳。
  • 学员常在测试里写真实数据库 ------ 测试应该用独立 DB 或 mock。

七、过渡到下一章

"测试通过、日志齐整、文档漂亮 ------ 一切就绪,可以上线了。下一章讲部署。"


第22章 项目部署与上线

〇、项目约定

本章讲解如何将前面所有章节的项目部署到生产环境

  • 集成入口 : app/main.py(完整 42 路由)
  • 章节独立入口 : app/chXX_app.py(uv run python run_chapter.py chXX)
  • 默认端口 : 各章节对应 8800 + 章节号;集成应用常用 8000(生产可改为 80)

所有 .py 文件首行均有 # -*- coding: utf-8 -*- 声明(Windows 必备)。

讲师视角: 最后一章。本章给学员一条"从本地到生产"的完整路径。

一、教学目标

  1. 把项目打包为可分发的形态。
  2. uvicorn 的生产参数启动。
  3. Docker 容器化(选讲)。
  4. 配置反向代理(Nginx)与 HTTPS(选讲)。

二、零跳跃铺路

第21章我们让项目"工程化";本章让它"出门可用"。

三、生产级 uvicorn 命令

bash 复制代码
uv run uvicorn app.main:app \
  --host 0.0.0.0 \      # 允许外部访问
  --port 8000 \
  --workers 4 \          # 进程数(经验值 = CPU 核数 × 2)
  --proxy-headers \      # 信任反向代理的 X-Forwarded-*
  --forwarded-allow-ips="*" \
  --log-level info

或者用 gunicorn 管理进程:

bash 复制代码
uv add gunicorn
uv run gunicorn app.main:app \
  -w 4 -k uvicorn.workers.UvicornWorker \
  -b 0.0.0.0:8000

三·补、依赖安装

本项目依赖已在 pyproject.toml 中声明,uv sync 会一次性装齐。

若手动添加,完整命令如下(按章节出现顺序):

bash 复制代码
# 第03-11章:核心 + 模板 + 静态 + 文件上传
uv add fastapi "uvicorn[standard]" pydantic jinja2 python-multipart

# 第12-14章:认证(注意:用 bcrypt + pyjwt,不再用 passlib / python-jose)
uv add bcrypt pyjwt

# 第16、19章:数据库(SQLModel 自带 SQLAlchemy;aiosqlite 提供异步驱动)
uv add sqlmodel aiosqlite

# 第20章:限流
uv add slowapi

# 第21章:测试
uv add pytest pytest-asyncio httpx

依赖选型说明:

  • 密码哈希用 bcrypt ,不再用 passlib(后者维护停滞且对新版 bcrypt 兼容性差);
  • JWT 用 pyjwt ,不再用 python-jose(后者维护停滞);
  • 限流用 slowapi,FastAPI 生态主流选择;
  • ORM 用 sqlmodel (自带 SQLAlchemy 2.x),教学项目用 aiosqlite 跑异步 SQLite。

四、Docker 化(选讲)

新建 Dockerfile:

dockerfile 复制代码
# 1. 基础镜像:Python 3.14 官方镜像
FROM python:3.14-slim

# 2. 设置工作目录
WORKDIR /code

# 3. 先复制依赖文件,利用 Docker 缓存
COPY pyproject.toml uv.lock ./

# 4. 用 uv 安装依赖(更快)
RUN pip install uv && uv sync --frozen --no-dev

# 5. 复制源码
COPY app ./app

# 6. 暴露端口
EXPOSE 8000

# 7. 启动命令
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

新建 .dockerignore:

复制代码
.git
.venv
__pycache__
*.pyc
.env

构建并运行:

bash 复制代码
docker build -t cat-cafe-api .
docker run -d --name cat-cafe -p 8000:8000 -e JWT_SECRET="$(openssl rand -hex 32)" cat-cafe-api

更新提示 :生产部署务必通过 -e JWT_SECRET 注入密钥,不要使用代码里的默认值。

镜像默认 4 worker,可用 --workers N 覆盖。

五、Nginx 反向代理(选讲)

新建 nginx.conf:

nginx 复制代码
server {
    listen 80;
    server_name api.cat-cafe.example;

    location / {
        # 1. 转发到本地 FastAPI
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 2. 静态资源直接由 Nginx 服务(更快)
    location /static/ {
        alias /code/app/static/;
        expires 7d;
    }
}

六、HTTPS(选讲)

用 Let's Encrypt 一键申请证书:

bash 复制代码
sudo apt install certbot python3-certbot-nginx
sudo certbot --nginx -d api.cat-cafe.example

七、环境变量与配置

本教学项目目前只读一个环境变量:JWT_SECRET(由 app/core/security.py 读取)。

新建 .env(开发环境,可选;不设则代码自动生成临时密钥并告警):

复制代码
JWT_SECRET=dev-secret-not-for-prod-please-override-in-prod

生产环境通过 systemd / Docker 环境变量注入:

bash 复制代码
# 生成足够长的随机密钥
export JWT_SECRET="$(openssl rand -hex 32)"
# 或在 Docker 启动时注入
docker run -e JWT_SECRET="$JWT_SECRET" ...

安全提示 :app/core/security.pyJWT_SECRET 未设置时会用 secrets.token_urlsafe(32) 生成临时密钥并 logger.warning 告警,该密钥重启后失效 ,仅用于本地开发。生产环境必须显式注入。

八、部署清单(给学员的"上线检查表")

  • DEBUG=False
  • JWT_SECRET 从环境变量读取,且足够长(32+ 字节随机串)
  • 数据库迁移用 Alembic,不用 create_all
  • 日志写到文件 + 集中收集
  • 健康检查接口 /healthz
  • Nginx 限流与 HTTPS
  • 进程管理(systemd / supervisor)
  • 监控(Prometheus + Grafana)
  • CI/CD 自动部署

九、健康检查接口

app/main.py 中的健康检查端点:

python 复制代码
# -*- coding: utf-8 -*-
# 8. 健康检查(第22章)
@app.get("/healthz", tags=["猫咪"])
async def health():
    return {"status": "ok"}

更新提示 :本项目统一使用 /healthz(而非 /health)作为健康检查端点,

与 K8s liveness/readiness 探针约定一致。各章节入口 app/chXX_app.py 也都暴露了 /healthz

十、毕业寄语

22 章结束,你能从零搭建一个生产级 FastAPI 项目。

下一步建议:

  1. httpx 写自动化测试。
  2. 学习 Alembic 做数据库迁移。
  3. 学习 Docker Compose 编排多服务。
  4. 学习 K8s 让服务弹性伸缩。
相关推荐
国服第二切图仔1 小时前
LabGuide (Pip) 的 【GPASS x 百宝箱】参赛获奖之路
人工智能·语音识别·gpass x 百宝箱·蚂蚁
中科天工1 小时前
数智引领 载誉启航|中科天工亮相第一届包装行业数字化大会,以AI驱动智能工厂从“蓝图”到“标杆”
大数据·人工智能
红色星际1 小时前
跨国车企智驾供应链重新洗牌
人工智能
OpenApi.cc1 小时前
来了,来了,我来了,Mocode
人工智能·深度学习·神经网络·目标检测·数据挖掘
你驴我2 小时前
WhatsApp 消息撤回与编辑的幂等性设计实践
java·服务器·前端·后端·python
2601_962932512 小时前
河南新流量科技GEO源码的口碑与资质情况
大数据·人工智能·科技
2601_964702892 小时前
Claude Opus 5 API 新手指南:从零搭建第一个 AI 应用
人工智能·log4j
向日的葵0062 小时前
Redis会话机制vsJWT机制深度解析
数据库·redis·python·缓存·系统架构·jwt
祉猷并茂,雯华若锦2 小时前
Win下完美解决Allure报错,生成Web自动化测试报告
android·python·selenium·自动化