D8 学习笔记:数据库接入——SQLAlchemy + Pydantic 双剑合璧
系列:海口三港 AI 全栈实战 · 从参赛大屏到 AI 平台
仓库:https://github.com/2003Tim/haikou-ai-port
前言:D8 我问了 13 个"为什么"
D8 是信息量最大的一天。我心里充满疑问:
- SQLite 和 SQLAlchemy 是啥关系?
- 为什么要分 5 个文件?
- Engine / Session / Base 各自干啥?
- 为什么要"这么"创建数据库?
- commit 为什么是关键?
- 持久化到底是什么?
这篇博客把这些问题全整理清楚,作为"数据库入门参考"。
💡 D8 完成了 v1.0 的后端核心:数据从硬编码 → API 实时读写 → 持久化到磁盘。重启服务数据还在,这是 D7 做不到的事。
一、SQLite vs SQLAlchemy:两个完全不同的东西
D8 之前我以为 uv add sqlalchemy 是装数据库,错了。
┌──────────────────────────────────────────────────┐
│ 我们的代码(main.py / crud.py) │
│ "我要查港口" │
└────────────────────┬─────────────────────────────┘
│ 调用
▼
┌──────────────────────────────────────────────────┐
│ SQLAlchemy │
│ Python 的"数据库操作工具箱" │
│ 把 Python 代码翻译成 SQL,操作数据库 │
└────────────────────┬─────────────────────────────┘
│ 发送 SQL
▼
┌──────────────────────────────────────────────────┐
│ SQLite │
│ 真正的数据库软件,负责: │
│ - 接收 SQL │
│ - 在 ports.db 文件里读写数据 │
│ - 把结果返回给 SQLAlchemy │
└────────────────────┬─────────────────────────────┘
│ 操作文件
▼
┌──────────────┐
│ ports.db │
│ (磁盘文件) │
└──────────────┘
餐厅类比
| 角色 | 比喻 |
|---|---|
| 顾客("我要查港口") | 我们的代码 |
| 服务员(传菜员) | SQLAlchemy(Python 库) |
| 厨房(做菜) | SQLite(数据库软件) |
| 冰箱(放菜) | ports.db(磁盘文件) |
你不会直接进厨房找厨师要菜——你要找服务员。SQLAlchemy 就是服务员。
对比表
| SQLite | SQLAlchemy | |
|---|---|---|
| 是什么 | 数据库软件 | Python 操作数据库的库 |
| 职责 | 真正存数据、操作文件 | 翻译 Python 代码 → SQL |
| 类比 | 仓库 | 仓库管理员 |
| 能不能换 | ❌ 换 MySQL/PostgreSQL 也行 | ❌ 换 Django ORM 也行 |
二、为什么要"分层"架构?为什么不都写 main.py?
反面教材:一锅端
D7 我所有代码都在 main.py 一个文件。D8 我把它拆成 5 个文件。为什么?
一锅端的痛苦:
# main.py 一锅端(500 行)
from fastapi import FastAPI
from sqlalchemy import ...
app = FastAPI()
# 数据库配置混在路由代码里
engine = create_engine("sqlite:///./ports.db")
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()
# ORM 模型混在路由代码里
class Port(Base):
...
# API 模型混在路由代码里
class PortCreate(BaseModel):
...
# CRUD 函数混在路由代码里
def create_port(db, port):
...
# 路由混在路由代码里
@app.post("/ports")
def create_port_api(...):
...
问题:
- 一个文件 500 行,看不过来
- 数据库换 PostgreSQL,要改一堆代码
- 想测数据库函数,要启动整个 uvicorn
- 多人协作 git 冲突
分层架构(我们 D8 的方案)
┌──────────────────────────────────────────────────┐
│ main.py 🛣 路由层 │
│ "URL → 调哪个函数" │
│ 关心:HTTP 请求长什么样 │
└──────────────────┬───────────────────────────────┘
↓ 调用 crud
┌──────────────────────────────────────────────────┐
│ crud.py ⚙️ 业务层 │
│ "怎么增删改查" │
│ 关心:数据怎么操作 │
└──────────────────┬───────────────────────────────┘
↓ 用 models + Session
┌──────────────────────────────────────────────────┐
│ models.py 📦 数据层 │
│ "数据库表长什么样" │
│ 关心:数据库结构 │
└──────────────────┬───────────────────────────────┘
↓ 连接
┌──────────────────────────────────────────────────┐
│ database.py 🔌 连接层 │
│ "怎么连上数据库" │
│ 关心:数据库连接配置 │
└──────────────────────────────────────────────────┘
↓ 配合
┌──────────────────────────────────────────────────┐
│ schemas.py 📋 API 模型层 │
│ "API 长什么样" │
│ 关心:HTTP 请求/响应数据格式 │
└──────────────────────────────────────────────────┘
单一职责原则
每层只关心自己的事:
| 文件 | 职责 | 不知道的事 |
|---|---|---|
| database.py | 配置数据库连接 | 不知道表长啥样,不知道 API 长啥样 |
| models.py | 数据库表结构 | 不知道如何连数据库,不知道 API 长啥样 |
| schemas.py | API 请求/响应格式 | 不知道表长啥样,不知道如何连数据库 |
| crud.py | 数据库操作函数 | 不知道 API 长啥样,不知道 HTTP |
| main.py | API 路由 | 数据库连接细节,数据库表怎么定义 |
餐厅分工类比:
| 文件 | 比喻 |
|---|---|
| main.py | 服务员(接单、传菜) |
| crud.py | 厨师(做菜) |
| models.py | 菜单设计(写菜名) |
| schemas.py | 点菜单格式(顾客填的) |
| database.py | 后厨与仓库的连接(管道) |
三、5 个文件语法详解
📄 database.py:连接层
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
3 个 import 的作用:
| 名字 | 来源 | 作用 |
|---|---|---|
create_engine |
sqlalchemy | 创建数据库引擎 |
sessionmaker |
sqlalchemy.orm | 创建 Session 工厂 |
declarative_base |
sqlalchemy.orm | 创建模型基类 |
SQLALCHEMY_DATABASE_URL = "sqlite:///./ports.db"
- 字符串变量(常量),存数据库路径
sqlite:///是 SQLite 数据库的 URL 协议./ports.db表示"当前目录下的 ports.db 文件"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
)
create_engine(...)调用函数,返回 Engine 对象connect_args={"check_same_thread": False}是 SQLite 必须的(SQLite 默认单线程,FastAPI 多线程要关掉这个检查)
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
)
sessionmaker(...)调用函数,返回 Session 工厂autocommit=False:不自动提交bind=engine:这个 Session 绑定的引擎SessionLocal()才创建 Session 实例(工厂模式)
Base = declarative_base()
declarative_base()调用函数,返回一个类(基类)- 所有 ORM 模型继承这个
Base
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
这是个生成器函数(有 yield,不是普通函数)。
普通函数 vs 生成器函数:
# 普通函数
def get_db():
db = SessionLocal()
return db # 返回一次就结束
# 生成器函数(有 yield)
def get_db():
db = SessionLocal()
yield db # "暂时返回",后面还能回来
db.close() # yield 之后还能执行!
为什么用生成器? FastAPI 的 Depends() 会:
- 调用
get_db(),拿到 Session - 把 Session 注入到路由函数的
db参数 - 路由函数执行完毕
- 回到
get_db()继续执行finally里的db.close()
这就是自动管理资源(用完自动关)。
📄 models.py:数据层
from sqlalchemy import Column, Integer, String
from .database import Base
Column, Integer, String是 SQLAlchemy 的列类型from .database import Base是相对导入(.表示当前包app)
class Port(Base):
"""港口表"""
__tablename__ = "ports" # 数据库表名
class Port(Base):继承Base(D3 学过的"继承")__tablename__ = "ports"是类变量,告诉 SQLAlchemy 表名是ports__xxx__是 Python 的"魔术属性",有特殊含义
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False)
location = Column(String(100), nullable=False)
capacity = Column(Integer, default=0)
Column(...)调用函数创建"列定义"Integer/String:列类型primary_key=True:主键(每行唯一标识)index=True:建索引(加快查询)nullable=False:不能为空default=0:默认值
📄 schemas.py:API 模型层
class PortBase(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
location: str = Field(...)
class PortCreate(PortBase):
"""创建港口的请求体(继承 PortBase)"""
capacity: int = Field(default=0, ge=0)
模型继承:PortCreate(PortBase) 表示 PortCreate 自动有 PortBase 的字段。
class PortResponse(PortBase):
id: int
capacity: int
class Config:
from_attributes = True
class Config:Pydantic 配置类from_attributes = True:允许从 ORM 对象转换- 没这一行,SQLAlchemy 对象 → Pydantic 模型会失败
📄 crud.py:业务层
def create_port(db: Session, port: schemas.PortCreate) -> models.Port:
"""创建港口"""
db_port = models.Port(**port.model_dump())
db.add(db_port)
db.commit() # 提交事务(关键!)
db.refresh(db_port) # 刷新,获取数据库自动生成的字段(如自增 ID)
return db_port
3 个动作的顺序:
db.add(...) ① 加入 Session(暂存内存)
db.commit() ② 写入数据库文件(持久化)
db.refresh(...) ③ 重新读取,获取数据库生成的值
def update_port(db, port_id, port_update):
db_port = get_port(db, port_id)
update_data = port_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_port, field, value)
db.commit()
exclude_unset=True 是关键:
- PUT 请求 body:
{"capacity": 7000}(只传了 capacity) model_dump(exclude_unset=True)→{"capacity": 7000}(只包含传入的字段)- 没传的字段(name、location)不会被覆盖
📄 main.py:路由层
models.Base.metadata.create_all(bind=engine)
- 启动时自动建所有表
Base.metadata收集了所有继承的模型create_all:检查表是否存在,不存在就创建
def list_ports(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
):
依赖注入:db: Session = Depends(get_db)
- FastAPI 自动调用
get_db(),把 yield 出来的 Session 传给db - 路由函数结束,自动执行
get_db()里的finally: db.close()
四、Engine / Session / Base 三件套
我用"拨号通话"类比让你一辈子忘不了。
Engine = 电话拨号器
engine = create_engine("sqlite:///./ports.db")
职责:
- 管理连接池(高效复用连接,避免每次新建)
- 执行 SQL 语句
- 处理数据库方言(SQLite / MySQL / PostgreSQL 不同)
关键认知:Engine 不是连接,是连接的工厂。
Session = 一次通话
db = SessionLocal()
db.add(port)
db.commit()
db.close()
职责:
- 代表"和数据库的一次对话"
- 暂存所有操作(
add、delete) - commit 时才真正写入磁盘
- 提供查询接口(
query)
为什么每次请求一个 Session?
- 隔离性:不同请求不能共享数据
- 自动清理:用完即关,防止内存泄漏
Base = 模型基类
Base = declarative_base()
class Port(Base):
__tablename__ = "ports"
职责:
- 收集所有继承它的模型
- 提供"批量创建表"的能力
为什么需要 Base? 因为 Base.metadata.create_all() 能一次性建所有表:
models.Base.metadata.create_all(bind=engine)
# ↓
# 一次性建 ports, sailings, parking_lots... 所有表
三者关系图
Base (模型基类,定义"表长啥样")
↓ 继承
Port, Sailing, ParkingLot...
Engine (数据库引擎,管理连接池)
↓ bind绑定
SessionLocal (Session 工厂)
↓ 调用
db = SessionLocal() # 创建 Session 实例
↓ 在 db 上操作
db.add(port)
db.query(Port).all()
五、ORM:数据库的翻译官
ORM 是什么
ORM = Object-Relational Mapping(对象关系映射)
| 数据库概念 | ORM 概念 | Python 写法 |
|---|---|---|
| 表(Table) | 类(Class) | class Port |
| 行(Row) | 对象(Object) | port = Port(...) |
| 列(Column) | 属性(Attribute) | port.name |
| 主键 | 主键字段 | id = Column(primary_key=True) |
为什么用 ORM
之前(直接 SQL):
SELECT * FROM ports WHERE id = 1;
用 ORM 后:
port = db.query(Port).filter(Port.id == 1).first()
4 大好处:
- ✅ 不写 SQL(对新手友好)
- ✅ 类型安全(IDE 提示字段)
- ✅ 防 SQL 注入(自动转义)
- ✅ 切换数据库方便(SQLite → PostgreSQL 代码不变)
六、CRUD:增删改查
4 个基本动作
| 字母 | 全称 | 含义 | SQL | ORM |
|---|---|---|---|---|
| C | Create | 增 | INSERT | db.add() |
| R | Read | 查 | SELECT | db.query() |
| U | Update | 改 | UPDATE | 修改属性 |
| D | Delete | 删 | DELETE | db.delete() |
每个动作的完整流程
Create:
db.add(port) # ① 加入 Session(暂存)
db.commit() # ② 写入数据库(持久化)
db.refresh(port) # ③ 重新读取,获取自增 ID
Read:
db.query(Port).all() # 查所有
db.query(Port).filter(Port.id == 1).first() # 条件查
db.query(Port).filter(Port.capacity >= 6000).all() # 多条件
Update:
port = db.query(Port).filter(Port.id == 1).first()
port.capacity = 7000 # 直接修改属性
db.commit() # 提交!
Delete:
port = db.query(Port).filter(Port.id == 3).first()
db.delete(port)
db.commit()
⭐ commit 是关键
没 commit 的数据:
- 在内存里
- 还没真正写入磁盘
- 关掉 Session / 程序崩溃 → 数据丢失
commit 后的数据:
- 已经在磁盘上了
- 即使程序崩溃 → 数据还在
七、持久化:从内存到磁盘
持久化是什么
持久化(persistence)= 把数据从内存搬到磁盘。
内存(快,易失) 磁盘(慢,永久)
┌──────────────┐ ┌──────────────┐
│ Python 列表 │ commit │ ports.db │
│ PORTS_DATA │ ──────▶ │ 文件文件 │
└──────────────┘ └──────────────┘
重启就没了 重启还在
为什么需要持久化
| 场景 | 没持久化 | 有持久化 |
|---|---|---|
| 重启服务 | 数据丢了 ❌ | 数据还在 ✅ |
| 程序崩溃 | 数据丢了 ❌ | 数据还在 ✅ |
| 服务器断电 | 数据丢了 ❌ | 数据还在 ✅ |
为什么 SQLite 可以持久化
SQLite 是文件型数据库,整个数据库就是一个文件(ports.db)。
- 数据写到文件
- 文件在磁盘上
- 重启电脑也还在(除非你删了文件)
类比:
- Word 文档.docx → 文件存到硬盘 → 重启还在
- ports.db → SQLite 数据库文件 → 重启服务还在
八、依赖注入 Depends(get_db)
是什么
@app.get("/ports")
def list_ports(db: Session = Depends(get_db)):
...
Depends(get_db):告诉 FastAPI 自动调用 get_db(),把结果注入到 db 参数。
为什么需要
不用 Depends(手动管理):
@app.get("/ports")
def list_ports():
db = SessionLocal() # 1. 手动开
try:
return crud.get_ports(db)
finally:
db.close() # 2. 手动关
# 容易忘记 close!
用 Depends(自动管理):
@app.get("/ports")
def list_ports(db: Session = Depends(get_db)):
return crud.get_ports(db)
# FastAPI 自动开 + 自动关!
依赖注入的工作流程
FastAPI 收到 GET /ports 请求
↓
1. 看到 Depends(get_db)
↓
2. 调用 get_db() 函数,执行到 yield db
↓
3. 把 yield 的 db 注入到 list_ports 的 db 参数
↓
4. list_ports 执行(可以正常使用 db)
↓
5. list_ports 返回
↓
6. FastAPI 回到 get_db(),执行 finally 里的 db.close()
好处:
- ✅ 自动开、自动关
- ✅ 不会忘记关闭(防内存泄漏)
- ✅ 每个请求独立 Session(隔离性)
九、模型继承
class PortBase(BaseModel):
name: str = Field(...)
location: str = Field(...)
class PortCreate(PortBase):
capacity: int = Field(default=0, ge=0)
PortCreate(PortBase) 表示 PortCreate 自动继承 PortBase 的字段。
效果:
# PortCreate 自动有 name + location + capacity
pc = PortCreate(name="秀英港", location="海口", capacity=6400)
# ↑ PortBase 的字段 ↑ PortBase 的字段 ↑ PortCreate 自己的字段
为什么用继承
- DRY 原则(Don't Repeat Yourself):不写重复代码
- 修改方便:PortBase 加字段,PortCreate / PortResponse 自动有
- 语义清晰:看出"哪些字段是创建时必填"
实际例子
| 模型 | 字段 |
|---|---|
PortBase |
name, location |
PortCreate(PortBase) |
name, location, capacity(POST 用) |
PortResponse(PortBase) |
name, location, id, capacity(GET 用) |
PortUpdate |
name?, location?, capacity?(PUT 用,全可选) |
十、exclude_unset 的妙用
是什么
update_data = port_update.model_dump(exclude_unset=True)
exclude_unset=True:只包含用户实际传入的字段,没传的字段不包含。
为什么需要
没有 exclude_unset:
# PUT /ports/1 body: {"capacity": 7000}
update_data = port.model_dump()
# → {"capacity": 7000, "name": None, "location": None}
# name 和 location 被设为 None!数据丢了!
有 exclude_unset:
# PUT /ports/1 body: {"capacity": 7000}
update_data = port.model_dump(exclude_unset=True)
# → {"capacity": 7000} # 只包含传入的
# name 和 location 不变!
对比:
model_dump()→ 所有字段(包括默认值)model_dump(exclude_unset=True)→ 只包含用户实际传入的
十一、一个 POST 请求的完整旅程
你 POST /ports 创建港口,数据流过哪里?
1. 浏览器发 POST /ports + JSON body
{"name":"秀英港", "location":"...", "capacity":6400}
↓
2. uvicorn 接收请求
↓
3. FastAPI 路由表匹配 POST /ports → create_port()
↓
4. Pydantic 验证 body → PortCreate 对象
↓
5. Depends(get_db) 触发:
- 调用 get_db()
- SessionLocal() 创建 Session
- yield db(把 Session 注入到 db 参数)
↓
6. crud.create_port(db, port) 被调用
↓
7. db.add(db_port) 把数据加入 Session(内存)
↓
8. db.commit() 把数据写入 ports.db 文件 ⭐
↓
9. db.refresh(db_port) 从数据库读取自动生成的 ID
↓
10. yield 后的代码继续执行:db.close()
↓
11. FastAPI 把 PortResponse 对象转 JSON
↓
12. uvicorn 返回 201 Created + JSON 给浏览器
十二、我踩过的坑(D8)
坑 1:uv add 报 src\backend\__init__.py 错误
原因:pyproject.toml 里有 [build-system],让 uv add 试图 build 当前项目。
修复:删除 [build-system] 和 [project.scripts](FastAPI 应用不需要打包)。
坑 2:uvicorn 启动时找不到 app.main
原因:在 app/ 子目录里跑 uvicorn。
修复:从 backend/ 根目录跑 uvicorn app.main:app。
坑 3:虚拟环境没激活,用到系统 Python
症状:提示符没有 (backend) 前缀。
修复:.\.venv\Scripts\Activate.ps1。
坑 4:SQLite 测试文件 PermissionError(Windows)
症状:PermissionError: [WinError 32] 另一个程序正在使用此文件
原因:SQLite 在 Windows 上 close() 后仍持有文件句柄。
修复:手动 Remove-Item test_d8.db -Force 或忽略(不影响主流程)。
十三、从 D6 到 D8 的数据演进
| 阶段 | 数据存在哪 | 重启会丢? | 多个请求共享? |
|---|---|---|---|
| D6 | Python dict(内存) | ❌ 会丢 | ❌ 不能 |
| D7 | Python list(内存) | ❌ 会丢 | ❌ 不能 |
| D8 | 数据库文件(磁盘) | ✅ 不会丢 | ✅ 能 |
十四、下一步:D9
D8 我有了真实的数据库。D9 我会:
- 改造前端,从硬编码(
sailing_xiuying.js)改为 fetch 调用 FastAPI API - 前端 HTML 不动(已经有完整页面)
- 只改 JS 文件,从硬编码数组改为 API 调用
到 D9 结束时,v1.0 全部完成 —— 前端展示真实数据库里的数据!
参考资料
- SQLAlchemy 官方文档:https://docs.sqlalchemy.org/en/20/
- FastAPI SQL 数据库教程:https://fastapi.tiangolo.com/zh/tutorial/sql-databases/
- SQLite 官方文档:https://www.sqlite.org/docs.html
- 《流畅的 Python》第 23 章(SQLAlchemy 入门)

浙公网安备 33010602011771号