三十秒版:这一讲在干嘛
上一章(26~32 讲)做出了画图程序的浏览器端,顺手做了一个"假的"服务端——能跑,
但数据只在内存里、没有用户、没有真数据库。这一章(41~44 讲)就是把那个假服务端做成真的。而 41 讲是第一步,只做三件事:
① 把手写的 HTTP 处理代码,换成用一个【RPC 框架】 ② 讲清楚业务逻辑应该分成哪两层 ③ 把几乎没有的单元测试补起来★ 这三件事有一个共同点:它们一行业务逻辑都没改。
改的全是"业务外面那一圈"——协议、路由、序列化、错误码、测试。
而这正是 40 讲第 8.2 节说的那一圈。所以这一讲是 40 讲那份清单的第一次落地。
而原文有四处一带而过的地方值得停下来:
- “看起来不再那么像 HTTP 处理函数,倒像一个普通函数” —— 为什么这句话是整讲的重点,而不是"省了 40% 代码"?
- “为什么 ResponseWriter 接口是以指针方式传入?” —— 这一句话里藏着整个中间件机制。
- “心里多多少少有些不踏实” —— 这个不踏实是对的。它对在哪?
- “我们第一次开始依赖第三方的代码库” —— 结语这句轻描淡写的话,代价是什么?
而这一讲最好的抓手是一次 diff:换掉了整个 RPC 框架,业务代码一个字节都没改(详见 1.6)。
怎么读这份带读
| 这一部分是 | 建议 | |
|---|---|---|
| 第一部分(一) | 这一讲在干嘛 · 项目的三个文件 · "RPC 框架"是什么 · 然后才是那份 diff | 一定先读,尤其 1.1~1.4 |
| 第二部分 · 原文(二~五) | 41 讲本身讲了什么,把省掉的推导补上 | 主体 |
| 第三部分 · 引申(六~七) | 这套东西在 2026 年的样子 · 它有个学名 | 标了 ⚠ 非原文 |
⚠ 卡在 Go 语法上,去看专门那份:
带读 00 · 读懂 qpaint 服务端需要的 Go 底子
——只讲"读懂这份代码"所需的最少 Go,全部对上 Python,五个脚本都不用装 Go。
本篇 1.3 只是一张速查表。
只想搞懂某一件事:
这一讲在干嘛 → 1.1 | Go 读不懂 → 带读 00 | "RPC 框架"是什么 → 1.4 |
框架到底省了什么 → 二 | Env 和"指针" → 2.4 · 2.5 | 为什么要分两层 → 三 |
测试与 match → 四 | 依赖的代价 → 五
配套资源
| 东西 | 干什么 | 配合 | |
|---|---|---|---|
| 脚本 | RPC框架省掉了什么.py |
手写版 vs 框架版跑同样六个用例 · 行数对账 · 可测性差在哪 | 二 |
| 脚本 | Env就是上下文管理器.py |
OpenEnv/CloseEnv = with · “传指针”= 中间件 |
2.4 · 2.5 |
| 脚本 | 测试DSL便宜多少.py |
60 行实现 httptest DSL,跑原文那段脚本 · match 一个动作干两件事 |
四 |
| 源码 | v31-服务端/ |
mock 版(30 讲那一版,全手写 HTTP) | 一 |
| 源码 | v41-服务端/ |
restrpc 版。只有 service.go 和 service_test.go 变了;drawing.go · shape.go 与 v31 字节相同 |
一 · 三 |
| diff | service · service_test |
v31→v41 逐行对比 | 1.6 |
| 其他 | go.mod |
第一次出现的依赖清单 | 五 |
第一部分 · 地基
一 · 这一讲在干嘛,以及读它需要的三样东西
1.1~1.4 是原文假定你已经会的三样东西:
这一章的路线(1.1)· 这个项目的文件(1.2)· "RPC 框架"是什么(1.4)。
都不难,但缺了它们后面全是噪音。(Go 语法单独成篇,1.3 只是一张速查表。)1.5~1.7 才是这一讲的抓手。
python3 代码/RPC框架省掉了什么.py # 配合 1.5~1.6
1.1 先说清:这一讲在干嘛
整章的路线(不知道自己在哪,后面全是细节噪音):
26~32 讲(上一章) 做出了 qpaint 的【浏览器端】
29 讲顺手做了一个【mock 服务端】——能跑,
但数据只在内存里、没有用户、没有真数据库
41~44 讲(这一章) 把那个 mock 服务端【做成真的】
★ 41 讲(本讲) ① 换掉手写的 HTTP 处理 → 用 RPC 框架
② 讲清业务逻辑的分层
③ 补真正的单元测试
42 讲 多租户 + 换成真数据库(mongodb)
43 讲 帐号与授权(OAuth 2.0)
44 讲 继续实战
这一讲的三件事有一个共同点,也是它最容易被读漏的一点:
★★ 41 讲一行业务逻辑都没改。
它改的全是"业务外面那一圈"——协议、路由、序列化、错误码、测试。而 40 讲第 8.2 节刚说过:服务端可复用的部分,恰恰全是"和业务无关"的那一圈。
所以这一讲其实是 40 讲那份清单的第一次落地。 这就是它在干的事。
1.2 先认识这个项目:服务端只有三个文件
不用去 GitHub 翻,两个版本的完整源码都在 代码/qpaint源码/ 下,可以直接对着读。
| 文件 | 属于哪一层 | 干什么 | 行数 |
|---|---|---|---|
shape.go |
业务 · Model | 图形的数据定义:Point / Line / Rect / Ellipse / Path 各自长什么样 |
89 |
drawing.go |
业务 · Model | 一张图 + 一个文档:Drawing 管一张图里的图形(增删改查、调层次),Document 管所有图 |
241 |
service.go |
协议层 | 把 HTTP 请求翻译成对上面两个的方法调用 | 286 → 169 |
main.go |
组装 | 起 HTTP 服务,把路由表挂上去 | 几行 |
★ 注意一件事:只有
service.go跟 HTTP 有关系。
另外两个文件里不出现任何网络的东西——没有http、没有json、没有状态码。
这是 29 讲刻意做的(“Model 与网络无关”),而 41 讲要兑现它。
那 “v31 / v41” 是什么? 是 git 的两个 tag,也就是两个时间点的快照:
| 是什么时候的样子 | 服务端怎么处理 HTTP | |
|---|---|---|
| v31 | 上一章讲完时 | 全手写(自己解析 URL、自己解 JSON、自己写状态码) |
| v41 | 这一讲讲完时 | 用 restrpc 框架 |
1.3 读这一讲的 Go 代码:一张速查表
★ Go 语法全部交给专门那份:
带读 00 · 读懂 qpaint 服务端需要的 Go 底子(十一个机制 + 五个脚本,都不用装 Go)。
这里只放一张表,够你读完本篇引用的那些片段;卡住了就去查右边那一列。
先拆一行最典型的:
func (p *Service) GetShape(env *restrpc.Env) (shape Shape, err error) {
// ╰─── ① ───╯ ╰──── ② ────╯ ╰────── ③ ──────╯
// 谁的方法(=self) 参数 返回值
| 你会撞上的写法 | 一句话 | 详见 |
|---|---|---|
名字 类型(中间只有空格) |
念成冒号:err error → err : error |
带读00 · 2.2 |
func (p *Service) X(...) |
(p *Service) 就是 self |
带读00 · 2.3 |
(shape Shape, err error) |
返回两个值,而且起了名字 | 带读00 · 2.2 · 2.5 |
if err != nil { return } |
Go 没有异常,错误是返回值——满屏都是这个 | 带读00 · 2.4 · 2.5 |
光秃秃的 return |
等于 return 那些命名返回值 |
带读00 · 2.5 |
type X interface {...} |
方法要求清单,隐式实现,不用写 implements | 带读00 · 2.8 |
type Line struct { shapeBase; ... } |
嵌入:把字段名删掉,里面的东西被"提升"上来 | 带读00 · 2.7 |
ID ShapeID `json:"id"` |
反引号里是标签,决定 JSON 里叫什么名字 | 带读00 · 2.10 |
map[K]*V / []T / make |
字典 / 切片 / 必须先 make | 带读00 · 2.9 |
* & nil := |
指针 / 取地址 / None / 声明并赋值 |
带读00 · 2.11 |
| 首字母大写 = 导出 | Go 没有 public/private,用大小写,强制的 | 带读00 · 1.2 |
★ 其中跟本篇关系最大的两条:
①
if err != nil { return }满屏都是 —— 原文那"省掉的 40% 代码",一大半就是这个(1.6 会数)。②
interface是隐式实现的 —— 原文那个itfEnv interface { OpenEnv(...); CloseEnv() }
意思是"框架不管你的 Env 是什么类,只要它有这两个方法就行"。这是 2.4 节的全部基础。
1.4 什么是「RPC 框架」(先不看代码)
一个网络请求到达之后,从"一堆字节"到"你的业务函数"之间,有一段每个接口都一模一样的苦工:
① 从 URL 里抠出参数 /drawings/10001/shapes/3 → ("10001", "3")
② 把 body 的 JSON 变成对象 {"line":{...}} → 一个 Line 实例
③ ★ 调你的函数 drawing.Add(line) ← 只有这一步是你的业务
④ 把返回值变成 JSON 写回去
⑤ 把错误翻译成 HTTP 状态码 "找不到"→404,"参数不对"→400
★★ RPC 框架 = 把 ①②④⑤ 抽出来,让你只写 ③。就这一句。
而名字里的 RPC = Remote Procedure Call(远程过程调用)。它的理想是:让"调一个远程的函数"看起来跟"调一个本地的函数"一样。
于是它有一个很自然的验收标准:看你的函数签名里还剩多少"协议的东西"。
// v31:签名里全是【协议】
func (p *Service) PostShapes(w http.ResponseWriter, req *http.Request, args []string)
// ↑ 往哪回复 ↑ 原始请求 ↑ 一堆字符串
// v41:签名里全是【业务】
func (p *Service) PostShapes(aShape *serviceShape, env *restrpc.Env) (err error)
// ↑ 一个图形 ↑ 出了什么错
★★ 所以原文那句看起来随口的评论——“看起来不再那么像 HTTP 处理函数,倒像一个普通函数”——不是在夸代码好看。
它就是 RPC 框架的验收标准。
这也是为什么 2.3 节会说:重点不是省 40% 代码,是签名变了。
1.5 restrpc 在 Python 里就是 FastAPI
这一讲最省事的心智模型:手写版是裸 WSGI,restrpc 版是 FastAPI。
# ── 手写版(对应 v31):签名里全是【协议的对象】 ──
def PostShapes(self, w, req, args):
dgid = args[0] # 手动取 URL 参数
drawing, err = self.doc.get(dgid)
if err: ReplyError(w, err); return # 手动回错误
o = json.loads(req.body) # 手动解析 body
shape = Shape(**o)
err = drawing.add(shape)
if err: ReplyError(w, err); return
ReplyCode(w, 200) # 手动回成功
# ── 框架版(对应 v41):签名里全是【业务的对象】 ──
@app.post("/drawings/{dgid}/shapes")
def PostShapes(dgid: str, shape: Shape):
self.doc.get(dgid).add(shape) # 就这一句
| Go / restrpc | Python / FastAPI |
|---|---|
env.Args[0] |
路径参数 dgid: str |
参数列表里声明 aShape *serviceShape → 自动解析 body |
参数列表里声明 shape: Shape → Pydantic 自动解析 |
返回值列表里 (shape Shape, err error) → 自动序列化 |
-> Shape → 自动序列化 |
err 非空 → 框架翻成状态码 |
raise HTTPException / 异常处理器 |
routeTable 或方法名约定 |
装饰器 @app.post(...) |
restrpc.Env(OpenEnv/CloseEnv) |
依赖注入 Depends(...) + 中间件 |
★ 所以这一讲不是在教一个七牛的私有库。
它在讲所有现代 Web 框架共有的那一件事:把协议边界上的类型转换自动化。
你在 Python 里已经天天在用它,这一讲是在解释"它凭什么能这么做、代价在哪"。
1.6 ★ 那份 diff:删掉了哪些函数
先看那张 diff 的全貌(我逐文件对比过两个版本):
| v31 → v41 | |
|---|---|
service.go(协议层) |
286 → 169 行,重写 |
service_test.go(测试) |
75 → 55 行,重写 |
drawing.go(业务层,241 行) |
一个字节没改 |
shape.go(业务层,89 行) |
一个字节没改 |
shape_test.go · main.go · README.md |
一个字节没改 |
| 浏览器端全部文件 | 一个字节没改 |
★ 换掉了整个 RPC 框架,只动了协议层那一个文件。
这不是巧合——它就是第三节那个"业务逻辑的分层"在起作用。
41 讲讲了这个分层,同时用一次 diff 证明了它成立(谁的功劳,见 1.7)。
从 service.go 里整体消失的顶层函数(自己 grep 两个版本对比可得):
func Reply(w, code, data) ← 序列化 + 写 header + 写状态码
func ReplyCode(w, code) ← 只写状态码
func ReplyError(w, err) ← 把 syscall 错误码翻成 HTTP 状态码
func getRoute(req) (route, args) ← 从 URL 解析出路由和参数
func (p *Service) ServeHTTP(w, req) ← 分发
type RouteTable map[string]func(...) ← 路由表的类型
连带消失的 import:bytes encoding/json io strconv strings syscall。
新增的 import 只有两个:github.com/qiniu/http/restrpc、github.com/qiniu/x/jsonutil。
★ 注意消失的这一批东西的共同点:它们全是"协议",没有一个是"画图"。
一个框架能替你删掉的,恰好就是跟你的业务无关的那部分。
反过来说:如果引入一个框架之后你的业务代码变多了,那八成是框架选错了。
1.7 ★ 那句"业务层零改动"到底是谁的功劳
值得分清楚,因为这是两件事:
| 是什么 | 谁的功劳 | |
|---|---|---|
| service.go 变短了 | 框架替你写了样板 | restrpc 的功劳 |
| drawing.go / shape.go 一个字没改 | 业务层根本不知道 HTTP 换了 | 29 讲那个分层的功劳 |
★ 第二件事比第一件重要得多。
换框架是件大事——如果业务层里到处import net/http、到处w.Write(...),
那这次改造要动的就不是 2 个文件,而是全部 6 个。29 讲把 Model 层做成"与网络无关"的时候,就已经把 41 讲的改造成本提前付掉了。
这就是分层的兑现方式:它平时看不出好处,只在你要换东西的那天,一次性还给你。
第二部分 · 原文这一讲
二 · RPC 框架省掉了什么
2.1 逐行对照(原文那两个例子)
// ── 建一个新图形 ──────────────────────────────────
// v31(手写) 19 行
func (p *Service) PostShapes(w http.ResponseWriter, req *http.Request, args []string) {
id := args[0]
drawing, err := p.doc.Get(id)
if err != nil { ReplyError(w, err); return }
var aShape serviceShape
err = json.NewDecoder(req.Body).Decode(&aShape)
if err != nil { ReplyError(w, err); return }
err = drawing.Add(aShape.Get())
if err != nil { ReplyError(w, err); return }
ReplyCode(w, 200)
}
// v41(restrpc) 6 行
func (p *Service) PostShapes(aShape *serviceShape, env *restrpc.Env) (err error) {
id := env.Args[0]
drawing, err := p.doc.Get(id)
if err != nil { return }
return drawing.Add(aShape.Get())
}
// ── 取一个图形(返回包复杂一些)─────────────────────
// v31 14 行 → v41 9 行
func (p *Service) GetShape(env *restrpc.Env) (shape Shape, err error) {
id := env.Args[0]
drawing, err := p.doc.Get(id)
if err != nil { return }
shapeID := env.Args[1]
return drawing.Get(shapeID)
}
最极端的一个(原文也举了):
func (p *Service) DeleteDrawing(env *restrpc.Env) (err error) {
id := env.Args[0]
return p.doc.Delete(id)
}
两行。 记住这个"两行",第三节要用它当分层是否成立的度量。
2.2 省掉的 117 行,是同一件事重复 8 遍
| 手写版要做 | restrpc 怎么替你做 | 每个方法省 |
|---|---|---|
从 args[i] 取 URL 参数 |
env.Args[i](一样,没省) |
0 |
json.NewDecoder(req.Body).Decode(&x) + 判错 |
在参数列表里声明类型 | 3~4 行 |
每一步 if err != nil { ReplyError(w, err); return } |
return err,框架翻译成状态码 |
2 行 × 2~3 处 |
json.Marshal + 写 header + 写状态码 |
return 值,框架序列化 |
1~2 行 |
| 路由解析 + 分发 + 错误码映射(全局) | 框架自带 | ~60 行,一次性 |
脚本里用两个方法量了一下:33 行 → 6 行。真实的 8 个方法:286 → 169 行(原文口径 280 → 163)。
★ 一个框架的价值 ≈ 它替你消掉了多少重复。
而重复的位置很有讲究:这 117 行全在协议与业务的边界上。
边界上的代码天生重复,因为边界两侧的类型系统不一样,每次穿过去都要翻译一遍。
2.3 ★ 重点不是省 40% 代码,是签名变了
原文这句话是整讲的重点,很容易被当成一句闲话划过去:
“restrpc 版本的 HTTP 请求的处理函数,看起来不再那么像 HTTP 处理函数,倒像一个普通函数。”
手写版: func PostShapes(w http.ResponseWriter, req *http.Request, args []string)
^^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^ 全是协议的东西
框架版: func PostShapes(aShape *serviceShape, env *restrpc.Env)
^^^^^^^^^^^^^^^^^^^^ 是业务的东西
为什么这件事这么值钱?因为它决定了这个函数能不能被便宜地测。 脚本第三节实测:
── 测框架版:直接调用 ──
svc.PostShapes(Shape(id="7", x=1, y=2), Env(Args=["10001"], Req={}))
got = svc.GetShape(Env(Args=["10001","7"], Req={}))
assert got.x == 1 ✓ 没有 HTTP、没有端口、没有 json
── 测手写版:得先造一个假的写回器和假的请求 ──
假请求 = {"body": '{"id":"7","x":1,"y":2}'} ← 要手动 json.dumps 一遍
svc.PostShapes(假写回器, 假请求, ["10001"])
拿到的是 HTTP 包:200 {"id":"7","x":1,"y":2}
要断言 x == 1,还得 json.loads 回来 ——【测试在重新实现一遍协议】
★ 一个函数的可测性 ≈ 它的签名里有多少"环境对象"。
这条判断可以直接搬到你的 Python 代码里:
签名 要测它得先干什么 def handler(request)造一个 request(Django/Flask 的痛) def sync(config, db_conn, logger)造三个假对象 def handler(uid: int, shape: Shape) -> Shape直接调 原文那句"我们不用再需要包装服务的 Client SDK,然后再基于 Client SDK 做单元测试",
说的就是这件事:手写版的测试必然长成半个 Client SDK。
2.4 Env:它不是参数袋子,是 with 语句
python3 代码/Env就是上下文管理器.py
原文这段很短,但要点全在里面:
type itfEnv interface {
OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error
CloseEnv()
}
“在 OpenEnv 方法中,我们一般进行 Env 的初始化工作。CloseEnv 方法则反之。”
restrpc 对每个请求做的事,翻成 Python 就是一个 with:
with Env(w, req) as env: # ← OpenEnv
handler(args..., env)
# ← CloseEnv(无论成功失败都执行)
| Go / restrpc | Python |
|---|---|
OpenEnv |
__enter__ |
CloseEnv |
__exit__ / finally / contextlib |
defer env.CloseEnv() |
with 块退出 |
★ 所以 Env 表达的不是"一堆参数",是【一次请求的生命周期】。
请求结束一定要做的事——关连接、还回连接池、打访问日志、上报耗时——都放CloseEnv。而"框架并没有限定 Env 类具体是什么样子的,只是规定它需要满足以下接口"这句话,
是 41 讲留给 42 讲的接缝:42 讲要往里塞一个UID。见 2.7。
2.5 ★ 「ResponseWriter 以指针方式传入」= 中间件机制
原文自己提问自答了,但答得很短:
“为什么 OpenEnv 方法中,ResponseWriter 接口是以指针方式传入?因为可能会有客户希望改写 ResponseWriter 的实现。
比如,假设我们要给 RPC 框架扩展 API 审计日志的功能。那么我们就需要接管并记录用户返回的 HTTP 包。”
为什么非要"换掉整个对象",不能事后读一下?
ResponseWriter 是【只写】的。数据一旦 w.Write() 出去就进了 socket,
事后你无从知道刚才写了什么。想记录,只能在写的路上截一刀。
于是要有能力把 w 替换成一个包了一层的 w'——而"替换调用者手里的那个变量",
在 Go 里就必须传指针(*http.ResponseWriter)。
class 审计写回器: # 包一层
def __init__(self, 内层): self.内层, self.审计 = 内层, None
def 写(self, code, data=None):
self.审计 = (code, data) # ← 抄一份
self.内层.写(code, data) # ← 照常转交
★ 这个"包一层再传下去"的形状,就是中间件(middleware)的全部机制:
生态 长什么样 Python / WSGI · ASGI app = 审计中间件(压缩中间件(app))Go / net/http handler = Audit(Gzip(handler))restrpc / Env 在 OpenEnv里把*w指向一个包过的实现你早就懂的那个 装饰器 @审计判断:一个框架允不允许你"换掉环境对象",决定了你能不能在【不改业务代码】的
前提下加日志、限流、压缩、审计。这个口子的有无,就是框架可扩展性的分水岭。顺带一句:这也是为什么 Go 的
http.ResponseWriter是个 interface 而不是 struct——
只有接口才包得住。接口在这里的作用不是"多态",是"可包装"。
2.6 路由:约定 vs 配置
restrpc 支持两种,原文都给了:
① 自动路由(按方法名)
POST /drawings/<DrawingID>/shapes → 方法名 PostDrawings_Shapes
GET /drawings/<DrawingID>/shapes/<ShapeID> → 方法名 GetDrawings_Shapes_
规则:路径里的 "/" 由单词首字母大写分隔;URL 参数替换成 "_"
② 手工路由(routeTable)—— QPaint 选的是这个
{"GET /drawings/*/shapes/*", "GetShape"},
仍有限制:方法名必须 Get / Put / Post / Delete 开头
原文说"有的人会认为这种方法名字看起来很丑"。丑只是表面,真问题在别处:
| 自动路由 | 手工路由表 | |
|---|---|---|
| 要维护映射吗 | 不用(名字即路由) | 要维护一张表 |
| 改路径 | 必须改方法名 | 改表的一行 |
| 改方法名 | 等于悄悄改了对外协议 ⚠ | 无影响 |
| 一眼看全协议 | 要扫遍所有方法 | 表就是协议(v41 那 8 行) |
★ 自动路由把"协议"和"代码结构"绑成了一体——这既是它的优点也是它的缺点。
优点是没有可能不一致;缺点是一次重命名重构就能改掉对外协议,而编译器不会报错。而 29 讲第 3.4 节那条判断在这里又用上了:协议一旦发出去就收不回来。
所以 QPaint 选手工表是对的——多维护一张表,换来"改代码不会改协议"。这就是 Rails 那句"约定优于配置"(convention over configuration)的代价:
约定省掉了配置,但也让"改名"变成了一个有外部后果的动作。
2.7 Env 这个接缝,42 讲立刻兑现
41 讲讲了 itfEnv 这个机制,但 QPaint 一点没用——restrpc.Env 就是个空壳。
42 讲第一件事就是往里塞东西:
type Env struct {
restrpc.Env // ← 嵌进来
UID UserID // ← 加一个字段
}
func (p *Env) OpenEnv(rcvr interface{}, w *http.ResponseWriter, req *http.Request) error {
解析 Authorization 头 → p.UID = uid
return p.Env.OpenEnv(rcvr, w, req)
}
★ 于是"授权在哪一层发生"这件事被钉死了:在每个 handler 之前,在
OpenEnv里。
每个业务方法只是多认识一个env.UID。
43、44 讲把 mock 授权换成真 OAuth,改的还是这一个OpenEnv。Python 里对应的位置:FastAPI 的
Depends(get_current_user)、Flask 的before_request。
接缝定对了,换实现就是局部替换。
三 · 业务逻辑的分层
3.1 两层,和它们的边界
paintdom/
├── service.go (169 行) RESTful API 层 ← 41 讲重写了它
├── drawing.go (241 行) ┐
└── shape.go ( 89 行) ┘ 业务逻辑实现层(DOM 树)← 41 讲一个字没改
原文的定义:
“一层是业务逻辑的实现层,通常我们有意识地把它组织为一颗 DOM 树。
另一层则是 RESTful API 层,它负责接收用户的网络请求,并转为对底层 DOM 树的方法调用。”
3.2 sqlite / mysql 那个类比,值得展开
原文的类比很准,但只有两句:
“我们都知道 mysql 是通过 TCP 协议提供服务接口的,而 sqlite 是嵌入式数据库,是通过本地的函数调用提供服务接口的。
这里分层就类似于我实现 mysql 的时候,先在底层实现了一个类似 sqlite 的嵌入式数据库,然后再提供基于 TCP 协议的网络接口。”
这个形状在真实世界里到处都是,看一批就懂了:
| 里面那个"库" | 外面那个"服务" | 谁能用里面那个 |
|---|---|---|
| SQLite / DuckDB | MySQL / PostgreSQL | 你的进程里直接 import |
| Lucene | Elasticsearch / Solr | 任何 Java 程序 |
| Git 的 plumbing 命令 | GitHub / Gitea | 本地脚本 |
| libgit2 | git daemon | 任何语言(有绑定) |
drawing.go + shape.go |
service.go |
一个 CLI 工具、一个迁移脚本、一个单元测试 |
★ 判断:网络协议是【一种】使用方式,不是【唯一】使用方式。
一旦你把业务逻辑写死在网络协议里,你就永久失去了"不走网络地用它"的能力。
3.3 ★ 分层是否成立,有一个可观测的指标:API 层有多薄
func (p *Service) DeleteDrawing(env *restrpc.Env) (err error) {
id := env.Args[0]
return p.doc.Delete(id)
}
两行。这不是代码风格,这是一个体检指标。
| API 层方法里出现了什么 | 说明 |
|---|---|
| 只有"取参数 + 调一次业务方法 + 返回" | ✓ 分层成立 |
if / for / 业务判断 |
⚠ 业务漏到协议层了 |
| 直接访问数据库 | ✗ 分层已经没有了 |
| 事务的 begin/commit | ⚠ 边界画错了(事务是业务层的事) |
★ 一个很实用的自查:把你的 API 层所有 handler 的平均行数量出来。
如果它在 5 行以内,分层大概是真的;如果它在 50 行,那"分层"只是目录结构上的分层。顺带解释了为什么 41 讲必须先引入 restrpc:
手写版的 handler 天生就有 14~19 行,你根本看不出"业务有没有漏进来"——
因为协议样板已经把它淹掉了。框架把样板抽走之后,这个指标才变得可读。
3.4 反过来会痛在哪:四个具体的痛
原文给了两条正面理由(可能不需要网络、可能要多协议)。从反面看更有说服力。
假设业务逻辑直接吃 *http.Request:
| 场景 | 痛在哪 |
|---|---|
| 写单元测试 | 每个测试都要造一个 http.Request(就是 2.3 那个) |
| 写一个 CLI 工具(批量导出所有 drawing) | 你得起一个 HTTP server,然后自己调自己 |
| 写一次数据迁移脚本 | 同上,或者绕过业务层直接改库——于是业务校验被跳过了 |
| 做压测/回放 | 只能走网络,压不到业务逻辑本身的性能 |
★ 第三条最要命:一旦"不走 HTTP 就用不了业务逻辑",人们就会选择绕开它直接改数据库。
分层的失守,往往不是从"写错"开始的,是从"绕过去"开始的。
3.5 多协议并存:关键在"共享同一份实例"
原文:
“在需要同时支持多套网络接口的时候,这种分层的价值就体现出来了,不同网络接口的模块之间,
共享了同一份 DOM 树的实例,整个体系不仅实现了多协议并存,还实现了完美的解耦。”
RESTful API 层 GraphQL 层 gRPC 层
│ │ │
└───────────┬───────┴───────────────┘
▼
同一个 *Document 实例 ← 一份状态
│
drawing.go / shape.go
★ "共享同一份实例"这四个字是重点。
常见的错法是:为 GraphQL 再写一套业务逻辑,两套各自读数据库。
于是你有了两份实现同一个业务规则的代码,它们迟早不一致——
而且不一致的表现是"从 REST 改的数据,从 GraphQL 看是另一个样"。原文举了 GitHub 的例子(REST → GraphQL)。GitHub 至今两套 API 并存,
而且原文那句话很清醒:“我们不可能为了赶时髦,就把老用户弃之不顾了。”
3.6 这条跟前面五讲是同一条
| 讲 | 说的是 |
|---|---|
| 26~28 | Model 与显示无关——dom.js 不知道有 canvas |
| 29 | Model 与网络无关——drawing.go 返回 syscall.ENOENT,不知道有 404 |
| 30(带读-07) | Model 层的厚度——什么该放进 Model |
| 41 | 业务与协议无关——换掉整个 RPC 框架,drawing.go 一个字没改 |
★ 四句话是同一句:把业务从它所处的环境里剥出来。
环境是"屏幕"、是"网络"、是"某个 RPC 框架"、是"某个数据库"——都一样。
凡是"将来可能换"的东西,都是环境;凡是"换了它业务还是那个业务"的,都是业务。
四 · 单元测试
python3 代码/测试DSL便宜多少.py
4.1 原来的测试为什么那么弱:因为它贵
v31 的全部业务测试(service_test.go):
func TestNewDrawing(t *testing.T) {
ts := newServer()
defer ts.Close()
var ret idRet
err := Post(&ret, ts.URL + "/drawings", "")
if err != nil { t.Fatal("Post /drawings failed:", err) }
if ret.ID != "10001" { t.Log("new drawing id:", ret.ID) }
}
原文说"我们单元测试基本上没怎么做",还很客气。真实情况是:
- 只测了一件事:能建一个 drawing。
- 而且
if ret.ID != "10001" { t.Log(...) }—— 是t.Log不是t.Error。
也就是说它连这一件事都没真的断言,只是打了行日志。 - 为了写这 7 行,还先手写了
Post()(12 行)和newServer()(5 行)两个辅助函数。
如果要手写"建 drawing → 加一条直线 → 取回来比对"这条链路,要多少行?
起 server 3 行
POST /drawings,解 json 取 id 6 行
构造那条直线的结构体(line/pt1/pt2/style 四层嵌套) 12 行
json.Marshal,POST 上去,判 200 7 行
GET 回来,json.Unmarshal 成结构体 6 行
逐字段 assert(12 个字段) 12 行
────────────────────────────────────────────────────────────
~46 行
★ 测试写得少,往往不是因为不想写,是因为写起来太贵。
DSL 不是在"让测试更漂亮",是在降低测试的单价。
而单价一降,人们自然就多写了——原文那段"实际我们应该去测试更多的情况"才成立。
4.2 DSL 版:v41 真实的测试代码
ctx := httptest.New(t)
ctx.SetTransport(transport)
ctx.Exec(`
post http://qpaint.com/drawings
ret 200
json '{ "id": $(id1) }'
match $(line1) '{
"id": "1",
"line": {
"pt1": {"x": 2.0, "y": 3.0},
"pt2": {"x": 15.0, "y": 30.0},
"style": { "lineWidth": 3, "lineColor": "red" }
}
}'
post http://qpaint.com/drawings/$(id1)/shapes
json $(line1)
ret 200
get http://qpaint.com/drawings/$(id1)/shapes/1
ret 200
json $(line1)
`)
if !ctx.GetVar("id1").Equal("10001") { t.Fatal(`$(id1) != "10001"`) }
先看一条容易漏掉的语法规则(脚本里实现了):
| 位置 | json 的含义 |
|---|---|
ret 之前 |
这是请求体 |
ret 之后 |
这是对返回包的匹配 |
因为原文说了:“请求发出去的时间是在
ret指令执行的时候。”
req/header/auth/body都只是在攒一个请求,ret才是扣扳机。
4.3 ★ match 为什么是"这套 DSL 中最核心的概念"
原文说了这句话但没解释。答案是:它一条指令干两件事。
$(id1) 还没绑定 → 把实际值【记下来】 (提取)
$(id1) 已经绑定 → 和记着的值【比一比】 (断言)
脚本实测:
✓ 第一次见 $(id) —— 提取 $.id: $(id) ← '507f1f77' 【提取】
✓ 第二次见 $(id) —— 断言(一致) $.id: $(id) 断言通过
✗ 第三次见 $(id) —— 断言(不一致) $.id: $(id)='507f1f77' ≠ '别的'
于是那段脚本的上下两个请求就串起来了:
post /drawings
ret 200
json '{"id": $(id1)}' ← ① 断言"有个 id 字段",② 把它的值记进 id1
post /drawings/$(id1)/shapes ← ③ 用它拼 URL
match 还有第二条隐含规则:只比期望里写了的字段。 脚本实测:
match '{"id":"1"}' 对上 {"id":"1","line":{...},"createdAt":"2026-08-31"} → ✓
★ 这叫【部分匹配】,它是接口能向后兼容演进的必要前提。
如果 DSL 要求全等,那服务端多返回一个字段就会让所有测试集体失败——
于是没人敢加字段。一套要求全等的测试,会把接口冻住。
这也解释了原文为什么要特意区分 match / equal / equalSet:
| 指令 | 允许未绑定变量 | 精确相等 | 用来干什么 |
|---|---|---|---|
match |
允许(这是它的核心能力) | 部分匹配 | 表达期望(顺带提取变量) |
equal |
不允许 | 要求全等 | 纯断言 |
equalSet |
不允许 | 数组先排序再比 | 测 list 类 API——次序不可预期 |
equalSet那条很实用:"列出目录下所有文件"这类 API,你能预期有哪些,
但不能预期它们的顺序。 拿equal去测这种接口,就会得到一个随机失败的测试。
4.4 mockhttp「不真去监听端口」换来了什么
原文只留了一句"感兴趣的同学可以研究一下"。它换来四件事:
| # | 换来 | 为什么 |
|---|---|---|
| 1 | 测试可以并行跑 | 真监听端口的话,两个测试同时起服务就撞端口,只能串行或搞端口池 |
| 2 | 没有 flaky | 没有网络抖动、没有 TIME_WAIT 堆积、没有"偶尔失败" |
| 3 | 快 | 省掉三次握手和内核拷贝 |
| 4 | 协议层仍然真的跑了 | 路由、序列化、header 都执行了 |
★ 第 4 条是关键:mockhttp 不是"跳过 HTTP",是"跳过 TCP"。
协议还在,只是不过网卡。Python 世界里同一个东西,你大概天天在用:
框架 那个"mockhttp" Flask app.test_client()Django django.test.ClientFastAPI TestClient(app)(httpx 的ASGITransport)requests responses/httpx.MockTransport所以"测试要用 test_client,不要
requests.get('http://localhost:5000')"
这条 Python 常识,背后就是 mockhttp 这套道理。
4.5 ★ 那句"心里多多少少有些不踏实",是对的
原文很诚实地承认了一件事:
“我们有这样的一种低成本测试方式,但还是会担心这种测试方法可能不能覆盖一些编码上的小意外,
毕竟我们没有走 HTTP 协议,心里多多少少有些不踏实。”
这个不踏实对在哪?把 Service 当普通类测,测不到这些:
| 测不到 | 为什么 |
|---|---|
| 路由表写错 | 路径→方法名的映射根本没被执行 |
json:"..." 标签写错 |
没走序列化,字段名错了也发现不了 |
| 状态码翻译错 | 框架翻译那一层被跳过了 |
| header / Content-Length | 协议细节 |
★ 于是 41 讲那两件事其实是一件事的两半:
restrpc 把"业务"从"协议"里剥出来 → 于是能便宜地测业务(多数测试) httptest 把"协议"完整地跑一遍 → 补上剥出来时丢掉的那部分(少量测试)这就是测试金字塔:底下一大层便宜的业务测试,上面一小层贵的协议测试。
原文的"不踏实"不是遗憾,是在说明为什么两种测试都要有。
五 · 第一次有了外部依赖
原文结语里有一句很轻的话,其实是这一讲的第三个产出:
“这样我们第一次开始依赖第三方的代码库……一旦有了外部依赖,我们就需要考虑依赖库的版本管理。”
v41 的 go.mod——这个文件在 v31 里只有三行(module + go version),现在多了:
require (
github.com/qiniu/http v0.0.0-20190911142430-e7fd9badfb42 ← restrpc
github.com/qiniu/qiniutest v1.0.1 ← httptest DSL
github.com/qiniu/x v0.0.0-20190911131702-ec64d9399366 ← mockhttp / jsonutil
)
注意那两个 v0.0.0-2019...-e7fd9ba —— 那不是版本号,是"某个 commit 的时间 + hash"。
它叫伪版本(pseudo-version),出现在上游没打过 tag 的时候。
| 对应 Python | |
|---|---|
go.mod 的 require |
requirements.txt / pyproject.toml 的依赖声明 |
go.sum(记每个模块的哈希) |
requirements.txt 的 --hash= / poetry.lock / uv.lock |
伪版本 v0.0.0-<时间>-<hash> |
git+https://…@a1b2c3d |
★ 判断:依赖不是"引入了一个库",是【引入了一个别人的发布节奏】。
具体要背的三件事:
锁 没有 lock 文件,"同一份代码"在两台机器上会编出不同的程序 供应链 你现在信任的不只是 qiniu,还有 qiniu 信任的所有人(传递依赖) 上游停更 伪版本尤其危险——上游连 tag 都不打,说明它没有发布纪律 一个具体的印证:
github.com/qiniu/qiniutest是v1.0.1(打过 tag),
而qiniu/http和qiniu/x是伪版本。从 go.mod 就能读出上游的成熟度。
第三部分 · 引申
⚠ 这一部分不是原文内容。 原文写于 2019 年,讲的是七牛的三个内部库;
这一节回答"这套东西今天叫什么、在 Python 里怎么用、它有没有学名"。
六 · ⚠ 2026 年的对照
6.1 restrpc 的位置:三条路
| 路线 | 代表 | 协议怎么定的 | 什么时候选它 |
|---|---|---|---|
| 注解式 REST | FastAPI · Spring Boot · restrpc | 代码里的类型/装饰器就是协议 | 对外开放 API、要给人看的接口 |
| IDL 先行 | gRPC · Thrift · Connect | 先写 .proto,代码从它生成 |
内部服务之间、多语言、要强约束 |
| Schema 后生成 | FastAPI 自动出 OpenAPI | 代码 → 文档 | 想两头兼顾 |
★ 两条路的分水岭是"谁是真源":
注解式里代码是真源,协议是它的投影——所以改名字就能悄悄改协议(2.6 那条)。
IDL 式里**.proto是真源**,代码是它的产物——所以改协议必须动那个文件,动了大家都看得见。41 讲选手工
routeTable,等于在注解式里手动补了一个"真源"——那 8 行就是 IDL 的雏形。
6.2 41 讲讲的东西有个学名
第三节那个分层,在架构文献里有名字,而且是同一个东西的三个说法:
| 名字 | 提出者 | 说法 |
|---|---|---|
| 六边形架构 / 端口与适配器 | Alistair Cockburn, 2005 | 业务在中间,每种外部交互是一个"端口 + 适配器" |
| 洋葱架构 | Jeffrey Palermo, 2008 | 依赖只能从外向内 |
| 整洁架构 | Robert C. Martin, 2012 | 同上,加一条"依赖倒置" |
外面:会换的东西
┌───────────────────────────────┐
│ RESTful GraphQL gRPC CLI │ ← 适配器(41 讲的 service.go)
│ ┌─────────────────────────┐ │
│ │ 业务逻辑(DOM 树) │ │ ← 内核(drawing.go / shape.go)
│ │ 不认识上面任何一个 │ │
│ └─────────────────────────┘ │
│ MongoDB 内存 文件 测试桩 │ ← 适配器(42 讲要换的那个)
└───────────────────────────────┘
箭头【只能向内】
★ 但要说句实话:这些名字比它们表达的内容重得多。
41 讲用两句话(“业务逻辑不假设一定通过 RESTful 暴露” + “sqlite/mysql 的类比”)
说完了三本书讲的事,而且给了一个可验证的指标(3.3 那个"handler 有多薄")。
认这个名字有用的地方只有一个:跟别人讨论时能少解释十分钟。
6.3 那句"没走 HTTP 心里不踏实",今天有了第三条路
2019 年只有两个选项(当普通类测 / 走真 HTTP)。今天多了一层:
| 层 | 工具 | 覆盖什么 |
|---|---|---|
| 业务层测试 | 直接调函数 | 业务分支 |
| 进程内协议测试 | mockhttp · app.test_client() |
路由 + 序列化 + 状态码,不过 TCP |
| 契约测试(新) | Pact · Schemathesis · OpenAPI diff | 协议本身有没有破坏兼容 |
| 端到端 | 真服务 + 真网络 | 部署、配置、网关 |
★ 契约测试是 41 讲那条判断的自然延伸:
29 讲说"协议一旦发出去就收不回来",41 讲说"要严谨对待",
但两讲都没说怎么在 CI 里自动挡住"不小心改坏了协议"。
契约测试就是那道闸:把上一版的 OpenAPI/proto 存下来,新版跟它 diff,
删字段、改类型、收窄枚举 → CI 直接红。
七 · 收口:这一讲的三个产出
① 引入 restrpc → handler 的签名里只剩业务类型 → 能便宜地测
② 确立两层分层 → 换框架时业务层零改动(那份 diff 就是证据)
③ 引入 httptest / DSL → 测试单价降下来,于是测试才写得起
(代价:第一次有了外部依赖,从此要管版本)
串成一句话:
★ 41 讲干的是同一件事:把"跟画图无关的东西"从 QPaint 里挪出去。
协议样板挪给了 restrpc,测试样板挪给了 httptest。挪完之后剩下的
drawing.go+shape.go(330 行)才是 QPaint 本身——而它一个字都没改。这条主线会贯穿整个后端四讲:
42 讲把"存数据"挪给 mongodb,43/44 讲把"帐号授权"挪给 dex。
到 44 讲结束,你会发现服务端实战的全部内容就是"认出哪些不该自己写"。
验收
地基(一)
| # | 问题 | 答案在 |
|---|---|---|
| 1 | 这一讲在整章的什么位置?它做的三件事有什么共同点,和 40 讲是什么关系? | 1.1 |
| 2 | 「RPC 框架」一句话是什么?它的验收标准是什么? | 1.4 |
| 3 | v31 → v41 一共改了几个文件?没改的那些说明了什么? | 1.6 · 1.7 |
| 4 | restrpc 在 Python 里对应什么?它自动化的到底是哪一类工作? | 1.5 |
原文(二~五)
| # | 问题 | 答案在 |
|---|---|---|
| 5 | 省掉的 117 行是什么?为什么原文说"重点是它不像 HTTP 处理函数"而不是"省了 40%"? | 二 |
| 6 | OpenEnv/CloseEnv 在 Python 里是什么?为什么 ResponseWriter 要传指针? |
2.4 · 2.5 |
| 7 | 自动路由(按方法名)有什么隐藏代价?QPaint 为什么选手工路由表? | 2.6 |
| 8 | 怎么用一个可观测的指标判断"业务/协议分层"是不是真的成立? | 3.3 |
| 9 | v31 的测试弱到什么程度?DSL 真正解决的是什么问题? | 4.1 |
| 10 | match 为什么是这套 DSL 最核心的概念?它和 equal、equalSet 差在哪? |
4.3 |
| 11 | mockhttp 不监听端口换来了什么?它跳过的是 HTTP 还是 TCP? | 4.4 |
| 12 | 那句"心里不踏实"为什么是对的?它推出了什么结论? | 4.5 |
引申(六~七)
| # | 问题 | 答案在 |
|---|---|---|
| 13 | 注解式框架和 IDL 先行(gRPC)的分水岭是什么? | 6.1 |
| 14 | 41 讲那个分层的学名叫什么?知道这个名字的实际用处是什么? | 6.2 |
答案
1. 上一章(26~32 讲)做出了浏览器端 + 一个假服务端(数据只在内存、没用户、没数据库);
这一章(41~44)把它做成真的,41 讲是第一步,做三件事:换 RPC 框架 / 讲清分层 / 补单元测试。
共同点是:一行业务逻辑都没改——改的全是"业务外面那一圈"(协议、路由、序列化、错误码、测试)。
而 40 讲第 8.2 节刚说过"服务端可复用的部分恰恰全是和业务无关的那一圈"——
所以这一讲是 40 讲那份清单的第一次落地。
2. 一个请求从"字节"到"你的函数"之间,有一段每个接口都一样的苦工:
①从 URL 抠参数 ②把 body 的 JSON 变成对象 ③调你的函数 ④把返回值变成 JSON ⑤把错误翻成状态码。
RPC 框架 = 把 ①②④⑤ 抽出来,让你只写 ③。
名字里的 RPC = Remote Procedure Call,理想是让"调远程函数"看起来跟"调本地函数"一样。
验收标准就是:你的函数签名里还剩多少"协议的东西"。
所以原文那句"看起来不再那么像 HTTP 处理函数,倒像一个普通函数"不是在夸代码好看——它就是这个验收标准。
3. 两个:service.go(286→169)和 service_test.go(75→55)。drawing.go、shape.go、shape_test.go、paintweb/main.go、README.md、以及浏览器端全部文件,都是字节相同。
没改的那些说明:换掉整个 RPC 框架,业务层根本不知道发生了什么。 这是 29 讲"Model 层与网络无关"那个设计在 41 讲兑现——分层平时看不出好处,只在你要换东西的那天一次性还给你。
4. 对应 FastAPI(手写版对应裸 WSGI)。它自动化的是协议与业务边界上的类型转换:URL 参数提取、请求体反序列化、返回值序列化、错误码翻译。这类代码天生重复,因为边界两侧的类型系统不一样,每次穿过去都要翻译一遍。
5. 省掉的全是同一件事重复 8 遍:json.Decode(req.Body) + 判错、每一步的 if err != nil { ReplyError; return }、json.Marshal + 写 header + 写状态码,再加一次性的路由解析/分发/错误码映射(约 60 行)。
重点在签名:手写版收的是 http.ResponseWriter / *http.Request(协议对象),框架版收的是 *serviceShape / *restrpc.Env(业务对象)。一个函数的可测性 ≈ 它的签名里有多少环境对象。 手写版要测就得造假 request、造假 writer,然后把返回的 HTTP 包再 json.loads 回来断言——测试在重新实现一遍协议,必然长成半个 Client SDK。
6. OpenEnv/CloseEnv 就是 __enter__/__exit__——Env 表达的不是"参数袋子",是一次请求的生命周期(请求结束一定要做的事放 CloseEnv)。
ResponseWriter 传指针,是为了让中间件能换掉整个写回器对象。必须换掉而不能事后读,因为写回器是只写的:数据 Write() 出去就进了 socket,事后无从知道写了什么,想记录只能在写的路上截一刀。而"包一层再传下去"就是中间件/装饰器的全部机制。这个口子的有无,决定了能不能在不改业务代码的前提下加日志、限流、审计。
7. 隐藏代价:方法名就是对外协议。改路径必须改方法名;反过来,一次重命名重构就能悄悄改掉对外协议,而编译器不会报错。而 29 讲那条"协议一旦发出去就收不回来"仍然成立,所以 QPaint 宁愿多维护一张 8 行的表——换来"改代码不会改协议",而且那张表本身就是一眼看全的协议。
8. 量 API 层 handler 的平均行数。 5 行以内,分层大概是真的;50 行,那"分层"只是目录结构。DeleteDrawing 只有两行,这就是达标的样子。handler 里出现 if/for/业务判断 → 业务漏到协议层了;出现数据库访问 → 分层已经没有了。
顺带:这也是 41 讲必须先引入 restrpc 的原因——手写版的 handler 天生 14~19 行,协议样板把这个指标淹掉了。
9. 弱到 if ret.ID != "10001" { t.Log(...) } —— 是 t.Log 不是 t.Error,连唯一那件事都没真的断言;而且为了这 7 行还先手写了 17 行辅助函数。手写一条"建→加→取→比对"的链路要 ~46 行。
DSL 解决的是单价:测试写得少往往不是不想写,是太贵。降价之后测试才写得起。 另外那 12 行字段断言在 DSL 里是 0 行——写下期望值本身就是断言,手写版里"构造对象"和"断言对象"是两份重复代码,DSL 合成一份。顺带那段脚本还同时是接口文档。
10. 因为它一条指令干两件事:变量未绑定 → 提取(记下实际值),已绑定 → 断言。上下两个请求就靠这个串起来(json '{"id": $(id1)}' 记下 id,下一行 URL 里 $(id1) 用它)。
第二条能力是部分匹配(只比期望里写了的字段)——这是接口能向后兼容演进的前提:要求全等的话,服务端多返回一个字段就让所有测试集体失败,于是没人敢加字段。
区别:match 允许未绑定变量 + 部分匹配(表达期望);equal 不允许 + 要求全等(纯断言);equalSet 数组先排序再比——用来测"列出目录下所有文件"这种次序不可预期的 list API。
11. 换来四件:① 不抢端口,测试能并行;② 没有网络抖动,不 flaky;③ 快(省三次握手和内核拷贝);④ 协议层仍然真的执行了。
它跳过的是 TCP,不是 HTTP——路由、序列化、header 全都跑了,只是不过网卡。Python 里对应 app.test_client() / TestClient(app)。
12. 对,因为把 Service 当普通类测,测不到路由表写错、json 标签写错、状态码翻译错、header/Content-Length 这些协议接缝。
结论是两种测试都要有,也就是测试金字塔:底下一大层便宜的业务测试(restrpc 让它变便宜),上面一小层贵的协议测试(httptest 负责)。41 讲那两件事是一件事的两半:剥出来,再把剥掉的那部分单独测一遍。
13. 分水岭是谁是真源。注解式(FastAPI/restrpc)里代码是真源,协议是它的投影——所以改名字能悄悄改协议;IDL 式(gRPC/Thrift)里 .proto 是真源,代码是产物——改协议必须动那个文件,动了所有人都看得见。
QPaint 的手工 routeTable 等于在注解式里手动补了一个真源,那 8 行就是 IDL 的雏形。
14. 六边形架构 / 端口与适配器(也叫洋葱架构、整洁架构,是同一件事的三个说法):业务在中间,每种外部交互是一个适配器,依赖箭头只能向内。
实际用处只有一个:跟别人讨论时少解释十分钟。41 讲用两句话说完了三本书讲的事,而且多给了一个书上没有的可验证指标(handler 有多薄)。
下一步
带读 16 · 42 讲:使用界面、数据结构与算法
41 讲:换掉了协议层,业务层一个字没动
42 讲:轮到业务层了,而且这次要动三个地方
使用界面 —— 多租户要不要给 DOM 树加一层?(答案:不要,为什么)
数据结构 —— 为什么是 mongodb?两个索引各为了什么?
算法 —— "算法 = 用户故事背后的实现机制"
★ 结尾那句一带而过的"应该以事务形式来做",其实是整讲最重的一句
回看:
带读 00 · Go 底子(读懂这四讲代码所需的最少 Go·配可跑的 Python 对照脚本)
带读 06 · 29 讲(这个服务端的出生 · Model 与网络无关 · 协议发出去就收不回来 · mock 版的价值)
带读 07 · 30 讲(Model 层的厚度——41 讲第 3.6 节接的就是它)
[加餐 · 如何做 HTTP 服务的测试](…/加餐-| 如何做HTTP服务的测试?/原文-加餐-| 如何做HTTP服务的测试?.md)(httptest DSL 的完整文法,第四节的底本)
