编程语言研究书库

第 3 章拆解 — 应用骨架:从一行路由到一个可维护的最小全栈

应用骨架:从一行路由到一个可维护的最小全栈

这一章讲一件事:把一个玩具 API 养成「像一个正经项目」的最小全栈,要补哪些课。 四段:先讲代码怎么摆(八种组织方案);再把 API 从头长到尾——路由、JSON、数据库、 错误格式、模块化重构(主线例子);然后临时拉一个 JS 前端顶上,讲清前后端联调时 必然撞见的同源策略和 CORS;最后补上两个工业界地基——数据库迁移和持续集成。 读完你会有一个能给别人看的项目骨架,以及「为什么这样拆文件」的完整理由。

1. 这一章在全书链条里的位置

第 2 章选了型(客户端-服务器 + Axum + SQLite + 日后再上 ORM)。 第 3 章兑现这个选型,但刻意选「最小」路线:先把一个只有两个端点的 API 写对、 写整洁、写上 CI——骨架结实了,第 4 章才往上面盖业务。 书里给这一路定了个调子,是著名的软件工程三步:

Make it work. Make it right. Make it fast.1

——先能跑,再做对,最后才求快。本章全部力气花在第二步。

2. 顶层全景

  代码组织:八种切法 + C4 模型(先想清楚文件怎么摆)
    ↓
  主线例子 minimalist-api 的生长史
    两条硬编码路由 → JSON 序列化 → 接 SQLite(SQLx)→ 统一错误格式 → 按职责拆文件
    ↓
  临时前端:Vite/SolidJS 四件套 + 同源策略与 CORS + 双 dev server 并行
    ↓
  工业界地基:数据库迁移(可复现)+ 持续集成(Rust/JS/安全审计三条流水线)

  图说:先摆对文件,再长对代码,再接上前端,最后把「可复现」和「自动化」钉死。

3. 代码组织:八种切法,没有唯一答案

解决什么问题: 文件和文件夹怎么摆,就是代码组织;它是架构的最后一个视角(第 2 章)。 书里摆了八种方案,每种一句话说清「按什么切」2:

切法按什么分例子
按架构切分层架构的角色Presentation/Controllers、Business/Services、Persistence/Repositories
内外分离能不能被外部触达Core(内部)+ Infrastructure(对外的 port/adapter)
客户端-服务器分离两端各自独立成目录Backend/ + Frontend/
按 MVC 角色Model / View / Controller 三个文件夹
按功能切(feature-based)功能领域购物车功能、下单功能——和功能边界一一对应的切法
按模式切(pattern-based)设计模式名Domain、Repository(TicketRepository)、Service(TicketService)、DTO
按职能切(function-based)横切的公共职能Error(ConnectionError/ValidationError)、Form、Validation
按应用模块切不同职责的大块Website / SPA / Admin interface / REST API / Batches

按模块切的那一节有一个重要警告: 模块之间如果共用代码,要把共用代码搬进 独立的共享库,绝不允许模块 A 伸手进模块 B 里拿——否则模块性就是假的; 模块长大以后可以整体挪成独立项目,但前提是低耦合保持住了3。

C4 模型(Simon Brown 提出): 给上面所有切法一个统一的变焦镜头—— 四个 C 是四种放大级别,每个级别的「构件块」可以包含下一级的构件块4:

级别看什么例子
System context你处在什么业务里IT 项目管理
Container可独立部署的模块一个 SPA + 一个 REST 后端
Component容器内的主要功能件controller 等常规构件
Code最小的语言构造组一个文件、一个 trait 实现

书里给的总提醒: 没有「终极目录结构」;只用「刚好够把文件区分开」的组织量—— 提前把所有可能目录都建好,只会收获一堆被版本控制无视的空文件夹5。 结构跟不上代码生长时,用重构把它扳回来,关键是避免重复和紧耦合6。

4. 主线例子:minimalist-api 的生长史

这一节是本章的承重墙:一个真实项目从零长到「可维护」的每一步,都在这儿走一遍。 每一步都有原书给的命令、代码或输出。

第 ① 步:骨架——两条路由

cargo new minimalist-api,依赖照旧是 axum + tokio。 先写能跑通的最小版:/tickets 挂两个 handler,GET 返回 "GET Tickets", POST 返回 "POST Tickets"7。

  curl 127.0.0.1:8000/tickets         → GET Tickets
  curl 127.0.0.1:8000/tickets --data  → POST Tickets
                                                (--data 让 curl 发 POST)

  图说:路由表 "/" + get/post(handler) 是 Axum 的最小骨架,第 1 章见过,这里换了真资源名。

第 ② 步:接 JSON——Serde 登场

定义数据模型:Ticket { id: i64, title: String }, 给它挂上 #[derive(Serialize, Deserialize)]——这两个 trait 来自 Serde, 效果是「这个结构体能变成 JSON、也能从 JSON 变回来」。

  • GET 改成返回 response::Json(Ticket { id: 1, title: "New Ticket" }): 响应体会自动序列化成 JSON,Content-Type 头自动设为 application/json8;
  • POST 用提取器接输入:async fn post_tickets(Json(ticket): Json<Ticket>)—— Axum 看到 handler 参数里有 Json<Ticket>,会帮你把请求体反序列化好递进来9。

第一次撞墙(故意演示的): 不带 Content-Type: application/json 头发请求, 会得到一条错误:Expected request with 'Content-Type: application/json'—— 提取器在 handler 跑起来之前就把关了10。正确的 curl:

  curl --header "Content-Type: application/json" \
       --data '{"id":1, "title":"Hello from the other side"}' \
       127.0.0.1:8000/tickets
  → {"id":1,"title":"Hello from the other side"}

第 ③ 步:开发体验——格式化和热重载

书里先补两件「舒服」的事,再动数据库:

  • 保存即格式化: VS Code 设置里给 Rust 指定 editor.formatOnSave: true 并默认用 rust-analyzer 当 formatter;再把 rust-analyzer.check.command 从 check 改成 clippy——保存文件时自动排版 + 自动 lint11;
  • 热重载: cargo install cargo-watch 之后, cargo watch -w src -x run——盯 src 目录,一变就重编译重跑; 加 -c 每次先清屏12。这是 Rust 世界对「别的语言的热重载」的标准答案。

第 ④ 步:接数据库——SQLx、连接池、.env

目标:把收到的工单真的存下来、随时取得出。 端点定型为两个: GET /tickets/:id(取一个)和 POST /tickets(存一个)13。

依赖加一行:

sqlx = { version = "0.6", features = ["runtime-tokio-rustls", "sqlite", "macros"] }

三个 feature 逐个解释:runtime-tokio-rustls——用 Tokio 当异步运行时、 用 rustls 加密连接(「能不离开 Rust 就不离开」);sqlite——驱动选 SQLite, 零配置,而且「后面容易换」;macros——不是必需,但让代码更好读14。

连接池(connection pool): 不建单条连接,直接建池:

let pool = SqlitePoolOptions::new()
    .max_connections(5)                 // 池里最多养 5 条连接
    .connect(&database_url)
    .await
    .unwrap();                          // 连不上库,程序就该当场死——fail fast

书里给了理由:数据库连接不该成为瓶颈;连接是异步建立的; 连不上就没有启动应用的意义,所以这里 unwrap(让程序 panic)是合理的15。

配置从哪来: 加 dotenvy crate,从环境变量(操作系统级的键值配置)或 .env 文件读 DATABASE_URL。 用 anyhow 的 .context("DATABASE_URL must be set") 给 panic 补上上下文—— 裸的 dotenvy::var 报错时不会告诉你缺的是哪个变量16。

