跳转至

后端定型:换数据库、上 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。

然后书里如实演示了这次迁移有多烦。 即便应用这么小,也要动七处:

  1. 改 .env 里的数据库 URL;2. Cargo.toml 驱动 sqlite → mysql;
  2. 代码里所有驱动引用;4. SQL 语句本身(方言不同!);
  3. 类型差异;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。

两个建模智慧(书里点明的):

  • backlog 不需要实体——它是动态视图,随时能从现有数据查出来18;
  • 数据模型不绑定关系库——这么建模,底下换 MariaDB 还是 MongoDB 都成立, 「技术决定可以后置」19。

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。

  1. ORM 买的是「换库自由」: 方言差异(SQL 语法、类型、last_insert_rowid) 是真实存在的迁移成本;但 ORM 也是范式转移,要在项目小时换。
  2. workspaces 是 Rust 多成员项目的标准切法; 前后端共享代码是它最香的应用 (不再生成两份实体)。
  3. 演化式建模: 状态集不是想出来的,是被「过程视图」这类需求逼出来的(SELECTED 态); 模型随需求调整,不追求一次画对。
  4. 建模三智慧: backlog 是视图不是表;数据模型独立于存储技术;只追踪状态变更, 其余写操作收紧给 manager。
  5. 迁移铁律: 永不修改已应用的迁移;要改就新写一条 alter 迁移。
  6. 可视化校对: 用 DBeaver 从库反生成 ER 图,当场抓疏漏。
  7. 错误处理进化链: 模式匹配 → map_or_else 链 → ? 早返回; 终点是「handler 里只剩业务,错误全部委托给框架翻译」。
  8. 统一错误格式要标 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

  1. SELECTED 态的诞生——process view 的讨论发现「拉进来」≠「开工」, 「这是演化方法,我们会随新需求持续调整模型与技术栈」;
  2. user_id 外键——DBeaver 的 ER 图抓到 ticket_update 漏连 user, 「补一条迁移」;
  3. 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 可带走的(补两条)

  1. 迁移六动词记全: up/down/fresh/refresh/reset/status——「改结构就新写一条 alter 迁移,永不改已应用的」。
  2. 可视化校对是建模流程的一部分: DBeaver 反生成 ER 图,抓到了 ticket_update 漏连 user 的疏漏——「图不是装饰,是校对」。

8. 本篇卷尾(一句话版)

后端半章的全部贡献,是把「业务怎么建模、数据怎么流、错误怎么归一」 三件事定型成可复用的形状;前端半章(07)补上图与合同。两章合卷, 第 4 章的「从骨架到全栈」宣告完成。50

8. 本篇卷尾补记(后端验收单)

