应用骨架:从一行路由到一个可维护的最小全栈¶
这一章讲一件事:把一个玩具 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。
依赖加一行:
三个 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:
一个时代的痕迹值得会心一笑:当时 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 build |
matrix 策略:同一套步骤对 Node 16.x/18.x/20.x 各跑一遍 |
| Security Audit | cargo 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。
作者自己的评估:
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 流水线 + 徽章。
- 代码组织没有终极答案,但有红线: 共享代码进共享库,模块间不许伸手; 目录组织量「刚好够区分文件」。
- 提取器是 Axum 的关卡:
Json<T>、Path<i64>在 handler 前把关, 参数不对请求根本到不了你的代码。 - SQLx 的宏在编译期核对 SQL; 代价是构建时要活库或
sqlx-data.json离线元数据—— 后者必须进版本库。 - 连接池 + fail fast: 池防瓶颈;启动必需的连接建不上,panic 是正确选择。
- 统一错误格式是 API 的「普通话」: 状态码 + JSON 错误体;默认错误格式(纯文本) 一定要覆盖掉。
- 前后端不同端口必撞同源策略; 开发期 CORS 通配、生产期双向白名单。
- main 只干 bootstrap: 启动所需留下,其余搬走;「能跑」和「整洁」是两步,别合并。
- 迁移买的是可复现性;CI 买的是每次推送都有人检查。 两个地基都比业务功能优先。
11. 原文地图¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| Make it work/right/fast | Writing the Application Skeleton | text/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} + Serde |
GET 返回 {"id":1,"title":"New Ticket"},Content-Type 自动 application/json |
| ④ | cargo-watch 热重载 | cargo watch -w src -x run;编译错误当场刷在屏幕上 |
| ⑤ | SQLx 连接池 + dotenvy | max_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.rs | main 瘦到 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 收 1 | Writing the Application Skeleton | text/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 前端目录与构建的三个决定(及其理由)¶
frontend/放 Rust 项目根的子目录,不共享项目根—— 「不想让 JS 配置文件污染 Rust 项目根;分离清楚,将来拆出去也容易」;- 前端管后端一起起(concurrently)而不是反过来—— 「Node.js 的构建支持更成熟;Cargo 侧得自己写构建代码」;
- 完整实现不进书、进仓库——「前端创建流程各家不同,全部代码在 项目仓库里」。这个决定贯穿全书:正文讲机制,代码在 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
- 格式与 lint 进保存动作(rustfmt + Clippy 钩子)——把「事后收拾」
变成「顺手发生」;第 3 章 CI 的
fmt --check与clippy -D warnings就是这条纪律的服务器端镜像; - 错误格式是协议——
JsonError{code,message}从第 ③ 步立起来, 一路用到第 10 章(加origin)、第 13 章(加details); 「客户端永远读得懂失败」是 API 设计里不许省的预算项; - 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/a | 400 + 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
- 错误处理逻辑重复(每个 handler 里 copy 一份 JsonError 构造)—— 「比我们愿意看到的重复还多」;
- uniform_error 帮手函数出现——「这个函数和 JsonError 是好候选人: 该搬出去,别让它污染 main 里的业务逻辑」;
- 「文件要 thin,最优一个文件一个目的;main 的存在理由只有一个: 启动应用」——划界:启动所需留下,其余搬走;
- 结构成形:
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) |
| Linters | Clippy 兼任 |
| 代码格式化 | 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
| 平台 | 装法(原书给的) |
|---|---|
| Windows | Chocolatey / Scoop / Winget 三选一,再装 SQLite Browser |
| Linux | sudo apt install sqlite sqlitebrowser(Ubuntu)/ dnf(Fedora);或 Snap/Flatpak |
| macOS | Homebrew: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
/backend反代约定——第 9 章 lighttpd 把这条约定原样搬进生产 容器(Wasm 读不到 env 的同一约束);- CORS 中间件的挂法(
.layer(cors)加在 Extension 之后)—— 中间件的层叠顺序自此成为全书的固定手艺; - 「先占位、后灌水」的异步呈现模式——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) |
| 项目结构 | 单 crate | workspaces 五成员 |
| 测试 | CI 就位、用例为零 | 第 6 章还债(单元/集成/Wasm/浏览器/BDD) |
交接的用意: 每个接口都处在「能跑但不够好」的状态—— 「Make it work」到此为止,接下来四章轮流做「Make it right」; 这也正是原书把换装放在第 4 章开头的理由:「现在还没做多少,正是 范式转移最便宜的时机」。80
13. 原文地图补充¶
| 主题 | 原书章 | 原文位置 |
|---|---|---|
| 「手动建表缺可追溯可复现」 | Writing the Application Skeleton | text/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
-
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:1249,搜「Make it work」)。 ↩ -
出处:「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」)。 ↩ -
出处:「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.」 ↩ -
出处:「Code organizing approaches」(
text/19-fm-code-organizing-approaches.txt:217,搜「C4」;:231,搜「System context」)。 ↩ -
出处:「Code organizing approaches」(
text/19-fm-code-organizing-approaches.txt:293,搜「ultimate project structure」)。 ↩↩ -
出处:「Code organizing approaches」(
text/19-fm-code-organizing-approaches.txt:283,搜「refactoring」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:33,搜「Ticket: A struct」;:155,搜「application skeleton」;:161,搜「curl」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:216,搜「serialized JSON」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:228,搜「Json(ticket)」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:251,搜「Expected request」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:306,搜「formatOnSave」;:310,搜「clippy」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:322,搜「cargo-watch」;:328,搜「watch -w src」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:380,搜「GET /tickets/:id」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:393,搜「runtime-tokio-rustls」;:396,搜「why would we leave Rust」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:408,搜「SqlitePoolOptions」;:411,搜「no use in starting the application」)。 ↩↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:447,搜「DATABASE_URL」;:453,搜「Anyhow」)。 ↩↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:459,搜「layer(Extension(pool))」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:534,搜「no id, no ticket」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:540,搜「query_as!」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:1188,搜「no rows returned」;:1218,搜「404 Not Found」;:1221,搜「content-type」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:651,搜「INSERT INTO tickets」;:666,搜「last_insert_rowid」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:1298,搜「422」;:1313,搜「Failed to deserialize」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:1325,搜「JsonRejection」;:1334,搜「custom extractor」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:1456,搜「one single purpose」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:1625,搜「pub fn router」;:1628,搜「merge」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:1919,搜「rarely replaces」)。 ↩↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:772,搜「CREATE TABLE」;:787,搜「AUTOINCREMENT」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:827,搜「traceability」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:10,搜「more mature」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:17,搜「Vite」;:22,搜「Tailwind」;:25,搜「daisyUI」;:28,搜「Solid JS」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:32,搜「skip that」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:45,搜「same-origin policy」)。 ↩↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:48,搜「5173」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:54,搜「CORS」;:57,搜「CorsLayer」)。 ↩↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:88,搜「different domains」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:113,搜「concurrently」;:128,搜「dev」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:128,搜「unstable-options」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:143,搜「Accept」;:155,搜「JSON.stringify」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:170,搜「isTicket」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:96,搜「pollute」)。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:13,搜「reproducible」;:25,搜「managed SQL scripts」)。 ↩↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:46,搜「migrate!」)。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:52,搜「prepare」;:55,搜「sqlx-data.json」;:58,搜「SQLX_OFFLINE」)。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:82,搜「rename this to a sample」)。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:88,搜「central repository」;:92,搜「automated processes」)。 ↩↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:317,搜「node-version」;:377,搜「cron」;:413,搜「cargo deny」)。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:428,搜「badge」)。 ↩ -
步骤与输入输出均出自原书 Creating an API 一节各代码清单与 curl 实录。 ↩
-
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:1170,搜「we sent id 0」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:411,搜「no use in starting the application」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:88,搜「different domains」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:128,搜「unstable-options」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:143,搜「Accept」)、:170(搜「isTicket」)。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:49,搜「check our queries」)、:55(搜「sqlx-data.json」)、:58(搜「SQLX_OFFLINE」)。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:350,搜「npm ci」)、:317(搜「node-version」)。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:377,搜「cron」)、:413(搜「cargo deny」)。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:447,搜「updated automatically」)。 ↩ -
判据为我们的归纳;八种方案的定义见
text/19-fm-code-organizing-approaches.txt各小节原文。 ↩ -
出处:「Code organizing approaches」(
text/19-fm-code-organizing-approaches.txt:211,搜「shared library」)。 ↩ -
出处:「Code organizing approaches」(
text/19-fm-code-organizing-approaches.txt:283,搜「no fixed nomenclature」)。 ↩ -
出处:「Creating a frontend」(
text/21-fm-creating-a-frontend.txt:96,搜「pollute」)、:113(搜「concurrently」)、:99(搜「repository」)。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:82,搜「rename this to a sample」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:1249,搜「Make it work」)、text/42-fm-introduction.txt:90(搜「reinventing the wheel」)。 ↩ -
纪律为我们对全章的归纳;各支撑出处见前文脚注 11、22、24。 ↩
-
衔接依据:第 4 章 Setting up a serious database / Setting up an ORM 两节与原书行文顺序。 ↩
-
样例集出自原书各 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」)。 ↩ -
出处:「Creating an API」(
text/21-fm-creating-a-frontend.txt:4,搜「uniform language」)。 ↩ -
因果链出自原书 Optimizing our code 一节:
text/20-fm-creating-an-api.txt:1408(搜「duplications」)、:1453(搜「pollute」)、:1456(搜「one single purpose」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:1919,搜「rarely replaces」)。 ↩ -
八类清单见「Further improvements」(
text/22-fm-further-improvements.txt:88,搜「automated processes」);落点映射为我们的对照。 ↩ -
出处:「Further improvements」(
text/22-fm-further-improvements.txt:422,搜「email notification」)。 ↩ -
三步格言见
text/20-fm-creating-an-api.txt:1248;性能让位给正确性的顺序为我们对全书的归纳(第 14 章运维节为其落点)。 ↩ -
三平台命令见
text/20-fm-creating-an-api.txt:727(搜「Chocolatey」)、:734(搜「apt install」)、:743(搜「Homebrew」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:750,搜「touch dev.sqlite」)、:787(搜「AUTOINCREMENT」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:839,搜「DATABASE_URL=sqlite:dev.sqlite」)、:435(搜「dotenvy」)。 ↩ -
出处:「Creating an API」(
text/20-fm-creating-an-api.txt:453,搜「Anyhow」)。 ↩ -
三件遗产为我们的归纳;反代与 CORS 见
text/21-fm-creating-a-frontend.txt:54、:323。 ↩ -
教学法归纳为我们的判断;「临时前端」安排见
text/21-fm-creating-a-frontend.txt:10。 ↩ -
交接清单为我们的对账;换装时机见
text/32-fm-setting-up-an-orm.txt:4(搜「the right time」)。 ↩ -
出处:「Setting up an ORM」(
text/32-fm-setting-up-an-orm.txt:4,搜「paradigm shift」)。 ↩ -
卷尾为我们的归纳;验收链条见本篇 §4、§6、§7。 ↩
-
口诀见本篇 §14;四件套对应 §4(迁移)、§6(CI)、§3(模块化)、
12§6.5(dockerignore)。 ↩ -
四指标为我们的产品化建议;骨架定义见本篇 §14。 ↩
-
检查单为我们的产品化整理;四项见本篇 §4、§6、§7。 ↩