池子怎么进 handler: 路由上挂一层 .layer(Extension(pool))—— 之后每个 handler 都能把 Extension<SqlitePool> 当参数要,框架自动注入17。

第 ⑤ 步:写查询——宏检查的 SQL

取工单(handler 签名进化): async fn get_tickets(pool: Extension<SqlitePool>, Path(id): Path<i64>) -> impl IntoResponse

  • Path(id): Path<i64>:路径提取器。/tickets/:id 里的那段会被解析成 i64;解析不了请求直接被拒——「没有 id,就没有票」18;
  • 查询本身:
let result = sqlx::query_as!(Ticket, "SELECT * FROM tickets WHERE id = $1", id)
    .fetch_one(&*pool)
    .await;

query_as! 是 SQLx 的宏:它在编译期把你的 SQL 拿去跟真实数据库核对 ——表存在吗?列名对吗?类型对得上吗?核对不过,编译就报错。 这就是第 4 节「六军规」里 SQLx 那条「编译期检查查询、无 DSL 附加层」的含义19。

错误要统一成 JSON。 查不到(id 不存在)时,第一版直接返回 (StatusCode::NOT_FOUND, "Not Found")——纯文本,客户端会懵; 书里升级成带 Content-Type: application/json 的 JSON 错误体:

struct JsonError { code: String, message: String }
// 响应:({"code":"404 Not Found","message":"no rows returned by a query ..."},)

实测:curl 127.0.0.1:8000/tickets/4 返回

  {"code":"404 Not Found","message":"no rows returned by a query that expected to return at least one row"}

且 -v 能看到 content-type: application/json 确实设对了20。

写(insert): sqlx::query!("INSERT INTO tickets (title) VALUES ($1)", ticket.title), 成功后结果里有 last_insert_rowid()——数据库自增出来的真 id, 把它包进 Ticket 返回给客户端21。实测细节值得记住:

  发:{"id":0, "title":"Hello from the other side"}
  收:{"id":1,"title":"Hello from the other side"}
  ——发 0 收 1 是正常的:程序故意忽略客户端给的 id,数据库自增说了算。

  图说:幂等地「以服务器为准」——客户端撒谎不影响数据。

第 ⑥ 步:错误格式的最后一课——422 与统一错误函数

再试一个边界:POST 一个 {}(缺字段)。Axum 默认会回 422 Unprocessable Entity 加一段纯文本:Failed to deserialize the JSON body into the target type: missing field 'id'... ——状态码对,格式又不对了22。

Axum 允许把提取器包成 Result<Json<Ticket>, JsonRejection> 自己处理; 但「好几个提取器组合、还要统一响应」时,匹配逻辑会迅速复杂。 书里的折中:接受包 Result,再抽一个统一错误函数:

fn uniform_error(code: StatusCode, message: String) -> impl IntoResponse {
    (code,
     [(header::CONTENT_TYPE, HeaderValue::from_static("application/json"))],
     json!(JsonError { code: code.to_string(), message }).to_string())
}

定制提取器(把每个内置提取器都包一层、保证任何错误都统一响应)被点名是 「advanced option」,现在做就是 overkill(过度设计)——先记住这个名字,第 4 章还会遇到 「early return of errors」这个更顺手的方案23。

第 ⑦ 步:按职责拆文件——bootstrap 原则

现在代码能跑了,但主文件臃肿、错误处理重复。书里的重构原则: 文件要 thin(薄),最优是一个文件一个目的;main 文件的存在理由只有一个: 启动应用。所以划一条线:「启动应用所需的一切」留在 main; 其余一切,搬出去24。落地成三个文件:

  src/
  ├── main.rs        ← bootstrap:读配置、建连接池、调 api::serve(pool)
  ├── api.rs         ← serve():起服务器;router():并路由、挂 layer
  │   ├── error.rs   ← JsonError + to_uniform_response()
  │   └── tickets.rs ← Ticket 结构体 + 两个 handler + 本资源路由表

  图说:错误、启动、资源各归其位;新增资源 = api/ 下新增一个文件 + 一行 merge。

api.rs 里的 router() 干一件事:Router::new().merge(tickets::router()).layer(Extension(db)) ——资源各自带路由表,顶层只负责合并;以后加 users,就是 api/users.rs + merge(users::router())25。main 瘦成了 18 行。

最后留了一个「想过但不做」的决定: 把 API 组件和 axum 解耦(抽象掉框架依赖、 以便将来换框架)。书里的理由很实在:现实中没人换写好了应用的框架,所以不值得26。 ——「可替换性」也是一种成本,按六军规的「合目的」一项,这笔不买。

第 ⑧ 步:数据库本体

touch dev.sqlite 建空文件,SQLite Browser 打开,执行:

CREATE TABLE "tickets" (
  "id"    INTEGER NOT NULL UNIQUE,
  "title" TEXT    NOT NULL,
  PRIMARY KEY("id" AUTOINCREMENT)
)

AUTOINCREMENT:id 从 1 起自动递增——这就是第 ⑤ 步「发 0 收 1」的来源27。

书里特意加了注释:这不是建表的业界标准做法。 手动操作缺两个性质: 可追溯性(traceability)和可复现性(reproducibility)——下一个开发者怎么知道 该有哪些表?正确答案是迁移,马上就到28。

5. 临时前端:一次故意的「离开 Rust」

解决什么问题: API 已经能用 curl 调了,但「别人得自己写客户端才能看内容」。 书里做了一个刻意的安排:先用一版 JavaScript 前端顶上(因为它成熟), 正式 Rust 前端(Yew)留到第 4 章29。

技术栈四件套:Vite(极速打包/热更新)、Tailwind CSS(utility-first 的 CSS 写法)、 daisyUI(Tailwind 上的预置组件和主题)、SolidJS(类 React 体验、 但没有状态层层传递的麻烦)30。不加 TypeScript,省去导出类型的麻烦31。

5.1 同源策略与 CORS:前后端联调的第一道墙

先解释词。 浏览器有一条安全规矩叫同源策略(same-origin policy): 网页 A 里的脚本,不能读网页 B 的数据——防的是「你开着银行页面时, 恶意网站冒你的名义发请求偷数据」。「源(origin)」= 协议 + 域名 + 端口, 任何一个不同就算跨源32。

问题来了:开发时后端在 8000 端口,前端 Vite 自己的 dev server 在 5173—— 不同端口 = 不同源,浏览器会把前端的请求拦下33。

解法:CORS(Cross-Origin Resource Sharing,跨源资源共享)。 思路翻转:同源策略默认全拒;服务器可以在响应里加 access-control-allow-origin 头显式声明「我接受谁来」。 Axum 有现成中间件:

let cors = CorsLayer::new()
    .allow_methods([Method::GET, Method::POST])
    .allow_headers(Any)
    .allow_origin(Any);          // 开发期:接受任何来源(通配)
// ...
Router::new().merge(tickets::router())
    .layer(Extension(db))
    .layer(cors)

中间件的意思是:它拦下所有出站的响应,统一补上那个头34。 生产环境的提醒(书里注释): 上线后不走 Vite dev server, 但前后端仍可能在不同域名——那时要双向配置,让两头知道对方的地址35。

5.2 两个 dev server,一键并起

