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(ch03→8803、ch21→8821) - 章节入口 :
app/chXX_app.py - 集成入口 :
app/main.py
讲师视角 : 学员第一次接触 FastAPI,只需要知道"它是什么、能干什么、为什么要学"。
不要在这里讲任何语法细节,后续章节会逐步展开。
一、教学目标
完成本章后,学员应当能够:
- 用一句话向别人解释 FastAPI 是什么。
- 说出 FastAPI 主要解决的三个问题。
- 明白 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 在中间做的事情:
- 解析请求路径、查询参数、请求体(JSON)。
- 校验数据是否符合预期(类型、长度、范围)。
- 执行你写的处理函数。
- 序列化返回值为 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 返回。
一、教学目标
- 安装 FastAPI 与 uvicorn。
- 写出最小可运行的 FastAPI 程序。
- 访问
/、/docs、/openapi.json三个端点。 - 理解
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"}
逐行解读:
from fastapi import FastAPI------FastAPI类就是"应用对象",所有路由都挂载到它身上。app = FastAPI(title=..., version=..., description=...)------ 创建实例;title/version/description都会出现在 Swagger UI 顶部。@app.get("/")------ 装饰器,把下面的函数绑定到 "GET /" 这个 URL;装饰器是 Python 的语法糖,第04章会展开。async def root() -> dict[str, Any]------async def表示异步处理函数(即使函数体里没有await也能写);返回类型标注dict[str, Any]让 IDE 与 FastAPI 都知道返回结构,Any来自typing。- 直接
return {...}------ FastAPI 自动序列化为 JSON,HTTP 状态码默认 200。 /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 变得像真正在用的样子。
一、教学目标
- 理解"路由 = URL + HTTP 方法"。
- 掌握 GET、POST、PUT、DELETE 四种方法在 REST 设计中的含义。
- 用
@app.get / post / put / delete实现"猫咪"模块的增删改查。 - 不要在本章引入数据库,先用内存列表 ------ 第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 会序列化为 JSONnull;第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 个接口。
按顺序尝试:
GET /cats/------ 看到 2 只猫。POST /cats/------ body{"name": "豆沙", "breed": "布偶", "age_months": 8}。GET /cats/{cat_id}------ 用返回的 id 查询。PUT /cats/{cat_id}------ 改名。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 必备)。
讲师视角: 学员第一次正式面对"参数到底放在请求的哪里"。本章把路径参数、查询参数、请求体讲清楚。
一、教学目标
- 区分 Path(路径参数) 、Query(查询参数) 、Body(请求体)。
- 用 FastAPI 标注参数类型,体验"类型即校验"。
- 在 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 模型做"完整的、声明式的校验"。
一、教学目标
- 理解 Pydantic BaseModel 是 FastAPI 推荐的请求体声明方式。
- 掌握常用字段约束(
Field、conint、conlist)。 - 体验"校验失败时自动返回 422"。
- 用嵌套模型表达"陪玩时段"包含多个"猫咪"。
二、零跳跃铺路
我们已经在函数签名里写过 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和 SQLAlchemyColumn搞混 ------ 强调"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 对象。
一、教学目标
- 用
status_code设定 HTTP 状态码。 - 用
response_model声明返回结构,并能利用exclude隐藏字段。 - 用
JSONResponse、Response直接控制响应对象。 - 设置响应 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 模板与静态资源挂载。
一、教学目标
- 用
StaticFiles挂载静态资源(CSS、JS、图片)。 - 用 Jinja2 模板渲染"猫咪列表"页面。
- 理解
TemplateResponse的Request注入。
二、零跳跃铺路
第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章讲了静态资源,本章讲"动态"文件 ------ 用户上传猫咪照片、用户下载预约回执。
一、教学目标
- 用
UploadFile与File实现上传。 - 校验文件类型与大小。
- 用
FileResponse与StreamingResponse实现下载。
二、零跳跃铺路
我们已经在第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;本章把它讲透,并引入自定义异常与全局处理器。
一、教学目标
- 用
HTTPException主动返回错误。 - 区分 4xx(客户端错误)与 5xx(服务端错误)。
- 写自定义异常类 与全局异常处理器。
- 统一错误响应格式。
二、零跳跃铺路
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+ PydanticBookIn请求体(cat_id、slot),不再用 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.py6 个用例覆盖 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。
一、教学目标
- 用
@app.middleware("http")写自定义中间件。 - 用
CORSMiddleware解决浏览器跨域问题。 - 理解"中间件 = 请求前后都能插手"。
二、零跳跃铺路
我们在第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 的"杀手锏"之一。讲师必须把"依赖"讲成"工厂函数",让学员直观理解。
一、教学目标
- 理解
Depends的作用:把"获取某种资源"的代码抽出来复用。 - 写出函数依赖与类依赖。
- 用依赖实现"分页参数解析"与"模拟当前用户"。
二、零跳跃铺路
第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 的铺垫。
一、教学目标
- 理解 HTTP Basic Auth 的工作原理(Base64 编码的用户名密码)。
- 用 FastAPI 实现 Basic Auth。
- 实现"表单登录"返回 token 的简单机制。
- 强调: 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 的事实标准。
一、教学目标
- 理解 JWT 的三段式结构(Header.Payload.Signature)。
- 用
pyjwt签发与校验 token。 - 在 FastAPI 依赖里"取当前用户"。
- 配置过期时间、签发者、受众。
二、零跳跃铺路
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" 按钮:
- 点击 → 输入 username/password。
- Swagger 自动调用
/jwt_demo/login获取 token。 - 之后所有受保护接口的请求会自动带上
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/。本章讲解"为什么分层"以及"如何分层"。
一、教学目标
- 理解分层的目的:职责单一。
- 掌握标准 FastAPI 项目骨架。
- 把前面章节的代码重构成规范目录。
二、零跳跃铺路
第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
教学点 : 路由器只看得到
HTTP、Session、Schema,看不到 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(含 sub、role 等)。
七、教学陷阱
- 不要"为了分层而分层": 小项目保留单文件也可以。
- 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。
一、教学目标
- 用 SQLModel 定义 ORM 模型(同时是 Pydantic 模型)。
- 配置数据库引擎、Session 工厂、依赖。
- 实现"用户-猫咪-预约"三表的关联查询。
- 体验"表结构改动 → 重启即建表"的便利。
二、零跳跃铺路
我们之前所有"数据库"都是 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 自带);异步AsyncSession用await 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(...)),且select从sqlalchemy导入而非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.py用with 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 种主流方案。
一、教学目标
- 理解 API 版本管理的必要性。
- 掌握三种实现方式:
- URL 前缀 (
/v1/cats) - Header (
X-API-Version: 1) - 请求参数 (
?version=1)
- URL 前缀 (
- 在 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 必备)。
讲师视角 : 学员第一次真正面对
def与async def的选择。本章讲清"什么时候用哪个"。
一、教学目标
- 理解同步/异步在 I/O 密集场景下的差异。
- 区分"CPU 密集"与"I/O 密集"。
- 在 FastAPI 中混用
def与async def。 - 用
time.sleep与asyncio.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 不再阻塞事件循环。
一、教学目标
- 把第16章的同步 SQLModel 改造为异步。
- 用
aiosqlite(教学)+ 切到asyncpg(生产)的能力。 - 掌握异步 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 CatDB与from 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.scalar与session.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章讲了"异步能不能让单个请求更快"。本章讲"当请求数爆炸,如何不让服务挂掉"。
一、教学目标
- 理解"高并发"的本质:有限资源面对海量请求。
- 掌握四种实战策略:
- 异步 + 连接池
- 限流(Rate Limit)
- 缓存(Redis/内存)
- 后台任务(队列)
- 用
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
七、高并发清单(运维视角)
- 限流: 网关层 / 应用层(slowapi)。
- 连接池: 避免每次请求新建数据库连接。
- 缓存: 把"读多写少"的数据塞到 Redis。
- 异步: 让 I/O 不阻塞。
- 队列: 把耗时任务(发邮件)丢到 Celery / RQ。
- 熔断: 下游故障时快速失败,不要拖垮上游。
- 监控: 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 必备)。
讲师视角: 本章是"工程化"章节。三个主题看似无关,实际都是"让项目能被团队维护"。
一、教学目标
- 配置结构化日志(
logging.config.dictConfig)。 - 用
pytest+httpx.AsyncClient写集成测试。 - 自定义 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,
)
教学补充 :在路由上还可以加更详细的元信息(
summary、description、response_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 必备)。
讲师视角: 最后一章。本章给学员一条"从本地到生产"的完整路径。
一、教学目标
- 把项目打包为可分发的形态。
- 用
uvicorn的生产参数启动。 - 用
Docker容器化(选讲)。 - 配置反向代理(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.py在JWT_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 项目。
下一步建议:
- 用
httpx写自动化测试。- 学习
Alembic做数据库迁移。- 学习
Docker Compose编排多服务。- 学习 K8s 让服务弹性伸缩。