第 4 章拆解(上)— 后端定型:换数据库、上 ORM、业务建模与 CRUD
后端定型:换数据库、上 ORM、业务建模与 CRUD
这一篇(和下一篇 07 一起)是第 4 章的拆解。 第 3 章给的是「骨架」, 这一章给它换发动机、画图纸:数据库升级、ORM 进场、项目分块、业务规则建模, 最后把 CRUD 端点写齐。主线例子:一张工单从「创建」到「完成」, 会在状态机规则、CRUD 权限矩阵和错误处理三遍重写里各走一遍。
1. 顶层全景
换数据库:SQLite → MariaDB(一次真实的、烦人的迁移)
→ 上 ORM:SQLx → SeaORM(为什么:方言差异被吃掉)
→ 分块:workspaces 五成员(backend/entity/frontend/migration/shared)
→ 建模:看板流 → 五实体 → 状态机规则 → CRUD×角色权限矩阵 → 工作流场景
→ 实现:迁移(建表/改表/默认值)→ 生成实体 → CRUD 端点 → 错误处理三遍进化
图说:每一步都是被上一步的痛点逼出来的——尤其前两步。2. 换数据库:MariaDB 与那次「吃力不讨好」的迁移
解决什么问题: SQLite 是嵌入式库,适合嵌进设备;Web 应用要的 scalability (可伸缩性)它给不了——尤其将来要横向扩容(加机器而不是加配置)时1。
SQLx 支持多库,书里选了 MySQL,并且用它的社区分支 MariaDB (Oracle 收购 MySQL 之后的开源分支,协议仍兼容,所以 MySQL 驱动照用)2。
然后书里如实演示了这次迁移有多烦。 即便应用这么小,也要动七处:
- 改
.env里的数据库 URL;2. Cargo.toml 驱动sqlite→mysql; - 代码里所有驱动引用;4. SQL 语句本身(方言不同!);
- 类型差异;6. 方法引用差异;7. SQLx 文件重跑迁移3。
书里特意给了两个安全教训:
.env绝不可以进版本库。 而且「删了也没用」——版本控制的 load diff 链接照样能在「已删除内容」里看到你的密码;真误提交了,第一时间换密码4;- 书里还坦白:选 PostgreSQL 会更难——连「返回上次插入行的 id」这种小事, 各库的原生做法都不一样(这就是第 2 章「依赖原生特性会让迁移变 tedious」的实锤)5。
这一节的结论就是 ORM 的存在理由: 把数据库方言的细节抽象掉, 「avoid such surprises, we can use an ORM」5。
3. 上 ORM:SQLx → SeaORM
ORM(Object-Relational Mapping,对象关系映射)先认名:在「程序里的结构体」和「数据库里的表」之间放一层翻译,你操作结构体,它生成 SQL。
先解释词。 ORM = Object-Relational Mapping,对象关系映射—— 在「程序里的对象/结构体」和「数据库里的表/行」之间放一层翻译, 你操作结构体,它生成 SQL。书里的提醒很实在:上 ORM 不是拨个开关, 是一次范式转移(paradigm shift),要趁早做——项目还没长大,正是换的时候6。
换装的十步(原书清单一字排开,都是实操): 去掉 SQLx 依赖(SeaORM 内部还用 SQLx,
但不用再碰);删掉 sqlx-data.json 和手写 SQL 迁移(类型安全改由实体保证,
迁移改用 SeaORM 的函数);加 SeaORM 依赖;直连驱动换成 SeaORM 的连接;
迁移换成 SeaORM 迁移;装 sea-orm-cli;用 workspaces 建两个新成员:
migration(sea-orm-cli migrate init)和 entity(放生成的实体);
改生成的迁移文件(重命名枚举、删多余列);生成实体;
最后把 CRUD 从 SQLx 宏换成 SeaORM 调用7。
实体生成命令(带完整参数,以后照抄):
sea-orm-cli generate entity -o entity/src -l --with-serde both \
--serde-skip-deserializing-primary-key
图说:-o 输出目录;-l 只生成本地 schema 对应的实体;--with-serde both
= 让实体同时可序列化/反序列化(当 DTO 用);跳过主键反序列化(主键由数据库发号)。CRUD 在 SeaORM 里的新名字: 读叫 find(返回 Option,要处理「没有」的情况);
建仍叫 insert,但要先构造一个 ActiveModel(待激活的模型——你要改哪些字段
就 Set 哪些,然后 .insert(db))8。
一个顺手的小决定: 原来的 Ticket 结构体改名 TicketDto 又改回来——
DTO 模式(数据传输对象)在这里其实不需要单独一个容器,实体直接兼任,
「differences 以后出现时再处理」9。
4. 项目分块:Cargo workspaces 五成员
时间戳(记录某刻时间的数值)与自增 id 是本章 migrations 的常客,后文直接用了。
解决什么问题: 现在项目里有 clearly distinct 的部分——后端、数据库层、迁移、 以及即将到来的前端——它们该各自有根目录、各自的依赖清单,又能一条命令一起构建。
Cargo workspaces 就是干这个的:一个项目里开发若干紧密相关的库, 各自独立、一起构建10。落地的目录结构:
.
├── Cargo.toml ← [workspace] members = [".", "backend", "entity",
│ "frontend", "migration", "shared"]
├── backend/ (src, Cargo.toml)
├── entity/ (生成的实体)
├── frontend/ (即将入场)
├── migration/(SeaORM 迁移)
└── shared/ (前后端共享代码)
图说:一个仓库、五个成员;成员之间用 path 依赖互相引用
(frontend 的 Cargo.toml 里写 shared = { path = "../shared" })。这一节还埋下了全书最响亮的卖点之一: 前后端共享代码—— 「以前得把后端的实体生成一份给前端,繁琐且容易不同步; 有了 Rust + Wasm,我们再也不需要重复任何代码」11。 下一篇(07)兑现它。
5. 业务建模:从看板流到权限矩阵
这一节是全书「业务驱动设计」的示范,值得完整走一遍。 书里声明:怎么设计业务超出本书范围,假设利益相关者已经坐下讨论过了12。
5.1 数据建模:backlog、状态与三个视图
业务的底子是看板方法论(Kanban):一个可以无限增长的有序任务队列(backlog), 按创建时间排序(升序——最老的最紧急,或降序——新的在前,都可,还可手动插队, 这叫 prioritizing)13。
任务(production 里最要紧的不是创建,而是进展)有里程碑状态。 书里先列了六态(Created / Ready / Started / Reviewing / Testing / Done / Closed), 然后产品经理视角逼出一个新发现:他们需要随时看清「在制的任务」—— process view(过程视图);而且「从队列拉进来」和「真的开工」是两回事, 于是从实践中进化出了 SELECTED 态14。
注意这四个字:evolutionary approach(演化式)。 书里明说: 模型和技术栈都会随新需求不断调整——「这是演化方法,我们会一直调」15。
三组件定型:ticket(任务容器)、backlog(有序队列)、process view(列=状态)。 流转规则:进队列取 CREATED → 被拉进 process view 取 SELECTED → 开工 STARTED → 评审过 REVIEWING → 测试过 TESTING → 完成 DONE(有截止日期压着)—— 任何时刻都可 CLOSED;经理还想看状态变更历史(于是有了 TicketUpdate 实体)16。
5.2 五实体与关系图
建模产出五个实体(属性从简,MVP 思维):
| 实体 | 属性要点 |
|---|---|
| Project | Summary、Deadline、Owner、Active |
| Ticket | Title、Description、Priority、State、Project 引用、Owner |
| TicketUpdate | Ticket 引用、PreviousState、NextState、Timestamp、Owner——状态变更日志 |
| Comment | Text、Owner、Timestamp、Ticket 引用 |
| User | Name、Password、Role(Manager/Developer) |
关系图用简化 Barker 记号:箭头末端分叉(鸦脚)表示「多」—— 一个用户能建多张票、一个项目含多张票、一张票下多条评论17。
两个建模智慧(书里点明的):
5.3 业务规则:状态机与权限矩阵
规则从讨论里「自己长出来」20:
状态机规则(每个状态的进入条件与可降级去向):
| 状态 | 规则 |
|---|---|
| CREATED | 新建自动赋值,不可覆盖 |
| SELECTED | 就绪待处理;所有 SELECTED 的票出现在 process view |
| STARTED | 手动标开工;资源收紧时可降回 SELECTED |
| REVIEWING | 实现结束手动标;评审有发现可降回 STARTED |
| TESTING | 评审过手动标;测试失败可降回 STARTED |
| DONE | 测试过手动标;不再允许更新 |
| CLOSED | 任何时刻可进入;不再允许更新 |
操作权限矩阵(角色 × CRUD):
| 操作 | Developer | Manager | 其它约束 |
|---|---|---|---|
| 建票 | 任何时刻(无条件) | 同 | 评论在处理阶段可建 |
| 看 | process 里推票 | 全流程可看讨论 | 历史视图全员开放 |
| 改 | 处理阶段改状态 | 关闭前可改票 | 历史数据谁都不能改 |
| 删 | — | 项目(不含票时)、票(无状态变更记录时)、用户 | 评论本人可删(无回复时);TicketUpdate 永远不可删 |
这套设计的自我约束(书里值得记住的理由): 数据模型对历史记录支持有限, 所以只追踪状态变更,把其余修改都限制给 manager; 早加功能有风险——加出来的功能可能没人用,而开发资源是稀缺的21。
5.4 工作流场景
书里把设计落成四个场景脚本:创建票(填表→提交→读回详情)、更新票(详情页→改→提交→读回)、 加评论(详情页→提交→刷新详情)、process view 里改状态22。 「写场景」这个动作本身就是设计文档——实现阶段会拿回来对照(这正是第 2 章 「specification should precede implementation」的落地)。
6. 主线例子走一遍:一张工单的合规之旅
开发者小李打开 process view,把票 #42 从 SELECTED 推到 STARTED
│ ① 权限检查:Developer 在处理阶段可改状态 → 放行
▼
后端收到 PUT /tickets/42,带上新状态 STARTED
│ ② 状态机检查:SELECTED → STARTED 是合法迁移 → 放行
▼
SeaORM 写入两行:票 42 的 state 改 STARTED;TicketUpdate 记一条
(prev=SELECTED, next=STARTED, ts=现在, owner=小李)
│ ③ 「历史不可改」约束由「TicketUpdate 永不可删」兜底
▼
统一 JSON 响应:200 + 更新后的票;失败则 4xx/5xx + {code,message,origin}
——后来评审有发现:REVIEWING → STARTED 降级,同样走这三关。
图说:权限矩阵、状态机、审计日志,是这张票要过的三道关。
(票号 42、人名是为演示编的;状态名与规则出自原文。)7. 实现后端:迁移 → 实体 → CRUD → 错误处理
7.1 SeaORM 迁移:Rust 文件,不是 SQL 文件
SeaORM 的迁移是普通 Rust 文件,文件名定顺序(m + 年月日 + 序号 + snake_case 名),
实现 MigrationTrait 的两个函数:up(应用时执行)和 down(回滚时执行)23。
CLI 的六个动词:fresh(删光重建)、refresh(回滚再应用)、reset(只回滚)、
status(看状态)、up、down24。
建表(users 表)的写法是链式构造:Table::create().table(User::Table).if_not_exists() .col(ColumnDef::new(User::Id).big_unsigned().not_null().auto_increment().primary_key())…;
文件底部那个 #[derive(Iden)] 枚举把「表名/列名」变成类型安全的标识符25。
外键:ForeignKey::create().name("fk-project-user_id").from(Project::Table, Project::UserId).to(User::Table, User::Id)26。
改表用另一套语法(alter_table + add_*),书里明说这是
「well documented but counter-intuitive(文档齐全但反直觉)」27。
一条铁律(红线的味道):不改已存在的迁移。 改了会让 SeaORM 的变更追踪混乱; 要改结构,就新写一条 altering migration,叠在前面的迁移上——可回滚性也保住了28。
默认值: .default("created") 给状态列一个默认值;动态值(当前时间戳)用
表达式 .default(Expr::current_timestamp())29。
验证工具: DBeaver(免费)连库,还能从现有 schema 反生成 ER 图—— 书里靠这张图当场抓到一个疏漏:ticket_update 没连上 user,补一条迁移修复30。 可视化不是装饰,是校对。
7.2 生成实体与 CRUD 接线
迁移满意后:sea-orm-cli generate entity -o entity/src -l --with-serde both ...
把库里的表翻译成 entity 成员里的 Rust 结构体31。
之后 API 和服务只跟实体打交道——对应第 2 章的分层架构:
持久层由 SeaORM 代表,不再直连数据库32。
每个资源一张路由表 + 五个端点,映射关系是死的:
| CRUD | 端点 | SeaORM 动作 |
|---|---|---|
| Create | POST /users → post_user | users::ActiveModel{}.insert(db) |
| Read all | GET /users → get_users | User::find().all(db) |
| Read one | GET /users/:id → get_user | User::find_by_id(id).one(db) |
| Update | PUT /users/:id → put_user | ActiveModel{…}.update(db) |
| Delete | DELETE /users/:id → delete_user | ActiveModel{…}.delete(db) |
7.3 错误处理的三遍进化(本章最值钱的一课)
第一遍:裸模式匹配。 每个 handler 里 match result { Ok… Err… }——
清晰,但嵌套一深就没法看(get_user 里两层 match 套 Option)33。
第二遍:map_or_else + 自定义错误类型。 给 JsonError 实现
From<(StatusCode, String)> 和 IntoResponse,然后
User::find().all(&*db).await.map_or_else(|e| JsonError::…, |list| Json(list)…)
——成查询、错误、响应一条链34。书里还给了个安全提醒:
把原始错误信息直接回给客户端,可能泄露基础设施状态;
但也强调「无论数据库什么状况,应用总能回一个标准 JSON 错误」35。
第三遍:? 早返回(early return)。 ? 运算符的意思是:
值在就解开继续,是错就当场带着错误跳出函数——前提是错误类型能被 Axum
变成响应。用 thiserror crate 派生 From、手写 IntoResponse 即可。
于是列表用户只剩两行:
let list = User::find().all(&*db).await?;
Ok(Json(list))连最复杂的 put_user 也瘦下来了:返回类型改成 Result<Json<Model>, ApiError>,
数据库错误分支全部消失,只剩「找到了就更新、找不到就 NOT_FOUND」这一件事36。
一个配套工具:WithRejection<Path<u64>, ApiError> 提取器——
把路径/JSON 的解析失败也统一翻译成你的 ApiError,客户端看到的永远是
同一种 JSON 错误37。实测效果:
http POST localhost:8000/users "name=John Sample" "password=password" "role=developer"
→ 200 OK {"id":3,"name":"John Sample",...}
错路径:{"code":"400 Bad Request","message":"Invalid URL: Cannot parse `\"a\"` to a `u64`","origin":"path_rejection"}
缺字段:{"code":"422 Unprocessable Entity","message":"Failed to deserialize ... missing field `role` ...","origin":"json_rejection"}
库挂了:{"code":"500 Internal Server Error","message":"Failed to acquire connection from pool","origin":"db_error"}
图说:三种失败,同一门语言——origin 字段标明了「这话是谁说的」。作者的态度也写明:嵌套不深时他更喜欢模式匹配的可读性; 两种写法都保证错误必然被处理,handler 没有 panic 的机会38。
8. 作者的判断与证据(分开标)
有出处的: 迁移的七处改动、SQLite→MariaDB 的选型理由、
.env 泄露的 load diff 机制,都是原书实操记录34;
SeaORM CLI 命令序列来自官方工具行为7。
作者自己的评估:
- 「趁早换 ORM」「范式转移」的判断(时机选择是经验之谈);
- 「MVP 不加没用功能」的克制哲学21;
- 模式匹配 vs map 的个人口味38;
- 「应用 rarely 换框架/库」的推论(上一章也用过同款论证)。
9. 边界与局限
- SeaORM 的学习成本被一笔带过: 「范式转移」具体苦在哪(ActiveModel 的心智模型、 关系查询的写法)本书只演示了最浅的一层;复杂查询该回退原生 SQL 的边界没展开。
- 权限矩阵还是纸面规则: 「Developer 只能改处理阶段的状态」这条, 到第 7 章(访问控制)才有机制兜底——本章只建数据约束。
- User 表里密码明文(
password字段直接存)——本章为了演示没做哈希; 第 7 章会做。别照抄到任何真实系统。 - 版本快照: sea-orm-cli 的命令参数、axum 0.6 的
Extension提取器在 新版本已演进(0.7 起推荐State),机制不变、写法要查新文档。
10. 可带走的
主线例子一行走完: 小李推票 #42 过三关(权限矩阵 → 状态机 → 审计日志);
后端对应一条 ActiveModel.update + 一条 TicketUpdate 插入,
错误路径永远落到 {code,message,origin} 的统一 JSON。
- ORM 买的是「换库自由」: 方言差异(SQL 语法、类型、last_insert_rowid) 是真实存在的迁移成本;但 ORM 也是范式转移,要在项目小时换。
- workspaces 是 Rust 多成员项目的标准切法; 前后端共享代码是它最香的应用 (不再生成两份实体)。
- 演化式建模: 状态集不是想出来的,是被「过程视图」这类需求逼出来的(SELECTED 态); 模型随需求调整,不追求一次画对。
- 建模三智慧: backlog 是视图不是表;数据模型独立于存储技术;只追踪状态变更, 其余写操作收紧给 manager。
- 迁移铁律: 永不修改已应用的迁移;要改就新写一条 alter 迁移。
- 可视化校对: 用 DBeaver 从库反生成 ER 图,当场抓疏漏。
- 错误处理进化链: 模式匹配 →
map_or_else链 →?早返回; 终点是「handler 里只剩业务,错误全部委托给框架翻译」。 - 统一错误格式要标 origin: 客户端需要知道这话是路由、参数还是数据库说的。
11. 原文地图
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| SQLite 局限与 MariaDB 选择 | Tickets, Workflows, and Business Rules | text/31-fm-setting-up-a-serious-database.txt:4(搜「horizontal scaling」)、:6(搜「MariaDB」) |
| 迁移七处改动 | 同上 | text/31-fm-setting-up-a-serious-database.txt:11(搜「Update the database URL」) |
| .env 泄露教训 | 同上 | text/31-fm-setting-up-a-serious-database.txt:41(搜「load diff」) |
| 「范式转移」与十步换装 | 同上(Setting up an ORM) | text/32-fm-setting-up-an-orm.txt:4(搜「paradigm shift」)、:7(搜「Getting rid of SQLx」) |
| 实体生成命令 | 同上 | text/32-fm-setting-up-an-orm.txt:67(搜「generate entity」)、:85(搜「with-serde」) |
| find/insert/ActiveModel | 同上 | text/32-fm-setting-up-an-orm.txt:73(搜「find」)、:76(搜「active model」) |
| workspaces 五成员 | 同上(Modularize) | text/33-fm-modularize-the-application.txt:16(搜「Cargo.lock」)、:92(搜「members」) |
| 「不再重复代码」 | 同上 | text/33-fm-modularize-the-application.txt:4(搜「duplicate」) |
| 看板与 backlog 排序 | 同上(Designing) | text/34-fm-designing-the-application.txt:10(搜「Kanban」)、:10(搜「prioritizing」) |
| 六态与新发现 | 同上 | text/34-fm-designing-the-application.txt:17(搜「Created」)、:39(搜「process view」) |
| SELECTED 与演化方法 | 同上 | text/34-fm-designing-the-application.txt:65(搜「SELECTED」)、:202(搜「evolutionary」) |
| 五实体清单 | 同上 | text/34-fm-designing-the-application.txt:75(搜「Project」)、:99(搜「TicketUpdate」) |
| 关系图与鸦脚记号 | 同上 | text/34-fm-designing-the-application.txt:145(搜「crow」) |
| backlog 不需实体 | 同上 | text/34-fm-designing-the-application.txt:148(搜「generated dynamically」) |
| 模型独立于存储 | 同上 | text/34-fm-designing-the-application.txt:158(搜「not bound to relational」) |
| 状态机规则表 | 同上 | text/34-fm-designing-the-application.txt:173(搜「CREATED: Automatically」) |
| 权限矩阵与 MVP 克制 | 同上 | text/34-fm-designing-the-application.txt:246(搜「Developer」)、:210(搜「valuable developer resources」) |
| 四场景工作流 | 同上 | text/34-fm-designing-the-application.txt:271(搜「Scenario」) |
| MigrationTrait 与六动词 | 同上(Implementing backend) | text/35-fm-implementing-the-backend.txt:10(搜「MigrationTrait」)、:27(搜「Fresh」) |
| 建表与外键 | 同上 | text/35-fm-implementing-the-backend.txt:82(搜「create_table」)、:280(搜「foreign_key」) |
| alter_table 反直觉 | 同上 | text/35-fm-implementing-the-backend.txt:307(搜「counter-intuitive」) |
| 不改已应用的迁移 | 同上 | text/35-fm-implementing-the-backend.txt:365(搜「do not change an existing migration」) |
| 默认值与 current_timestamp | 同上 | text/35-fm-implementing-the-backend.txt:391(搜「default(«created»)」)、:403(搜「current_timestamp」) |
| DBeaver 反生成 ER 图抓疏漏 | 同上 | text/35-fm-implementing-the-backend.txt:409(搜「DBeaver」)、:419(搜「shortcoming」) |
| CRUD 映射表 | 同上 | text/35-fm-implementing-the-backend.txt:526(搜「ActiveModel」)、:646(搜「Mapping CRUD」) |
| map_or_else 第二遍 | 同上 | text/35-fm-implementing-the-backend.txt:760(搜「map_or_else」) |
| 错误泄露基础设施的提醒 | 同上 | text/35-fm-implementing-the-backend.txt:862(搜「sensitive information」) |
| ? 早返回与 thiserror | 同上 | text/35-fm-implementing-the-backend.txt:874(搜「question mark」)、:880(搜「all(&*db).await?」) |
| WithRejection 与 origin 实测 | 同上 | text/35-fm-implementing-the-backend.txt:784(搜「WithRejection」)、:1117(搜「db_error」)、:1135(搜「path_rejection」) |
| HTTPie 测试 | 同上 | text/35-fm-implementing-the-backend.txt:1009(搜「http POST」) |
| 章节结论 | 同上(Conclusion) | text/38-fm-conclusion.txt:1(搜「Conclusion」) |
7.4 迁移一节的三条命令账与一张铁律
SeaORM 迁移的命令全集(本章实际用到的子集):39
sea-orm-cli migrate generate create_user_table # 生成 m<日期>_<序号>_create_user_table.rs
sea-orm-cli migrate up # 应用未跑的迁移(调 up 方法)
sea-orm-cli migrate down # 只回滚(调 down 方法)
sea-orm-cli migrate fresh # 删光所有表、重放全部迁移
sea-orm-cli migrate refresh # 回滚全部 → 再重放
sea-orm-cli migrate reset # 只回滚,不重放
sea-orm-cli migrate status # 报告每个迁移的状态
图说:文件名(m+年月日+序号)是顺序契约;六个动词覆盖「应用/回滚/重建」全谱。「不改已应用迁移」的完整理由(原书注释级): 改了会让 SeaORM 的变更 追踪混乱——它按文件名记录「谁跑过了」;「要改结构,就新写一条 altering migration,叠在前者之上。这样这些改动也可以被回滚。」40
建表代码与生成 SQL 的对照(书里给了逐行映射,值得接住):迁移源码的
Table::create().table(User::Table).if_not_exists().col(ColumnDef::new(User::Id) .big_unsigned().not_null().auto_increment().primary_key())… 落到库里的 DDL 是
CREATE TABLE IF NOT EXISTS users (id bigint(20) unsigned NOT NULL AUTO_INCREMENT, name varchar(255) NOT NULL, … PRIMARY KEY (id))——源代码每一行都有 SQL
回声;底部的 #[derive(Iden)] 枚举把「表名/列名」变成类型安全标识符,
跨迁移引用时编译器帮你查拼写41。
外键与改表的两套语法( again,反直觉是原话):
- 建表时挂外键:
ForeignKey::create().name("fk-project-user_id").from(Project::Table, Project::UserId).to(User::Table, User::Id)——一次 fluent 调用; - 改表时挂外键:
alter_table+add_column+add_foreign_key, 「对照建表那两行看,同样的目标写法完全不同——文档齐全但反直觉」; - 默认值两法:静态串
.default("created");动态值(当前时间).default(Expr::current_timestamp())42。
7.5 DBeaver 反生成 ER 图那次抓虫(可视化的价值实证)
迁移全跑通之后,书里做了一步「肉眼验收」:用 DBeaver 连库,从现有 schema 反生成 ER 图——「和我们最初的设计图高度相似,除了 project 与 user 之间多出来的归属关系(我们后来加的)」;然后当场抓到一个 疏漏:ticket_update 没连上 user。「刚受益于 DBeaver 生成的可视化, 它帮我们抓到了迁移的一处短板」——补一条迁移修复。这个插曲是 「图表不是文档装饰,是校对工具」的最佳广告43。
7.6 端点测试的一页账(HTTPie 实录)
没有前端的阶段,端点全靠命令行验收;书里把 curl 升级成了 HTTPie
(http POST localhost:8000/users "name=John Sample" "password=password" "role=developer" --verbose),三个理由:语法像表单(key=value)、
输出自动美化、verbose 模式把请求/响应头体全摆开。实录里的两个回应
值得存样:44
200 OK → {"id":3,"name":"John Sample","password":"password","role":"developer"}
500 → {"code":"500 Internal Server Error","message":"Failed to acquire connection from pool","origin":"db_error"}
400 → {"code":"400 Bad Request","message":"Invalid URL: Cannot parse `\"a\"` to a `u64`","origin":"path_rejection"}
422 → {"code":"422 Unprocessable Entity","message":"Failed to deserialize ... missing field `role` ...","origin":"json_rejection"}「这些消息来自合适的当事方,我们不必显式指定它们」——这句是
? 早返回 + WithRejection 组合的价值总结:错误文案是各层自己说的
(路由层说路径、提取器说 JSON、数据库说连接),handler 只管业务45。
5.5 建模决策的「演化式」证据链(本章三次回头)
第 4 章的建模不是一次画对的,书里自己留下了三次回头的脚印:46
- SELECTED 态的诞生——process view 的讨论发现「拉进来」≠「开工」, 「这是演化方法,我们会随新需求持续调整模型与技术栈」;
- user_id 外键——DBeaver 的 ER 图抓到 ticket_update 漏连 user, 「补一条迁移」;
- TicketDto 的可空字段——第 4 章(本组 07 篇)把实体的 id/password
改成
Option,「新建没有 id、密码不回传」。
同一章内的双重一致性: 业务模型(状态机/权限矩阵)与数据模型 (五实体/关系图)互相校核——「约束该加强的地方,模型里都有落点」 (删票规则 ←→ TicketUpdate 只增;权限矩阵 ←→ Role 枚举)47。
3.6 ORM 换装的真实账单(十条清单的逐条代价)
第 3 节引了原书的十步清单,这里给每一步配上「不做会怎样」:48
| 步 | 动作 | 不做的代价 |
|---|---|---|
| 1 | 去 SQLx 依赖 | SeaORM 底下仍用 SQLx,留着会双头管理 |
| 2 | 删 sqlx-data.json 与 SQL 迁移 | 两套真相并存,编译期核对用错版本 |
| 3 | 加 SeaORM 依赖 | — |
| 4 | 直连驱动换 SeaORM 连接 | 连接方言差异回到代码里(第 2 章换库的七处之苦重演) |
| 5 | 迁移换 SeaORM 版 | SQL 手写迁移与实体可能漂移 |
| 6 | 装 sea-orm-cli | 实体与迁移无法生成 |
| 7 | workspaces 加 migration/entity 成员 | 迁移代码与业务代码混居一 crate |
| 8 | 改生成迁移文件(重命名枚举、删列) | 样例迁移建的表与需求不符 |
| 9 | 生成实体(--with-serde both) | 前后端共享 DTO 时没有可序列化的实体 |
| 10 | CRUD 改 SeaORM 调用 | SQLx 宏与 ORM 并存的混乱期 |
「范式转移」具体苦在哪(书里一句带过、本章处处可见):ActiveModel
不是「结构体 + 值」而是「结构体 + 哪些字段要变」(Set/NotSet);
find 返回 Option 逼你处理「没有」;关系查询要绕 entity 的关联定义。
好处在第 4 章兑现:换 MariaDB 时,这十条已经付过一遍,不再付49。
10.1 可带走的(补两条)
- 迁移六动词记全: up/down/fresh/refresh/reset/status——「改结构就新写一条 alter 迁移,永不改已应用的」。
- 可视化校对是建模流程的一部分: DBeaver 反生成 ER 图,抓到了 ticket_update 漏连 user 的疏漏——「图不是装饰,是校对」。
8. 本篇卷尾(一句话版)
后端半章的全部贡献,是把「业务怎么建模、数据怎么流、错误怎么归一」 三件事定型成可复用的形状;前端半章(07)补上图与合同。两章合卷, 第 4 章的「从骨架到全栈」宣告完成。50
8. 本篇卷尾补记(后端验收单)
后端定型收卷前四问:迁移可回滚吗?实体可重新生成吗?错误必有出处吗? handler 里没有 panic 路径吗?四问全「是」,才算 right。51
Footnotes
-
出处:「Setting up a serious database」(
text/31-fm-setting-up-a-serious-database.txt:4,搜「horizontal scaling」)。 ↩ -
出处:「Setting up a serious database」(
text/31-fm-setting-up-a-serious-database.txt:4,搜「MariaDB」;:4,搜「community fork」)。 ↩ -
出处:「Setting up a serious database」(
text/31-fm-setting-up-a-serious-database.txt:11,搜「Update the database URL」;:22,搜「different dialects」)。 ↩ ↩2 -
出处:「Setting up a serious database」(
text/31-fm-setting-up-a-serious-database.txt:41,搜「load diff」)。原文:「Deleting an .env file that already contains sensitive information is not enough.」 ↩ ↩2 -
出处:「Setting up a serious database」(
text/31-fm-setting-up-a-serious-database.txt:44,搜「tedious task」;:44,搜「avoid such surprises」)。 ↩ ↩2 -
出处:「Setting up an ORM」(
text/32-fm-setting-up-an-orm.txt:4,搜「paradigm shift」)。 ↩ -
出处:「Setting up an ORM」(
text/32-fm-setting-up-an-orm.txt:10,搜「Getting rid of SQLx」;:40,搜「sea-orm-cli」;:46,搜「migrate init」)。 ↩ ↩2 -
出处:「Setting up an ORM」(
text/32-fm-setting-up-an-orm.txt:73,搜「find」;:76,搜「active model」)。 ↩ -
出处:「Setting up an ORM」(
text/32-fm-setting-up-an-orm.txt:82,搜「DTO」;:86,搜「simplify our code」)。 ↩ -
出处:「Modularize the application」(
text/33-fm-modularize-the-application.txt:10,搜「workspaces」)。 ↩ -
出处:「Modularize the application」(
text/33-fm-modularize-the-application.txt:4,搜「duplicate code」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:10,搜「way beyond the scope」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:10,搜「Kanban」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:39,搜「process view」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:202,搜「evolutionary approach」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:65,搜「pulled into the implementation phase」)、:65(搜「how the status of a ticket was changed over time」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:145,搜「crow」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:148,搜「generated dynamically」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:158,搜「not bound to relational databases」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:169,搜「manifest from the preceding discussion」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:210,搜「waste valuable developer resources」)。 ↩ ↩2 -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:274,搜「User clicks a button」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:10,搜「MigrationTrait」;:14,搜「Up」;:197,搜「Fixed marker」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:27,搜「Fresh」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:82,搜「create_table」;:163,搜「Iden」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:280,搜「foreign_key」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:307,搜「counter-intuitive」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:365,搜「do not change an existing migration」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:391,搜「default(«created»)」;:403,搜「Expr::current_timestamp」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:409,搜「DBeaver」;:419,搜「catch a shortcoming」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:434,搜「generate entity」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:428,搜「layered architecture」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:679,搜「match param」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:760,搜「map_or_else」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:862,搜「reveal sensitive information」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:874,搜「question mark」;:880,搜「await?」;:910,搜「Result<Json」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:784,搜「WithRejection」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:865,搜「more readable」;:865,搜「give no chance to our handler to panic」)。 ↩ ↩2 -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:27,搜「Fresh」)、:46(搜「sea-orm-cli migrate」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:365,搜「do not change an existing migration」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:82,搜「create_table」)、:244(搜「CREATE TABLE IF NOT EXISTS」)、:268(搜「corresponds」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:280,搜「foreign_key」)、:307(搜「counter-intuitive」)、:391(搜「default(«created»)」)、:403(搜「Expr::current_timestamp」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:409,搜「DBeaver」)、:419(搜「catch a shortcoming」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:1003,搜「HTTPie」)、:1009(搜「http POST」)、:1114(搜「Failed to acquire connection」)。 ↩ -
出处:「Implementing the backend」(
text/35-fm-implementing-the-backend.txt:1123,搜「appropriate stakeholders」)。 ↩ -
出处:「Designing the application」(
text/34-fm-designing-the-application.txt:202,搜「evolutionary approach」);「Implementing the backend」(text/35-fm-implementing-the-backend.txt:419,搜「shortcoming」)。 ↩ -
双重一致性为我们的归纳;两侧原文各见 text/34 的权限矩阵段与 text/35 的迁移段。 ↩
-
十步清单见「Setting up an ORM」(
text/32-fm-setting-up-an-orm.txt:10,搜「Getting rid of SQLx」)。 ↩ -
ActiveModel 语义出处:「Setting up an ORM」(
text/32-fm-setting-up-an-orm.txt:76,搜「active model」);换库之苦见text/31-fm-setting-up-a-serious-database.txt:11。 ↩ -
卷尾为我们的归纳;三件事见本篇 §5、§6、§7。 ↩
-
验收单为我们的产品化整理;四问对应本篇 §7.1–§7.3。 ↩