前后端热更新机制不同(各自热各自的,别互相掺和), 但希望一条命令都拉起来。书里选了「后端挂进前端的构建脚本」, 理由:Node.js 的构建支持更成熟,而 Cargo 侧得自己写构建代码; concurrently 这个工具让两条任务并行跑、共享输出36:

"dev": "concurrently -k \"cargo -Z unstable-options -C ../ watch -c -w src -x run\" \"vite\""

一个时代的痕迹值得会心一笑:当时 Cargo 不支持切目录,得开 nightly 的 unstable options(-Z unstable-options -C ../)37。之后 npm run dev 一条命令, 8000 和 5173 一起活。

前端发请求用浏览器自带的 Fetch API(书里也提了 Angular 的 HttpClient 可选): GET 要带 Accept: application/json 头,POST 要带 Content-Type: application/json 头和 JSON 字符串正文38。回来的响应做 duck typing(鸭子类型:看它「有没有 id 和 title 属性」来判断是不是 Ticket)39。

5.3 为什么把前端塞进 Rust 项目的子目录

frontend/ 放在 Rust 项目根下的一个子文件夹,不共享项目根—— 不想让 JS 的配置文件污染 Rust 项目根;分离清楚,将来想把前端项目拆出去也容易40。

判断(我们的,不是书里的): 「临时 JS 前端」这个安排是全书的教学法点睛之笔—— 它先在读者熟悉的世界里把前后端协作的通用难题(同源、CORS、联调)讲完, 等第 4 章换 Yew 时,读者只需要学「新工具」而不是同时学「新问题」。 如果错,会错在: 对已经熟 Yew 或只想全 Rust 的读者,这一章显得绕路; 但对目标读者(后端背景的 Rust 学习者),这是降低坡度的正解。

6. 工业界地基之一:数据库迁移

解决什么问题: 手动建表把「数据库该长什么样」变成了藏在某个人脑子里的隐性知识。 迁移(migration)= 受管理的 SQL 脚本,跟着项目走,任何人任何环境都能一键追平41。

SQLx 的 CLI 工具(cargo install sqlx-cli)给了一套动词:

  sqlx database drop        # 删掉旧库(会要确认)
  sqlx database create      # 按 DATABASE_URL 建新库
  sqlx migrate add tickets  # 建 migrations/ 目录 + 一个带时间戳的 tickets.sql
  sqlx migrate run          # 立即执行未跑过的迁移

  图说:每个迁移文件一份 SQL;数据库记着自己跑到第几个,只补差的部分。

迁移脚本的内容就是之前那条 CREATE TABLE。然后把它挂进 bootstrap: main 里建池之后加一行 sqlx::migrate!().run(&pool).await.unwrap();—— 应用启动时自动补表,新同事的机器上「clone → 起库 → 跑应用」三步完事42。

这引出 SQLx 的一个鸡生蛋问题(书里实打实演示的): SQLx 的宏要在编译期核对 SQL,可新机器上数据库还没建好、迁移还没跑, 核对什么?解法:offline 模式。Cargo.toml 给 SQLx 开 offline feature, 在开发机上跑一次 cargo sqlx prepare -- --workspace——它生成一个 sqlx-data.json (描述 schema 的元数据),提交进版本库,再给 .env 加 SQLX_OFFLINE=true。 之后任何机器编译时,宏拿这份 JSON 核对,不需要活的库43。

.env 的去留也在这里交代: 目前全项目共用一个 .env 因为没敏感配置; 将来环境一多(各连各的库),就得改成「提交 .env.sample、各人自配」的规矩44。

7. 工业界地基之二:持续集成(CI)

解决什么问题: 多人共推一个仓库,每次推送都可能带进错误。 持续集成(CI)= 推送触发的一串自动化检查,书里列了它可能包括的活: 构建、依赖检查、代码质量分析、lint、格式化、测试、覆盖率、文档生成45。

配置载体是仓库里的 YAML 工作流文件(.github/workflows/,push 和 pull_request 触发)。 三条流水线:

工作流干什么关键细节
Rust CI三个 job:Test(cargo test)、Rustfmt(cargo fmt --check 强制格式)、Clippy(cargo clippy -- -D warnings 警告当错误)复用现成 action:checkout、toolchain、rust-cache
JavaScript CI在前端目录跑 npm ci + npm run buildmatrix 策略:同一套步骤对 Node 16.x/18.x/20.x 各跑一遍
Security Auditcargo deny check advisories 扫依赖里的已知漏洞每天零点(cron 0 0 * * *)+ Cargo.toml/lock 变更时双触发

细节里藏着两个工程常识:npm ci 是「按 lock 文件干净重装」, 和 npm install 不同;矩阵构建用一个参数表把「多版本兼容」跑成三次独立构建46。

收尾:README 徽章(badge)。 三条流水线各配一张 SVG 徽章, 状态随流水线结果自动更新——绿不绿,一眼看见47。

判断(我们的,不是书里的): 注意书里 CI 的先例:「我们一行测试都没写, 但 Cargo 宽容,pipeline 不会红」——它把 CI 架在「还没有测试」的项目上, 因为 CI 的价值一半在「以后写的每个测试自动有人跑」。测试债是第 6 章才还的内容, 但 CI 的地基第 3 章就打——这个顺序在真实项目里也是对的。 如果错,会错在: 「没有测试的 CI」容易给人虚假安全感;如果有人因此推迟写测试, 绿灯就是自欺欺人。书的第 6 章确实回头补了这一课。

8. 作者的判断与证据(分开标)

有出处的: fail-fast(连接失败即 panic)与 anyhow 的用法来自原书代码与注释1516; CORS 与同源策略是 Web 平台标准行为3234;迁移与 CI 的命令序列是原书实操记录4145。

作者自己的评估:

  • 「先 JS 前端后 Yew」的教学安排(见 5.3 判断块);
  • 「框架解耦层不值得做」是基于经验的工程判断26;
  • 八种代码组织方案「没有终极结构」的结论5。

9. 边界与局限

  • 版本快照再提醒: sqlx 0.6、axum 0.6、-Z unstable-options -C 那个 nightly 技巧 都是写书时的状态;新版 Cargo 已支持 --manifest-path 等更稳的替代。
  • SQLite 是教学选择不是生产选择: 并发写、多机部署都会撞墙——第 4 章就会迁走。
  • CI 深度有限: 只到「三条流水线跑起来」,覆盖率门禁、部署流水线没有展开(第 9 章才碰部署)。
  • 错误处理还只是过渡方案: 统一错误函数 + 包 Result 的提取器, 第 4 章会换成更地道的「? 早返回」。

10. 可带走的

主线例子一行走完: cargo new minimalist-api → 两条硬编码路由 → Ticket + Serde JSON → SQLx 连接池(max_connections(5))挂 Extension → query_as! 编译期核对 SQL → JsonError 统一错误(404/422)→ uniform_error 抽公共 → 拆成 api/{error, tickets}.rs + 瘦 main → SQLite 手动建表 → 迁移 + offline 元数据 → 三条 CI 流水线 + 徽章。

  1. 代码组织没有终极答案,但有红线: 共享代码进共享库,模块间不许伸手; 目录组织量「刚好够区分文件」。
  2. 提取器是 Axum 的关卡: Json<T>、Path<i64> 在 handler 前把关, 参数不对请求根本到不了你的代码。
  3. SQLx 的宏在编译期核对 SQL; 代价是构建时要活库或 sqlx-data.json 离线元数据—— 后者必须进版本库。
  4. 连接池 + fail fast: 池防瓶颈;启动必需的连接建不上,panic 是正确选择。
  5. 统一错误格式是 API 的「普通话」: 状态码 + JSON 错误体;默认错误格式(纯文本) 一定要覆盖掉。
  6. 前后端不同端口必撞同源策略; 开发期 CORS 通配、生产期双向白名单。
  7. main 只干 bootstrap: 启动所需留下,其余搬走;「能跑」和「整洁」是两步,别合并。
  8. 迁移买的是可复现性;CI 买的是每次推送都有人检查。 两个地基都比业务功能优先。

