SOURCE READING / 2026.09.05
从 source code 理解
优秀的 Go component
22 OSS implementations · 27 chapters.
从一个小 contract,走到整个 Go ecosystem。
下载 Go 实验代码 ↓ · 下载 Markdown 教程 ↓
给熟悉 Java/Scala/Python/C++ 的工程师。跳过 syntax 入门,直接讨论 abstraction cost、 state ownership、 concurrency protocol 和 testability。
这门课的目标:当需求改变时,你知道应该修改哪个 component;当 error 或 cancellation 发生时,你知道谁负责收尾;当 performance 下降时,你能提出可以验证的解释。
00 · 从设计问题出发,跨 repo 阅读
这门课从 GitHub 高星项目起步,现在扩展到 22 个 OSS implementation、27 个章节。原来的 10 个案例保留;通过 awesome-go discovery 和补充项目,加入 HTTP composition、structured logging、CLI boundary、DI lifecycle、concurrency、retry、circuit breaker、message acknowledgment 与 contract testing。
先选问题,再选 repo。 awesome-go 帮你发现不同设计,不是 quality certification。新增 12 个 implementation 中,8 个出现在固定的 awesome-go snapshot,4 个是为比较而补充;Chapter 26 给出完整目录、维护 state 和跨 repo decision map。Wire 已 archived,只作为 code generation 的历史案例。
| 学习路线 | 阅读顺序 | 你应该能解释什么 |
|---|---|---|
| Contract 与 composition | 02 → 03 → 18 → 20 → 25 | interface、transport boundary、wrapper 和 test seam |
| Ownership 与 lifecycle | 04 → 05 → 06 → 21 | dependency wiring、 resource 获取、部分失败与 shutdown |
| State 与 concurrency | 07 → 08 → 09 → 10 → 22 | mutation、coalescing、admission、cancel 与 join |
| Recovery 与 side effect | 11 → 23 → 24 | reconcile、retry budget、breaker 与 acknowledgment |
| 实践与 review | 12–16 → 19 → 26 | runnable lab、observability 与逐阶段 workshop |
读过旧版的读者可以直接从 18–26 开始;原来的 chapter anchor 和进度保留。初次阅读先走 01–03,再按自己遇到的问题选路线,不必按 repo 的 stars 顺序阅读。
Evidence boundary。 source code 入口固定到完整 commit 和 line range。我们精读指定 contract、call chain 和部分 test,未 audit 整个 repo,也未 build 上游项目。 source code 观察与原创 teaching sketch 分开;下载过的 file 不等于全部精读。 runnable lab 的 verification 与新章节的 design exercise 也分开记录。
最初的 GitHub stars snapshot:保留选样记录
最初查询使用 language:Go is:public fork:false,按 stars 降序。“Top”表示当时的 stars,不是代码质量排名,也不再限定课程范围。默认 branch snapshot 不等于 stable release。
查询 snapshot:2026-09-05 14:28:13 UTC(洛杉矶 07:28:13 PDT)。API 报告 incomplete_results=false。
| 原始排名 | 项目 | Stars | 本课处理 |
|---|---|---|---|
| 1 | avelino/awesome-go | 183,219 | resource 目录,保留榜单 |
| 2 | ollama/ollama | 180,216 | 纳入 source code 研习 |
| 3 | golang/go | 137,518 | 纳入 source code 研习 |
| 4 | kubernetes/kubernetes | 126,370 | 纳入 source code 研习 |
| 5 | microsoft/TypeScript | 110,897 | 纳入 source code 研习 |
| 6 | fatedier/frp | 109,224 | 纳入 source code 研习 |
| 7 | JuliusBrussee/caveman | 103,745 | Go 核心 BSL,保留榜单 |
| 8 | infiniflow/ragflow | 90,086 | 纳入 source code 研习 |
| 9 | gohugoio/hugo | 89,704 | 纳入 source code 研习 |
| 10 | gin-gonic/gin | 89,172 | 纳入 source code 研习 |
| 11 | syncthing/syncthing | 88,317 | 纳入 source code 研习 |
| 12 | junegunn/fzf | 82,826 | 纳入 source code 研习 |
GitHub 查询入口会随时间变化;ranking.json 保留本次原始响应。
原始榜单里的 awesome-go 是 discovery catalog;caveman 的固定 version 采用分目录 license,Go engine 等目录属于 BSL-1.1,因此没有作为 OSS implementation 纳入。保留原始排名并补入 Syncthing 和 fzf。依据见 awesome-go 说明、Caveman license。TypeScript 和 RAGFlow 在原始 query 中出现,这里研究它们的 Go implementation。
每次只追一条有边界的 source route:一个 contract → 一个 caller → 一个 implementation → 一个 failure test。先预测,再读 source code,关掉页面复述 invariant,最后写 counterexample。能解释“为什么不该照搬”,比认出 design pattern 名字更有用。
01 · 从你熟悉的语言迁移
优秀的 Go component 通常很直接: concrete type 保存 state, method 完成动作,小 interface 描述某个 call site 需要的 capability,普通 function 把它们组装起来。 abstraction 的价值要体现在修改范围和 contract 上。
| 你已有的经验 | 在 Go 中保留什么 | 需要调整什么 |
|---|---|---|
| Java 的 interface、DI、 package encapsulation | 替换边界、 constructor injection、隐藏 implementation | 不必给每个 struct 配 IService; interface 通常由 consumer 定义;组装常是一段普通代码 |
| Scala 的 function composition、trait、 generics | 用 function 表达 strategy,用 type 表达约束 | embedding 没有 trait 的 virtual method overriding 语义;不用给每个流程建立一套 higher-order abstraction |
| Python 的 duck typing、context manager | 关注 object 能做什么, resource 获取后就安排 release | interface 满足关系在 compile-time checks;defer 到 function 返回才执行,不是到 code block 结束 |
| C++ 的 value semantics、RAII、move、const | 认真分析 copy 成本和 resource owner | 没有通用 RAII destruction /move-only 保证;slice 的 copy 不 copy backing array; read-only semantics 常靠 API contract |
| coroutine/Future | cancellation propagation、 concurrency limit、等待完成 | 一条 go f() 不建立父子 lifecycle;cancel() 也不等于 join() |
官方 Code Review Comments 倾向在 consumer 定义 interface、 implementation provider 返回 concrete type,并建议在真实用途出现之后再 abstraction。这是默认选择:后面会看到 standard library 和 plugin 系统返回 interface 的合理例外。
三个 Go 特有的边界问题
1. implicit implementation 不等于没有 contract。 method signature 吻合只证明“能 call ”。是否 concurrency-safe、返回 slice 能不能改、 cancellation 后多久返回、 error 怎样分类,都必须另行定义。
2. T 和 *T 的 method set 不同。 普通 named type T 的 method set 中不含 receiver 为 *T 的 method;*T 包含两者。 variable addressable 时的 call convenience,不能替代 interface assignment rules。有 lock 或 mutable state 的 object 一般通过 pointer 使用,而且使用后不能 copy。参见 Go 规范:Method sets。
3. interface 中的 typed nil 不是 nil interface。 var p *MyError = nil; var err error = p 可以让 err != nil。成功路径明确 return nil,不要返回装有 nil pointer 的 error。 dependency injection 也可能遭遇同样的问题。参见 Go FAQ。
自测: interface 越小, coupling 就一定越小吗?
不一定。即使 interface 只有一个 method, parameter 如果是巨大的 AppContext、*gorm.DB 或框架 Context, caller 仍然 dependency 这些 type 和 lifecycle。 method count、 parameter types、 error protocol、 state ownership 要一起看。
02 · Go standard library:用 capability composition,避免 type hierarchy
需求: copy 来自任意来源的 bytes
如果按 Java class hierarchy 思考,容易先设计 AbstractInputSource,再让 file、 network 和 in-memory object inheritance 它。Go 的 io.Reader 只描述读取 capability。 file 和 network connection 不必因为 bytes copy 这个需求就被归入同一个 inheritance tree。
source code 路线: Reader contract → copyBuffer 的选择顺序 → MultiReader 的 composition。
io.Copy 先询问来源有没有 WriterTo,再询问目标有没有 ReaderFrom;都没有才 execution 通用读写 loop。这是“最小基础 contract + optional capabilities ”的扩展方式。基础 caller 仍然只需要 Reader/Writer。
MultiReader 持有多个 Reader,自己也提供读取 capability。 caller 无需知道它内部是单个来源还是 composition 来源。它还 copy 传入的 interface slice,避免 caller 改变列表;这没有 deep copy 每个 Reader object。
迁移到自己的代码
以下是原创用法片段,省略 imports;它让 export 逻辑只关心 byte capabilities:
func Export(dst io.Writer, header string, body io.Reader) error {
input := io.MultiReader(strings.NewReader(header), body)
if _, err := io.Copy(dst, input); err != nil {
return fmt.Errorf("export document: %w", err)
}
return nil
}
call 时可以传 file、bytes.Buffer 或压缩 writer。这个 function 不擅自关闭它们: resource 由 caller 创建,关闭责任也留在 caller。若 function 自行打开 file,则应在 function 内安排关闭。
真正值得读的是边界条件
Reader 允许同一次 call 返回 n > 0 和非 nil error。正确 loop 先消费这些 bytes,再处理 error;不能写成 if err != nil { return err } 后才处理 buf[:n]。io.Copy 还处理短写,且不会把正常 EOF 作为 copy 失败返回。对应 loop
适用条件: caller 需要稳定的小 capability,多种 implementation shared semantics。不宜照搬: 为每个私有辅助 function 都创建一个只有单 implementation、没有独立 contract 的 interface;或用不断增长的可选 type assertion 代替清楚的 capability 分组。
练习:给 Export 加审计计数,应改所有 Reader 吗?
不需要。最简单是使用 io.Copy 的 byte count return value;只有确实需要逐次观察 Read 时才增加 Reader wrapper。 wrapping 可能隐藏底层的 WriterTo capability,所以还要评估是否改变 performance path。不要为了“Decorator pattern ”忽略现成 return value。
03 · Gin:middleware 是 control flow,不是 annotation 魔法
需求:一个 request sharing authorization、 tracing 和 business 处理
source code 路线: Context.Next/Abort → request object pool → Abort 的上游 test。
Gin 用 handler slice 和一个推进位置组织执行。Next 在当前 middleware 中执行后续 handler,返回之后继续当前 function 的 trailing code。Abort 把位置移动到终止区间,不会替你从当前 function 返回。因此拒绝 request 之后通常还需要显式 return。
这和你熟悉的 around advice 接近,但 execution order 可以直接沿普通 function call 展开。不要把“没有 call Next”简单理解为一定阻断 Gin 后续 handler;外层的推进 loop 仍可能继续。需要终止后续链时使用 Abort。
我们用 standard library 写一个可直接 execution test 的 version。http.HandlerFunc 是 named function type 带 method 的 adapter,不需要创建一个只有 ServeHTTP 的类。standard library adapter source code
// Package middleware demonstrates composition through http.Handler.
package middleware
import "net/http"
// Trace marks normal execution before and after the next handler.
// mark must be safe for concurrent requests. This is a teaching trace, not a
// panic recovery layer or an HTTP status/latency metrics implementation.
func Trace(name string, mark func(string), next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mark(name + ":before")
next.ServeHTTP(w, r)
mark(name + ":after")
})
}
// Require stops the handler chain if allowed rejects the request.
// It demonstrates control flow; callers supply the actual access policy.
func Require(allowed func(*http.Request) bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !allowed(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
注意 Require 的 return,以及拒绝后外层 Trace 的 after 仍然执行。顺序 error 会改变 log、 authorization 和恢复行为,因此 test 要检查整个 trace,而不只是最终 HTTP status。
object 复用使 lifecycle 成为 contract
Gin 在 request 结束后将 Context 放回 pool。若 goroutine 保留原始 *gin.Context,可能在下一次 request 复用时读到 error state。Copy 对部分 container 做 copy,Request pointer 仍是 sharing 的,map 中的任意值也不因此 deep copy。Context.Copy
迁移建议: background task 尽量只接收提取后的 ID、 immutable payload 和明确的 lifecycle。c.Copy() 不能延长 HTTP connection 的写入窗口,也不能让 request cancellation 自动变成 durable 任务系统。
不要照搬: 未经测量就给 business object 加 sync.Pool。Pool 允许 cache 项被 reclamation,不能充当可靠 resource pool; object 归还后也不应继续持有 mutable reference。sync.Pool 文档
练习:为什么成功的 logging chain 是 outer-before → inner-before → handler → inner-after → outer-after?
每一层在 call next 前记录 before,next 返回后记录 after。因此进入次序和退出次序相反。如果把 after 放进 defer,panic 路径行为又会改变,需要明确记录语义。练习项目的 Trace 只记录正常返回路径, test 同时覆盖允许和拒绝 request。
04 · Hugo: configuration、 dependency 与 optional capabilities 各司其职
需求: rendering 内容,但不同 renderer capability 不一样
source code 路线: converter 的小 interface 族 → Deps 的组装信息 → filesystem wrapper。
Hugo 的基础 Converter 只要求转换。可选的 ParseRenderer 把 parsing 和 rendering 分开,使支持该 capability 的 implementation 可以先提取目录,再 rendering。Provider 负责按文档 context 创建 converter;NewProvider 把一个创建 function adaptation 为有名字、有 New method 的 object。
这给出了两种独立的变化轴:怎样创建 object和object 能做什么。不要因为需要一个 factory,就连带引入 DI container、 lifecycle 框架和几十个 registry。
对 component 输入做一次分类
| input categories | 例子 | 建议位置 |
|---|---|---|
| required dependencies | 文档来源、输出 storage、 renderer | constructor 的明确 parameter |
| 固定 configuration | concurrency、 timeout、输出格式 | 有名字的 Config/Options |
| 本次 call 数据 | key、文档内容、 request context | method parameter |
| runtime state | cache、正在处理的任务 | 私有 field,由 component 管理 |
Hugo 的 Deps 很大,是大型站点 build 过程的组装信息载体。这是 source code 事实,并不意味着你应该给每个 component 都传 *App 或 *Deps。若一个 formatter 只需要时钟和 writer,就只传这两个;这样它的真实 dependency 在 signature 中可见。
Functional options 什么时候值得用
Gin 的 constructor entry point 确实接收 OptionFunc。source code 但我们的 mini reconciler 只有两个 required dependencies,因此直接 New(source, dest) 最清楚。公开库有很多独立、 extensible 的可选设置时再考虑 WithTimeout 一类 function。
迁移建议: required dependencies 保持显式; configuration 先填默认值,再应用覆盖,最后验证。不要在 option 执行过程中 startup goroutine 或建立 partially initialized resources。若 0 是合法 business 值,不要同时把它定义为“没填写”;用 pointer、显式标记或不同 constructor entry point 区分。
不要照搬: 每个 configuration field 都变成 option;用一个巨大 dependency bag 隐藏十几个 dependency;把 configuration object 当成可以随时 unsynchronized mutation 的 sharing map。
练习:产品增加“生成目录”功能,要向所有 Converter 加一个 method 吗?
先明确目录生成是否是所有 renderer 的必需 capability。如果只有部分 implementation 支持,可以保留基础转换 interface,额外定义 parsing /目录 capability。 caller 必须明确不支持时的行为,而不是让每个 implementation 返回一个无意义的空结果。Hugo 的 interface 拆分就是可参照的例子。
05 · frp: composition、 strategy 和 plugin registration 的边界
需求:不同代理 protocol sharing 部分行为
source code 路线: Proxy contract 与 factory → GeneralTCPProxy 的 embedding → Plugin 的创建和 lifecycle。
frp 的 GeneralTCPProxy 嵌入 *BaseProxy 复用 method。 plugin 部分使用名字到创建 function 的 mapping,建立 object 后通过 Name/Handle/Close 工作。这对应你熟悉的 Strategy 和 Factory,但关 key 是 registration 时机、 configuration type 和 connection lifecycle。
embedding 不是 virtual inheritance
下面是原创语义片段,省略 package 和 main;注释给出在 main 中 call 后的结果:
type Base struct{}
func (Base) Name() string { return "base" }
func (b Base) Describe() string { return b.Name() }
type Child struct{ Base }
func (Child) Name() string { return "child" }
// Child{}.Name() -> "child"
// Child{}.Describe() -> "base"
call 提升后的 Describe 时, method 中的 receiver 仍是 Base;它不会通过“实际 subclass ” redispatch Name。若 sharing algorithm 需要 call 变化行为,显式传 function / interface,或者把 object 放到独立 field 里 delegation。不要写 dependency “ subclass overriding ”的 Template Method 后期待 Go 自动 implementation 它。
plugin 的本质是可替换 contract
frp 的 registry 是包级 map,重复 registration 会 panic,具体 implementation 常在 init registration。这种设计 dependency initialization phase 完成 registration 等使用前提;从这几行代码不能推出 runtime 可以随意 concurrency 增删 plugin。
迁移建议:只有两三个固定 strategy 时, constructor 里的 switch 通常足够。确实要让 plugin composition 随 build 变化时,才考虑显式 registry。动态 configuration 首先验证,再创建 instance,最后进入 runtime。不要在 hot path dependency reflection 去弥补不清晰的 type boundary。
练习:只有两个 backend,是否需要设计 Registry、Factory、Provider 三层?
通常不需要。先在 entry point 选出一个 implementation,再传给 consumer。只有“ backend 发现”“创建多个 instance ”“不同 instance lifecycle ”成为独立需求时,才拆出相应职责。结构应该解释当前变化点,而不是提前模拟一个生态系统。
06 · RAGFlow: interface 的一致性比 method count 更重要
需求:同一功能使用不同 object storage
source code 路线: Storage contract → MemoryStorage 的 defensive copy → Factory 的选择逻辑。
MemoryStorage 在写入时 copy bytes,读取时再次 copy,并用 RWMutex 保护 map。你可以把它理解为对 mutable memory 做 ownership 隔离: lock 保护内部操作, copy 防止 call 结束后外部继续修改 internal array。
它是值得学习的 in-memory adapter。与此同时,Storage interface 有较多 capability,Factory 使用 singleton 和全局 configuration。这些选择并不能自动让所有 consumer 都低 coupling。
读 source code 要检查承诺是否吻合
这次 version 中,Storage.Get 的注释说 missing 时可返回 nil;MemoryStorage.Get 对 missing 返回 wrapping 过的 ErrMemoryNotFound。只看 interface 名,无法得出跨 backend 一致的 missing 语义。contract、implementation
我们不在这里推断整个系统有缺陷;需要继续读 adaptation caller 才能判断影响。但你写自己的 API 时应该主动消除这类不 determinism:区分“ missing ”“内容为空”“ permission failure ”“暂时不可用”,并让各 implementation 服从同一组 contract tests。
另一个具体例子:RAGFlow 的 RetrievalService 只有一个 Search method, parameter 中却有 *gorm.DB。source code 它仍然暴露了 ORM dependency。因此“只有一个 method ”不能等同于“ pure domain boundary ”。
本课程采用的 contract
type Reader interface {
Read(ctx context.Context, key string) ([]byte, error)
}
signature 之外还要写清楚: missing 返回可被 errors.Is 识别的 ErrNotFound;成功返 callback 用方独占的 bytes;支持 concurrency call;检查 cancellation;空 bytes 是合法值。我们的 Store 在此基础上只增加写入 capability。
不要照搬: 把一个通用 Storage interface 传遍系统;用 (nil, nil) 同时表示多种情况;以为用了 Mutex 就允许 caller 任意修改返回的 slice。
练习:如果 business read-only 取 object,为什么 parameter 不直接用完整 Store?
接收 Reader 可以让 read-only implementation、 cache 读取器和最小 fake 都满足 dependency;也明确表达 component 没有写 permission。返回新 interface 并不是目的,限制所需 capability 才是目的。若确实只传 concrete type 且没有变化边界,直接传 concrete type 也可以。
07 · TypeScript: generics 用于 sharing 结构, function 用于变化行为
需求: compiler 需要稳定 traversal 次序,也需要改写 AST
本章读取的是 microsoft/TypeScript/tsc/internal 下的 Go implementation。source code 路线: OrderedMap → 公开 API 的 test → NodeVisitor → 有变化才 copy。
OrderedMap 用一个 map 负责查找,一个 key slice 保存 insertion order。K comparable 表达 keys must be comparable;V any 不限制值。这里 generics 消除的是 container 结构的重复,没有吞掉 business 语义。
注意几个实际设计细节: zero value 在 Set 时懒 initialization;更新已有 key 不把它再追加到列表; iteration 用 iter.Seq/Seq2;yield 返回 false 时停止;删除还要维护 key slice,因此不能假定所有操作都是 O(1)。noCopy 提供给 vet 的 copy 检查线索,不是 runtime lock,也不使 container thread-safe。
Iterator 与你熟悉的 generator 有什么关系
iter.Seq[T] 可以理解为把 yield function 交给 traversal 逻辑, caller 的 break 会反馈为 false。它本身不表示 startup 了新的 goroutine。这个 OrderedMap 还特意用动态长度 loop,让 traversal 能看到 iteration 期间追加的新项目;这是具体 container 的 contract,不应泛化为所有 Go iterator 的行为。
Visitor 不需要几十个 subclass
NodeVisitor 中保存 Visit function 和 hook function。VisitSlice 先扫描,发现 node 被替换/删除等变化时才 copy 已访问的 prefix,未改变时返回原 slice。 function 处理变化 strategy, struct 保存 traversal dependency,避免仅为覆盖一个行为建立 class hierarchy。
这让你能提出清楚的 invariant:未变的输入保持 structural sharing;新结果不能意外修改旧视图。它不是 deep immutable 的自动保证, callback 是否 in-place mutation Node 仍需 contract 约束。
了解 performance 技巧,但晚一点使用
同一 repository 还有 Arena[T],用 bulk allocation 减少细碎 allocation。它仍使用 Go 管理的数组,旧块是否能 reclamation 取决于 reference;不能类比为“ call arena.free 就手动 release 全部 object ”。先从 benchmark/profile 证明 allocation 是 bottleneck,再评估 object lifetime 和 aliasing 风险。
练习:为什么不把 business Service 统一写成 Service[T, R, E]?
container 的 algorithm 和 invariant 在不同 T 上通常稳定; business service 的 permission、 transaction、 error 和 lifecycle 却可能完全不同。如果 type parameter 越来越多、内部到处 type switch,说明你正在把不相关行为压进一个 template。 generics 应该减少结构重复,而不是掩盖差异。
08 · Syncthing:把 state 修改变成受控 protocol
需求: execution 中的多个 component 一起响应 configuration 变化
source code 路线: Modify 与 Serve → 验证、发布、通知 → Committer 的承诺。
Syncthing 的 caller 提交一个修改 function。 service 按顺序处理修改:取得当前 configuration 副本,让 function 修改副本,准备并验证,发布新 configuration,通知 subscriber,并等待本轮处理。 configuration 读取还使用 mutex;这不是“有一个 event loop 就永远不需要 lock ”。
这个 pattern 可以迁移到 hot reload routing table、 service discovery cache 和小型 control plane:修改入口集中;读取结果有约定;修改顺序可解释。 比允许所有 module 拿到 sharing map 后各自 lock acquisition 更容易检查。
三个不能混淆的 state
- 新 configuration 通过验证、写入 memory。
- 所有 component 完成 configuration callback;有 component 可能要求重启。
- configuration persistence 成功。
source code 的 CommitConfiguration 返回 false 会标记需要重启,不会神奇 rollback 已经响应的所有 component。Save 另有路径。因此不要把“收到 Waiter”或“ callback 结束”描述成 distributed atomic commit。
迁移时的工程约束
修改 function 保持短小、 synchronization,不在里面做任意 network call,不把传入的 configuration pointer 存到外部。发布给 subscriber 的 snapshot 按 read-only 使用; copy struct 不等于所有 nested references 都自动隔离。 queue 满时应该有明确反馈,停止接收更新和等待 callback 退出也需要完整 protocol。
适用条件: 多个 component sharing 一份需要有序更新的 state。不宜照搬: 一个只有两个 field 的局部 object,为了“Actor pattern ”专门开启 goroutine 和 message queue。
练习: hot configuration 验证通过,但一个 component 不能在线应用,应该返回成功吗?
先区分“ configuration 被接受”和“所有 runtime behavior 已经生效”。可以接受 configuration 并返回 requiresRestart,也可以在前置验证阶段拒绝;选择取决于产品 contract。不能悄悄把部分生效说成全部生效,更不能只靠一个 bool 同时表达所有 state。
09 · fzf:交互 event 可以 coalescing, business command 未必可以
需求:用户快速输入,不值得算完每个过时查询
source code 路线: EventBox → Matcher.Loop → 分片 worker → cancellation 与等待。
EventBox 用 map[EventType]any 存放 event;同一 type 的新 Set 会覆盖旧值。这种按 type coalescing 很适合 UI 最新 state。它不是保证每一条 event 都投递的 FIFO,也不承诺不同 type 间的全局时间顺序。
Matcher 将搜索拆成有限个 worker,按 CPU/ configuration 和工作量限定 concurrency;每个 worker 使用自己的 scratch space。 cancellation 旧扫描时,还需要等待相关计算结束,才能安全修改它们可能正在读取的数据。
从机制反推产品语义
| event | 可以丢掉中间 state 吗? | 处理 strategy |
|---|---|---|
搜索框从 g 到 go 到 golang |
通常可以 | coalescing / cancellation 过时查询,最终展示最新结果 |
| desired replicas 从 2 到 3 到 4 | controller 通常可以重新读取最新目标 | key deduplication、 reconcile 当前 state |
| “给账户加 10”连续三次 | 不可以 | 每个 command 有身份,按 transaction / idempotency key 处理 |
| 进度从 31% 到 32% 到 33% | 通常可以 | 保留最新进度 |
这才是 queue 选型的前提。你已经懂 coroutine,下一步是明确哪些工作可以被 cancellation、 coalescing、 retry 或重复执行。
一个值得检查的 lock 边界
EventBox.Wait 在 while holding a lock call callback。这个 callback 若反过来 call 同一 EventBox 的 Set,就可能尝试重复获取同一把 lock。 source code 中的 consumer 式约束了它;你写公共 callback API 时,应明确“是否在 under a lock callback ”,或取出副本后再 unlock call。Wait/Set
练习:把所有搜索 request 放进一个 unbounded queue,再 startup 更多 goroutine,能解决 response latency 吗?
未必。你可能把 CPU 花在用户已经不关心的查询上,排队时间继续增长。先定义最新结果语义,丢弃过时工作,再设 concurrency 和 cache 边界。 concurrency 不是 throughput 或 unlimited latency knob。
10 · Ollama: admission request、占用 resource、结束工作要分开
需求:模型加载昂贵,多个 request sharing 有限 resource
source code 路线: Scheduler field 与 initialization → request admission → request 完成/模型到期 → function injection test。
Scheduler 使用容量受 configuration 限制的 request channel,满了会返回 ErrMaxQueue;已加载且可复用的 runner 有 fast path。加载 resource、并行使用已加载 resource、 request 完成和到期 reclamation,是不同动作。相应 state 既有 channel event,也有 mutex 保护,不能把整个 object 简单叫成 lock-free actor。
loadFn/newServerFn/getGpuFn 等 function field 提供了替换边界。 test 把真实 startup dependency 替换成 function,就能制造加载失败。你不必为一个 call site 创造 AbstractModelLoaderFactoryProvider。
buffered queue 是 admission policy 的一部分
bounded queue 必须回答满了怎么办: blocking 并支持 cancellation、立即拒绝,还是按明确 rules 覆盖。只写 make(chan Job, 100) 没有完成设计。 timeout budget 也应包括排队时间,而不是 dequeue 后重新给完整 timeout。
source code 为单次 request 的 success/error channel 设置了容量 1,能让一次结果发送不 dependency receiver 恰好同时就绪。但容量 1 并不能普遍解决多次发送、永不停止的 producer 和 sharing object ownership 问题。
cancellation、退出和 resource 复用是三件事
此 version 的 Scheduler.Run startup goroutine 后立即返回,没有从这个 method 提供 join 保证。 request object 保留自己的 context 以跨 queue 携带该 request lifecycle;这是具有明确含义的 asynchronous 工作项,不能据此把 Context 当作普通 Service 的永久 field。Run 与 admission 路径、request 使用 runner
迁移建议:先明确 construction、 execution 和停止 API。简单 component 优先让 Run(ctx) error blocking 到内部工作全部退出;如果必须 asynchronous startup,另给 Wait/Done。 cancellation 是合作 signal,不能强杀一个不检查 ctx 的 function。context 文档
练习:一次模型 request timeout,是否应该立刻 release 该模型的全部 resource?
不能直接推出。其他 request 可能仍在使用相同 runner;单个 request 的 lifetime、 sharing 模型的 reference state 和 idle reclamation strategy 必须分开。先终止该 request 的工作,确认 reference 关系,再按 resource strategy 决定是否卸载。
11 · Kubernetes: reconcile 当前 state,而不是重放每个 event
需求: event 会重复, state 会变化,写入会失败
source code 路线: DeploymentController 的 dependency → processNextWorkItem → syncDeployment → workqueue state machine。
DeploymentController 把 key 放进 queue。worker 取出 key,再从 lister 读取当前 Deployment; object 已经删除时可能正常结束,修改前先 DeepCopy,避免改坏 sharing informer cache。这是典型的 reconcile loop:对照当前 desired 与 observed state,执行必要动作。
event 相当于“这个 object 值得再检查一次”,不是必须逐条重放的交易 log。 cache 也可能落后于 service 端,所以设计仍需处理冲突、重新读取和 retry;不能因为当前从 cache 没看到变化,就推断远端没有变化。
五行 call 顺序,背后是两个不同 protocol
原创 pseudocode,突出职责而非复刻上游 implementation:
key := queue.Get()
defer queue.Done(key) // 结束本轮 processing state
err := reconcile(ctx, key)
if err == nil { queue.Forget(key) } // 清除 retry history
if err != nil { queue.AddRateLimited(key) }
真实 implementation 还要处理 shutdown、 error classification 和 retry limit。DeploymentController 成功/特定情形 call Forget;可 retry error 受次数上限控制,达到上限也会结束本轮 retry。上游 worker
Done 不等于 Forget。 Done 改变“谁正在处理”及是否需要再 enqueue;Forget 清除 rate limiter 的历史,不会替你结束 processing,也不是删除所有待处理工作。rate limiter wrapper
queue 为什么不是一个普通 channel
它至少管理三个 state sets:
| state | 意义 |
|---|---|
| queue | 可以被 worker 获取的 key 顺序 |
| dirty | 需要处理的 key;可能已在处理但又收到新变化 |
| processing | 已取走、尚未 Done 的 key |
正常操作中的关 key invariant 是:queue 中的 key 属于 dirty,且不属于 processing。Get 把 key 加入 processing,并清除本轮 dirty;若处理期间再次 Add,相同 key 被标 dirty,但不会同时 allocation 给另一个 worker;Done 发现它仍 dirty,就重新 enqueue。Add/Get/Done
同一个 queue 内的 key reconciliation 不等于整个 distributed system exactly-once。跨 process /重启/外部写入还需要 idempotent operation、 version check 或 transaction。尤其不要把“加一”这样的 non-idempotent side effect 原样放进可 retry reconcile。
lifecycle 也是 component contract
DeploymentController.Run 等待 cache 首次 synchronization, startup worker;退出时关闭 queue 并等待 worker。这里的 shutdown 和等待是明确可定位的代码。Run 这比只给 goroutine 一个 ctx 更完整。
test 也在验证这个 protocol:例如 TestAddWhileProcessing 主动让处理中的项目再次加 enqueue 列。学它的行为场景,比背“Controller/Observer/Factory”这些名字更有用。
练习:Add(A) → Get(A) → Add(A) → Add(A) → Done(A), queue 中有几个 A?
一个。第一次 Get 已清除本轮 dirty。处理中第一次 Add 把它重新标 dirty,第二次被 coalescing;Done 才重新放回 queue。再 Get 并 Done、且期间没有新 Add 后,三个 set 里都不再有 A。若只 Forget 不 Done,processing state 并没有完成。
12 · 把这些观察变成自己的 component boundary
从一个具体需求开始
假设需求是:“读取 source 文档,把 destination 更新到相同内容;重复 execution 不做多余写入; supports cancellation,同时最多执行 N 个不同 key。”
先写顺序 version:读源 → 读目标 → 比较 → 必要时写。不要先创建 domain/application/infra/factory/manager 一整套空目录。第二个真实 backend 出现,或需要独立验证 error path 时,再把 storage dependency 提取为 consumer 的 Reader/Store。 concurrency scheduling 成为独立需求时,再移入 batch。
main 也直接 import batch 和 reconcile。memstore reference reconcile 的 error / interface contract;reconcile 不 import memstore。batch 只接收一个工作 function。
batch call main 提供的 work function, function call Reconciler;Reconciler 再通过 Reader/Store call 具体 implementation。 runtime calls 指向 implementation, compilation dependency 仍指向 contract。
用变化原因决定 package
| Package | 它知道什么 | 它拥有的 state /职责 | 改动它的理由 |
|---|---|---|---|
reconcile |
读取/写入 contract、 desired 与当前内容 | 一次 reconciliation 的 rules;不拥有 background task | 比较 rules、 missing 语义改变 |
memstore |
如何在 memory 保存 bytes | map、mutex、 copy 边界 | storage 机制改变 |
batch |
key 和一个工作 function | worker 数、 queue 关闭、 cancellation 与等待 | concurrency scheduling policy 改变 |
cmd/syncdemo |
哪些具体 implementation 组装在一起 | process 级 context、 execution 顺序 | startup configuration / deployment 环境改变 |
middleware |
HTTP request 和 next | request 链的外围行为 | HTTP 接入需求改变 |
我们把 ErrNotFound 放在 consumer 定义的 storage contract 附近,所以 memstore dependency reconcile 的 contract;reconcile 不 import memstore。这是一个小工程里的具体 dependency direction。如果 error / value type 将被多个独立 use case sharing,才评估提取一个有 business 含义的小 package,不必预先建立通用 types 大包。
比“Clean Architecture 目录”更有效的四个检查
修改局部性。 换成 S3 adapter 时,应该主要新增 adapter 并修改 main 的组装, reconciliation rules 和 batch 无需变化。
输入诚实。 不把 DB、logger、config 和几十个 service 全部藏进 ctx.Value 或 *Application; signature 应让 dependency 可见。
encapsulation 有内容。 private fields 是为了维护 invariant;如果一层 getter/setter 完整暴露同一个 mutable map,只是增加输入成本,没有保护 state。
read path 短。 一次普通 business call 最好能在少量相邻 function 中读完。四层都只有 return next.Do(...) 时,说明 layering 没有解释变化点。
internal 是 Go 工具执行的 import 边界;cmd 是组织多个 entry point 的常见方式。pkg 不是 compiler 赋予的“公开 API”标记,也没有要求所有工程采用同一套目录。参见 官方 module 布局。
练习:现在加 PostgreSQL, implementation 了 Read/Write, reconciler 就自动正确了吗?
还没有。你要检查 missing error 是否转换、Read 的 data ownership、Write 是否 whole-value replacement、 transaction 和 concurrency 语义、ctx 是否传到底层、同 key 的 race,以及 test 是否覆盖这些 contract。能 assignment 给 interface 是 compile time 条件,不是行为一致性的证明。
13 · runnable lab:一个小型 reconciler
下载完整 Go 实验代码,解压得到 go-oss-lab/,只有 standard library dependency。要求 Go 1.25+;本次实际验证环境为 Go 1.26.2、macOS arm64。当前上游 default branch 可能需要更新的 toolchain,实验不 dependency 它们的 build 环境。
unzip go-oss-lab.zip
cd go-oss-lab
go run ./cmd/syncdemo
go test ./...
go test -race ./...
go vet ./...
预期 demo 输出:
pass 1: changed=2
pass 2: changed=0
after source update: changed=1
第一次把两个文档写入目标。第二次重新读取并比较,没有变化就不写。 source 改变一个文档后,下一轮只有一个写入。输入里重复的 guide 在同一轮 batch 内被 deduplication。
13.1 reconciliation rules 不需要知道 in-memory storage
// Package reconcile converges destination bytes toward a source snapshot.
// It owns the use case and its dependency contracts, not storage mechanisms.
package reconcile
import (
"bytes"
"context"
"errors"
"fmt"
)
// ErrNotFound means the requested key has no value. Empty bytes are a value.
// Adapters must translate their backend's missing-object error to this error.
var ErrNotFound = errors.New("object not found")
// Reader returns caller-owned bytes or an error wrapping ErrNotFound.
// Implementations must honor cancellation and support concurrent calls.
type Reader interface {
Read(ctx context.Context, key string) ([]byte, error)
}
// Store replaces a whole value. Write must not retain the caller's byte slice.
// Callers must not mutate the input while Write is executing.
type Store interface {
Reader
Write(ctx context.Context, key string, value []byte) error
}
// Result describes one completed reconciliation, not a global system state.
type Result struct {
Changed bool
}
// Reconciler reads current state on each call. It has no background goroutines.
// Different keys may be reconciled concurrently. The caller must serialize
// calls for the same key if it needs to prevent concurrent duplicate writes.
type Reconciler struct {
source Reader
dest Store
}
// New wires dependencies without acquiring resources. Dependencies must be
// non-nil, including the concrete values stored in their interfaces.
func New(source Reader, dest Store) *Reconciler {
return &Reconciler{source: source, dest: dest}
}
// Reconcile copies the current source value only when the destination differs.
// A missing source is an error; this use case never deletes destination data.
// It provides convergence on repeated calls, not a distributed transaction.
func (r *Reconciler) Reconcile(ctx context.Context, key string) (Result, error) {
if key == "" {
return Result{}, errors.New("key is required")
}
if err := ctx.Err(); err != nil {
return Result{}, err
}
want, err := r.source.Read(ctx, key)
if err != nil {
return Result{}, fmt.Errorf("read source %q: %w", key, err)
}
have, err := r.dest.Read(ctx, key)
if err != nil && !errors.Is(err, ErrNotFound) {
return Result{}, fmt.Errorf("read destination %q: %w", key, err)
}
if err == nil && bytes.Equal(want, have) {
return Result{}, nil
}
if err := r.dest.Write(ctx, key, want); err != nil {
return Result{}, fmt.Errorf("write destination %q: %w", key, err)
}
return Result{Changed: true}, nil
}
阅读时抓住三处:目标读取失败不能统统按“ missing ”处理; empty content 与不存在不同;比较一致才返回 Changed=false。每次 call 都重新读取 source,因此 source 变动会在后续 call 中继续 convergence。
13.2 in-memory adapter:Mutex 与 Clone 解决两个不同问题
// Package memstore implements an in-memory adapter for the reconcile contracts.
package memstore
import (
"bytes"
"context"
"fmt"
"sync"
"example.com/go-oss-lab/internal/reconcile"
)
// Store is safe for concurrent use. Its zero value is ready to use.
// A Store must not be copied after first use.
type Store struct {
mu sync.RWMutex
values map[string][]byte
}
var _ reconcile.Store = (*Store)(nil)
// Read returns an independent copy. Cancellation is checked after acquiring
// the lock; the mutex acquisition itself cannot be interrupted by ctx.
func (s *Store) Read(ctx context.Context, key string) ([]byte, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if err := ctx.Err(); err != nil {
return nil, err
}
value, ok := s.values[key]
if !ok {
return nil, fmt.Errorf("read %q: %w", key, reconcile.ErrNotFound)
}
return bytes.Clone(value), nil
}
// Write atomically replaces one value within this process.
// This in-memory store does not persist data across process restarts.
func (s *Store) Write(ctx context.Context, key string, value []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := ctx.Err(); err != nil {
return err
}
if s.values == nil {
s.values = make(map[string][]byte)
}
s.values[key] = bytes.Clone(value)
return nil
}
lock 避免内部 map concurrency 访问失序,Clone 避免 lock release 后外部继续写 internal array。两个问题不能互相替代。Read 检查 ctx,但 mutex 等 lock 期间本身 non-cancellable;这里 critical section 短且不做 network I/O。
13.3 batch:限制 concurrency,收到 error 后 cancellation,并等待所有 worker
// Package batch owns bounded, finite concurrent work and its lifetime.
package batch
import (
"context"
"errors"
"fmt"
"sync"
)
// Run processes each distinct key at most once in this invocation.
// The first cancellation cause stops dispatch and is returned after all
// workers exit. Work already started may complete. Results are not rolled back.
// fn must honor ctx, be safe for concurrent calls, and must not panic.
// Run does not retry, serialize keys across invocations, or persist a queue.
func Run(ctx context.Context, keys []string, workers int, fn func(context.Context, string) error) error {
if workers < 1 {
return errors.New("workers must be positive")
}
if fn == nil {
return errors.New("work function is required")
}
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
jobs := make(chan string)
var wg sync.WaitGroup
for range min(workers, len(keys)) {
wg.Go(func() {
for {
select {
case <-ctx.Done():
return
case key, ok := <-jobs:
if !ok || ctx.Err() != nil {
return
}
if err := fn(ctx, key); err != nil {
cancel(fmt.Errorf("process %q: %w", key, err))
return
}
}
}
})
}
seen := make(map[string]struct{}, len(keys))
dispatch:
for _, key := range keys {
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
select {
case <-ctx.Done():
break dispatch
case jobs <- key:
}
}
close(jobs) // This function is the only sender and therefore owns closure.
wg.Wait() // Cancellation requests exit; Wait observes that exit completed.
return context.Cause(ctx)
}
unbuffered jobs 提供 dispatch 处的 backpressure,固定 worker 数限制同时执行的 function 数。唯一 sender 关闭 jobs;context.WithCancelCause 保存先发生的 cancellation cause;Wait 把 execution lifetime 的所有 worker 收回来。
这个实验明确承诺什么
| 性质 | 是否提供 |
|---|---|
| 稳定输入下 repeated reconciliation 不重复写 | 提供, test 验证 |
| in-process map 安全、读写 bytes 不 sharing internal array | 提供, test 及 race 检查 |
| 单次 batch 中同 key deduplication、 concurrency limit | 提供, deterministic tests 验证 |
| 出错/ cancellation 后等待已 startup worker 退出 | 提供,前提是工作 function 合作响应 ctx |
| 每个 call 都能被强制 timeout 终止 | 不提供;Go 不强杀任意 function |
| 多次 concurrency Run 之间的同 key serialization | 不提供;需要 sharing reconciliation 机制 |
| 动态到达 event、 delayed retry 和 durable queue | 不提供;这是有限输入 batch |
| 跨 storage atomicity、exactly-once、自动删除 | 不提供; interface 和 use case 均未作此承诺 |
Reconcile 的读-比较-写不是跨 backend transaction。两个 concurrency call 处理同一 key,可能都判断需要写; source 在一次 call 中变化,也可能让目标短暂落后。真实产品按需求增加 key serialization、 version /CAS 检查或 transaction,并保证后续有再次 reconciliation 的触发。
“第一次失败 cancellation 其他 worker”也不撤销已经成功的写入。 error 后可以重跑,因为这个 use case 的写入是替换为 desired value;若改为发邮件、扣款或递增计数,必须重新设计 side effect protocol。
远端 Write 还可能已经提交,只是成功回执丢失。此时返回 error 或 Changed=false 都不能证明“没有 side effect ”;需要重新读取、 idempotent retry 或使用操作身份核对。实验中的 Changed 描述本次确认完成的结果,不是外部世界的 transactional proof。
14 · Clean code:写出能够审查的 contract
error 既是 diagnostics,也是 API 的一部分
中间层补充动作和 key:fmt.Errorf("read source %q: %w", key, err)。上层按 errors.Is/errors.As 决策,别比较 error string。使用 %w 等于允许 caller 观察 underlying error 身份;是否公开某个驱动 error,要在边界处决定。Go 官方 error 设计说明
通常在最了解操作结果的边界记录 error,中间层返回并补充 context,避免每层重复打一条相同 stack trace。 retry loop 可以记录 retry event,但应区别于最终失败。用户输入、I/O 失败和 resource 不足用返回 error 处理;panic 不应替代正常 business 分支。
只有当 caller 能做出不同决定时,才值得新增 sentinel/typed error。多个操作部分成功时,应返回足够结果信息;随便一个 bool 往往不能说明已发生什么。
zero value 可用,与 constructor 不矛盾
memstore 的 zero value 可用,因为它能在首次 Write initialization map。reconciler 需要外部 dependency,因此要求 construction 时提供有效 implementation。不要强求每个 type zero value 都可执行,也别只为了写 NewX 而禁止一个自然可用的 zero value。
construction 尽量只组装。确需打开 file、 startup 监听或建立 connection 时,失败必须可 rollback,成功后的关闭责任必须明确。不要把“ object 已创建”“已开始 execution ”“完全可 service ”揉成一个无法判断的 state。
reference 和 lifecycle 速查
| 容易误判的写法 | 实际要检查什么 |
|---|---|
copy := originalStruct |
map/slice/pointer 是否仍 reference 同一份数据; lock 和 once 是否被 copy |
snapshot := slices.Clone(items) |
只 copy 元素;元素如果是 pointer/slice,内部 object 仍可能 sharing |
defer file.Close() 写在长 loop 里 |
直到外层 function 返回才关闭;需要时把单次处理提取为 function |
go work(ctx) 后立即返回 |
谁保留工作 lifetime、接收 error、等待退出 |
| 从 pool 取 object,Put 后继续用 | 另一个 caller 可能已经获得相同 mutable object |
append(s, x) 后认为原 slice 不变 |
容量足够时会复用 backing array;是否允许写入取决于 ownership |
defer 的 parameter 在 defer 语句执行时求值;关闭/ release 顺序是 LIFO。写 file 时,Flush/Close 也可能有需要返回的 error;不能因为用了 defer 就把成功视为理所当然。Effective Go:Defer
concurrency 和 performance 检查
channel 用来传递工作/ signal / ownership;mutex 用来保护 shared state invariant。哪种更好取决于 state 归属和操作粒度,不能通过“全换成 channel”消除 race。GC 管理 reachable memory,不能替你结束一个 blocking 的 goroutine。 visibility 和 happens-before 关系仍需依据 synchronization operations 判断。Go Memory Model
先让 function 行为清楚,再测量。 container generics、手写 loop、 cache、pool、arena 都可以有价值,但必须能回答哪个 allocation 或哪段 CPU 时间值得 optimization。 test 项目提供一个 4 KiB copy 读取 benchmark,便于观察 ownership 隔离的代价:
go test ./internal/memstore -run '^$' -bench BenchmarkReadCopy -benchmem
不要从一次结果推断其他机器或生产负载的 performance。race detector 也只能发现本次 execution path 中的 race;它不是 concurrency correctness 的证明。
不要机械 copy 旧 Go 教程
Go 1.22 起,在相应语言 version 下 loop 内新 declaration 的 iteration variable 具有每轮独立语义;反复添加 v := v 不再是普遍必要修复。外部复用 variable、 sharing pointer object 和 concurrency 写 map 仍然可能 race。检查 go.mod 和实际 sharing 的数据,而不是背旧反例。官方说明
15 · 用 test 描述 component,而不是描述 implementation
test 应让更换内部 implementation 后仍能成立。本实验用 external test package 检验可观察行为,并用少量手写 fake 制造 error;不用生成几十个 interface mock 来验证“某 function call 了某 function ”。
| 实验 test | 它防止的 regression |
|---|---|
TestConvergesAndDoesNotRewrite |
每轮无条件写入;把 empty content 误判为 missing |
TestPreservesErrorsAndAvoidsUnsafeWrite |
目标读失败被当作不存在; error identity 丢失 |
TestOwnsBytes |
修改 caller 输入/ return value,污染 storage 内部 state |
TestBoundsConcurrencyAndDeduplicates |
每 key startup 一个 goroutine;重复 key 同轮多次执行 |
TestCancelWaitsForAllWorkers |
Run 已返回但 worker 仍活着 |
TestFailureCancelsSiblingsAndKeepsCause |
触发 cancellation 的 business error 被 sibling 的 context.Canceled 覆盖 |
TestTraceOrderAndRejection |
拒绝 request 后仍 call business;middleware 顺序错乱 |
concurrency tests 尽量使用可控 event
不要 time.Sleep(100*time.Millisecond) 之后“希望 worker 已经 startup ”。 test 用 gate channel 控制何时允许继续,用 Go 1.25+ 的 testing/synctest 等待 test bubble 中其他 goroutine 都 blocking,再作 assertion。它适用于受控的 concurrency 逻辑;真实 network、系统 call 和外部 dependency 有其适用限制。synctest 文档
查看实际 test: cancellation 后,返回前必须观察到全部 worker 退出
func TestCancelWaitsForAllWorkers(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
var started, exited atomic.Int64
done := make(chan error, 1)
go func() {
done <- batch.Run(ctx, []string{"a", "b", "c", "d"}, 3, func(ctx context.Context, _ string) error {
started.Add(1)
defer exited.Add(1)
<-ctx.Done()
return ctx.Err()
})
}()
synctest.Wait()
if started.Load() != 3 {
t.Errorf("started=%d", started.Load())
}
cancel()
if err := <-done; !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
if exited.Load() != started.Load() {
t.Fatalf("returned with active workers: started=%d exited=%d", started.Load(), exited.Load())
}
})
}
上游 test 可以教你选场景:Gin 检查 execution order,TypeScript 检查 container 外部行为,K8s 检查处理中重新 enqueue。我们阅读过这些选定 test;本次执行的是课程自带 lab 的 test,不把它们混为“上游全部验证通过”。
Code review 时的十个问题
- 一个 component 究竟负责什么变化?它的名字能否说明责任?
- import 方向是否让 business rules dependency 具体基础设施?
- interface 是否来自真实 call 需求? parameter 是否偷偷带入巨大 dependency?
- 谁创建/关闭 resource?谁能修改返回的 reference?
- error 是否区分 missing、拒绝、 cancellation 和 transient failure?
- 每条 goroutine 在什么条件下退出?谁等待它?
- queue 和 concurrency 有没有上限?排队也计入 timeout 了吗?
- 重复执行是否安全?“ idempotency ”是否覆盖外部 side effect?
- test coverage 了 cancellation、部分成功、 aliasing 和失败,而不只是 happy path 吗?
- optimization 是否有测量依据?引入的复杂度能否由收益解释?
16 · 六次练习,把知识变成写代码的 capability
每次建议 60–90 分钟。先做再看参考答案;练习在课程目录的独立 lab 中完成。
| 次数 | 阅读与动手 | 可验收结果 |
|---|---|---|
| 1 | standard library、Gin;自己重写 Trace/Require | 两条 execution order test 通过;能解释拒绝后的外层 after |
| 2 | Hugo、frp、RAGFlow;画 dependency graph 并加一个 read-only source | 不修改 reconciler 即可接入;明确 missing / empty value protocol |
| 3 | TypeScript;写一个保持 insertion order 的小 generics set | 覆盖新增、覆盖已有 key、删除、break、 zero value 行为 |
| 4 | Syncthing、fzf;设计一个只保留最新值的 UI 更新 queue | 写出为何可 coalescing;演示相同机制不适用于三次增量 command |
| 5 | Ollama;给 bounded workers 加“ admission limit /拒绝”实验 | 能区分活跃数、待处理数和拒绝数; cancellation 后全部退出 |
| 6 | K8s;在实验中增加动态 key queue 和失败 retry | test 处理中再 Add、重复 coalescing、失败 delay、成功清历史、shutdown |
最终作业:增加一个 file storage adapter
从只有 memory 的 demo 演进,但不要一次做完整 file synchronization 产品。约定 key 由程序内部生成,先禁止 path separator 和 path traversal。 implementation Read/Write,转换 missing error,明确一次 whole-value replacement 的语义;处理写入、关闭、替换失败以及 temporary file cleanup。
这个阶段涉及真实 persistence: temporary file 后 rename 可以提供某些同 filesystem 下的替换性质,但不自动等于跨平台、断电可恢复的 durable transaction。若需要 crash durability,继续研究目标平台的 fsync/目录 synchronization 和恢复流程。本课程未 implementation 该 adapter,不会把练习建议当成已验证的 persistence 保证。
验收时至少证明:更换 storage 不改 reconciliation rules; missing 和 empty file 可区分;失败不会被误报为 Changed=true;source 不存在不会悄悄删除 destination; concurrency 和 cancellation 行为与 contract 一致。
进阶练习的设计提示
动态 key queue 不能只复用有限 batch 的 seen set:处理期间再次到来的变化必须触发后续一轮。需要明确 dirty/processing state,并为同 key 的 serialization、 delayed retry 和 shutdown 分别写场景。不要在 workqueue.Len()>0 的检查和 Get 之间推断 atomicity。
file backend 如无法直接满足原 contract,应修改或收紧 contract,然后重新审查所有 caller;不能只让 compilation 通过。一个好的 component boundary 允许你发现需求不一致,而不是把不一致藏起来。
完成后,应当能在两分钟内说明一个 component 的职责、 dependency、 ownership、 error 和退出 protocol,并在代码中指到对应位置。
17 · source code 索引与可复现证据
下面是按研习路径整理的固定 version 入口。每个项目还可以沿课程中的 function 行号链接继续读。research/ranking.json 保存原始查询;repositories.json 保存排名、commit、许可标识和筛选原因;reading-map.json 保存本课程的定位范围;source-index.json 保存下载 file 的 SHA-256。
| 样本 | 主要研习内容 | 固定 commit | source code 入口 |
|---|---|---|---|
| ollama/ollama | resource scheduling、 backpressure、 function injection | 83ed7d9965b1 |
定位 function |
| golang/go | capability interface、 function adaptation、 composition | c5941983810b |
定位 function |
| kubernetes/kubernetes | reconcile loop、key queue、 idempotency 与退出 | b2ec8b6fefac |
定位 function |
| microsoft/TypeScript | generics container、Visitor、 copy on change | 1f70213d4922 |
定位 function |
| fatedier/frp | embedding、 strategy、 plugin lifecycle | 832df8dff66d |
定位 function |
| infiniflow/ragflow | storage contract、 adapter、 reference ownership | 0c28d59ea1d3 |
定位 function |
| gohugoio/hugo | dependency 组装、Provider、 optional capabilities | 9c2527f8558e |
定位 function |
| gin-gonic/gin | middleware、 context 复用、顺序 test | dcaa4296d111 |
定位 function |
| syncthing/syncthing | configuration 更新 protocol、 state ownership | 9af3c75f377c |
定位 function |
| junegunn/fzf | event coalescing、 bounded workers、 cancellation | 52f4319a72c1 |
定位 function |
新增 12 个 repo 的 source index 在 Chapter 26。ecosystem-repositories.json 记录 discovery 与 state,ecosystem-reading-map.json 记录精读范围,ecosystem-source-index.json 记录已下载 file 的 SHA-256。用 python3 research/fetch_ecosystem.py 可按固定 commit 重取;discover_ecosystem.py 会创建新的 discovery snapshot,不能用它替代旧 version 的复现。
本地复查 source file 可以 execution python3 research/fetch_sources.py。脚本只下载固定 commit 的 source code 与许可,不执行上游代码。 source code cache 和原始大目录树留在本地,未纳入课程代码提交;课程中的永久链接不 dependency cache 存在。
查看实验验证记录:包含 Go 实验的 test command、环境和已验证的 source code 提交。上游项目未 build,也未进行全库审计。
18 · Chi × Gin:把 HTTP composition 留在 boundary
需求:同一个 use case 同时被 HTTP 和 CLI call
前面 Gin 展示了 framework Context、handler chain 和 object pool。现在读 Chi,问一个更具体的问题:routing library 的选择,应该影响多少 business code?
Source route: chain 的逆序 wrapping → With 与 Group → Timeout 的真实行为。
Chi 的 middleware 接收 http.Handler,返回 http.Handler。这使 composition 的输入输出保持同一种 contract:一个 wrapper 可以继续被另一个 wrapper 使用。chain 从最后一个 middleware 向前 wrapping,所以传入 A, B 得到 A(B(handler));进入顺序是 A、B,返回顺序相反。它没有另造一套 business service hierarchy。
With 创建 inline Mux,带上 middleware slice,但仍 sharing routing tree。新的 wrapper 不等于独立的 deep copy object。 Group 借助 With 给一组 route 加 behavior;不要把它理解成随时可修改且天然 thread-safe 的 configuration snapshot。
两种 API,保护同一条 boundary
| 问题 | Gin 的切入点 | Chi 的切入点 | 自己的 component 应该知道什么 |
|---|---|---|---|
| HTTP parsing、status、header | framework Context | http.Request / http.ResponseWriter |
transport adapter 负责 |
| business action | handler call use case | Handler call use case | 普通 Go parameter、context.Context、result/error |
| middleware composition | Context 的 Next/Abort protocol | func(http.Handler) http.Handler |
只在 HTTP boundary 组装 |
| CLI 复用 | 不传 Gin Context | 不传 Request | 复用同一个 use case |
原创 sketch,省略 imports;重点是 dependency direction,不是推荐某个 routing library:
type Importer interface {
Import(context.Context, string) error
}
func ImportHandler(importer Importer) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
key := r.URL.Query().Get("key")
if key == "" {
http.Error(w, "missing key", http.StatusBadRequest)
return
}
if err := importer.Import(r.Context(), key); err != nil {
http.Error(w, "import failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
})
}
这里的 HTTP error mapping 为简化示例;实际 adapter 应区分 validation、not found、conflict、cancellation。不要直接把内部 error string 输出给 client。也不必为了使用一个 Handler 而引入 Chi: standard library 已经提供这个 contract。
Counterexample:Timeout 不会强制终止 handler
固定 version 的 Chi Timeout 用 context.WithTimeout, call next.ServeHTTP,返回后才在 deferred function 中检查 deadline 并尝试写 504。handler 如果不观察 Context,仍可能继续 execution;如果 response 已经写出,后面的 504 也不能替换已经发送的 status。这不是一个能在 deadline 时强行抢占 handler 的机制。implementation 与注释
练习:怎样证明 timeout contract,而不是只验证 status code?
用 gate 控制一个 cooperative handler:等待 Context cancellation 后退出,记录它确实结束。再让另一个 handler 等待独立 gate,说明 Context 到期后它仍未返回; test 自己 release gate,避免泄漏。最后 test 已经写出 response 的情况。将 cancellation、handler completion、client-visible status 分成三个 assertion。
迁移 rules: 把 framework 放在 edge。每加一个 middleware,先写清它是否 call next、是否修改 request、是否拥有 response,以及它的 after behavior 在拒绝或 error 时是否 execution。
19 · Zap × Zerolog:API ergonomics 背后的 allocation 与 ownership
需求:可搜索的 structured logs,同时控制 hot path 成本
Source route: Zap Core contract → Check 与 child logger → 多 sink composition;对照 Zerolog Event 的使用周期。
Zap 把 encoding、output 和 level policy 放在 zapcore.Core 周围。With 为 child context clone encoder;Check 决定某个 Entry 是否需要写入,Write 消费已决定写入的 Entry。NewTee 把多个 Core composition 起来,但多个 sink 的 Write 不构成 atomic transaction:其中一个失败,另一个可能已经写入。
Zerolog 用 fluent Event 累积 fields,再由 Msg / Send 完成一次 event。这个 Event 有 mutable state 并会回到 pool; source code 明确要求不能对同一个 Event call 两次 Msg。它不是 Scala immutable builder,也不应该被保存起来供多个 goroutine 复用。Event lifecycle
不要把 fluent API 当成 lazy evaluation
Go 在 call function 前先求值 argument。下面两种写法的成本不同:
// Original sketch: expensiveSnapshot runs even when Debug is disabled.
log.Debug("state", zap.String("snapshot", expensiveSnapshot()))
// Gate expensive field construction with the level decision.
if ce := log.Check(zap.DebugLevel, "state"); ce != nil {
ce.Write(zap.String("snapshot", expensiveSnapshot()))
}
Zerolog 的 MsgFunc 在 Event 为 nil 时不 call callback,可用于延后 message construction;这不意味着其它 field argument 自动 lazy。Zap 的 WithLazy 也不是 immutable snapshot:它可能保留 object reference,在实际 logging 时观察 object state。先明确想记录“现在的值”还是“稍后的 state ”。Zap、Zerolog
Clean code 不等于统一所有 logging library
| 设计选择 | 得到什么 | 需要承担什么 |
|---|---|---|
| 在 adapter 使用具体 logger | API 简单,保留 typed fields | business 层避免 dependency 具体 field type |
| 为一个 use case injection event function | event schema 清楚,容易 test | 只适合确实有独立语义的 event |
| 全公司通用超大 Logger interface | 统一入口 | 可能 copy 所有 vendor API,失去具体 capability 仍增加维护成本 |
迁移建议:确定 operation、key、attempt、duration、outcome 等稳定 fields,避免同一件事在每层重复 log。通常由能决定 action outcome 的 boundary 记录一次 error,底层提供带 context 的 error。 resource owner 决定何时 Sync/flush,并处理 sink failure;不要让普通 business function 关闭 shared logger。
练习:你该 test log text 还是 event contract?
从 Zap Core test 学习过滤和 fields assertion。给 disabled Debug 的 expensive function 加 counter, assertion 为零;再验证 child fields 不污染 parent。 business test 关注稳定 fields 和 outcome,不把 timestamp、field 排序或整行格式 lock 死。需要合规 audit trail 时,单独定义 durability contract;普通 logger 的成功返回不自动满足它。
20 · Cobra:command 是 adapter,PostRunE 不是 finally
需求:CLI 支持 flag、validation 和可 test 的 business action
Source route: execute 的完整 hook 顺序 → happy-path hook test。
Cobra 的价值是集中处理 command tree、argument 和 flag protocol。它不要求 business rule 接收 *cobra.Command。沿 execute 往下读,会看到 ValidateArgs、pre-run hooks、required flags validation、RunE、post-run hooks。RunE 返回 error 时,会直接 return,不执行后面的 public PostRunE / PersistentPostRunE。 source code 里的 deferred c.postRun() 是内部 finalizer 入口,不能与公开的 PostRunE 混为一谈。
下面是原创 adapter sketch,省略 imports。closure 只拥有这个 command 的 flag state;真正工作由普通 function 完成:
func NewImportCommand(run func(context.Context, string) error) *cobra.Command {
var key string
cmd := &cobra.Command{
Use: "import",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if key == "" {
return errors.New("key is required")
}
return run(cmd.Context(), key)
},
}
cmd.Flags().StringVar(&key, "key", "", "Document key")
return cmd
}
在 process boundary 用 ExecuteContext 传入 cancellation,并把最终 error mapping 成 exit status。business function 不 call os.Exit;它会绕过当前 process 的 deferred cleanup,也使复用和 test 困难。需要输出时, injection io.Writer 或在 adapter 用 command 的 output writer。
resource 在哪里取得,就在哪里安排 release
不安全的设计:PersistentPreRunE 打开 file/connection, desired PersistentPostRunE 永远关闭它。validation 或 RunE 失败,都可能跳过你期待的 public post hook。
更小的设计:在 RunE call 的 function 内取得 resource,立即 defer Close,并在需要时把 close error coalescing 到 return value。如果多个 command 真正 sharing 长 lifecycle resource,再让更外层的 owner 负责;不要靠 hook 名字猜测 exception semantics。
练习:从 happy path 扩展 test
保留 source code 中的 hook 顺序 test 思路,再增加 RunE 返回 sentinel error 的 case:assert errors.Is,assert public post hook 没执行,assert你自己的 deferred cleanup 执行。重复创建两个 command,验证 flag state 不串扰;business use case 的 test 完全不需要创建 Cobra command。
迁移 rules: CLI adapter 转换 input/output,use case implementation action,composition root 决定 dependency 和 process lifetime。这与 Java controller/service 的分工相似,但不用为每一层再加一个 interface。
21 · Manual DI × Fx × Wire:construction 不等于 lifecycle
需求:一组有 dependency 关系的 component 要 startup、停止,并处理部分 startup 失败
Source route: Fx Hook contract → Start / Stop 的计数与顺序 → App 的 rollback。对照 Wire 的 generated code 和 provider contract。
先用普通 Go function 组装:创建 storage,创建 service,再创建 HTTP server。对少量 component,显式 constructor 加明确的 cleanup 顺序通常已经够用。DI 是 dependency 显式传入;并不等于需要 container。
Fx 将 lifecycle 变成可 composition 的 Hook。固定 implementation 按 registration order Start,按已 startup Hook 的反向顺序 Stop;App.Start 遇到 error 会尝试 rollback。失败的 OnStart 自己不属于已成功 startup 的 Hook,因此它在返回 error 前必须 cleanup 自己已经取得的部分 resource。Stop 会收集 error,Context budget 到期可能终止后续 cleanup;不合作的 hook 也不会被 Context 强制中断。
| 情况 | 谁负责 |
|---|---|
| constructor 创建了纯 in-memory object | 普通 Go ownership,通常无需 lifecycle hook |
| OnStart 开 socket 后第二步失败 | 该 OnStart cleanup 刚取得的 socket,再 return error |
| 前两个 Hook 成功,第三个失败 | App rollback 已完成的 Hook;第三个自 cleanup |
| 正常 shutdown | 停止 admission,drain/join work,最后关闭 dependency |
Wire 教你观察 generated Go,不是推荐新项目采用 archived dependency
Wire 用 provider declarations 生成普通 constructor calls;tutorial 的输出 很短,可以直接 tracing dependency 和 error return。provider 可以返回 cleanup function,generator 按 dependency 关系安排 cleanup。provider markers
维护 state: 本次 2026-09-05 snapshot 的 google/wire 已 archived,README 明确说不再维护。项目 declaration 这里把它作为 code generation 的历史案例。不要因为出现在教程里就把它加入新系统。
| 选择 | 值得使用的条件 | 代价与限制 |
|---|---|---|
| Manual DI | dependency graph 小到能一眼审查 | 随 graph 增长需维护显式 wiring |
| Fx | 很多 module 贡献 lifecycle,统一 startup 与退出有实际收益 | runtime graph、hook ordering、 startup 失败与 deadline 都要 test |
| Wire 的设计思路 | 希望把 wiring 变成可审查的 generated Go | generator/toolchain lifecycle;此 repo 已停止维护 |
练习:画两张 graph
第一张是 construction dependency:HTTP server → service → store。第二张是 shutdown protocol:stop accepting → cancel/drain handlers → wait → close store。它们有关联,却不是把箭头反过来就总能得到正确答案。用一个 failing OnStart 和两个成功 Hook,记录 call 序列,验证部分 resource 的 cleanup owner。
迁移 rules: 先能用手写 Go 解释 wiring,再考虑自动化。无论使用哪种 DI,resource ownership、readiness、rollback 和 shutdown 都需要独立 contract。
22 · x/sync:errgroup 的 scope、admission 和 cancellation
需求:并行执行一批有限工作,首个失败时通知其它工作,然后等待全部结束
Source route: Group / WithContext / Wait → Go / TryGo / SetLimit → Wait 后 Context 的 test → semaphore 的 cancelable Acquire。
errgroup.WithContext 把 error propagation、cancellation signal 和 join 放在一起。它没有给任意 goroutine 自动建立完整 structured concurrency:所有相关工作仍必须通过 Group register,工作必须合作观察 Context, caller 必须 Wait。
最容易漏掉的一条:Wait 返回时,派生的 Context 会被 cancel,即使所有工作都成功。 不要在 Wait 成功后继续用这个 Context 发下一阶段的 request。
// Original sketch; fetch must honor the supplied Context.
func FetchAll(ctx context.Context, keys []string,
fetch func(context.Context, string) error) error {
g, workCtx := errgroup.WithContext(ctx)
g.SetLimit(4)
for _, key := range keys {
key := key
g.Go(func() error { return fetch(workCtx, key) })
}
return g.Wait()
}
这是 finite batch sketch,不是完整 admission policy。Go 在 limit 满时等待 semaphore slot,这段等待不是对 Context 的 select;TryGo 则在没有 slot 时立即返回 false。需要 cancellation-aware admission 时,可以研究 weighted semaphore 的 Acquire(ctx, n),或采用前面 lab 的固定 worker 与显式 queue protocol。
与 Future / task group 的相似和差异
| 观察 | 对 caller 的影响 |
|---|---|
| 第一个非 nil error 被保留 | “第一个”取决于 execution timing,不是 business 优先级排序 |
| Wait 等所有 registered function 返回 | cancellation 不等于强制退出;一个卡住的 function 会拖住整体 |
| WithContext 在 Wait 返回时 cancel | 下一阶段使用正确的 parent/next-stage Context |
| SetLimit 限 active work | 不自动定义 queue deadline、拒绝 strategy 或公平性 |
| 不把 panic 转成 ordinary error | 保留 process failure policy;不要假设 Group 是 exception container |
反例:limit 为 1,worker 内再 call 同一个 Group.Go,然后等待 child,会怎样?
外层 worker 占住唯一 slot,内部 Go 在等 slot,外层又不能完成 release 它,形成 deadlock。用 flat task graph,或让 child 工作在当前 goroutine 顺序执行;不要用无限增加 limit 掩盖 ownership 问题。
练习 acceptance: test 成功、首个失败、parent cancellation、blocked admission;用 gate 控制时机,不用猜 sleep 时长。assert 所有 registered worker 在返回前退出,且成功 Wait 后 workCtx.Done 已关闭。下一阶段的 operation 必须用你明确选择的 Context。SetLimit 不能在仍有 active worker 时随意修改。
23 · Backoff × Gobreaker:分开 retry、deadline、circuit breaker
需求: call 不稳定的远端,但不让失败流量拖垮整个 service
Source route: Backoff option contract → retry loop → cancellation test;对照 Gobreaker settings、Execute admission 与 generation accounting。
本章固定到 cenkalti/backoff 的 v7 branch snapshot,不是把网上其它 major version 的 API 拼在一起。学习 control flow 后,使用时仍应核对你实际 dependency 的 release。
| 机制 | 回答的问题 | 没有替你保证什么 |
|---|---|---|
| retry + backoff | 失败后是否再试、等多久 | operation 可安全重复、总量受控 |
| deadline / cancellation | caller 还愿意等待多久 | 不合作的 operation 被强制停止 |
| circuit breaker | 已知远端异常时,是否拒绝新的尝试 | 每个正常 request 的 concurrency limit |
| admission limit | 当前可同时做多少工作 | retry eligibility 或 remote health |
Backoff 的 WithMaxTries(3) 表示最多三次 total attempts。WithMaxElapsedTime 约束 retry scheduling budget,不会中断已经 execution 的 operation;Operation[T] 本身没有 Context parameter,需要 closure 将 Context 传给底层 I/O。这个 version 的 Retry 会先执行一次 operation,再在 failure path 检查 Context;所以 caller 传入已经 canceled 的 Context,也不代表 operation 一次都不会被 call。operation 自己要观察它。
Backoff policy 有 mutable state,不能把同一个 instance 随便 sharing 给多个 concurrent Retry。把它当作一次逻辑 call 的 state,而不是 package-level singleton。
Gobreaker 的 MaxRequests 限制的是 half-open 时允许探测的 request 数量,不是正常 closed state 的全局 concurrency cap。其 Timeout 是 open state 的停留时间,不是 operation timeout。执行真正 request 时不持有 breaker mutex;完成后会核对 generation,防止上一轮 request 的结果污染新的 state 周期。implementation、state 处理
Composition 顺序会改变统计含义
retry(breaker(oneAttempt)) → breaker 看到每次 attempt
breaker(retry(oneAttempt)) → breaker 看到一次 logical operation 的最终结果
这是对 control flow 的推导,不是某个 library 对所有 backend 的推荐。前者可能让 breaker 快速看到连续失败,也可能对 ErrOpenState 做无意义 retry;后者可能把多次失败藏在一次最终成功里。先决定 metrics 和 failure budget 的单位,再决定 wrapping 顺序。
三层各做最多三次 total attempts,最坏会放大成 3 × 3 × 3 = 27 次底层尝试。避免在 client、service、job 三层不知情地叠加 retry;让一个 owner 负责总 budget。对 payment、create 或 publish 等 side effect,先设计 idempotency key 和结果查询,再讨论 retry。
练习:用 error taxonomy 代替“所有 error 都 retry ”
列出 invalid input、caller cancellation、temporary transport failure、rate limit、already-applied、breaker open。分别决定 retry eligibility、delay、metric classification。Gobreaker 可以自定义 IsSuccessful / IsExcluded;把 caller cancellation 算 remote failure 可能误开 breaker。用可控 operation 计数,验证最大 attempt、cancel during wait、成功后停止,以及已经 canceled 时 operation 的行为。不要在 test 里真实 sleep 一个 exponential schedule。
24 · Watermill:Ack 是 protocol,不是 business transaction
需求:接收 message、修改 state、发布后续 event,失败后可以恢复
Source route: Message Ack / Nack → Router 的处理顺序 → Publisher / Subscriber contract → Ack/Nack tests。
Watermill 的 Message 用 channel 和 state 记录 Ack/Nack。重复同一种 terminal action 可以成功;相反的 action 会失败。这是“第一个 terminal state 生效”的 local protocol。它不说明 broker 已经 durable commit,也不把 database 修改包含进来。
Router 的正常路径是: execution handler → publish produced messages → Ack 原 message。handler error 或 publish error 会 Nack。但 Publisher interface 明确允许具体 implementation 采用 synchronous 或 asynchronous behavior;batch publish 通常也不是 atomic。Publish 返回 nil 的含义必须去具体 adapter 核对。
用 crash window 代替口号
| 中断位置 | 可能看到什么 | 你的 design 需要解决什么 |
|---|---|---|
| DB update 之前 | redelivery,尚未修改 business state | 可以重新执行 |
| DB commit 之后、Ack 之前 | redelivery,同一 action 再来一次 | business idempotency / deduplication |
| 后续 publish 部分成功 | 一部分 event 已对外可见 | batch 非 atomic,处理重复与部分结果 |
| 手动提早 Ack 后 handler 失败 | Nack 不能撤销之前 Ack | 不要把提前 Ack 当作无成本 optimization |
“有 Ack”不能推出 exactly-once business effect。迁移建议是使用稳定的 business operation ID,在同一个可满足要求的 transaction 中记录 state 改变和 deduplication;需要可靠 event handoff 时,研究 transactional outbox,再设计 relay 的重复发布处理。这是 architecture exercise,不是声称 Watermill 的 generic Router 已替你完成 transaction。
与前面 K8s reconcile 的关系:两者都需要认真面对重复执行,但 workqueue 的 key coalescing 与 broker 的 delivery / acknowledgment 不是同一种 protocol。 state 对齐可以重读最新 state;处理不可丢弃的 command/event 不能只保留“最后一个 key”。
练习:fault injection 的最小矩阵
fake store 在 commit 前失败一次;fake publisher 在第 N 个 event 失败;handler 在 store commit 后、Ack 前模拟 crash。重复输入同一个 operation ID,assert business effect 只发生一次,并记录 delivery 次数可以大于一。再复现先 Ack 后 Nack 的 return value。 test 不应凭一个 memory fake 推断真实 broker 的 durability。
Ownership 提醒: NewMessage 接收 payload slice,不会自动 deep copy。publisher/handler 的 goroutine 何时还能访问它、caller 何时可以 reuse buffer,都需要具体 contract;GC 不会阻止 aliasing 或 data race。
25 · Afero × go-cmp:testability 取决于 contract,不是 mock 数量
需求:把 storage 换成 memory fake,同时确保 test 没有掩盖真实差异
Source route: Afero Fs → ReadOnlyFs → io/fs adapter → optional capability forwarding;对照 cmp.Equal 的 rules 和 EquateEmpty。
Afero 的 Fs interface 包含 create、open、remove、rename、permission 等很多 filesystem operation,适合需要这些 capability 的 infrastructure code。对于 read-only 取 configuration 的 consumer,接收整个 Fs 会暴露不需要的 write capability;io/fs.FS 或 consumer 自己的小 contract 可能更准确。
ReadOnlyFs 保留 Fs method set,但写操作在 runtime 返回 permission error。这和 type system 中根本没有写 method 是两种设计。IOFS 则把 Afero adaptation 到 io/fs,处理 path validation、file adaptation 和 directory entries。不要把 read-only wrapper 或 base path wrapper 当作自动成立的 security sandbox。
Wrapper 的可替换性包括 optional capability
前面 io.Copy 会查询 WriterTo / ReaderFrom。Afero 的 BasePathFile 有 forwarding:底层有 capability 就 call,否则 fallback 到普通 copy。对应代码 因此一个 wrapper 除了“有没有 implementation 基本 method ”,还可能影响 performance path。先测真实工作负载,再决定是否值得 forwarding;不要为了保留每个可选 interface 把 wrapper 变成庞大代理层。
Comparator 也是你的 specification
go-cmp 的默认 rules 会区分 nil slice/map 和 non-nil empty value;可能 call type 自己的 Equal method;遇到未处理的 unexported field 会 panic。cmpopts.EquateEmpty 明确改变 nil/empty 的等价关系。这是 domain decision,不能因为 diff 难看就加 option。contract、option
// Original test sketch: use only when the API defines nil and empty equally.
if diff := cmp.Diff(want, got, cmpopts.EquateEmpty()); diff != "" {
t.Fatalf("result mismatch (-want +got):\n%s", diff)
}
| 随手加的 test helper | 可能掩盖的 bug |
|---|---|
| IgnoreFields 所有 timestamp | business dependency 的 ordering 或 expiration 出错 |
| EquateEmpty 到处应用 | API 用 nil 表示未加载、empty 表示已加载但无数据 |
| memory fake 的 rename 总成功 | 真 filesystem 的 permission、cross-device、close failure |
| 只 assert call 了 Write 一次 | 写入内容错了、失败后 error 地报告成功 |
练习:让同一个 contract suite 跑两个 adapter
为 memory 和临时目录 filesystem adapter execution 相同 public contract cases:missing/empty、whole-value replacement、error classification、caller buffer reuse。另写 OS-specific cases,不要求 memory fake 模拟真实 crash durability。比较结果时解释每一个 cmp option 为什么符合 business 语义。一个 option 若说不清,就删掉它并先修正 assertion。
迁移 rules: 从 caller 的需求定义 test seam,验证可观察行为。fake 是一种 model,有明确覆盖范围;它不是生产环境的证明。
26 · 从 catalog 到自己的 component:一份可执行 design workshop
先问设计问题,再打开 awesome-go
awesome-go 是 discovery catalog。被收录、stars 多、API 好看,都不能替代 contract review。下面 12 个新增 implementation 中,8 个可在本次固定的 awesome-go README 找到;另外 4 个是为比较完整而补充的上游项目。没有把它们都说成 awesome-go 当前推荐,也没有对这些 library 做 performance 排名。
| Repo | Design focus | Discovery | Pinned source |
|---|---|---|---|
| go-chi/chi | HTTP composition | awesome-go | ae6be7469132 |
| uber-go/zap | Core, structured logging | awesome-go | bb1a55dd1325 |
| rs/zerolog | Event ownership, lazy evaluation | awesome-go | dfd11cca1143 |
| spf13/cobra | CLI boundary, cleanup | awesome-go | adbc8813901b |
| uber-go/fx | DI lifecycle, rollback | awesome-go | d5da5b04ac90 |
| google/wire | Code generation; archived | Supplement | 9c25c9016f68 |
| golang/sync | Structured concurrency, admission | Supplement | f75267d8412f |
| cenkalti/backoff | Retry budget, cancellation | Supplement | ffcfd8ab39e2 |
| sony/gobreaker | Circuit breaker, generation | Supplement | fed8e9eb35f9 |
| ThreeDotsLabs/watermill | Ack/Nack, delivery protocol | awesome-go | 080d4b4e7fe6 |
| spf13/afero | Filesystem adapter, capabilities | awesome-go | 768f1fb0e553 |
| google/go-cmp | Equality contract, test oracle | awesome-go | b133f1f1932e |
Source snapshot:2026-09-05。 ecosystem.json 公开保存新增 repo 的完整 commit、license identifier、archived state 与 awesome-go membership 行号。未 archived 不等于仍在积极维护;本次检查没有完整审计 release cadence、security history 或 maintainers。选型前在实际采用的 version 重新检查这些因素。
跨 repo 的 decision map
| 你要保护的 boundary | 连起来读 | 默认从哪里开始 | 什么时候才增加机制 |
|---|---|---|---|
| caller 只 dependency 需要的 capability | io → Afero → RAGFlow | 小 interface / io/fs |
多个真实 adapter 有一致语义 |
| transport 不渗入 business | Gin → Chi → Cobra | 普通 use case function | 多 transport 确实需要复用 |
| construction 与 shutdown | Hugo → Fx → Wire | 手写 wiring 和 explicit owner | lifecycle graph 的 coordination cost已经可见 |
| bounded concurrency | fzf → Ollama → errgroup | finite worker 或 Group | 需要 queue、priority、cancelable admission 时再扩展 |
| failure recovery | K8s → Backoff → Gobreaker | error taxonomy + 一个 retry owner | measured remote failures 值得加 breaker |
| 可重复执行的 side effect | reconcile → Watermill | stable operation ID + contract | durable handoff 需要 outbox / deduplication |
| 可观察、可验证 | Zap → Zerolog → go-cmp | structured outcome + behavior tests | profiling 或 test friction 指出具体问题 |
Workshop:一个 document import service
需求:CLI 与 HTTP 都能提交 document key;读取 source,更新 target;每次成功 change 产生一个 event;支持 cancellation、 bounded workload 和重复 delivery。不要一次引入所有 library。先用现有 lab,把新的 requirement 一条条加上去。
cmd/importer chooses dependencies, owns process lifetime
├─ adapters/http → Import(ctx, key)
├─ adapters/cli → Import(ctx, key)
└─ application/importer
├─ Source.Read(ctx, key)
├─ Target.Apply(ctx, operationID, value)
└─ outcome / error
adapters/storage implements the application contract
adapters/events implements a separately specified delivery protocol
这是建议的 dependency sketch,不是要求每个 repo 都照抄目录名。先将 application 放在一个 package;只有责任确实分开、变化原因不同,再拆 package。避免 common、utils、万能 Manager 让 dependency 失去方向。
先写 contract card: Input 是 key 还是完整 command?operation ID 由谁生成?相同 ID 不同 payload 是 conflict 还是覆盖?哪些 error 可 retry?谁拥有返回的 slice?timeout 是否包含 admission wait?shutdown 先拒绝新工作还是先关 store?log 表示 attempt 还是 logical operation?每个答案都应有 owner 和可观察 acceptance。
| 阶段 | implementation 任务 | 验收证据 |
|---|---|---|
| A · boundary | HTTP 和 CLI 调同一个 Import function | 不创建 router/command 也能测 business rule; error 正确 mapping |
| B · ownership | storage adapter + 明确 buffer/close contract | caller mutation 不破坏已存值;部分失败能 cleanup |
| C · concurrency | bounded work + cancellation + join | active 不超过上限;返回前 worker 全部退出;admission 行为明确 |
| D · recovery | 一个 retry budget + stable operation ID | 重复输入不重复 business effect;attempt 总量可预测 |
| E · delivery | 定义 DB→event handoff 与故障恢复 | commit 后 crash、部分 publish、redelivery 都有 test |
| F · operation | structured fields + shutdown sequence | 能区分 rejected/failed/canceled/changed; dependency 关闭前 work 已结束 |
Review challenge:拒绝一份看起来“企业级”的方案
方案给每个 struct 都加 interface,建通用 Repository[T]、Service[T]、Manager[T],用 DI container 自动 startup 全部 object,三层 retry,handler 结束立即 Ack。逐条问:真实 caller 是谁、保护哪个 invariant、谁负责 cleanup、哪种 failure 会暴露 error?如果删去某个 abstraction 后需求仍成立且 test 更直接,就先删掉。
优秀 Go code 的证据是清楚的 change boundary 和 failure behavior。 source code pattern 提供选择;你仍需为具体的 requirement 负责。
如何继续扩展,而不是再追一张榜单
选一个当下的问题,从 awesome-go 对应 category 找两种不同 API,固定各自 version。各读一个 contract、一个 implementation、一个 caller、一个 failure test,写出相同需求下的 trade-off。把一个想法移植到小实验,再用 counterexample 试图推翻它。阅读收益来自这种比较,而不是累计安装的 dependency 数量。
新增章节的 inline code 是原创 teaching sketch,省略 imports,不在现有 lab verification 的执行范围内;source facts 来自 pinned reading ranges。现有可下载 lab 仍是 standard-library-only 的 runnable 基础。扩展 workshop 是下一步作业,不伪装成已经 implementation 和通过 test 的完整 service。
没有匹配的章节。试试「 interface 」「 cancellation 」「 storage 」,或清空搜索。