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() 会:

  1. 调用 get_db(),拿到 Session
  2. 把 Session 注入到路由函数的 db 参数
  3. 路由函数执行完毕
  4. 回到 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()

职责:

  • 代表"和数据库的一次对话"
  • 暂存所有操作(adddelete)
  • 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 全部完成 —— 前端展示真实数据库里的数据!


参考资料

posted @ 2026-08-25 19:02  吴逸清  阅读(16)  评论(0)    收藏  举报