11. 原文地图

主题原书章原文位置
Make it work/right/fastWriting the Application Skeletontext/20-fm-creating-an-api.txt:1249(搜「Make it work」)
八种代码组织方案同上text/19-fm-code-organizing-approaches.txt:7(搜「Architecture-based」)、:85(搜「Feature-based」)、:91(搜「Pattern-based」)、:139(搜「Function-based」)、:190(搜「application modules」)
共享代码进共享库同上text/19-fm-code-organizing-approaches.txt:211(搜「shared library」)
C4 模型四层同上text/19-fm-code-organizing-approaches.txt:217(搜「C4」)、:231(搜「System context」)
「没有终极结构」同上text/19-fm-code-organizing-approaches.txt:293(搜「ultimate project structure」)
骨架路由与 curl 测试同上text/20-fm-creating-an-api.txt:155(搜「application skeleton」)、:161(搜「curl」)
Ticket 结构与 JSON 响应同上text/20-fm-creating-an-api.txt:190(搜「64-bit integer」)、:216(搜「serialized JSON」)
Content-Type 把关同上text/20-fm-creating-an-api.txt:251(搜「Expected request」)
rustfmt/Clippy 保存钩子同上text/20-fm-creating-an-api.txt:306(搜「formatOnSave」)、:310(搜「clippy」)
cargo-watch 热重载同上text/20-fm-creating-an-api.txt:322(搜「cargo-watch」)、:328(搜「watch -w src」)
SQLx 依赖三 feature同上text/20-fm-creating-an-api.txt:393(搜「runtime-tokio-rustls」)
连接池与 fail fast同上text/20-fm-creating-an-api.txt:408(搜「SqlitePoolOptions」)、:411(搜「no use in starting」)
dotenvy 与 anyhow context同上text/20-fm-creating-an-api.txt:447(搜「DATABASE_URL」)、:453(搜「Anyhow」)
Extension 层注入同上text/20-fm-creating-an-api.txt:459(搜「layer(Extension」)
Path 提取器当门卫同上text/20-fm-creating-an-api.txt:534(搜「no id, no ticket」)
query_as! 编译期核对同上text/20-fm-creating-an-api.txt:540(搜「query_as!」)
404 JSON 错误实测同上text/20-fm-creating-an-api.txt:1188(搜「no rows returned」)、:1218(搜「404 Not Found」)
INSERT 与 last_insert_rowid同上text/20-fm-creating-an-api.txt:651(搜「INSERT INTO tickets」)、:666(搜「last_insert_rowid」)
发 0 收 1同上text/20-fm-creating-an-api.txt:1170(搜「id 0」)
422 默认错误同上text/20-fm-creating-an-api.txt:1298(搜「422」)、:1313(搜「Failed to deserialize」)
Result 提取器与 custom extractor同上text/20-fm-creating-an-api.txt:1325(搜「JsonRejection」)、:1334(搜「custom extractor」)
uniform_error 与文件拆分同上text/20-fm-creating-an-api.txt:1411(搜「uniform_error」)、:1475(搜「api」)
main 只 bootstrap同上text/20-fm-creating-an-api.txt:1456(搜「bootstrapping」)、:1910(搜「api::serve」)
不解耦框架的决定同上text/20-fm-creating-an-api.txt:1919(搜「rarely replaces」)
CREATE TABLE 与 AUTOINCREMENT同上text/20-fm-creating-an-api.txt:772(搜「CREATE TABLE」)、:787(搜「AUTOINCREMENT」)
「手动建表缺可追溯可复现」同上text/20-fm-creating-an-api.txt:827(搜「traceability」)
前端四件套同上(Creating a frontend)text/21-fm-creating-a-frontend.txt:17(搜「Vite」)、:22(搜「Tailwind」)、:25(搜「daisyUI」)、:28(搜「Solid JS」)
同源策略的威胁模型同上text/21-fm-creating-a-frontend.txt:45(搜「same-origin policy」)
8000 对 5173 与 CORS同上text/21-fm-creating-a-frontend.txt:48(搜「5173」)、:54(搜「CORS」)、:57(搜「CorsLayer」)
concurrently 双起同上text/21-fm-creating-a-frontend.txt:128(搜「concurrently」)、:131(搜「unstable-options」)
Fetch 头与 duck typing同上text/21-fm-creating-a-frontend.txt:155(搜「JSON.stringify」)、:170(搜「isTicket」)
前端放子目录的理由同上text/21-fm-creating-a-frontend.txt:96(搜「pollute」)
sqlx migrate 四命令同上(Further improvements)text/22-fm-further-improvements.txt:16(搜「database drop」)、:31(搜「migrate add」)、:40(搜「migrate run」)
迁移挂进 bootstrap同上text/22-fm-further-improvements.txt:46(搜「migrate!」)
offline 与 sqlx-data.json同上text/22-fm-further-improvements.txt:52(搜「prepare」)、:55(搜「sqlx-data.json」)、:58(搜「SQLX_OFFLINE」)
CI 的八类自动化同上text/22-fm-further-improvements.txt:88(搜「automated processes」)
Rust CI 三 job同上text/22-fm-further-improvements.txt:155(搜「jobs」)、:215(搜「cargo fmt」)、:251(搜「-D warnings」)
JS CI 的 matrix同上text/22-fm-further-improvements.txt:317(搜「node-version」)
安全审计 cron 双触发同上text/22-fm-further-improvements.txt:377(搜「cron」)、:413(搜「cargo deny」)
README 徽章同上text/22-fm-further-improvements.txt:428(搜「badge」)

4. 主线例子再走一遍:minimalist-api 的每一步状态(带真数字的复盘)

把第 4 节的八步压缩成一张「状态快照表」——每一步旁边是当时的代码形态与 可观测的输入输出,读者照着能重建整个项目:48

步动作关键状态(输入 → 输出)
①cargo new minimalist-api目录三件套;依赖 axum 0.6 + tokio 1.28
②两条硬编码路由curl /tickets → GET Tickets;curl --data → POST Tickets
③Ticket{id:i64,title:String} + SerdeGET 返回 {"id":1,"title":"New Ticket"},Content-Type 自动 application/json
④cargo-watch 热重载cargo watch -w src -x run;编译错误当场刷在屏幕上
⑤SQLx 连接池 + dotenvymax_connections(5);.env 里 DATABASE_URL=sqlite:dev.sqlite
⑥query_as! + 统一 JsonError发 {"id":0,"title":"Hello from the other side"} → 收 {"id":1,...}(发 0 收 1)
⑦Result 提取器 + uniform_error缺字段 → 422 + JSON;错误格式与成功格式同门同户
⑧拆 api/error.rs、api.rs、api/tickets.rsmain 瘦到 bootstrap;新资源 = 新文件 + 一行 merge

第 ⑥ 步的「发 0 收 1」为什么重要: 它演示了服务器是唯一的事实来源—— 客户端给的 id 被忽略,AUTOINCREMENT 从 1 开始发号。恶意或错误的客户端 输入不会污染数据;「幂等地以服务器为准」是写接口时的默认立场49。

第 ⑤ 步的 fail-fast 再补一刀: 连接池建不上就 unwrap 崩掉—— 「连不上数据库,这个应用就没有启动的意义」。anyhow 的 .context() 只负责把「哪个变量缺失」写进遗言。第 4 章会把这个脾气改良成 ? 早返回, 但启动路径的 panic 立场不变50。

5.4 前端一节的更多细节(CORS 之外的三件事)

为什么开发期可以通配、生产期不行: 开发时前后端是两个 dev server (8000 对 5173),加 allow_origin(Any) 只为图省事;生产环境前后端 仍可能在不同域名(静态资源走带缓存的优化存储),「那时要两头配置, 让后端知道前端地址、前端知道后端地址」。CORS 的Wildcard 出不了开发机—— 这是常识,书里用一句注释带过,别当没看见51。

concurrently 那行的时代痕迹: cargo -Z unstable-options -C ../ watch… ——当时 stable Cargo 不支持切目录,被迫 nightly。今天这条已经可以用 --manifest-path 或 workspace 根跑,但**「前端脚本编排后端」的结构本身 仍成立**:构建生态 Node 更成熟,就让 Node 当总指挥52。

Fetch 与 duck typing 的分工: GET 带 Accept: application/json (告诉服务器我要 JSON),POST 带 Content-Type: application/json + JSON.stringify(ticket)(告诉服务器我发的是 JSON);回来以后不建类、 只查形状——result.hasOwnProperty('id') && result.hasOwnProperty('title') 就当 Ticket。这个「鸭子判别」很土,但对 DTO 足够53。

6.1 迁移一节的完整命令账本

  # 一次性准备
  cargo install sqlx-cli                     # 装 CLI

  # 数据库级
  sqlx database drop                         # 删库(要确认)
  sqlx database create                       # 按 DATABASE_URL 建库

  # 迁移级
  sqlx migrate add tickets                   # 生成 migrations/<时间戳>_tickets.sql
  sqlx migrate run                           # 执行未跑过的迁移

  # 应用级(bootstrap 内)
  sqlx::migrate!().run(&pool).await.unwrap() # 启动时自动补表

  # 离线元数据(编译期核对的「死库替身」)
  cargo sqlx prepare -- --workspace          # 生成 sqlx-data.json
  SQLX_OFFLINE=true                          # .env 里告诉宏:别找活库,查 JSON

  图说:开发机上有活库 → prepare 出 JSON 入库;任何机器上 JSON 代库编译。

鸡生蛋问题的一次完整叙述: SQLx 的宏要在编译期核对 SQL;新同事的 机器上库还没建、迁移还没跑,核对什么?解法分两半:开发机上跑一次 prepare 生成 sqlx-data.json(它必须提交进版本库),再给 .env 加 SQLX_OFFLINE=true——宏切换数据源,用 JSON 描述的旧 schema 做编译期核对。 「clone 项目的人不用重做这套」——可复现性的最后一公里就是这么走的54。

7.1 CI 三个工作流的逐项账

Rust CI(三条 job 的分工哲学):

  • test:checkout → toolchain → rust-cache → cargo test。 此时一行测试都没有,Cargo 宽容不报错——CI 的价值一半在 「以后写的每个测试自动有人跑」;
  • fmt:cargo fmt --check——强制格式;不统一的排版直接红;
  • clippy:cargo clippy -- -D warnings——把警告升格为错误; lint 债不过夜。

JS CI 的两个细节: npm ci(按 lock 干净重装,不是 npm install); matrix 策略对 Node 16.x/18.x/20.x 各跑一遍构建——「兼容矩阵」的 CI 形态55。

Security Audit 的双触发: cron 0 0 * * *(每天零点,不管有没有改动)

  • push 且 Cargo.toml/lock 变化时。cargo deny check advisories 扫的是 依赖树里已知漏洞——供应链安全的底线哨兵,和第 3 章的依赖检查是同族56。

徽章为什么不是虚荣: 三张 SVG 随流水线结果自动变——README 的徽章 是「对外可见的健康度」,别人(和三个月后的你)第一眼就看到这套项目 还能不能 builds57。

12. 原文地图补充

主题原书章原文位置
发 0 收 1Writing the Application Skeletontext/20-fm-creating-an-api.txt:1170(搜「id 0」)
启动 fail-fast同上text/20-fm-creating-an-api.txt:411(搜「no use in starting」)
「能推导的不传输」之外的 CI 账同上(Further improvements)text/22-fm-further-improvements.txt:155(搜「jobs」)、:317(搜「node-version」)、:377(搜「cron」)
徽章即健康度同上text/22-fm-further-improvements.txt:428(搜「badge」)

3.1 八种代码组织方案的适用判据(什么时候选哪种)

把第 3 节的清单补一张「选法判据」——原书没有明写判据,但每种方案的描述里 都藏着它的触发条件(我们的归纳,依据原文各节):58

切法触发条件
按架构切(分层)团队按层分工(DBA / 后端 / 前端各管一摊)
内外分离(Core/Infrastructure)要严格控制「什么能被外部触达」(六边形思路)
客户端-服务器分离两端构建链完全不同(我们的情形:Rust 后端 + 任一前端)
按 MVC 角色切框架或团队约定按 MVC 思考
按功能切走垂直切片架构;功能边界稳定
按模式切某模式有多个实现(ORM 的 Repository、Service 一片)
按职能切横切关注点已经重复出现第二次(错误、表单、校验)
按应用模块切大块之间角色根本不同(网站/SPA/管理台/API/批处理)

书里对最后一种的警告最重: 模块间共享代码必须进独立共享库, 「绝不允许模块 A 伸手进模块 B 拿代码」——伸手一次,模块边界就名存实亡; 而「保持低耦合,模块长大后才能整体搬走成独立项目」。59

C4 的落地提醒: 四级变焦(System context → Container → Component → Code) 是画图的脚手架不是目录规范;文件系统怎么摆仍由上面的判决定, 「不同框架做法不同,没有固定命名法」。60

5.5 前端目录与构建的三个决定(及其理由)

  1. frontend/ 放 Rust 项目根的子目录,不共享项目根—— 「不想让 JS 配置文件污染 Rust 项目根;分离清楚,将来拆出去也容易」;
  2. 前端管后端一起起(concurrently)而不是反过来—— 「Node.js 的构建支持更成熟;Cargo 侧得自己写构建代码」;
  3. 完整实现不进书、进仓库——「前端创建流程各家不同,全部代码在 项目仓库里」。这个决定贯穿全书:正文讲机制,代码在 GitHub61。

6.2 CI 之前的一条暗线:从 .env 到 .env.sample

书里在迁移节末尾补了一条演进路线:现在全项目共用一个 .env 是因为 它还没有敏感配置;「将来各环境数据库不同,就得改成提交 .env.sample、 各人自配真文件」——这个转变在第 4 章真发生了(数据库凭据进 .env, 再往后 .docker.env、compose 的 env_file 一路延续)。配置文件的 谱系本身,就是环境复杂度增长的年轮62。

7. 作者的判断与证据(补记)

有出处的: 发送/接收实录、CI 三工作流、迁移命令序列均为原书实操; 「make it work/right/fast」与「不重复造轮子」的引用在原文63。

作者自己的评估: 「先 JS 前端顶上」是教学安排(见 §5.3 判断块); 「框架解耦层不值得做」是经验判断;「测试留作练习」在前言里就声明过—— 这三处都是立场,引用时请带主语。

4.8 本章三条工程纪律的汇总(比步骤更重要的东西)

minimalist-api 这一章表面上给了「一个 API 怎么写」,底子里立了三条 贯穿全书的纪律:64

  1. 格式与 lint 进保存动作(rustfmt + Clippy 钩子)——把「事后收拾」 变成「顺手发生」;第 3 章 CI 的 fmt --check 与 clippy -D warnings 就是这条纪律的服务器端镜像;
  2. 错误格式是协议——JsonError{code,message} 从第 ③ 步立起来, 一路用到第 10 章(加 origin)、第 13 章(加 details); 「客户端永远读得懂失败」是 API 设计里不许省的预算项;
  3. main 只 bootstrap——启动所需的留下,其余搬走;这条线从 30 行的 骨架一直拉到第 9 章的七个服务(每个服务一个启动入口)。

与下一章的交接: 骨架章刻意停在「SQLite + 手写 SQL + 包 Result 的 提取器」;第 4 章开头的第一件事就是给这套骨架换发动机——换库领教方言 之苦,换 ORM 领教范式转移,两件烦事挨在一起做,正是为了让你看见 ORM 的账单和它的价值是同一笔65。

4.2 主线例子的失败路径全集(每种错都试一遍)

minimalist-api 调通之后,书里特意带读者把「会错的请求」挨个试了一遍—— 这份失败样例集比成功路径更值钱,因为它演示了统一错误格式的实际成色:66

错误输入实际响应(原书实录)
GET /tickets/4(不存在)404 + {"code":"404 Not Found","message":"no rows returned by a query that expected to return at least one row"}
POST 缺字段 {}422 + Failed to deserialize the JSON body into the target type: missing field 'id' at line 1 column 2(第一版纯文本)
POST 带字段但 Content-Type 不对Expected request with 'Content-Type: application/json'(提取器直接拒)
路径参数非数字 /tickets/a400 + Invalid URL: Cannot parse \"a\" to a \u64``(重构后)
数据库连接池耗尽500 + Failed to acquire connection from pool(重构后)

从这张表能读出的三层防线: 提取器(Content-Type、JSON 形状、路径 参数类型)在 handler 前拦;业务查询(查不到行)在 handler 里拦; 基础设施(连接池)在深处拦——三层全部归一到同一种 JSON 错误体。 「API 有了统一语言(成功与失败同格式),客户端可以依赖这种一致性」67。

4.3 重构动机的完整链条(为什么非要拆文件)

拆文件不是洁癖,书里给了因果链:68

  1. 错误处理逻辑重复(每个 handler 里 copy 一份 JsonError 构造)—— 「比我们愿意看到的重复还多」;
  2. uniform_error 帮手函数出现——「这个函数和 JsonError 是好候选人: 该搬出去,别让它污染 main 里的业务逻辑」;
  3. 「文件要 thin,最优一个文件一个目的;main 的存在理由只有一个: 启动应用」——划界:启动所需留下,其余搬走;
  4. 结构成形:api/error.rs(错误)、api.rs(serve + router 合并)、 api/tickets.rs(资源);「以后加资源,就是 api/ 下一个新文件 + router() 里一行 merge」。

「想过但不做」的框架解耦层(第 1919 段的完整论证):把 API 组件与 axum 解耦、抽象掉框架依赖,好处是「将来能换框架」;但「现实中几乎没人 换写好了应用的框架」——可替换性也是成本,按「合目的」军规,这笔不买。 这句和第 2 章「避免 lock-in」的立场看似相反,其实一致:lock-in 要防的是 「外部世界逼你换」(数据库方言),不是「自己心血来潮想换」(框架)69。

6.3 持续集成的八类自动化的完整映射

书里列的 CI 可能包含的八类活,与本书实际三条流水线的对应关系:70

清单项本书落点
构建项目JS CI 的 npm run build;Rust 由 test job 顺带编译
依赖检查Security Audit(cargo deny advisories)
代码质量分析Clippy(-D warnings)
LintersClippy 兼任
代码格式化Rustfmt(fmt --check)
测试cargo test(当时 0 个,管线已就位)
覆盖率未接(第 6 章明说了测试都留作练习)
文档生成cargo doc 未接(可由 Rust CI 扩展)

「三种工作流(Rust CI / JavaScript CI / Security Audit)就位后,每次推送 都会按触发器持续验证应用;流水线失败还会邮件通知」——CI 的闭环 = 自动化 + 通知 + 徽章对外可见71。

8. 本章一页复习(骨架项目的最终形态)

  一个命令的真相:clone → touch dev.sqlite → npm update && npm run dev
      │  ① 迁移自动建表(bootstrap 里的 migrate!)
      │  ② cargo-watch 盯后端,trunk/vite 盯前端,concurrently 总指挥
      │  ③ CI 在云端盯着每一次 push(测试/格式/lint/审计)
      ▼
  文件结构的全景:
      src/main.rs          bootstrap(配置+连接池+serve)
      src/api.rs           router 合并 + serve()
      src/api/tickets.rs   资源:路由 + handlers + Ticket
      src/api/error.rs     JsonError + to_uniform_response
      migrations/          sqlx 迁移(SQL 文件 + 时间戳)
      sqlx-data.json       离线 schema 元数据(入库)
      frontend/            独立前端(临时 JS,正式 Rust 在第 4 章)
      .github/workflows/   三条流水线

  图说:骨架 = 「能跑」+「能改」+「能复现」+「有人盯」四件事的最小合集。

与「Make it work / right / fast」的对位: 第 ①–⑥ 步是 work; 第 ⑦ 步(拆文件 + uniform_error)是 right;fast 全书没追—— 「先把正确性做满,性能等第 14 章的观测工具来指路」。这个顺序本身就是 一条可带走的纪律72。

4.9 SQLite 环境的完整搭建账(三平台命令实录)

原书给了一条跨平台的 SQLite 安装路径,照抄可用:73

平台装法(原书给的)
WindowsChocolatey / Scoop / Winget 三选一,再装 SQLite Browser
Linuxsudo apt install sqlite sqlitebrowser(Ubuntu)/ dnf(Fedora);或 Snap/Flatpak
macOSHomebrew:brew install sqlite3

建库 = 建一个空文件——touch dev.sqlite;用 SQLite Browser 打开, 在 Execute SQL 标签页粘贴 CREATE TABLE,「点播放按钮执行,点 Write Changes 落盘」。AUTOINCREMENT 的含义: PRIMARY KEY("id" AUTOINCREMENT) = 主键自动发号,从 1 起、永远取「已用最大值 +1」——这就是「发 0 收 1」的 机制本体74。

DATABASE_URL 的三个来源(优先级从低到高): 环境变量 (set/export 平台各异)→ .env 文件(SQLx 宏的硬性要求)→ docker 环境的注入(第 9 章接管)。书里选 .env 的动机很生活化: 「作者不想在三个操作系统上分别教怎么设环境变量」;代价第 4 章就付: 凭据混进开发目录,.gitignore 与 load diff 的教训接踵而来75。

dotenvy + anyhow 的组合技: dotenvy::var("DATABASE_URL") 找不到 变量时,错误信息不说是哪个变量;.context("DATABASE_URL must be set") 用 anyhow 的上下文包装补上遗言。这个「错误上下文」技巧在第 4 章的 ? 早返回里会升级成标配76。

4.10 临时前端的遗产(这一章给后面留下了什么)

那版「临时起意」的 JS 前端没有被第 4 章扔掉,而是留下了三件传家宝:77

  1. /backend 反代约定——第 9 章 lighttpd 把这条约定原样搬进生产 容器(Wasm 读不到 env 的同一约束);
  2. CORS 中间件的挂法(.layer(cors) 加在 Extension 之后)—— 中间件的层叠顺序自此成为全书的固定手艺;
  3. 「先占位、后灌水」的异步呈现模式——fetch → 默认渲染 → 数据到了再补; Yew 版前端(第 4 章)与 SSE 推送(第 12 章)都是这条模式的变形。

「教学项目的每一章都该给后面留东西」——本章留下的这三件,比 minimalist-api 本身活得更久78。

4.11 与第 4 章的交接清单(骨架章留下的五个接口)

本章结束、下一章开始时,以下五件东西以「现状」交接:79

接口现状第 4 章的处理
数据库SQLite + 手写 SQL(SQLx 宏)迁 MariaDB,换装 SeaORM
错误处理包 Result 的提取器 + uniform_error? 早返回 + thiserror + WithRejection
前端临时 JS(Vite/SolidJS)正式 Rust 前端(Yew + Trunk)
项目结构单 crateworkspaces 五成员
测试CI 就位、用例为零第 6 章还债(单元/集成/Wasm/浏览器/BDD)

交接的用意: 每个接口都处在「能跑但不够好」的状态—— 「Make it work」到此为止,接下来四章轮流做「Make it right」; 这也正是原书把换装放在第 4 章开头的理由:「现在还没做多少,正是 范式转移最便宜的时机」。80

13. 原文地图补充

主题原书章原文位置
「手动建表缺可追溯可复现」Writing the Application Skeletontext/20-fm-creating-an-api.txt:827(搜「traceability」)
Bootstrap 三件套同上text/12-fm-the-architecture-frenzy.txt:1326(搜「Data definition」)
迁移挂进 bootstrap同上(Further improvements)text/22-fm-further-improvements.txt:46(搜「migrate!」)

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

骨架章的全部贡献,可以浓缩成一句验收标准:「一个新同事 clone → 建库 → 一条命令,十分钟内看到系统在自己机器上跑起来」——迁移管住了「建库」, CI 管住了「系统没坏」,模块化管住了「一条命令」。第 4 章从这幅骨架上 直接长肉。81

15. 本篇卷尾补记(骨架验收口诀)

「新同事 clone → 建库 → 一条命令,十分钟跑起来」——这句验收标准 背后是四件套:迁移管建库、CI 管没坏、模块化管一条命令、.dockerignore 管别泄密。四件缺一,骨架就有洞。82

16. 本篇卷尾补记(骨架的度量)

骨架好不好,不看代码行数看四件事:新同事上手分钟数、改一行代码触发的 重编范围、错误格式的一致性、CI 红灯的频率。四件都向好,骨架就是活的; 反之,「能跑」只是暂时的幻觉。83

17. 本篇卷尾补记(交接检查单)

交给下一章前逐项打勾:错误格式统一了吗?迁移入库了吗?CI 三条流水线 绿了吗?前端能联调吗?四勾全绿,骨架章才算真正收卷。84

Footnotes

  1. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:1249,搜「Make it work」)。 ↩

  2. 出处:「Code organizing approaches」(text/19-fm-code-organizing-approaches.txt:7,搜「Architecture-based code organization」;:85,搜「Feature-based separation」;:91,搜「Pattern-based separation」;:139,搜「Function-based separation」;:190,搜「Separation by application modules」)。 ↩

  3. 出处:「Code organizing approaches」(text/19-fm-code-organizing-approaches.txt:211,搜「shared library」)。原文:「It is important to move all the shared code into a shared library and not let the modules reach out into another module for code to use.」 ↩

  4. 出处:「Code organizing approaches」(text/19-fm-code-organizing-approaches.txt:217,搜「C4」;:231,搜「System context」)。 ↩

  5. 出处:「Code organizing approaches」(text/19-fm-code-organizing-approaches.txt:293,搜「ultimate project structure」)。 ↩ ↩2

  6. 出处:「Code organizing approaches」(text/19-fm-code-organizing-approaches.txt:283,搜「refactoring」)。 ↩

  7. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:33,搜「Ticket: A struct」;:155,搜「application skeleton」;:161,搜「curl」)。 ↩

  8. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:216,搜「serialized JSON」)。 ↩

  9. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:228,搜「Json(ticket)」)。 ↩

  10. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:251,搜「Expected request」)。 ↩

  11. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:306,搜「formatOnSave」;:310,搜「clippy」)。 ↩

  12. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:322,搜「cargo-watch」;:328,搜「watch -w src」)。 ↩

  13. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:380,搜「GET /tickets/:id」)。 ↩

  14. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:393,搜「runtime-tokio-rustls」;:396,搜「why would we leave Rust」)。 ↩

  15. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:408,搜「SqlitePoolOptions」;:411,搜「no use in starting the application」)。 ↩ ↩2

  16. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:447,搜「DATABASE_URL」;:453,搜「Anyhow」)。 ↩ ↩2

  17. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:459,搜「layer(Extension(pool))」)。 ↩

  18. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:534,搜「no id, no ticket」)。 ↩

  19. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:540,搜「query_as!」)。 ↩

  20. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:1188,搜「no rows returned」;:1218,搜「404 Not Found」;:1221,搜「content-type」)。 ↩

  21. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:651,搜「INSERT INTO tickets」;:666,搜「last_insert_rowid」)。 ↩

  22. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:1298,搜「422」;:1313,搜「Failed to deserialize」)。 ↩

  23. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:1325,搜「JsonRejection」;:1334,搜「custom extractor」)。 ↩

  24. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:1456,搜「one single purpose」)。 ↩

  25. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:1625,搜「pub fn router」;:1628,搜「merge」)。 ↩

  26. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:1919,搜「rarely replaces」)。 ↩ ↩2

  27. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:772,搜「CREATE TABLE」;:787,搜「AUTOINCREMENT」)。 ↩

  28. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:827,搜「traceability」)。 ↩

  29. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:10,搜「more mature」)。 ↩

  30. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:17,搜「Vite」;:22,搜「Tailwind」;:25,搜「daisyUI」;:28,搜「Solid JS」)。 ↩

  31. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:32,搜「skip that」)。 ↩

  32. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:45,搜「same-origin policy」)。 ↩ ↩2

  33. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:48,搜「5173」)。 ↩

  34. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:54,搜「CORS」;:57,搜「CorsLayer」)。 ↩ ↩2

  35. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:88,搜「different domains」)。 ↩

  36. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:113,搜「concurrently」;:128,搜「dev」)。 ↩

  37. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:128,搜「unstable-options」)。 ↩

  38. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:143,搜「Accept」;:155,搜「JSON.stringify」)。 ↩

  39. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:170,搜「isTicket」)。 ↩

  40. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:96,搜「pollute」)。 ↩

  41. 出处:「Further improvements」(text/22-fm-further-improvements.txt:13,搜「reproducible」;:25,搜「managed SQL scripts」)。 ↩ ↩2

  42. 出处:「Further improvements」(text/22-fm-further-improvements.txt:46,搜「migrate!」)。 ↩

  43. 出处:「Further improvements」(text/22-fm-further-improvements.txt:52,搜「prepare」;:55,搜「sqlx-data.json」;:58,搜「SQLX_OFFLINE」)。 ↩

  44. 出处:「Further improvements」(text/22-fm-further-improvements.txt:82,搜「rename this to a sample」)。 ↩

  45. 出处:「Further improvements」(text/22-fm-further-improvements.txt:88,搜「central repository」;:92,搜「automated processes」)。 ↩ ↩2

  46. 出处:「Further improvements」(text/22-fm-further-improvements.txt:317,搜「node-version」;:377,搜「cron」;:413,搜「cargo deny」)。 ↩

  47. 出处:「Further improvements」(text/22-fm-further-improvements.txt:428,搜「badge」)。 ↩

  48. 步骤与输入输出均出自原书 Creating an API 一节各代码清单与 curl 实录。 ↩

  49. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:1170,搜「we sent id 0」)。 ↩

  50. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:411,搜「no use in starting the application」)。 ↩

  51. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:88,搜「different domains」)。 ↩

  52. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:128,搜「unstable-options」)。 ↩

  53. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:143,搜「Accept」)、:170(搜「isTicket」)。 ↩

  54. 出处:「Further improvements」(text/22-fm-further-improvements.txt:49,搜「check our queries」)、:55(搜「sqlx-data.json」)、:58(搜「SQLX_OFFLINE」)。 ↩

  55. 出处:「Further improvements」(text/22-fm-further-improvements.txt:350,搜「npm ci」)、:317(搜「node-version」)。 ↩

  56. 出处:「Further improvements」(text/22-fm-further-improvements.txt:377,搜「cron」)、:413(搜「cargo deny」)。 ↩

  57. 出处:「Further improvements」(text/22-fm-further-improvements.txt:447,搜「updated automatically」)。 ↩

  58. 判据为我们的归纳;八种方案的定义见 text/19-fm-code-organizing-approaches.txt 各小节原文。 ↩

  59. 出处:「Code organizing approaches」(text/19-fm-code-organizing-approaches.txt:211,搜「shared library」)。 ↩

  60. 出处:「Code organizing approaches」(text/19-fm-code-organizing-approaches.txt:283,搜「no fixed nomenclature」)。 ↩

  61. 出处:「Creating a frontend」(text/21-fm-creating-a-frontend.txt:96,搜「pollute」)、:113(搜「concurrently」)、:99(搜「repository」)。 ↩

  62. 出处:「Further improvements」(text/22-fm-further-improvements.txt:82,搜「rename this to a sample」)。 ↩

  63. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:1249,搜「Make it work」)、text/42-fm-introduction.txt:90(搜「reinventing the wheel」)。 ↩

  64. 纪律为我们对全章的归纳;各支撑出处见前文脚注 11、22、24。 ↩

  65. 衔接依据:第 4 章 Setting up a serious database / Setting up an ORM 两节与原书行文顺序。 ↩

  66. 样例集出自原书各 curl/HTTPie 实录:text/20-fm-creating-an-api.txt:1188(搜「no rows returned」)、:1313(搜「Failed to deserialize」)、:1132(搜「path_rejection」)、text/35-fm-implementing-the-backend.txt:1117(搜「db_error」)。 ↩

  67. 出处:「Creating an API」(text/21-fm-creating-a-frontend.txt:4,搜「uniform language」)。 ↩

  68. 因果链出自原书 Optimizing our code 一节:text/20-fm-creating-an-api.txt:1408(搜「duplications」)、:1453(搜「pollute」)、:1456(搜「one single purpose」)。 ↩

  69. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:1919,搜「rarely replaces」)。 ↩

  70. 八类清单见「Further improvements」(text/22-fm-further-improvements.txt:88,搜「automated processes」);落点映射为我们的对照。 ↩

  71. 出处:「Further improvements」(text/22-fm-further-improvements.txt:422,搜「email notification」)。 ↩

  72. 三步格言见 text/20-fm-creating-an-api.txt:1248;性能让位给正确性的顺序为我们对全书的归纳(第 14 章运维节为其落点)。 ↩

  73. 三平台命令见 text/20-fm-creating-an-api.txt:727(搜「Chocolatey」)、:734(搜「apt install」)、:743(搜「Homebrew」)。 ↩

  74. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:750,搜「touch dev.sqlite」)、:787(搜「AUTOINCREMENT」)。 ↩

  75. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:839,搜「DATABASE_URL=sqlite:dev.sqlite」)、:435(搜「dotenvy」)。 ↩

  76. 出处:「Creating an API」(text/20-fm-creating-an-api.txt:453,搜「Anyhow」)。 ↩

  77. 三件遗产为我们的归纳;反代与 CORS 见 text/21-fm-creating-a-frontend.txt:54、:323。 ↩

  78. 教学法归纳为我们的判断;「临时前端」安排见 text/21-fm-creating-a-frontend.txt:10。 ↩

  79. 交接清单为我们的对账;换装时机见 text/32-fm-setting-up-an-orm.txt:4(搜「the right time」)。 ↩

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

  81. 卷尾为我们的归纳;验收链条见本篇 §4、§6、§7。 ↩

  82. 口诀见本篇 §14;四件套对应 §4(迁移)、§6(CI)、§3(模块化)、12 §6.5(dockerignore)。 ↩

  83. 四指标为我们的产品化建议;骨架定义见本篇 §14。 ↩

  84. 检查单为我们的产品化整理;四项见本篇 §4、§6、§7。 ↩

