先确认工程,而不是先讲概念
读代码之前,先确认三件事:工程在哪里、用什么版本、里面有哪些文件。这三件事看起来琐碎,却能避免大部分“代码跑不起来”的问题。
本书的配套工程叫 webapp-lab,模块名 webapp。它不依赖外部框架,也不引用第三方模块,所有代码都使用 Go 标准库。这样做的目的不是排斥框架,而是让读者把注意力放在判断和边界上:当一段代码只有标准库时,读者更容易看清它到底在解决什么问题,而不是被框架的配置层遮住。
工程在写作工作区里的位置是 .local/checks/webapp-lab。这个路径属于本书的临时工作区,不是永久发布地址;如果你拿到的是独立副本,以副本的实际路径为准。下面的命令都假设当前目录是工程根目录。
工程快照:文件、版本与模块
下面的输出来自一次真实运行。运行时间是 2026-11-04 13:58:43 CST,机器是 darwin/arm64。工程在写作过程中还会增补文件,因此这份快照只代表该时刻;读者应以自己拿到的工程为准,但可以用同样的命令重新确认。
$ date "+%Y-%m-%d %H:%M:%S %Z"
2026-11-04 13:58:43 CST
$ find . -type f -name "*.go" | sort
./internal/account/security_test.go
./internal/account/security.go
./internal/account/session_test.go
./internal/account/session.go
./internal/account/signup_test.go
./internal/account/signup.go
./internal/api/errors_test.go
./internal/api/errors.go
./internal/api/semantics_test.go
./internal/api/semantics.go
./internal/api/version_test.go
./internal/api/version.go
./internal/async/event_test.go
./internal/async/event.go
./internal/async/mode_test.go
./internal/async/mode.go
./internal/async/retry_test.go
./internal/async/retry.go
./internal/baseline/e2e_test.go
./internal/baseline/e2e.go
./internal/baseline/exit_test.go
./internal/baseline/exit.go
./internal/baseline/module_test.go
./internal/baseline/module.go
./internal/bff/aggregate_test.go
./internal/bff/aggregate.go
./internal/bff/client_test.go
./internal/bff/client.go
./internal/bff/tradeoff_test.go
./internal/bff/tradeoff.go
./internal/cache/hotkey_test.go
./internal/cache/hotkey.go
./internal/cache/profile_test.go
./internal/cache/profile.go
./internal/cache/window_test.go
./internal/cache/window.go
./internal/edge/gateway_test.go
./internal/edge/gateway.go
./internal/edge/origin_test.go
./internal/edge/origin.go
./internal/edge/static_test.go
./internal/edge/static.go
./internal/obs/incident_test.go
./internal/obs/incident.go
./internal/obs/signals_test.go
./internal/obs/signals.go
./internal/obs/sli_test.go
./internal/obs/sli.go
./internal/platform/capacity/capacity_test.go
./internal/platform/capacity/capacity.go
./internal/platform/capacity/cost_test.go
./internal/platform/capacity/cost.go
./internal/platform/capacity/elastic_test.go
./internal/platform/capacity/elastic.go
./internal/platform/capacity/sensitivity_test.go
./internal/platform/capacity/sensitivity.go
./internal/platform/capacity/storage_test.go
./internal/platform/capacity/storage.go
./internal/platform/design/adr_test.go
./internal/platform/design/adr.go
./internal/platform/design/ownership_test.go
./internal/platform/design/ownership.go
./internal/platform/design/usecase_test.go
./internal/platform/design/usecase.go
./internal/platform/product/shape_test.go
./internal/platform/product/shape.go
./internal/platform/slo/slo_test.go
./internal/platform/slo/slo.go
./internal/profile/deletion_test.go
./internal/profile/deletion.go
./internal/profile/pii_test.go
./internal/profile/pii.go
./internal/profile/visibility_test.go
./internal/profile/visibility.go
./internal/release/rollout_test.go
./internal/release/rollout.go
./internal/release/schema_test.go
./internal/release/schema.go
./internal/release/unit_test.go
./internal/release/unit.go
$ go version
go version go1.26.0 darwin/arm64
$ cat go.mod
module webapp
go 1.26.0
$ go test ./...
ok webapp/internal/account (cached)
ok webapp/internal/api (cached)
ok webapp/internal/async (cached)
ok webapp/internal/baseline 0.264s
ok webapp/internal/bff (cached)
ok webapp/internal/cache (cached)
ok webapp/internal/edge (cached)
ok webapp/internal/obs (cached)
ok webapp/internal/platform/capacity (cached)
ok webapp/internal/platform/design (cached)
ok webapp/internal/platform/product (cached)
ok webapp/internal/platform/slo (cached)
ok webapp/internal/profile (cached)
ok webapp/internal/release 0.455s
几点说明:
- 输出中的
(cached)表示该包之前已经运行过,Go 复用了测试缓存。首次运行会显示实际耗时。 - 耗时只是一次本机运行的结果,随机器负载变化,不作为性能承诺。
- 文件清单包含实现文件与测试文件,共 80 个 Go 文件,分布在 14 个包中。
go test ./...通过只说明这些包的测试在当前环境通过,不说明真实生产环境可用。
目录与包职责
工程按“平台能力”和“产品能力”分成两层。平台能力放在 internal/platform/ 下,产品能力放在 internal/ 下的其他包。这个划分不是微服务边界,而是阅读和依赖的边界:平台包尽量不依赖产品包,产品包可以依赖平台包。
internal/
├── account/ 账号、会话、安全
├── api/ API 语义、错误码、版本
├── async/ 异步模式、事件、重试
├── baseline/ 模块装配、端到端、基线对比
├── bff/ 端能力、聚合、裁剪
├── cache/ 流量画像、缓存窗口、热 key
├── edge/ 静态动态分类、网关、回源
├── obs/ SLI、信号审计、故障时间线
├── platform/
│ ├── capacity/ 容量、区间、成本、存储、弹性
│ ├── design/ 用例、所有权、ADR
│ ├── product/ 产品形状分类
│ └── slo/ 错误预算、burn rate
├── profile/ 可见性、PII 最小化、注销
└── release/ 发布单元、灰度、数据兼容
每个包的职责与卷一章节的对应关系如下:
| 包 | 主要文件 | 职责 | 本书用途 |
|---|---|---|---|
internal/platform/product | shape.go | 按实时、强一致、读写比、写多分类 | 第 1 章产品形状 |
internal/platform/slo | slo.go | 错误预算与 burn rate | 第 1、11 章 |
internal/platform/capacity | capacity.go、sensitivity.go、cost.go、storage.go、elastic.go | QPS、区间、成本、存储、弹性 | 第 1、12 章 |
internal/platform/design | usecase.go、ownership.go、adr.go | 用例边界、数据所有权、决策记录 | 第 2 章 |
internal/account | signup.go、session.go、security.go | 第三方身份、会话设备、登录风控 | 第 3 章 |
internal/profile | visibility.go、pii.go、deletion.go | 资料可见性、PII 最小化、注销导出 | 第 4 章 |
internal/bff | client.go、aggregate.go、tradeoff.go | 端能力、聚合、载荷裁剪 | 第 5 章 |
internal/api | semantics.go、errors.go、version.go | 资源命令语义、错误码、版本兼容 | 第 6 章 |
internal/edge | static.go、gateway.go、origin.go | 静态动态、网关路由、回源保护 | 第 7 章 |
internal/cache | profile.go、window.go、hotkey.go | 流量画像、一致性窗口、热 key | 第 8 章 |
internal/async | mode.go、event.go、retry.go | 同步异步判断、事件去重、重试补偿 | 第 9 章 |
internal/release | unit.go、rollout.go、schema.go | 发布单元、灰度回滚、双写兼容 | 第 10 章 |
internal/obs | sli.go、signals.go、incident.go | SLI 计算、观测信号、故障时间线 | 第 11 章 |
internal/baseline | module.go、e2e.go、exit.go | 模块校验、端到端、基线对比 | 第 13 章 |
包名和章节不是一一对应的行政划分。一个包可能服务多章,一个章节也可能引用多个包。例如 capacity 同时服务第 1 章的规模曲线和第 12 章的成本估算;第 13 章则把 account、profile、bff 和 baseline 串起来。
L1 到 L4 的分级
本书用四级描述代码能证明到什么程度。级别越高,越接近真实环境,但复现成本也越高。
| 级别 | 判定 | 验收方式 | 本卷落点 |
|---|---|---|---|
| L1 | 纯逻辑或单包,不依赖外部进程 | gofmt -l 无输出、go vet、go test,并发包加 -race | 绝大多数章节 |
| L2 | 进程内端到端,httptest 或本机端口,内存适配器 | 一个脚本跑通产品用例并断言状态 | 第 13 章 |
| L3 | 真实依赖,例如 Docker Compose + PostgreSQL | 固定场景脚本与原始输出,只写实际跑过的数字 | 本卷可选 |
| L4 | 多节点、故障注入、网络分区 | 真实环境演练与记录 | 本卷不做 |
卷一的要求是:所有章节至少 L1;账号、资料与 BFF 有一条 L2 用例;如果环境允许,补一次 L3 端到端。L4 不在本卷范围内,涉及多节点容灾的结论会明确标注“需真实环境验证”。
这个分级最重要的作用是防止过度声称。一个 L1 测试通过,只说明函数在给定输入下行为正确;它不能证明数据库连接池够用、网络分区时不会丢消息、第三方支付回调不会重复。把 L1 当成生产可用,是读代码时最容易犯的错误。
反过来,也不能因为“只是 L1”就轻视它。本书的很多关键判断都固定在 L1 里:分类优先级、错误预算公式、幂等去重、版本兼容规则。这些判断一旦写错,后面的 L2、L3 只会把错误放大。L1 是地基,不是演示。
三行头与 verify 约定
每个代码块的开头有三行固定头部:
// file: internal/platform/product/shape.go
// tier: L1
// verify: go test ./internal/platform/product/
三行的含义:
file:代码在工程中的相对路径。按这个路径能找到完整文件。tier:验证级别,取值 L1 到 L4。它告诉读者需要什么环境。verify:最小验证命令。复制到工程根目录执行,应该能复现代码块声称的结果。
代码块的内容与文件逐字节一致,包括注释、空行和测试文件。这样做的原因很实际:技术书里最常见的信任崩塌,是书里的代码能跑、复制下来不能跑。逐字节一致至少能保证读者看到的不是“整理过的版本”。
如果代码块来自测试文件,file 会指向 _test.go,verify 仍然是对应包的测试命令。读者可以把实现和测试放在一起看,理解作者为什么选择这些边界用例。
三行头不是装饰,也不建议省略。没有 file,读者不知道代码属于哪个包;没有 tier,读者不知道它需要什么环境;没有 verify,读者只能相信作者的口头结论。这三行是代码可复现的最小契约。
如何只跑一个包
不需要每次跑全仓。只验证一个包,通常更快,也更容易定位问题。
在工程根目录执行:
go test ./internal/platform/product/:运行产品形状包的全部测试。go test -run TestClassifyPriority ./internal/platform/product/:只运行一个测试函数。go test -v ./internal/account/:显示每个测试的名称和结果。go test -race ./internal/account/:开启竞态检测,适合会话和并发相关的包。go test -count=1 ./...:跳过测试缓存,强制重新运行。go vet ./internal/profile/:只对资料包做静态检查。
如果只想确认格式,使用 gofmt -l <目录>。没有输出表示该目录下的文件都符合格式;有输出时,列出的文件需要运行 gofmt -w 修正。
跑单包时要注意依赖。Go 的包测试只编译该包及其依赖,不会自动运行依赖包的测试。因此 go test ./internal/bff/ 通过,不代表 internal/account 也通过;反过来,某个底层包失败可能会让上层包编译失败。定位问题时,先看失败信息里的包路径。
按依赖顺序读代码
工程的包很多,不建议从文件列表第一行开始读。推荐的顺序是:
internal/platform/product:先理解产品形状分类,这是全书判断的起点。internal/platform/slo和internal/platform/capacity:再看可用性底线与容量模型。internal/platform/design:然后看用例、所有权和 ADR,理解边界怎么表达。internal/account和internal/profile:进入身份与资料,观察状态机和可见性规则。internal/bff和internal/api:看多端聚合、错误码和版本兼容。internal/edge、internal/cache、internal/async:看入口、读路径和异步边界。internal/obs和internal/release:最后看观测、发布、灰度与数据兼容。internal/baseline:把前面的模块装配成端到端用例,做基线对比。
这个顺序大致对应卷一的章节顺序,但不必严格一致。如果你正在做账号相关的工作,可以先读 account 和 profile,再回头补 platform 的约束。只要记住一点:后面的包通常假设前面的判断已经成立。
读每个包时,建议先看导出的类型和函数签名,再看测试文件。测试文件往往比实现更能说明边界:作者选择了哪些非法输入、哪些状态迁移、哪些重复执行场景,这些选择就是包的设计意图。
测试文件怎么读
本书的测试文件通常比实现文件长,因为边界比主路径更重要。读测试时,可以按下面的顺序。
先看测试函数名。TestClassifyPriority、TestLinkIsIdempotentForSameAccount、TestAdvanceRejectsInvalidTransitions 这样的名字,已经说明了被测行为和预期结果。把同一包的测试名连起来读,能快速看出作者认为哪些边界值得保护。
再看表驱动结构。很多测试用一个切片列出多组输入和期望输出,每组包含正常值、边界值和非法值。表驱动的好处是新增用例只需要加一行,缺点是失败时只看行号不容易定位;因此作者通常会给每组用例加一个名字。
然后看断言。好的断言不仅检查返回值,还检查副作用、错误类型和输入是否被修改。例如缓存窗口测试会检查原始切片没有被排序或改写,资料过滤测试会检查空输入返回的是空切片而不是 nil。这些细节是包契约的一部分。
最后看没有测什么。测试文件不会覆盖所有情况,作者选择的边界就是他认为最重要的风险。如果某个分支没有测试,读实现时就要多留意它。
失败信息怎么读
测试失败时,先读三行:失败的测试名、失败的文件和行号、断言输出的期望值与实际值。大多数问题能在这三行里定位。
如果失败发生在编译阶段,先看包路径和符号名。常见原因是包正在修改、接口签名变化或测试文件与实现文件不同步。这时不要急着改测试,先确认工程当前状态。
如果失败发生在断言阶段,先判断是输入问题还是实现问题。把失败用例的输入代入手算一遍,看看期望值是否合理;如果期望值本身错了,修测试;如果期望值合理,修实现。
如果失败只在 -race 下出现,说明存在并发访问问题。先看共享变量是否被多个 goroutine 同时读写,再看锁的粒度和生命周期。竞态问题不能靠重跑解决,必须找到具体的读写冲突。
如果失败只在 -count=1 下出现,说明之前的结果来自缓存,代码可能已经变了。以非缓存结果为准,并检查是否有文件在两次运行之间被修改。
一个包的阅读模板
第一次打开一个包,可以按固定模板走一遍,避免从第一行读到最后一行却抓不住重点。
- 先读包注释和导出类型,知道这个包对外承诺什么。
- 再读导出函数的参数和返回值,尤其是错误返回的条件。
- 然后读实现里的
switch、if err != nil和边界检查,这些地方通常藏着设计判断。 - 接着读测试名,确认作者认为哪些行为必须被保护。
- 最后读测试用例的输入,看看非法值、重复值和边界值是怎么构造的。
这个模板适用于本书的大多数包。它不能替代完整阅读,但能让你在十分钟内判断一个包是否值得深入,以及它和当前章节的关系。
如果一个包看完仍然不知道它解决什么问题,回到文章对应小节,先读产品场景,再回来看代码。代码是答案的一部分,不是问题本身。
小结
附录 B 给出的是工程事实,不是概念介绍。webapp-lab 使用 Go 1.26.0、模块 webapp,当前快照有 80 个 Go 文件、14 个包;代码按 L1 到 L4 分级,卷一主要做到 L1,第 13 章有一条 L2 用例;每个代码块有三行头,verify 是最小复现命令。
读代码时,先确认版本和目录,再按包运行测试,最后按依赖顺序阅读。不要把 L1 通过当成生产可用,也不要把全仓通过当成单包正确。工程是论证工具,不是脚手架;它的价值在于让书里的判断可以被检查,而不是替读者做架构决策。
常见错误与边界
- 在错误目录运行命令。所有相对路径都基于
webapp-lab根目录,不在根目录执行会找不到包。 - 忽略 Go 版本。书里的代码验证于 Go 1.26.0,其他版本可能有标准库行为差异。
- 把
(cached)当成没有真正运行。缓存命中表示之前运行过;需要强制重跑时加-count=1。 - 把
go test ./...通过当成生产可用。它只证明测试环境通过,不证明真实依赖和故障场景可靠。 - 跳过测试文件。测试是包的设计说明,尤其是非法输入和状态迁移部分。
- 修改代码块却不改工程文件。书里的代码与工程逐字节一致,单方面修改会让
verify失去意义。 - 把 L1 包当成微服务边界。这些包是阅读和依赖边界,不是部署单元。
- 认为文件清单永远不变。工程会增补,快照只代表运行时刻;用同样的命令重新确认即可。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。