重构:在项目中用上 SQLite
这一篇的目标:
SQLite 的用法在前面学过了,但 zero-to-tech 项目还没用上它------仍然在用 history.json 这个文件承担存储;这一篇就换成用 SQLite 做存储
要改造项目,但只动存储层:会改动不少 Python 代码,但不破坏接口约定(/api/analyze 和 /api/history 对老调用方的行为不变),前端也就 "毫无察觉",因此不需要改前端代码
先给 main.py 分层:
"把存储层换成数据库" 听着像一件事,落到代码里其实是好几处;打开 backend/main.py 从头到尾扫一遍,看看这个文件目前做了哪些事
文件也就几十行,看着是一整块,但仔细看,里面已经住着三种不同的职责:
python
import json # ← 存储
from datetime import datetime, timezone # ← 业务(时间戳是 analyze 生成的)
app = FastAPI() # ← 启动/配置区,不归这三层
# ... CORS ...
HISTORY_FILE = "history.json" # ← 存储
def load_history(): ... # ← 存储
def save_record(record): ... # ← 存储
@app.get("/api/profile") # ← 接口
def get_profile():...
def score_label(score):... # ← 业务
@app.post("/api/analyze") # ← 接口
def analyze(req: AnalyzeRequest):
text = req.text # ← 业务
score = round(SnowNLP(text).sentiments, 2) # ← 业务
result = { ... } # ← 业务
save_record(result) # (接口层喊一声存储层)
return result
@app.get("/api/history") # ← 接口
def history():
records = load_history() # ← 存储?
records.reverse() # ← 存储?
return records[:10] # ← 存储?
三层各管各的:
-
接口层------对接前端的 API(@app.post / @app.get),管的是 "接收什么、响应什么"
-
业务层------算情感分、算拼音、把结果拼成一条记录,管的是 "这件事本身怎么做"
-
存储层------所有跟 "东西存在哪儿、怎么存、怎么取" 打交道的代码
目前三层代码都放在 main.py;项目还小的时候这么做没问题,不一定要分开成不同文件------因为分层分的是职责,不是文件
但还有个小问题;看 /api/history 里那三行:
python
records = load_history() # 读出全部
records.reverse() # 倒序
return records[:10] # 切前 10 条
"读出全部、倒过来、切前十" ------这是取数据的手法,是地地道道存储层的活儿,可它现在长在接口函数里;可见之前写的 main.py,分层设计并不清晰
分层不清的代价是:当我们要改项目背后的存储时,因为接口函数里直接写了与存储相关的三行代码,我们不得不伸手去改接口函数(换成数据库之后,这三行会变成一句 SQL);所以在真正切换存储层之前,值得先对 main.py 做一次小型重构
重构方案:
前端阶段也做过重构,基本思路就是重新划分代码职责,用更易于管理的方式组织代码;main.py 也可以按分层思想重构:把代码拆成多个文件,再用 import 把依赖关系写进代码里
重构有一条铁律要记住:功能一点都不能变;重构只挪位置、不改行为,跑出来但凡有一点不一样,就说明搬错了;这条铁律不是额外的纪律,它就是 "重构" 这个词的定义------业内对重构的定义是:在不改变外部可观察行为的前提下,调整代码的内部结构;行为一变,就不叫重构了;(所以严格说,这一部分的第二步 "把文件换成 SQLite" 不算重构,那是换实现;只有第一步的搬家、以及后面把 10 改成参数,才是重构)
此外,不要为了重构而重构:现在 main.py 里代码比较少,接口层和业务层就那么几行,而且今天一行都不用改;真正要动的只有存储层,它今天整个要被换掉;这种情况下把业务层和接口层拆开几乎换不来任何好处,只会增加来回跳转的成本;而把存储层拆开就很值得
所以比较合适的方案是:只把存储层单独拆出来一个文件;拆完是两个文件:
backend/ main.py ← 接口层 + 业务层:对外的门,和"这件事怎么算"
storage.py ← 存储层:数据存在哪儿、怎么存、怎么取
那什么时候才该给业务层也开一个文件?等它也长到 "我们一眼看不出它的边界在哪" 的时候;之前给业务层换过一次分析内核(从写死的占位值换成 snownlp),那次没拆文件也很轻松------因为要换的东西就窝在一个函数里;但今天要换的存储层是散落在文件各处的,边界看不见了,才需要用文件把它框出来
再说文件名;为什么叫 storage.py?因为这一层的职责就是存储------无论用文件实现还是用 SQLite 实现,哪怕以后改了别的存储实现,它的职责不会变;给边界起名字,要用职责起名字,不用实现来起名字------这和 save_record()、get_history() 是一个道理:这两个函数名里,也没有 "file" 或 "SQL" 这些字眼
一通分析下来,今天要干的活就清楚了,分两步:
-
搬家------把存储层原样搬进 storage.py;功能一点不变,存储还是用 history.json
-
装修------把 storage.py 整个换成 SQLite 版;main.py 几乎不用碰
为什么非要分两步、不能一边搬一边改?因为搬坏了和改坏了,是两种完全不同的问题:搬完先跑一遍,如果一切没变,说明搬对了;装修之后再跑一遍,如果坏了,那一定是装修弄的;一次只做一件事,出了问题范围就小------这也是一个很值钱的习惯
而且这两件事性质本来就不一样:搬家是重构,行为必须不变,所以它自带一把尺子------跑一遍,不一样就是错了;装修是换实现,行为本来就该变,它没有这把尺子;混在一起做,那把尺子就废了:跑出来不对,我们根本分不清是搬错了还是换错了
第一步:搬家(功能零变化)
在 backend/ 里新建一个 storage.py,把 main.py 里所有属于存储层的东西原样剪切过来:
python
# backend/storage.py
import json
HISTORY_FILE = "history.json"
def load_history():
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
def save_record(record):
records = load_history()
records.append(record)
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(records, f, ensure_ascii=False, indent=2)
def get_history():
records = load_history()
records.reverse()
return records[:10]
前两个函数是一个字没改地搬过来的;最后的 get_history 是一个新定义的函数------里面写的就是之前那三行职责不清的部分,现在把它们从接口函数搬到了真正属于它的位置,原样包成一个函数;注意连那个写死的数字 10 都照抄了过来:搬家就是搬家,一个字都不改
再回到 main.py;这一侧要做四件事:
-
顶上把 import json 换成 from storage import save_record, get_history
-
删掉 HISTORY_FILE、load_history、save_record 这三样(它们已经在 storage.py 里了)
-
/api/history 瘦身成一句 return get_history()
-
其余一个字都不动
from datetime import datetime, timezone 要留着:时间戳是 analyze 生成的,那是业务层的活儿,不跟着搬家
搬完之后的 main.py 完整长这样,对着核一遍,别搬串了:
python
# backend/main.py
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from pypinyin import lazy_pinyin, Style
from snownlp import SnowNLP
from datetime import datetime, timezone
from storage import save_record, get_history # ← 新增:跟存储层打交道,只经过这一行
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_methods=["GET", "POST"],
)
profile = {
"heroTitle": "关于我",
"heroSubtitle": "项目,创意,灵感,心得,我的作品",
"featuredWork": {
"kicker": "作品",
"title": "文字实验室",
"copy": "拼音和情绪,挖掘中文里的细节",
"linkLabel": "打开作品",
},
"identity": {
"motto": "已识乾坤大,尤怜草木青",
"learning": "零到全栈",
},
}
class AnalyzeRequest(BaseModel):
text: str
def score_label(score):
if score >= 0.6:
return "偏积极"
elif score <= 0.4:
return "偏消极"
else:
return "中性"
@app.get("/api/profile")
def get_profile():
return profile
@app.post("/api/analyze")
def analyze(req: AnalyzeRequest):
text = req.text
score = round(SnowNLP(text).sentiments, 2)
result = {
"text": text,
"score": score,
"label": score_label(score),
"pinyin": " ".join(lazy_pinyin(text, style=Style.TONE)),
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
save_record(result)
return result
@app.get("/api/history")
def history():
return get_history()
改完之后启动应用,访问文字实验室、访问 /api/history,效果和改之前完全一样------那就说明重构成功了;再分析一下这次改动之后,两个文件是怎么共同工作的
fastapi dev 启动应用时,uvicorn 还是只去找 main.py;但因为下面这行 import,storage.py 就成功被加载了:
python
from storage import save_record, get_history
可以看出 Python 的 import 很朴素:因为 storage.py 和 main.py 在同一个目录里,写 from storage import save_record 就能找到------不用写路径,不用写 .py;也正因为这个原因,storage.py 一定要建在 backend/ 目录下,和 main.py 两个文件得在一块儿才行
职责真的分干净了吗?
文件拆开了,代码各就各位,看上去挺整齐;但整齐从来不是重构的目的
回想我们为什么要做这次重构------不是为了让代码好看,是为了让每一层只管自己该管的事;分层的价值在于:如果哪天要换某一层,只动那一层,别人不受牵连;今天马上要换存储层,指望的正是这个
所以判断一次重构做到位了没有,标准不是 "文件变多了""函数变短了",而是这么一句追问:
这一层里面,还有没有不属于它的东西?
就拿这把尺子,量一量刚拆出来的 storage.py
storage.py 的职责是存储------数据存在哪儿、怎么存进去、怎么取出来;除此之外的事,都不该由它操心;逐行看代码:
python
def get_history():
records = load_history() # 把数据取出来 ------ 它的事
records.reverse() # 新的排前面 ------ 它的事("怎么取"的一部分)
return records[:10] # 只给 10 条 ------ ?
前两行没有疑问,第三行得停一下:它是不是把两件事捏在了一起?
-
"切一刀,只给一部分" 这个动作------是存储层的活儿没错(换成数据库后,它就是那句 LIMIT)
-
"切在 10 这个位置" 这个决定------这应该是它管的事吗?
想两个场景:
-
哪天业务需求改了,想在手机上显示的时候取出来 20 条------照现在的写法,我们得去改 storage.py;可它是管 "数据存储" 的,凭什么把 "一次取多少条" 交给它管理?
-
反过来问:storage.py 需要知道什么时候取 10 条、什么时候取 20 条吗?需要知道用户是在手机上看还是在电脑上看吗?------不知道,也不该知道
所以答案很清楚:动作归存储层,决定归调用方;这个数字不属于这一层,它属于调用它的那一层;存储层该说的话只有一句------ "你要几条,我给几条"
那就把这个数字的决定权交出去;storage.py 里,把写死的数字换成一个参数:
python
def get_history(limit):
records = load_history()
records.reverse()
return records[:limit]
main.py 这一侧,由调用方把数字传给它:
python
@app.get("/api/history")
def history():
return get_history(10)
改完功能一点没变------还是最近 10 条,跑一遍结果一模一样;变的只是这个决定归谁管;这同样是一次重构:搬家挪的是代码的位置,这一次挪的是决定的归属;看似只是挪了一个数字,背后其实是职责的划分
第二步:装修(把文件存储换成 SQLite)
现在 storage.py 是一个独立的、边界清清楚楚的文件,但它目前还在用文件版存储;接下来换成 SQLite
这次 "装修" 要改 6 处代码,全在 storage.py 里:
| # | 现在(文件版) | 改成(数据库版) |
|---|---|---|
| 1 | import json | import sqlite3 |
| 2 | HISTORY_FILE = "history.json" | DB_FILE + 一个 get_conn() |
| 3 | (文件时代没有这一步) | init_db():启动时建表 |
| 4 | save_record:读全部+追加+整个写回 | 一句 INSERT |
| 5 | get_history:全量读+倒序+切片 | 一句 SELECT ... ORDER BY ... LIMIT |
| 6 | load_history | 删掉(数据库不需要 "整份读出来") |
下面一处一处来
第 1 处:换 import
把 storage.py 顶上的:
python
import json
换成:
python
import sqlite3
换成数据库,就不再需要和硬盘上的 .json 文件打交道了
第 2 处:从 "文件名" 到 "连接"
用 json 文件做存储时,入口就是一个文件名,open() 一下就能读写:
python
HISTORY_FILE = "history.json"
而 SQLite 是一个 .db 文件,程序得先通过 sqlite3 连上它------所以这一处不是简单换个常量,而是多出一个新概念;把上面那行换成下面这段:
python
DB_FILE = "history.db"
def get_conn():
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row # 让查询结果带上列名(默认是元组)
return conn
中间 row_factory 那行值得说一句:上一部分在试验田里查出来的每一行都是元组,比如 (1, '肖申克的救赎', '英语', '1994-09-23', ...)------光有值,没有名;加上这一行之后,查出来的每一行就带上了列名,可以直接用 dict(row) 转成一个带字段名的字典------而这正好就是要回给前端的 JSON 形状,等会儿第 5 处就会用到它
注意 get_conn() 是个函数,它定义了如何连接数据库;下面每个函数需要连接数据库时就调用它、用完 close()------用的时候开门、用完关门,这是最简单也最不容易出错的写法
第 3 处:建表
这一处比较特殊:文件版里没有对应的代码,是数据库多出来的一步
往文件里写,格式想怎么定就怎么定,写之前不用跟谁打招呼;而关系型数据库是先定表结构、再往里放数据的------所以多一个 "确保表在" 的函数;紧接着 get_conn() 下面新增:
python
def init_db():
conn = get_conn()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT,
score REAL,
label TEXT,
pinyin TEXT,
created_at TEXT
)
""")
conn.commit()
conn.close()
建表的方法学过了,这里只是换了表名和字段,id 依旧是自增主键;CREATE TABLE IF NOT EXISTS 的意思是:还没有这个表就建,已经在了就跳过
第 4 处:save_record 换成 SQL 的写法
文件版的 save_record 是 "读出整个文件 → 内存里追加一条 → 整个写回" 三步:
python
def save_record(record):
records = load_history()
records.append(record)
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
json.dump(records, f, ensure_ascii=False, indent=2)
用 SQLite 的实现,就是通过 SQL 的 INSERT 语句来操作:
python
def save_record(record):
conn = get_conn()
cur = conn.cursor()
cur.execute(
"INSERT INTO history (text, score, label, pinyin, created_at) VALUES (?, ?, ?, ?, ?)",
[record["text"], record["score"], record["label"], record["pinyin"], record["created_at"]],
)
conn.commit()
conn.close()
中间那句 INSERT 就是往表中插入数据的 SQL;注意:所有的值都没有直接拼进 SQL 里,而是用 ? 占位、把值放进后面那个列表交给 execute------这就是立下的防 SQL 注入的规矩
还有个细节值得说一句:时间戳沿用 Python 生成的那一份;上一部分用的是 SQLite 自带的 datetime('now'),图的是省事;这里我们没有依赖 SQLite 自己的时间函数,而是用了 Python 生成的时间
第 5 处:get_history------三行变一句
文件版的 get_history 是 "全量读 → 倒序 → 切片" 三行:
python
def get_history(limit):
records = load_history()
records.reverse()
return records[:limit]
现在换成 SQL 版:
python
def get_history(limit):
conn = get_conn()
cur = conn.cursor()
rows = cur.execute(
"SELECT * FROM history ORDER BY created_at DESC LIMIT ?",
[limit],
).fetchall()
conn.close()
records = []
for row in rows:
records.append(dict(row))
return records
看点有四个:
-
文件版要自己动手做的三件事(全量读、自己倒序、自己切片),现在一句 SQL 全包了
-
连 LIMIT 后面那个数字都走占位符 limit;值永远走 ?,没有例外------这条规矩的价值就在于:我们不必每次都停下来判断 "这个值危不危险",一律照办就不会漏
-
末尾的 .fetchall() 讲过:凡是需要拿到数据作为结果的就要写它
-
最后那个循环里的 dict(row),可以把数据库查回来的内容直接当 JSON 回给前端
别被 fetchall 里的 "all" 骗了:这个 "all" 不是表里全部的数据,而是 SQL 语句查回来的全部数据------如果 SQL 的 limit 是 10,那么就只会 fetch 到 10 条
第 6 处:把 load_history 删掉
这个函数现在没人调用了:
python
def load_history():
try:
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return []
整个删掉------ "把整份数据读进内存" 这个动作,从今天起再也不需要了
对一下:换完的 storage.py
六处都改完,storage.py 应该长这样,对着核一遍,别漏别错:
python
# backend/storage.py
import sqlite3
DB_FILE = "history.db"
def get_conn():
conn = sqlite3.connect(DB_FILE)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_conn()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT,
score REAL,
label TEXT,
pinyin TEXT,
created_at TEXT
)
""")
conn.commit()
conn.close()
def save_record(record):
conn = get_conn()
cur = conn.cursor()
cur.execute(
"INSERT INTO history (text, score, label, pinyin, created_at) VALUES (?, ?, ?, ?, ?)",
[record["text"], record["score"], record["label"], record["pinyin"], record["created_at"]],
)
conn.commit()
conn.close()
def get_history(limit):
conn = get_conn()
cur = conn.cursor()
rows = cur.execute(
"SELECT * FROM history ORDER BY created_at DESC LIMIT ?",
[limit],
).fetchall()
conn.close()
records = []
for row in rows:
records.append(dict(row))
return records
整个文件里,再也找不到 json、open()、history.json------存储的实现,从文件彻底换成了数据库
(这份还不是最终版:这一部分结尾的 "善后" 里,还会往 init_db() 里加一行索引,到时候会把最终的样子再贴一次)
那 main.py 呢?只多两行:
storage.py 整个被换掉了,回头看 main.py------只多了两行,都是为了那个新冒出来的建表动作:
python
from storage import init_db, save_record, get_history # ← 这一行:import 里多个 init_db
app = FastAPI()
# ... CORS 等原有代码,一个字不动 ...
init_db() # ← 这一行:启动时确保表在
storage.py 里定义的函数,通过一行 import 就引入到了 main.py;另外每次启动都执行一次 init_db(),是为了确保数据库和表都在------正常情况下只有第一次运行才会真的建表,以后每次启动都会跳过
验证切换成功:
把存储从文件系统换成 SQLite 之后,还要测试来检验切换是否成功:
-
打开文字实验室,分析一句,结果照常出来(前端用的是 /api/analyze,它的路径、请求体、返回字段一个没动,前端自然毫无察觉)
-
请求 /api/history 接口,看到数据库中已经存有刚才通过文字实验室分析的那行文本
上面两步通过,也只能说明功能和此前一致(除了 /api/history 的返回里多出来一个 id 字段);如果想看一看数据确实被写进了数据库,还可以用 DB Browser 这样的可视化工具打开 backend/history.db------在 Browse Data 里,刚才那句分析应该已经躺在表里,id 也自动发好了
顺带一提:/api/history 的返回里现在多了个 id 字段,是因为 SELECT * 把 id 也带了出来------往返回里加字段并不破坏约定,所以让这个 id 继续待在返回里,下一段还会用到它
还有一件事值得说:这一部分从头到尾,我们没有打开过任何一个前端文件;存储从一个 JSON 文件换成了一个数据库------这是地板下面天翻地覆的改动,可页面上什么都没发生
这就是那句 "换芯不换壳",今天又演了一遍,而且这次演了三层:
-
/api/analyze 和 /api/history 这两个 HTTP 接口没变,所以前端毫无察觉
-
save_record() 和 get_history() 这两个函数的名字和参数没变,所以 analyze 一个字都不用改
-
storage.py 这个文件对外的样子没变,所以 main.py 只多了两行
同一件事,在接口、函数、文件三个尺度上各成立了一次;只要边界立得住,边界后面的东西就可以整个换掉------部署的时候还会再用一次这个红利,那时候要换的是 SQLite 本身
善后四件事:
其一:数据不进 Git;所以 .gitignore 中需要加一行:
backend/*.db
道理:数据是运行时产生的,每台机器、每个环境都该有自己的数据------它和代码不是一类东西
这已经是立的第三条同类规矩了:前两条是 "依赖不进"(node_modules、.venv)和 "产物不进"(out、.next);这三条合起来到底意味着什么,等把项目搬上服务器、在一台全新的机器上 git pull 完的那一刻,会看得特别清楚
其二:history.json 退役;直接删掉就可以了;真实项目里 "数据迁移" 是一门正经手艺------老的数据一条都不能丢、格式还得对上;但我们这个项目还没上线,里面的历史数据也不值钱,从零开始最干净也最省事
其三:认识一下 ORM;真实项目里很多人不直接手写 SQL,而是用 ORM(比如 Python 的 SQLAlchemy):它的做法是把表包装成 Python 对象来操作,由框架自动生成 SQL;这个东西我们没有用到(就这两句 SQL,手写反而更清楚),也不打算展开讲------在这里提出来,你知道就行
其四:给 created_at 建一个索引;讲索引时立过一条原则------经常拿来排序、或经常拿来筛选的列,才值得建索引;回头看我们这张 history:每次查历史都是 ORDER BY created_at DESC,created_at 正是那种 "天天拿来排序" 的列,这是标准的建索引场景
那就建,回到 storage.py 的 init_db(),在建表语句后面再加一句:
python
cur.execute("CREATE INDEX IF NOT EXISTS idx_history_created ON history(created_at)")
IF NOT EXISTS 还是老搭档:第一次启动的时候建,以后跳过;名字 idx_history_created 是个惯例写法------idx表名列名,一眼看得出它是给谁建的
加完这一行,init_db() 的最终样子是这样(storage.py 里其余的函数都不用再动):
python
def init_db():
conn = get_conn()
cur = conn.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT,
score REAL,
label TEXT,
pinyin TEXT,
created_at TEXT
)
""")
cur.execute("CREATE INDEX IF NOT EXISTS idx_history_created ON history(created_at)")
conn.commit()
conn.close()
改完记得让后端重启一次,索引才会真的建出来
建完去看一眼:用 DB Browser 打开 history.db,在 Database Structure 那一栏展开------history 表下面多了个 Indices,idx_history_created 就挂在那儿;之前讲 "在正表旁边多存一份按时间排好序的目录" 时它只是个比喻,现在它是一个你能点开、能看见的东西了
不过我们这张表目前数据量不大:全表扫一遍比眨眼还快,所以建了之后无法察觉速度的提升,甚至 SQLite 的优化器可能压根不会用这个索引------因为走索引还得再回表拿数据,还不如直接扫;索引真正开始省时间,得是几十万、几百万条的时候;建这个索引主要是为了感受如何为一个表创建索引;但那条原则不能忘:只给经常排序、经常筛选的列建索引,别每个字段都建------一张表挂十几个索引的事真有人干,查是快了点,写的时候能慢到让人受不了
还差一步:让历史 "分到每个人"
存储层升级完成,历史稳稳落进了 history.db;但它现在还是全站一份------所有访客的记录混在一张表里,/api/history 查的是所有用户的记录
而怎么让每个访客只看到自己的记录------这要靠 "会话",下一篇就讲它