后端定型收卷前四问:迁移可回滚吗?实体可重新生成吗?错误必有出处吗? handler 里没有 panic 路径吗?四问全「是」,才算 right。51


  1. 出处:「Setting up a serious database」(text/31-fm-setting-up-a-serious-database.txt:4,搜「horizontal scaling」)。 ↩

  2. 出处:「Setting up a serious database」(text/31-fm-setting-up-a-serious-database.txt:4,搜「MariaDB」;:4,搜「community fork」)。 ↩

  3. 出处:「Setting up a serious database」(text/31-fm-setting-up-a-serious-database.txt:11,搜「Update the database URL」;:22,搜「different dialects」)。 ↩↩

  4. 出处:「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.」 ↩↩

  5. 出处:「Setting up a serious database」(text/31-fm-setting-up-a-serious-database.txt:44,搜「tedious task」;:44,搜「avoid such surprises」)。 ↩↩

  6. 出处:「Setting up an ORM」(text/32-fm-setting-up-an-orm.txt:4,搜「paradigm shift」)。 ↩

  7. 出处:「Setting up an ORM」(text/32-fm-setting-up-an-orm.txt:10,搜「Getting rid of SQLx」;:40,搜「sea-orm-cli」;:46,搜「migrate init」)。 ↩↩

  8. 出处:「Setting up an ORM」(text/32-fm-setting-up-an-orm.txt:73,搜「find」;:76,搜「active model」)。 ↩

  9. 出处:「Setting up an ORM」(text/32-fm-setting-up-an-orm.txt:82,搜「DTO」;:86,搜「simplify our code」)。 ↩

  10. 出处:「Modularize the application」(text/33-fm-modularize-the-application.txt:10,搜「workspaces」)。 ↩

  11. 出处:「Modularize the application」(text/33-fm-modularize-the-application.txt:4,搜「duplicate code」)。 ↩

  12. 出处:「Designing the application」(text/34-fm-designing-the-application.txt:10,搜「way beyond the scope」)。 ↩

  13. 出处:「Designing the application」(text/34-fm-designing-the-application.txt:10,搜「Kanban」)。 ↩

  14. 出处:「Designing the application」(text/34-fm-designing-the-application.txt:39,搜「process view」)。 ↩

  15. 出处:「Designing the application」(text/34-fm-designing-the-application.txt:202,搜「evolutionary approach」)。 ↩

  16. 出处:「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」)。 ↩

  17. 出处:「Designing the application」(text/34-fm-designing-the-application.txt:145,搜「crow」)。 ↩

  18. 出处:「Designing the application」(text/34-fm-designing-the-application.txt:148,搜「generated dynamically」)。 ↩

  19. 出处:「Designing the application」(text/34-fm-designing-the-application.txt:158,搜「not bound to relational databases」)。 ↩

  20. 出处:「Designing the application」(text/34-fm-designing-the-application.txt:169,搜「manifest from the preceding discussion」)。 ↩

  21. 出处:「Designing the application」(text/34-fm-designing-the-application.txt:210,搜「waste valuable developer resources」)。 ↩↩

  22. 出处:「Designing the application」(text/34-fm-designing-the-application.txt:274,搜「User clicks a button」)。 ↩

  23. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:10,搜「MigrationTrait」;:14,搜「Up」;:197,搜「Fixed marker」)。 ↩

  24. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:27,搜「Fresh」)。 ↩

  25. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:82,搜「create_table」;:163,搜「Iden」)。 ↩

  26. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:280,搜「foreign_key」)。 ↩

  27. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:307,搜「counter-intuitive」)。 ↩

  28. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:365,搜「do not change an existing migration」)。 ↩

  29. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:391,搜「default(«created»)」;:403,搜「Expr::current_timestamp」)。 ↩

  30. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:409,搜「DBeaver」;:419,搜「catch a shortcoming」)。 ↩

  31. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:434,搜「generate entity」)。 ↩

  32. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:428,搜「layered architecture」)。 ↩

  33. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:679,搜「match param」)。 ↩

  34. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:760,搜「map_or_else」)。 ↩

  35. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:862,搜「reveal sensitive information」)。 ↩

  36. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:874,搜「question mark」;:880,搜「await?」;:910,搜「Result<Json」)。 ↩

  37. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:784,搜「WithRejection」)。 ↩

  38. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:865,搜「more readable」;:865,搜「give no chance to our handler to panic」)。 ↩↩

  39. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:27,搜「Fresh」)、:46(搜「sea-orm-cli migrate」)。 ↩

  40. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:365,搜「do not change an existing migration」)。 ↩

  41. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:82,搜「create_table」)、:244(搜「CREATE TABLE IF NOT EXISTS」)、:268(搜「corresponds」)。 ↩

  42. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:280,搜「foreign_key」)、:307(搜「counter-intuitive」)、:391(搜「default(«created»)」)、:403(搜「Expr::current_timestamp」)。 ↩

  43. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:409,搜「DBeaver」)、:419(搜「catch a shortcoming」)。 ↩

  44. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:1003,搜「HTTPie」)、:1009(搜「http POST」)、:1114(搜「Failed to acquire connection」)。 ↩

  45. 出处:「Implementing the backend」(text/35-fm-implementing-the-backend.txt:1123,搜「appropriate stakeholders」)。 ↩

  46. 出处:「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」)。 ↩

  47. 双重一致性为我们的归纳;两侧原文各见 text/34 的权限矩阵段与 text/35 的迁移段。 ↩

  48. 十步清单见「Setting up an ORM」(text/32-fm-setting-up-an-orm.txt:10,搜「Getting rid of SQLx」)。 ↩

  49. 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。 ↩

  50. 卷尾为我们的归纳;三件事见本篇 §5、§6、§7。 ↩

  51. 验收单为我们的产品化整理;四问对应本篇 §7.1–§7.3。 ↩