目录
[1. FastAPI 是什么](#1. FastAPI 是什么)
[1.1 三大基石(组合拳)](#1.1 三大基石(组合拳))
[1.2 与 Flask 对比](#1.2 与 Flask 对比)
[1.3 最简示例 + 自动文档](#1.3 最简示例 + 自动文档)
[1.4 核心特性速览](#1.4 核心特性速览)
[2. 路由分发机制(核心)](#2. 路由分发机制(核心))
[2.1 路由本质:装饰器在"登记",不是执行](#2.1 路由本质:装饰器在"登记",不是执行)
[2.2 一条路由能配置什么](#2.2 一条路由能配置什么)
[2.3 匹配流程:方法 → 路径 → 参数绑定](#2.3 匹配流程:方法 → 路径 → 参数绑定)
[2.4 APIRouter 模块化分发(多文件项目关键)](#2.4 APIRouter 模块化分发(多文件项目关键))
[2.5 include_router 总控台](#2.5 include_router 总控台)
[2.6 匹配顺序的坑(重点)](#2.6 匹配顺序的坑(重点))
[2.7 路由分发全流程图](#2.7 路由分发全流程图)
[3. 路径参数 & 查询参数](#3. 路径参数 & 查询参数)
[3.1 路径参数:声明即校验](#3.1 路径参数:声明即校验)
[3.2 路径参数 vs 查询参数](#3.2 路径参数 vs 查询参数)
[4. 增删改查 CRUD](#4. 增删改查 CRUD)
[4.1 内存版完整示例(可运行)](#4.1 内存版完整示例(可运行))
[4.2 细节要点](#4.2 细节要点)
[5. 连接 MySQL(SQLAlchemy 集成)](#5. 连接 MySQL(SQLAlchemy 集成))
[5.1 核心答案:路径不变,变的只是函数内部](#5.1 核心答案:路径不变,变的只是函数内部)
[5.2 一次请求的完整链路(GET /books/3 为例)](#5.2 一次请求的完整链路(GET /books/3 为例))
[5.3 完整可跑示例(单文件版,SQLAlchemy 2.x)](#5.3 完整可跑示例(单文件版,SQLAlchemy 2.x))
[5.4 常见查询模式 → SQL 对照](#5.4 常见查询模式 → SQL 对照)
[5.5 三个最关键机制](#5.5 三个最关键机制)
[5.6 分层项目结构(真实项目标准长法)](#5.6 分层项目结构(真实项目标准长法))
[6. SQLAlchemy 核心机制](#6. SQLAlchemy 核心机制)
[6.1 一句话 + 为什么需要](#6.1 一句话 + 为什么需要)
[6.2 两层架构](#6.2 两层架构)
[6.3 四大核心组件(类比:银行柜台办业务)](#6.3 四大核心组件(类比:银行柜台办业务))
[6.4 一次查询的幕后全流程](#6.4 一次查询的幕后全流程)
[6.5 relationship 一对多](#6.5 relationship 一对多)
[6.6 必踩的坑清单](#6.6 必踩的坑清单)
[7. 实战项目解析:04_sqlalchemy(部门/员工)](#7. 实战项目解析:04_sqlalchemy(部门/员工))
[7.1 文件地图](#7.1 文件地图)
[7.2 base.py ------ 所有模型的"祖先"](#7.2 base.py —— 所有模型的"祖先")
[7.3 database.py ------ 连接配置中心](#7.3 database.py —— 连接配置中心)
[7.4 models.py ------ 手写模型(一对多经典写法)](#7.4 models.py —— 手写模型(一对多经典写法))
[7.5 main.py ------ CRUD 综合演示(老版 query API)](#7.5 main.py —— CRUD 综合演示(老版 query API))
[7.6 gen.py ------ 逆向工程 + 插数据](#7.6 gen.py —— 逆向工程 + 插数据)
[7.7 table_2_models.py ------ 自动生成的模型(2.x 风格)](#7.7 table_2_models.py —— 自动生成的模型(2.x 风格))
[7.8 fastapi_sqlalchemy.py ------ FastAPI 集成](#7.8 fastapi_sqlalchemy.py —— FastAPI 集成)
[7.9 坑与注意(务必记住)](#7.9 坑与注意(务必记住))
[8. 部署:uvicorn 与 nginx](#8. 部署:uvicorn 与 nginx)
[8.1 两者关系:前台 + 后厨(不是二选一)](#8.1 两者关系:前台 + 后厨(不是二选一))
[8.2 生产环境典型架构](#8.2 生产环境典型架构)
[8.3 为什么需要 nginx(uvicorn 的短板)](#8.3 为什么需要 nginx(uvicorn 的短板))
[8.4 路由分发的两层分工(重点辨析)](#8.4 路由分发的两层分工(重点辨析))
[8.5 生产细节](#8.5 生产细节)
[9. 附录:报错排查](#9. 附录:报错排查)
[9.1 ModuleNotFoundError: No module named 'routerss'(案例)](#9.1 ModuleNotFoundError: No module named ‘routerss’(案例))
1. FastAPI 是什么
现代、高性能的 Python Web 框架,用于构建 API。 2018 年由 Sebastián Ramírez 开发,基于 Starlette + Pydantic。
1.1 三大基石(组合拳)
| 组件 | 负责部分 |
|---|---|
| Starlette | Web 部分:请求路由、并发(性能接近 NodeJS/Go) |
| Pydantic | 数据校验:用 Python 类型注解自动校验请求/响应 |
| 类型注解 | 自动生成 API 文档 + 编辑器自动补全 |
1.2 与 Flask 对比
| 对比项 | Flask | FastAPI |
|---|---|---|
| 并发模型 | 同步为主 | 原生 async 异步 |
| 参数校验 | 手动校验 | 类型注解自动校验 |
| API 文档 | 需另装 flasgger | 自动生成 Swagger UI (/docs) |
| 性能 | 一般 | 高(基于 Starlette) |
| Python 版本 | Python 2/3 | 仅 Python 3.8+ |
1.3 最简示例 + 自动文档
from fastapi import FastAPI
app = FastAPI()
@app.get("/hello")
def hello(name: str = "world"):
return {"message": f"Hello {name}"}
启动:uvicorn main:app --reload
两个自动文档(可在线调试):
http://127.0.0.1:8000/docs --- Swagger UIhttp://127.0.0.1:8000/redoc--- Redoc
1.4 核心特性速览
- 路径参数 :
/users/{user_id},类型不匹配自动返回 422 - 查询参数:函数参数写默认值即自动识别
- 请求体 :声明 Pydantic 模型
class Item(BaseModel),自动校验+转文档 - 依赖注入 :
Depends(),做鉴权/数据库会话等,可复用可测试 - 异步支持 :
async def处理高并发 I/O(连数据库、调大模型 API 都合适)
学习路线关联:LLM 应用落地(LangChain 智能体、RAG 查询封装成服务)几乎都用 FastAPI 做接口层,它也是 LangServe、很多 Agent 框架的默认后端。
2. 路由分发机制(核心)
2.1 路由本质:装饰器在"登记",不是执行
@app.get("/books/{book_id}")
def get_book(book_id: int):
...
这行代码在 import 时就执行了,干两件事:
- 把
("GET", "/books/{book_id}")登记进 app 的路由表 - 绑定处理函数
get_book
函数体此刻【不执行】。 只有将来请求真的来了,才按登记表找到它执行。 FastAPI 也叫"声明式":你声明规则,框架负责匹配。
贯穿类比:路由分发 = 公司前台的分单系统
每个 @app.get 装饰器 = 前台登记册上的一行:"收到 X 类型单子,找张三";请求进来 → 前台查登记册 → 按方法+路径匹配 → 把单子(参数)递给对应的人(函数)。
2.2 一条路由能配置什么
@app.post(
"/books", # ① 路径
status_code=201, # ② 成功时返回的状态码
response_model=BookOut, # ③ 响应模型:过滤/校验返回数据
tags=["图书"], # ④ 分组标签:/docs 文档按组显示
summary="新增图书", # ⑤ 文档里的说明
dependencies=[Depends(verify_token)] # ⑥ 整条路由的前置依赖
)
def create_book(data: BookIn): ...
HTTP 方法对应语义:
| 方法 | 语义 |
|---|---|
@app.get |
查 |
@app.post |
增 |
@app.put |
整体改 |
@app.patch |
局部改 |
@app.delete |
删 |
@app.websocket |
WebSocket 连接(实时通信) |
2.3 匹配流程:方法 → 路径 → 参数绑定
请求进来后 FastAPI 依次做三件事:
第1步 方法匹配 POST /books 不会命中 @app.get("/books")
第2步 路径匹配 /books/3 按顺序试路由表:
/books ✗ 没匹配上(还多一段)
/books/{id} ✓ 匹配,把 3 提取出来
第3步 参数绑定 路径里的 3 → 函数参数 book_id:int
?page=2 → page:int=2
请求体 JSON → data: BookIn(Pydantic 校验)
类型不对直接 422,函数都不执行
一个请求最终"分发"成函数参数的三个来源:
| 来源 | URL 示例 | 函数写法 |
|---|---|---|
| 路径参数 | /books/3 |
book_id: int |
| 查询参数 | /books?page=2 |
page: int = 2 |
| 请求体(JSON) | POST 的 body | data: BookIn |
2.4 APIRouter 模块化分发(多文件项目关键)
真实项目不可能把所有路由堆在 main.py。APIRouter 是"可独立携带的路由表",最后统一挂到主应用:
# routers/books.py ------ 图书相关接口都放这
from fastapi import APIRouter
router = APIRouter(prefix="/books", tags=["图书"])
@router.get("") # 实际路径 = /books
def list_books(): ...
@router.get("/{book_id}") # 实际路径 = /books/{book_id}
def get_book(book_id: int): ...
@router.post("")
def create_book(): ...
# routers/users.py ------ 用户相关
user_router = APIRouter(prefix="/users", tags=["用户"])
@user_router.get("/{user_id}")
def get_user(user_id: int): ...
# main.py ------ 汇总挂载,只做"拼装"
from fastapi import FastAPI
from routers import books, users
app = FastAPI()
app.include_router(books.router) # 挂图书模块
app.include_router(users.router) # 挂用户模块
每个模块只管自己的 prefix 内部相对路径,前缀一改整个模块路径全变。
2.5 include_router 总控台
app.include_router(
books.router,
prefix="/api", # 全局加前缀:/api/books
tags=["接口"], # 覆盖/追加文档分组
dependencies=[Depends(verify_token)], # 整个模块都要登录
)
路由分发分层总表:
| 层级 | 位置 | 管什么 |
|---|---|---|
| 主应用 | main.py |
挂哪些模块、全局前缀、全局依赖 |
| 模块 | routers/xxx.py |
一类业务的相对路径、自己的依赖 |
| 单条路由 | @router.get(...) |
具体接口 + 参数 + 响应模型 |
| 函数参数 | def f(...) |
把请求内容分发给业务代码 |
真实项目目录标准长法:
project/
├── main.py # FastAPI() + include_router 汇总
├── routers/
│ ├── books.py # 图书相关 APIRouter
│ ├── users.py # 用户相关 APIRouter
│ └── auth.py # 登录注册 APIRouter
├── models.py # SQLAlchemy 表模型
├── schemas.py # Pydantic 模型
└── database.py # 引擎 + get_db
2.6 匹配顺序的坑(重点)
FastAPI 按 include 的顺序从上往下匹配,先声明先命中:
@app.get("/books/latest") # ✅ 固定路径必须放前面
def latest(): ...
@app.get("/books/{book_id}") # 放后面
def get_book(book_id): ...
若反了:/books/latest 会被 {book_id} 吞掉 → int 转换失败 422 或直接 404。
2.7 路由分发全流程图
请求 POST /api/books/3?debug=1
│
▼ include_router 的 prefix="/api" 剥掉一层
POST /books/3
│
▼ 模块路由表按序匹配 → 命中 router.post("/{book_id}")? 或 get?
→ 方法对不上就 405/404,对上就继续
│
▼ 参数绑定
book_id=3(路径) debug=1(查询) body→Pydantic 模型
│
▼ 执行函数 → 返回 → 序列化 JSON 响应
3. 路径参数 & 查询参数
3.1 路径参数:声明即校验
URL 里"会变的那一段",用 {花括号} 占位。
@app.get("/books/{book_id}") # {book_id} 就是路径参数
def get_book(book_id: int): # 函数参数同名,类型写 int
return {"你查的是": book_id}
# GET /books/42 → book_id = 42
# GET /books/abc → 自动 422,类型不匹配
URL 里的值天生是字符串,FastAPI 根据类型注解自动转换:
| 注解 | 传 /books/42 | 结果 |
|---|---|---|
book_id: int |
字符串 "42" | 整数 42(能做数学运算) |
book_id: str |
保持 "42" | 字符串 |
book_id: float |
--- | 支持小数 |
| 不写注解 | --- | FastAPI 报错,必须写 |
多个路径参数(天然支持层级路由):
@app.get("/users/{user_id}/books/{book_id}")
def get_user_book(user_id: int, book_id: int):
...
# GET /users/7/books/3 → user_id=7, book_id=3
⚠ 参数按位置对应,不是按名字,位置错位数据就全乱了。
3.2 路径参数 vs 查询参数
| 对比项 | 路径参数 | 查询参数 |
|---|---|---|
| URL 长相 | /books/3 |
/books?id=3 |
| 位置 | URL 路径里 | ? 号后面 |
| 写法 | {book_id} |
函数参数带默认值 |
| 声明 | def f(book_id: int) |
def f(id: int = 3) |
| 语义 | 定位"哪一个资源" | 附加"筛选/条件" |
| 典型场景 | /books/3 拿第3本 |
/books?page=2&size=10 分页 |
实际项目混用:
@app.get("/books/{book_id}/reviews")
def reviews(book_id: int, page: int = 1):
# 路径参数定位:哪本书的评论;查询参数:第几页
...
4. 增删改查 CRUD
CRUD 是业务系统的地基。类比"图书馆管书":
| 操作 | HTTP方法 | URL | 语义 | 典型状态码 |
|---|---|---|---|---|
| 增 | POST | /books |
创建新资源 | 201 Created |
| 查 | GET | /books |
查全部列表 | 200 OK |
| 查 | GET | /books/{id} |
查单个资源 | 200 OK / 404 |
| 改 | PUT | /books/{id} |
整体替换 | 200 OK |
| 改 | PATCH | /books/{id} |
局部修改 | 200 OK |
| 删 | DELETE | /books/{id} |
删除资源 | 200/204 |
口诀:POST 提交新东西、GET 拿、PUT/PATCH 改、DELETE 删。 GET 永远不改数据,其他三个都会动数据。
4.1 内存版完整示例(可运行)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
# ---------- 数据模型 ----------
class Book(BaseModel): # 请求/响应 的"图纸"
title: str
author: str
# ---------- 模拟数据库(真实项目换成 MySQL/PostgreSQL) ----------
books = [] # 存所有书
next_id = 1 # 自增主键
# ---------- 增 Create ----------
@app.post("/books", status_code=201)
def create_book(book: Book):
global next_id
item = {"id": next_id, **book.model_dump()}
books.append(item)
next_id += 1
return item
# ---------- 查 Read ----------
@app.get("/books") # 查全部
def list_books():
return books
@app.get("/books/{book_id}") # 查单个
def get_book(book_id: int):
for b in books:
if b["id"] == book_id:
return b
raise HTTPException(status_code=404, detail="书不存在")
# ---------- 改 Update ----------
@app.put("/books/{book_id}") # 整体替换
def update_book(book_id: int, book: Book):
for i, b in enumerate(books):
if b["id"] == book_id:
books[i] = {"id": book_id, **book.model_dump()}
return books[i]
raise HTTPException(status_code=404, detail="书不存在")
# ---------- 删 Delete ----------
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int):
for i, b in enumerate(books):
if b["id"] == book_id:
books.pop(i)
return # 204 不需要返回体
raise HTTPException(status_code=404, detail="书不存在")
4.2 细节要点
status_code=201 / 204:增成功返回 201(不是 200),删成功返回 204(无内容)- POST vs PUT :POST 每次创建新资源(同一个请求发两次 = 两条记录);PUT 覆盖指定 id 的资源(发两次结果一样 = 幂等)
- 404 是"找不到"的通用答案:查不到/改不到/删不到都抛 404 + detail
- 路径参数声明成 int :传
/books/abc自动 422,不用自己写判断 - 每个操作的三段套路:① 取参数(路径/请求体)② 找数据 ③ 给结果(找到就操作;找不到 raise 404)
5. 连接 MySQL(SQLAlchemy 集成)
5.1 核心答案:路径不变,变的只是函数内部
路径(URL)长什么样,跟用不用 MySQL 没有任何关系。 路径只负责"定位资源",数据存哪、怎么查,全在函数体内部。
| 接口(对外不变) | 底层数据库操作 |
|---|---|
GET /books |
SELECT 全部 |
GET /books/3 |
SELECT WHERE id=3 |
GET /books?author=刘慈欣&page=2 |
条件筛选 + 分页 |
POST /books |
INSERT |
PUT /books/3 |
UPDATE |
DELETE /books/3 |
DELETE |
5.2 一次请求的完整链路(GET /books/3 为例)
1. 浏览器发请求 GET /books/3
2. uvicorn 接收,交给 FastAPI 路由匹配 → 命中 get_book(book_id=3)
3. FastAPI 先执行 Depends(get_db) → 从连接池拿一个数据库会话 db
4. 函数体里 SQLAlchemy 把代码翻译成 SQL:
SELECT * FROM books WHERE id = 3
(经 pymysql 驱动真正发给 MySQL 执行)
5. MySQL 返回结果 → SQLAlchemy 包装成 Book 对象
6. FastAPI 把对象转成 JSON 返回给浏览器
7. finally 里关闭会话,连接还给连接池
类比餐厅:URL 是菜单菜名,路由函数是厨师,数据库会话是传菜口,SQLAlchemy 是翻译(不用自己搬货),MySQL 是仓库。换仓库(MySQL→PostgreSQL)只换连接串,菜单一字不用改。
依赖安装:
pip install fastapi uvicorn sqlalchemy pymysql
# pymysql 是 MySQL 驱动,SQLAlchemy 是 ORM
5.3 完整可跑示例(单文件版,SQLAlchemy 2.x)
from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker, DeclarativeBase, Mapped, mapped_column
# ---------- 第1步:连 MySQL ----------
DATABASE_URL = "mysql+pymysql://root:你的密码@localhost:3306/bookdb?charset=utf8mb4"
# 没装 MySQL 想先跑通?换成:sqlite:///./books.db 其他代码一字不改
engine = create_engine(DATABASE_URL, echo=True) # echo=True 控制台打印SQL
SessionLocal = sessionmaker(bind=engine)
# ---------- 第2步:定义"表"的模型 ----------
class Base(DeclarativeBase):
pass
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
author: Mapped[str]
Base.metadata.create_all(engine) # 没有表就自动建表
# ---------- 第3步:依赖注入 = 每个请求自动拿会话、用完自动关 ----------
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close() # 防连接泄漏
# ---------- 第4步:接口 ----------
app = FastAPI()
class BookIn(BaseModel):
title: str
author: str
# 查全部:SELECT * FROM books
@app.get("/books")
def list_books(db=Depends(get_db)):
return db.scalars(select(Book)).all()
# 查单个:SELECT * FROM books WHERE id = 3
@app.get("/books/{book_id}")
def get_book(book_id: int, db=Depends(get_db)):
book = db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
return book
# 新增:INSERT INTO books (title, author) VALUES (...)
@app.post("/books", status_code=201)
def create_book(data: BookIn, db=Depends(get_db)):
book = Book(**data.model_dump())
db.add(book)
db.commit() # 提交事务,真正写进 MySQL
db.refresh(book) # 拿回数据库生成的自增 id
return book
# 修改:UPDATE books SET ... WHERE id = 3
@app.put("/books/{book_id}")
def update_book(book_id: int, data: BookIn, db=Depends(get_db)):
book = db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
book.title = data.title # 直接改对象属性
book.author = data.author
db.commit()
return book
# 删除:DELETE FROM books WHERE id = 3
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int, db=Depends(get_db)):
book = db.get(Book, book_id)
if not book:
raise HTTPException(404, "书不存在")
db.delete(book)
db.commit()
启动后打开 /docs 逐个接口试,控制台会同步打印每条 SQL。
5.4 常见查询模式 → SQL 对照
| ORM 代码 | 生成的 SQL |
|---|---|
db.scalars(select(Book)) |
SELECT * FROM books |
db.scalars(select(Book).where(Book.author == "刘慈欣")) |
... WHERE author='刘慈欣' |
db.scalars(select(Book).where(Book.title.like("%三体%"))) |
... WHERE title LIKE '%三体%' |
db.scalars(select(Book).offset(10).limit(10)) |
... LIMIT 10 OFFSET 10(第2页) |
db.get(Book, 3) |
... WHERE id=3 只取一条 |
5.5 三个最关键机制
Depends(get_db)依赖注入:每个请求自动开会话、结束自动关。千万别在函数里手动SessionLocal()还不关------连接会耗尽。会话 ≈ 一次"事务边界",提交才算数。- ORM 翻译 SQL,路由函数里没有一句 SQL 字符串:
db.get→ WHERE、db.add+commit→ INSERT、改属性+commit → UPDATE。 - 增删改必须 commit,查不用:忘了 commit = 数据没写进去(新手第一大坑);commit 后报错要 rollback,否则锁住连接。
5.6 分层项目结构(真实项目标准长法)
database.py 引擎 + 会话工厂 + get_db
models.py 表模型(一张表一个类)
schemas.py Pydantic 请求/响应模型
main.py 路由(只写业务逻辑,不碰 SQL)
crud.py 数据库操作函数(可选)
6. SQLAlchemy 核心机制
6.1 一句话 + 为什么需要
SQLAlchemy = 用 Python 对象和代码操作数据库,而不是手写 SQL 字符串。Python 世界最流行的 ORM(对象关系映射)库。
| 手写 SQL 的痛点 | SQLAlchemy 的解法 |
|---|---|
| SQL 是字符串,拼错只在运行时报 | 代码有语法检查,IDE 能补全 |
| 数据库不同语法略不同 | 换库只改连接串 |
| 查询结果是裸元组 | 结果直接是 Python 对象,obj.字段 |
| 拼接条件容易 SQL 注入 | 参数自动转义 |
| 表结构改动要同步改很多 SQL | 只改模型类一处 |
ORM 三个字母拆开:Object(对象)↔ Relational(关系表)↔ Mapping(映射)
| 数据库概念 | ORM 概念 |
|---|---|
| 一张表 | 一个 Python 类 |
| 一行数据 | 一个类的实例对象 |
| 表之间的外键 | relationship 属性 |
类比图纸和实物:class Book(Base) = 书柜图纸;book = Book(...) = 按图纸造出的实物;db.add(book)+commit = 把实物摆进仓库。
6.2 两层架构
┌─────────────────────────────────────┐
│ ORM 层(高层):模型类、会话、对象 │ ← 平时主要用这层
├─────────────────────────────────────┤
│ Core 层(底层):SQL 表达式语言 │ ← 生成标准 SQL
│ Engine:连接池 + 方言(Dialect) │
├─────────────────────────────────────┤
│ 数据库驱动:pymysql / psycopg2... │ ← 真正发网络请求
└─────────────────────────────────────┘
▼
MySQL / PostgreSQL / SQLite...
ORM 不是魔法,最终也生成标准 SQL 发给数据库,只是把"写 SQL"替你做了。
6.3 四大核心组件(类比:银行柜台办业务)
1) Engine ------ 自来水厂(连接基础设施)
engine = create_engine("mysql+pymysql://root:密码@localhost:3306/bookdb")
- 连接池:预先维护一批连接,用完回收复用(建连接很贵)
- 方言:按数据库类型翻译 SQL 语法
2) Session ------ 柜台窗口(★核心,工作单元 Unit of Work)
db = Session() # 开一个窗口
book = Book(...) # 填单子(内存里构造对象)
db.add(book) # 单子放上窗口(还没生效!)
db.commit() # 盖章确认 → 才真正写入数据库
db.close() # 窗口关闭,资源归还
连接 vs 会话是两回事:
- 连接(connection):到数据库的物理管道(从 Engine 池子里借的)
- 会话(session):一次逻辑业务边界,内部按需借用连接
Session 三大纪律(银行柜台规则):
- 增删改必须 commit 才生效(忘了 = 单子没盖章)
- 反悔用 rollback(一笔业务全部撤销)
- 用完必须 close(不关 = 连接泄漏)
3) Model ------ 表结构图纸(声明式映射)
class Book(Base):
__tablename__ = "books"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str]
author: Mapped[str]
类的每个属性 = 表的一列。Base.metadata.create_all(engine) 按图纸自动建表。生产用迁移工具 Alembic。
4) Query ------ 查询单
# 2.x 新版(推荐)
db.scalars(select(Book))
db.scalars(select(Book).where(Book.author == "刘慈欣"))
db.get(Book, 3)
# 1.x 老版(老教程常见,功能相同)
db.query(Book).all()
db.query(Book).filter(Book.author == "刘慈欣").all()
db.query(Book).get(3)
6.4 一次查询的幕后全流程
你的代码 幕后发生的事
db = SessionLocal() 从连接池借一条连接
db.get(Book, 3) ORM 翻译 SQL:
SELECT * FROM books WHERE id=3
(拿到 book 对象) pymysql 发请求 → MySQL 执行
结果行 → 包装成 Book 实例
book.title 直接点属性拿值
db.close() 连接归还连接池
6.5 relationship 一对多
class Author(Base):
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
books: Mapped[list["Book"]] = relationship(back_populates="author")
class Book(Base):
...
author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"))
author: Mapped["Author"] = relationship(back_populates="books")
book.author.name # 通过对象直接拿作者名,不用手写 JOIN
author.books # 自动查出这个作者的所有书
7. 实战项目解析:04_sqlalchemy(部门/员工)系
7.1 文件地图
| 文件 | 作用 | 建模风格 |
|---|---|---|
base.py |
生成模型基类 Base | 老版 1.x (declarative_base) |
database.py |
引擎 + 会话工厂(全项目共用) | 核心配置 |
models.py |
手写模型:部门/员工 | 老版 1.x (Column) |
main.py |
CRUD + 关联查询演示 | 老版 query API |
gen.py |
逆向工具 + 插测试数据 | 脚本 |
table_2_models.py |
从数据库表自动生成的模型 | 新版 2.x (Mapped) |
fastapi_sqlalchemy.py |
FastAPI 集成示例 | 用 table_2_models 的模型 |
核心设计:
departments 部门表(一方) id, name(唯一), location
employees 员工表(多方) id, name, age, hire_date, department_id(外键)
三种玩法:
- 手写模型 → 建表 → CRUD(models.py + main.py)
- 数据库已有表 → 自动生成模型(gen.py + table_2_models.py)
- 模型 + FastAPI → Web 接口(fastapi_sqlalchemy.py)
7.2 base.py ------ 所有模型的"祖先"
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base() # 生成基类,所有模型需继承该类
每张表 = 一个继承 Base 的类。Base.metadata 统一登记所有表的元数据,create_all 遍历登记册建表。
7.3 database.py ------ 连接配置中心
DATABASE_URL = "mysql+pymysql://root:***@localhost:3306/fastapi_db"
# 格式:mysql+pymysql:// 用户名:密码@主机:端口/数据库名
engine = create_engine(
DATABASE_URL,
echo=True, # 控制台打印 SQL(调试神器)
pool_pre_ping=True # 用连接前先 ping,防连接失效
)
SessionLocal = sessionmaker(
autocommit=False, # 不自动提交,需手动 commit()
bind=engine
)
sessionmaker= 会话工厂,生产 Session 的机器- 注释写"相当于 cursor 对象"不准确:Session 是工作单元 + 事务边界
7.4 models.py ------ 手写模型(一对多经典写法)
class Department(Base):
__tablename__ = "departments"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=False, unique=True) # 部门名称唯一
location = Column(String(100))
# 一对多:一个部门 → 多个员工(返回列表)
employees = relationship(
"Employee",
back_populates="department",
lazy="selectin" # 查部门时顺带用 IN 查询取员工
)
class Employee(Base):
__tablename__ = "employees"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(50), nullable=False)
age = Column(Integer)
hire_date = Column(Date)
# 外键:绑定到 departments.id;RESTRICT = 部门有员工就不许删
department_id = Column(
Integer,
ForeignKey("departments.id", ondelete="RESTRICT"),
nullable=False
)
# 多对一:返回单个部门对象
department = relationship(
"Department",
back_populates="employees",
lazy="joined" # 查员工时 JOIN 出部门,少一次查询
)
要点:
- 外键列
department_id+ 关联属性department是两回事:外键存 id,relationship 存对象 - 双向关联用
back_populates互相指名 lazy策略:selectin(额外 IN 查询)/joined(JOIN 出来)/ 默认(用时才查,懒加载)
7.5 main.py ------ CRUD 综合演示(老版 query API)
| 函数 | 操作 | 关键代码 |
|---|---|---|
create_table() |
建表 | Base.metadata.create_all(bind=engine) |
insert_data() |
增 | 建部门→commit→refresh 拿 id→add_all([emp1,emp2])→commit |
delete_data() |
删 | query.filter(name=="李四").first() → delete() → commit |
update_data() |
改 | 查到对象 → 改属性 → commit(无需 update 语句) |
find_data() |
查 | 见下方查询姿势大全 |
查询姿势大全(find_data):
# 按主键查
dept = session.get(Department, 1)
# 条件过滤
rd = session.query(Employee).filter(Employee.department_id == 1).all()
# 逻辑运算 and_ / or_
from sqlalchemy import and_, or_
emp = session.query(Employee).filter(
and_(Employee.age.between(30, 40), Employee.department_id == 1)
).first()
# 表连接 join(内连接:员工 + 所属部门名)
result = session.query(Employee, Department).join(
Department, Employee.department_id == Department.id
).all()
for emp, dept in result:
print(f"员工 {emp.name} 属于 {dept.name}")
# 预加载 joinedload(避免 N+1)
from sqlalchemy.orm import joinedload
employees = session.query(Employee).options(
joinedload(Employee.department)
).all()
# 子查询:统计每个部门员工数,筛员工数>0 的部门
from sqlalchemy import func
dept_emp_count = session.query(
Employee.department_id,
func.count(Employee.id).label("count")
).group_by(Employee.department_id).subquery()
depts = session.query(Department).join(
dept_emp_count, Department.id == dept_emp_count.c.department_id
).filter(dept_emp_count.c.count > 0).all()
# 去重 distinct
locations = session.query(Department.location).join(Employee).distinct().all()
# first() 取一条 / all() 取全部
first_emp = session.query(Employee).first()
all_depts = session.query(Department).all()
标准会话套路(每个函数都这样):
session = SessionLocal()
try:
...操作 + session.commit()
except Exception as e:
session.rollback() # 出错回滚
print(f"失败:{e}")
finally:
session.close() # 一定关闭
7.6 gen.py ------ 逆向工程 + 插数据
# 1) 表 → 模型:调用 sqlacodegen 自动扫描数据库生成模型文件
cmd = [venv_python, "-m", "sqlacodegen", url] # url 指向 fastapi_db
# 结果写入 table_2_models.py
# 2) 插测试数据:with 上下文管理器自动关会话(等价 try/finally)
with Session(engine) as session:
session.add(dept)
session.commit()
适合场景:表是 DBA/别人建好的,不想手写模型,直接生成,保证和真实结构 100% 一致(索引、外键约束、默认值都会搬过来)。
7.7 table_2_models.py ------ 自动生成的模型(2.x 风格)
class Base(DeclarativeBase): # ← 注意:和 base.py 的 Base 不是同一个!
pass
class Departments(Base):
__tablename__ = 'departments'
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(50), nullable=False)
location: Mapped[Optional[str]] = mapped_column(String(100))
created_at: Mapped[Optional[datetime.datetime]] = mapped_column(
DateTime, server_default=text('CURRENT_TIMESTAMP'))
employees: Mapped[list['Employees']] = relationship('Employees', back_populates='department')
class Employees(Base):
__tablename__ = 'employees'
__table_args__ = (
ForeignKeyConstraint(['department_id'], ['departments.id'],
ondelete='SET NULL', name='employees_ibfk_1'),
Index('idx_dept', 'department_id')
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
...
department: Mapped[Optional['Departments']] = relationship('Departments', back_populates='employees')
数据库里的外键约束(ON DELETE SET NULL)、索引、默认值都被原样还原了。
7.8 fastapi_sqlalchemy.py ------ FastAPI 集成
# 依赖项:获取数据库会话
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# 新增部门(INSERT)
@app.post("/departments/")
def create_department(name: str, location: str, db: Session = Depends(get_db)):
db_department = Departments(name=name, location=location)
db.add(db_department)
db.commit()
db.refresh(db_department)
return db_department
# 查全部
@app.get("/departments/")
def read_departments(db: Session = Depends(get_db)):
return db.query(Departments).all()
# 查单个,找不到抛 404
@app.get("/departments/{department_id}")
def read_department(department_id: int, db: Session = Depends(get_db)):
department = db.query(Departments).filter(Departments.id == department_id).first()
if not department:
raise HTTPException(status_code=404, detail="Department not found")
return department
# 代码中直接启动:uvicorn.run(app="fastapi_sqlalchemy:app", host="0.0.0.0", port=8000, reload=True)
7.9 坑与注意(务必记住)
一句话串起项目 :手写模型告诉 SQLAlchemy 表长什么样 → database.py 提供引擎和会话 → main.py 用 CRUD 灌数据并演示各种查询 → gen.py 演示从库里的表逆向生成模型 → fastapi_sqlalchemy.py 用这些模型对外提供 HTTP 接口。
8. 部署:uvicorn 与 nginx
8.1 两者关系:前台 + 后厨(不是二选一)
| nginx | uvicorn |
|---|---|
| Web服务器/反向代理 | Python 应用服务器(ASGI) |
| C 语言写的,超快 | 跑你的 FastAPI 代码 |
| 管"流量进出" | 管"业务逻辑" |
| 不管 Python | 不懂 HTTP 细节之外的业务 |
| 端口通常 80/443 | 端口通常 8000 |
| 一个顶十万连接 | 单进程能力有限 |
类比餐厅:nginx = 前台接待(迎客、排号分流、检查证件 HTTPS、拦闹事);uvicorn = 后厨团队,真正炒菜(跑 FastAPI)。
8.2 生产环境典型架构
用户浏览器
│
▼
nginx (80/443 端口) ← 唯一对外的门
│ 反向代理,转发
├──────────┬──────────┐
▼ ▼ ▼
uvicorn① uvicorn② uvicorn③ ← 多个实例(多核并行)
│ │ │
▼ ▼ ▼
FastAPI FastAPI FastAPI
│ │ │
└──────────┴──────────┘
▼
MySQL / Redis
8.3 为什么需要 nginx(uvicorn 的短板)
| 短板 | nginx 的解法 |
|---|---|
| 单进程扛不住大流量 | 同时挂几万个连接 |
| 多核利用率低 | 多个 uvicorn + 负载均衡,挂一个自动跳过 |
| HTTPS 证书配置麻烦 | SSL 终结在 nginx,内部走明文 http |
| 静态文件也走 Python 慢 | 图片/CSS/JS 由 nginx 直接吐,快几十倍 |
| 裸奔在公网 | 隐藏真实端口、限流、拦恶意 IP |
| 部署重启有断连 | 逐个重启无感知 |
nginx 核心配置:
upstream fastapi_backend { # 后厨团队
server 127.0.0.1:8000;
server 127.0.0.1:8001;
}
server {
listen 80;
location / {
proxy_pass http://fastapi_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; # 让后端知道真实IP
}
location /static/ { # 静态文件 nginx 自己处理
alias /app/static/;
}
}
8.4 路由分发的两层分工(重点辨析)
nginx 做"大门级"分发(到服务为止);FastAPI 做"房间级"分发(到函数为止)
| 分发层级 | nginx | FastAPI |
|---|---|---|
| 看什么 | 域名 + URL 前缀(location) | 完整路径 + HTTP方法 + 参数 |
| 粒度 | 服务级 | 函数级 |
| 典型规则 | location /api/ → uvicorn |
@app.get("/books/{book_id}") |
| 解析参数 | 不关心 /books/3 的 3 | 把 3 提取成 book_id |
| 类比 | 大楼前台总机:转哪个部门 | 部门内部分机找谁 |
nginx 多服务分发示例:
server {
location /api/ { proxy_pass http://fastapi_backend; } # 动态接口
location /admin/ { proxy_pass http://admin_backend; } # 后台
location /static/ { alias /var/www/static/; } # 静态
location / { proxy_pass http://frontend; } # 前端页面
}
一句口诀:nginx 分到"服务"为止,FastAPI 分到"函数"为止。nginx 不问 /books/3 里的 3 是什么;FastAPI 才把 3 变成 book_id。
判断该在哪层分发:
- nginx:不同技术栈服务并存、静态/动态分离、按域名分网站
- FastAPI:同一应用内部所有业务接口
❌ 反面教材:nginx 里写 location /books/3 具体路径------路径一变就得改配置。 ✅ 正确:nginx 永远只写前缀级宽规则,具体资源一律交给 FastAPI 动态匹配。