加载中...

三十秒版:这一讲在干嘛

上一章(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.goservice_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 errorerr : 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.EnvOpenEnv/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/restrpcgithub.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.Client
FastAPI 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.modrequire 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/qiniutestv1.0.1(打过 tag),
qiniu/httpqiniu/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 最核心的概念?它和 equalequalSet 差在哪? 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.goshape.goshape_test.gopaintweb/main.goREADME.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 的完整文法,第四节的底本)

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关