On this page

应用骨架:从一行路由到一个可维护的最小全栈1. 这一章在全书链条里的位置2. 顶层全景3. 代码组织:八种切法,没有唯一答案4. 主线例子:minimalist-api 的生长史第 ① 步:骨架——两条路由第 ② 步:接 JSON——Serde 登场第 ③ 步:开发体验——格式化和热重载第 ④ 步:接数据库——SQLx、连接池、.env第 ⑤ 步:写查询——宏检查的 SQL第 ⑥ 步:错误格式的最后一课——422 与统一错误函数第 ⑦ 步:按职责拆文件——bootstrap 原则第 ⑧ 步:数据库本体5. 临时前端:一次故意的「离开 Rust」5.1 同源策略与 CORS:前后端联调的第一道墙5.2 两个 dev server,一键并起5.3 为什么把前端塞进 Rust 项目的子目录6. 工业界地基之一:数据库迁移7. 工业界地基之二:持续集成(CI)8. 作者的判断与证据(分开标)9. 边界与局限10. 可带走的11. 原文地图4. 主线例子再走一遍:minimalist-api 的每一步状态(带真数字的复盘)5.4 前端一节的更多细节(CORS 之外的三件事)6.1 迁移一节的完整命令账本7.1 CI 三个工作流的逐项账12. 原文地图补充3.1 八种代码组织方案的适用判据(什么时候选哪种)5.5 前端目录与构建的三个决定(及其理由)6.2 CI 之前的一条暗线:从 .env 到 .env.sample7. 作者的判断与证据(补记)4.8 本章三条工程纪律的汇总(比步骤更重要的东西)4.2 主线例子的失败路径全集(每种错都试一遍)4.3 重构动机的完整链条(为什么非要拆文件)6.3 持续集成的八类自动化的完整映射8. 本章一页复习(骨架项目的最终形态)4.9 SQLite 环境的完整搭建账(三平台命令实录)4.10 临时前端的遗产(这一章给后面留下了什么)4.11 与第 4 章的交接清单(骨架章留下的五个接口)13. 原文地图补充14. 本篇卷尾(一句话版)15. 本篇卷尾补记(骨架验收口诀)16. 本篇卷尾补记(骨架的度量)17. 本篇卷尾补记(交接检查单)Footnotes