读书导读 · MIT
《通过测试学习 Go》全书摘要与逐章导读
引文按 MIT 许可使用。免费阅读原书。
《通过测试学习 Go》讲了什么AI 辅助
测试的痛苦诊断的是硬连线的依赖,而不是 TDD 循环的缺陷
如何以测试先行的方式培育出值得信赖的 Go 软件?这一个问题驱动着整本书。它从刻意琐碎的练习开篇——Hello、Add、Repeat、Sum、Area、Wallet、Dictionary——每一个教一个语言特性,同时反复操练同一个循环:写一个失败的测试,读编译器的报错,看它因正确的原因失败,只写刚好够用的代码,重构。然后它攀升到 goroutine、通道、select、context、反射、泛型和基于性质的测试;一章一章地构建一个完整的 HTTP 和命令行扑克应用;并以验收测试、规格说明与驱动的分离、扩展 TDD 以及一份反模式名录收尾。贯穿全书的主线是:测试是一种设计工具,而不是验证的杂务:测试中的痛苦诊断出硬连线的依赖,所以协作者要在小型标准库接口后面被注入,失败要作为可检查的值而不是字符串被返回,而抽象只在具体的被测行为揭示它们之后才被提取。每一课都跑在小型 Go 程序上,所以关于大型系统的说法是被论证的,而不是被测量的。
一则讲透全书的寓言AI 辅助
用一个故事装下全书最核心的想法,并老实交代故事在哪里失真。
标准水桶使秘密小门和特殊通道变得不再需要
从前有一个村子,井设在磨坊主的屋子里,砌在他的壁炉墙里。只有磨坊主能打水,而且只能借着火光。村民们在烘焙日之前想尝一口水,磨坊主说:先烤,后尝。于是他们烤了,面包出炉是苦的,磨坊主发誓水是甜的,而不拆一堵墙,谁也无法证明不是。一个春天,一位名叫伊莎的年轻箍桶匠说:在我烤任何东西之前,我要一杯水。她想去取,却发现取不到——不是因为水不好,而是因为井被建在了没有水桶够得着的地方。邻居们说这是她的杯子的失败。伊莎说这是墙的失败。她没有为尝水者专开一个秘密小门;她说,井应当在任何带着水桶的人都能站到的地方,磨坊主也应当像其他人一样被递给他的水。于是墙拆了,井搬到了广场,打水的人如今需要的只是一副标准尺寸的绳桶——箍桶匠的桶,面包师的桶,孩子的锡杯,都合同一个口沿。伊莎打了一杯,尝了尝,发现是咸苦的,村子在点燃烤炉之前就知道了。后来来了约伦,他讨厌走去广场。他在厨房里放着一只涂了漆的桶,每天早晨凭着对井水从前味道的记忆把它灌满,每天晚上宣布水是甜的。他的面包也是苦的,但现在他的品尝却说相反的话——比磨坊主更糟的状态,后者至少承认没人能分辨。村子定了一条规矩:厨房里的桶,只有在它是真桶、装着真水,并且对它和对广场上的井运行同样的品尝仪式、二者必须一致时,才被允许。而每年一次,全村把一个面包从头到尾烤出来并吃掉,因为再诚实的一杯水,也证明不了面包。
故事里的每样东西代表什么
| 故事里 | 书里 |
|---|---|
| 伊莎要求在烤之前、而不是之后得到一杯水 | 先写测试,并看它因正确的原因失败,因为事后写的测试会变成常绿测试,或断言无关的细节 |
| 她够不着水,以及她把它读作墙的过错而不是自己杯子的过错 | 测试中的痛苦是关于硬连线的依赖、全局状态和过多协作者的设计反馈——修复属于生产设计 |
| 拒绝为尝水者开秘密小门,而是把井搬到广场 | 拒绝仅供测试的模式和为测试而发明的层,转而改变真实的结构 |
| 箍桶匠、面包师和孩子的容器都合得上的标准口沿 | 作为接缝的小型标准库接口——io.Reader、io.Writer、一个存储——使同一套逻辑能服务于命令行、HTTP 服务器和浏览器 |
| 像对待其他人一样,从外面把水递给磨坊主 | 依赖注入:协作者从外面传入,而不是在内部构造或作为全局变量去取 |
| 约伦凭记忆灌满的涂漆桶,宣布水是甜的,而面包依旧是苦的 | 模拟对象(mock)、桩(stub)和间谍对象(spy)作为分散的、未经验证的假设,使测试在软件已坏时仍然通过 |
| 厨房的桶必须装真水、并通过与井相同的仪式的规矩 | 一个有状态的假实现(fake),因为同一套可复用的契约测试同时对它和真实依赖运行而被信任 |
| 每年把整个面包烤出来吃掉 | 从外部驱动系统的验收测试——单元测试给不了的信心,昂贵到只能补充而不能取代它们 |
寓言在哪里失真
这则寓言的扭曲在于,把重新设计说成一次不可逆的砌墙行动,并配了个明显的恶棍。书里的版本是迭代的、廉价的:墙一块砖一块砖地拆,用薄薄的端到端切片,尽快脱离红色——必要时硬编码一个返回值——只有在具体的被测行为显出模式之后才提取标准口沿,绝不预先雕刻。它也无法容纳那些活在类型和工具里、而不在道德几何里的部分:作为可检查的哨兵值或带类型的值、用 errors.Is/As 检查的错误,在编译期使坏桶无法被表示的泛型与受约束类型,把速度说法变成测量的基准测试,以及整个并发的体系,其中互斥锁、通道和 context 承担串行化,而 sleep 从来不是同步。它也无法承载本书自己未解决的张力——扑克各章用寓言里谴责约伦的那种桩和间谍搭建了 HTTP 服务器,没有规则说硬编码的烂泥什么时候必须去掉,而把规格说明与驱动拆开恰恰加进了重构建议所警告的那种间接层。最后,这个故事背后的每一课都是在刻意琐碎的程序上演示的,所以村子对一个口沿适合所有容器的信心,是被论证的,而不是在规模上被测量的。
《通过测试学习 Go》的核心论点
全书捍卫的 10 个论点,按论证需要的顺序排列。
- 因为真实使用中的软件必须不断变化并累积复杂度,单元测试是持续的、保持行为不变的重构的前提条件——所以没有时间重构,是它早该进行的信号,而不是跳过它的理由。
“选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”
“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”
Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 → - 测试必须通过一个单元导出的 API 来断言外部可见的行为;把它们耦合到实现细节或原始输出字符串,会产生误导性的失败、脆弱的测试套件和受阻的重新设计。
- 测试必须先写,并看它因正确的原因失败,因为事后写的测试倾向于常绿,或断言无关的细节,而基于示例的用例系统性地漏掉那些由陈述出来的性质所暴露的领域真相。
- 测试中的痛苦是关于硬连线的依赖、全局状态或过多协作者的设计反馈——TDD 在概念上很简单,人们归咎于它的,是良好设计的难度,而循环只是把它暴露了出来。
- 依赖应当被注入到代码本就会接触的小接口后面——io.Reader、io.Writer、一个服务或存储——而不是硬连线,或包上为测试而发明的层,因为让代码可测试的同一条接缝,也让同一套领域逻辑能支撑命令行、HTTP 服务器和浏览器。
- 代码应当在薄的端到端切片中生长,尽快脱离红色——必要时硬编码——抽象只在具体的被测行为揭示模式之后才提取,绝不预先发明。
- mock、stub 和 spy 是一种负担,因为它们散布未经验证的假设,使测试在软件已坏时仍然通过,而有状态的假实现,借由对真实依赖运行的契约来检查,则以单元测试的速度给出集成层面的信心。
- 从外部驱动整个系统的验收测试给出单元测试给不了的信心,但成本高到必须补充而不是取代它们——而且只有当规格说明与驱动分离时,它们才保持可复用,所以行为变更从规格说明开始,并顺着编译器错误向下。
- 正确性属于类型和返回值,而不是字符串和运行时的运气:错误应当是用 errors.Is/As 检查的哨兵值或带类型的值,无效输入应由受约束或泛型的类型排除,而编译器应被用作把失败移到编译期的协作者。
- 并发和优化必须由证据而不是希望来驱动:共享的可变状态需要显式的串行化,而 sleep 从来不是同步;速度的说法,只有在代码能工作、已重构、并且基准测试测出收益之后,才是正当的。
逐章导读
每一章都在本页:论证怎样展开、关键论点、常见误读和核心概念。从目录里选一章。
关于本书:为什么要写单元测试,以及怎样让它们为你所用
你如何区分一个单元真正的边界与偶然的边界?
关于本书是怎样展开的AI 辅助
本章以围绕“软件”一词本身的一个谜题开篇:如果代码本应是柔软可塑的,为什么这么多项目最后都塌成必须重写的遗留系统?作者把若干有帮助的特性归功于 Go——一个很小的关键字集合,用于测试和基准测试的强大工具,丰富的标准库,快速编译,以及向后兼容的承诺(与一次痛苦的 Scala 升级形成对照)——但坚持认为没有哪种语言能阻止糟糕的系统。这引出了历史的锚点:曼尼·莱曼1974年的软件演化定律。其中两条被特别点出。持续变化定律说,软件必须变化,否则就越来越无用,作者借此批评那些被激励为按日期交付然后移交的团队,并论证即使优秀的工程师也无法预见未来的需求。复杂度递增定律说,除非做工作去降低,复杂度会增长,警告不要把团队当作“盲目的功能工厂”。
从这里,本章收窄到它所选的补救。在许多让软件保持柔软的实践之中——良好的关注点分离、架构、可观测性、可部署性、测试、反馈循环——作者选了重构,指出这个词被广泛误用,并引用马丁·福勒的话大意是:一个坏了好几天的系统并不是在被重构。含义靠一个课堂类比被找回:把 1/2 和 1/4 相加,办法是把 1/2 重新表示成 2/4,这改变了形式而不改变值。重构同样是重塑代码,使之适合你对领域当前的理解,而不改变行为。一个 Go 的例子使之具体:一个堆着按语言分支的 if 的 Hello 函数,变成一个查找映射加一个带默认值的小小 greeting 辅助函数。作者强调具体怎么重写并不重要——唯一的规则是行为不被触动,因为在重构时改变行为,就意味着一次做两件难事,这正是重构努力失败的地方。既然在一个不简单的系统上,你无法在脑子里验证等价,而手工测试无法扩展,单元测试就提供了缺失的检验:信心、对预期行为的文档,以及快速的反馈。一个简短的 TestHello 展示了节奏:小重构,运行测试,重复——亚秒级的反馈,作为对抗莱曼复杂度的安全网。
转折出现在作者直面相反的体验时:许多人发现单元测试阻碍重构,这与承诺正相反。诊断性的问题是:重构期间测试必须改变得多频繁。图示是一个由两个三角形拼成的正方形,测试断言的是三角形的性质;当后来的一位开发者用长方形重建这个正方形时,这些测试以误导的方式失败了,尽管内部形状从来不是重点。教训是,这类测试错误地抬高了实现细节。药方是测试行为而不是实现——抱怨通常能追溯到处于错误抽象层级的测试、对协作者的过度侦察、过度模拟,以及把追逐测试覆盖率当作虚荣指标。纯粹的黑盒测试也不是答案:对关键旅程有价值,但昂贵、缓慢,又不善于精确定位原因,所以对重构帮助不大。
解决办法把测试重新框定为设计问题。瞄准自成一体、解耦、围绕领域概念构建的单元——乐高积木,带着连贯的 API,如银行系统里的“账户”包,把让它们工作的任何一堆类型和函数藏起来。测试这些公共 API,你就必然是在测试有用的行为,让内部可以自由变化。是的,作者肯定,这些算作单元测试;“单元”从来不是指单个函数或类。
收尾部分把三条线编织在一起:重构提示你的测试是否处在正确的层级(手工检查意味着测试太少;错误地失败的测试意味着错误的抽象或没有价值)并管理复杂度;单元测试提供安全网和文档;设计良好的单元使测试和重构都变得容易。这引向对 TDD 的论证。与其是“糟糕的旧日子”——数月的需求与设计文档先于失败,作者指出,这种事仍在发生——敏捷和 TDD 强制迭代式的工作和快速反馈:为一个小行为写一个小测试,看它清楚地失败,写最少的代码让它通过,重构,重复。经过练习,这个循环会觉得自然,而待在“绿”之外就开始像一个警告信号。总结重申命题:变化不可避免却不可预测,所以别过度设计;靠持续的重构,由良好的测试套件和有意义、可组合的单元支撑,让软件保持柔软,而 TDD 是驱动这三者的纪律。
关键论点,以及支撑它们的原文
一门现代语言加上良好的工具,本身并不能保护系统不退化成遗留系统。
作者列出了让 Go 有前景的东西——很小的关键字集合,强大的标准库,快速编译,一等公民的测试与 lint 工具,以及他拿来与自己升级一个 Scala 项目耗掉的数周相对照的向后兼容承诺——然后承认,即使拥有这一切,仍然可以造出糟糕透顶的系统,这就是他转向莱曼1974年定律的原因:真实使用中的软件必须变化,否则就失去价值,而除非投入精力去降低,它的复杂度会不断上升。
“选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”
“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”
Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 →
重构意味着改进代码的形状,同时让它的行为完全保持原样;同时改变行为是另一桩更冒险的活。
他借用学校的算术:把 1/2 + 1/4 改写成 2/4 + 1/4,得到同样的 3/4,却是更便于处理的形式。他的 Go 例子对代码做同样的事——一堆按语言分支的 if 语句变成一个问候语映射加一个查找辅助函数——要点不在技巧,而在输出不变。他也引用福勒的检验:如果一个系统坏了好几天,那项工作就不是重构。同时做两件事,是各团队贪多嚼不烂的地方。
“选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”
“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”
Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 →
因为手工验证无法扩展,单元测试才是使在不简单的系统上重构变得安全的东西。
没有测试的团队退回到手工检查,这是一个时间黑洞,一旦超出小项目就行不通。测试则提供三样东西:重塑代码的信心,对预期行为给人看的文档,以及比手工检查快得多、可靠得多的反馈。具体而言,针对问候函数的一个测试加上 go test,片刻之间就告诉他一次改动是否改变了行为,这就维持着“小重构/跑测试/重复”的循环,把复杂度压住。
“选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”
“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”
Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 →
另外 3 个论点
测试应当瞄准通过组件公共 API 可见的行为,而不是它内部如何实现。
设想一个由两个三角形拼成的正方形,测试断言三角形的内角和为180度。数周后,一位开发者决定用两个长方形更好,却迎面撞上失败的测试,它们对唯一要紧的问题——真正的行为是否坏了——毫无回答。三角形的构造从来不重要,测试却把它提升到了需求的地位。作者把这追溯到对什么是单元的误解,以及追逐覆盖率这一虚荣指标。
“宁可测试行为,而不是实现细节”
“Favour testing behaviour rather than implementation detail”
Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail查看原文 →
写出有用的单元测试,实际上是一个关于系统如何划分的设计问题。
他请读者暂时忘掉测试:你本来就想要围绕领域概念的、解耦的组件,他把它们想象成带着连贯 API 的乐高积木,能组合成更大的系统,而几十个类型和函数在看不见的地方协作。一家银行的“账户”包是他的例子:一个不泄漏内部、易于集成的 API。把这些边界划对,行为层面的测试几乎就是白送的——这就是他对正方形和三角形的故事所暴露的问题的回答。
“宁可测试行为,而不是实现细节”
“Favour testing behaviour rather than implementation detail”
Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail查看原文 →
TDD 是让这个重构循环持续运转的工作过程,而不是试图预先设计出完美可扩展的系统。
接受了莱曼的观点——软件必须变化——有些人矫枉过正,陷入精心的预先设计:他描述的那个年代,六个月的需求文档,再六个月的架构,几年后项目失败,他指出这种事仍在发生。既然未来无法预测,他追随敏捷,迭代地工作,而 TDD 强制执行它:为一个小行为写一个小测试,一个清楚的红色失败,用最少的代码变绿,然后重构,重复。实践者逐渐期待那个循环是快的,而每当系统不是绿色时就感到不安,因为那预示着兔子洞。
“选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”
“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”
Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
测试作为重构的安全网
只有行为保持完全一致,重塑代码才算重构,但在任何规模可观的系统上,一个人没法在脑子里装下所有行为。快速的单元测试自动检查这一不变式,所以作者可以走一小步,一秒内重跑它们,并在行为变化发生的当下就抓住,而不是几小时之后。
机制 2
为什么绑定细节的测试阻碍变化
当测试断言的是某物如何被构造,而不是它做什么,构造的选择就被冻住了。后来的开发者选了另一种内部构造,看到一堵失败之墙,它们并没有说明真实的行为是否坏了,所以他们得先发掘旧的测试——最终干脆不再尝试这个改动。
机制 3
单元边界决定测试的耐久性
如果系统被划分成围绕领域概念构建的自成一体的组件,每个组件暴露一个把内部隐藏起来的连贯公共 API,那么针对该 API 写的测试只可能是在断言值得保留的行为。API 之下的一切都保持可以自由重新排布,所以重构很少触动测试。
常见误读
如果目标是测试行为而不是内部,诚实的答案就是跳过单元测试,依靠端到端或黑盒测试。
系统级测试对验证关键的用户旅程确实有价值,但作者把它们排除在重构安全网之外:它们写起来昂贵,跑起来缓慢,又不善于指出根因,所以它们的反馈循环太慢,支撑不了小步前进。恰当高度的行为测试,意味着通过单元的公共 API 测试,这既聚焦行为,又快。
“宁可测试行为,而不是实现细节”
“Favour testing behaviour rather than implementation detail”
Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail查看原文 →
按定义,单元测试是对一个函数、类或方法的测试,所以通过 API 测试整个包一定是别的东西——集成测试。
作者直接回答了这一点:这些就是单元测试,因为“单元”是自成一体、以领域为中心、带连贯公共 API 的组件,而不是单个函数或类。许多类型和函数可以在这个 API 之下协作;把最小的代码元素当作单元,恰恰把测试推进了实现细节,使它们阻碍重构。
“宁可测试行为,而不是实现细节”
“Favour testing behaviour rather than implementation detail”
Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail查看原文 →
关于本书的核心概念
- 重构
- 重塑代码,使之更易理解而不触动行为,就像分解一个分数。
- 适用处: 如果系统坏了好几天,或行为发生了变化,那就不是重构。
- 单元
- 一个围绕关键领域概念构建、自成一体、解耦的系统部分,暴露一个连贯的公共 API。
- 适用处: 单元不是单个类或函数;许多类型可以在其 API 之下协作。
- 莱曼软件演化定律
- 真实使用中的软件必须变化,否则就失去价值,而除非花工夫降低,它的复杂度会增长。
- 适用处: 它们证明迭代式变化的正当,而不是数月的预先过度设计。
另外 0 个概念
关于本书划线与批注AI 陪读
“Go 只有25个关键字”
“Go has only 25 keywords”
查看原文 →隐含的主张——关键字越少,代码越耐久——从未被论证。大量无法维护的 C 代码存在,而 C 也很小。约束有帮助,但遗留系统来自累积的决定,而不是词汇量的大小。
“除非做工作去降低,否则它的复杂度会增加”
“its complexity increases unless work is done to reduce it”
查看原文 →悄悄的那个词是“工作”。降低复杂度是没人会开工单的劳动,这恰恰是它输给功能的原因。这条定律与其说关乎软件,不如说关乎激励。
“你几乎可以确定他们不是在重构”
“you can be pretty sure they are not refactoring”
查看原文 →福勒的这句话之所以管用,是因为它可证伪:如果构建红了两天,这个名字就叫错了。很少见到一个定义自带一项检验。
另外 8 条批注
“把 1/2 改成 2/4,它就更容易适合我们的“领域”
“by changing 1/2 to 2/4 it fits into our "domain" easier”
查看原文 →这个类比很迷人,但它奉承了重构。算术有判定等价的程序;代码没有。测试是对行为的抽样,而不是对它的证明——这个比喻恰恰隐藏了风险所在。
“这次重构的性质其实并不重要”
“The nature of this refactor isn't actually important”
查看原文 →值得注意的是,这个例子在精神上确实稍微改变了行为:映射把语言变成数据,这邀请了 if 链从不允许的运行时配置。结构是关于未来的一个主张。
“got := Hello(“Chris”, es)”
查看原文 →花体引号和一个光秃秃的 es——这段代码无法编译。在一本整个论证都建立在紧凑、值得信赖的反馈循环之上的书里,一个未经测试的代码示例,是一桩小而好笑的控诉。
“我们的测试错误地抬高了我们实现细节的重要性”
“our tests have falsely elevated the importance of our implementation details”
查看原文 →这是本章真正的命题,而三角形到长方形的寓言配得上它。每一个失败的测试都是在主张某件事要紧;写足够多草率的主张,你的测试套件就成了你已不再持有的决定的纪念碑。
“追逐虚荣指标(测试覆盖率)”
“chasing vanity metrics (test coverage)”
查看原文 →说得公道,但覆盖率之所以成了虚荣指标,是因为“测试行为,而不是实现”无法测量,而覆盖率可以。这个批评需要一个替代信号,而本章没有提供。
“它们从来都不只是针对单个类/函数/随便什么”
“They were never about only being against a single class/function/whatever”
查看原文 →一个悄悄激进的举动:把“单元”重新定义为包级 API,凭一纸宣告消解了“什么都 mock”的学派。有说服力,但这意味着两个工程师可以都说“我们写单元测试”,而指的是互不相容的东西。
“如果测试错误地失败,那么我们的测试处在错误的抽象层级”
“If tests are wrongly failing then our tests are at the wrong abstraction level”
查看原文 →就其陈述而言,这是无法证伪的——任何痛苦的测试都可以归咎于抽象层级,而不是重构确实有风险。一条有用的启发,披着裁决的外衣。
“别过度设计,因为未来太难预测”
“don't try and over-engineer because it's too hard to predict the future”
查看原文 →整章诚实的张力:设计好的“单元”本身就是对哪些概念将保持稳定的一次预测。TDD 并不消除这个赌注,只是使输掉它更便宜。
读后感AI 陪读
把单元重新定义为包级 API 是激进的,而不仅仅是澄清
本章的架构比它任何单个论证都更强:莱曼定律确立变化是强制的,重构被点名为回应,测试被引入作为不对自己撒谎地重构的唯一办法,而单元设计被揭示为决定测试是帮忙还是碍事的东西。这条链——尤其是最后一步,即写出好的单元测试是一个设计问题而不是一个测试问题——是这里最有价值的想法,而三角形/长方形的寓言,以你真能带进代码评审的形式把它交了出来。它最弱的地方,也是它最自信的地方。因式分解的类比悄悄借用了数学对等价的确定性,而测试给不了这一点。认为重构期间测试套件失败就证明测试处在错误的抽象层级,这个说法循环到足以为任何重构开脱。对覆盖率的攻击是对的,却让读者没有可替代的信号,而这恰恰是覆盖率起初得胜的原因。而把“单元”重新定义为包级 API,是一项真正的贡献,本章却把它呈现为单纯的澄清,而它实际上是个有争议的立场。那个偶然的喜剧——在一章主张值得信赖的反馈循环的文字里,有一段无法编译的测试片段——是个体面的提醒:纪律比说教更难。把它当作一个有说服力的框架而不是证明来读,它确实有用。
在 FeedLitra 里读关于本书,每段旁边都有这些卡片。
开始读书Go 基础:安装 Go,搭建高效的环境
go.sum 的哈希究竟是怎样让构建失败的?
Go 基础是怎样展开的AI 辅助
开场:环境是第一道坎。 本章以尽可能简略的姿态谈安装——指向 Go 官方的说明——然后直接转向它显然认为更有趣的问题:Go 环境本身。第一个实质性的部分是关于模块的,它在 Go 1.11 中引入,自 1.16 起成为默认构建模式,直截了当的结论是不再推荐 GOPATH。文本并不只是断言这一点,而是停下来提供历史背景:在模块出现之前,一个环境变量指向一个目录(通常是 ~/go),所有 Go 代码、依赖和编译好的包都必须住在那里,这意味着你不能在随便哪个地方建个文件夹就开始写代码,因为编译器无法解析它之外的导入。这段背景故事做了实在的工作——它把“使用模块”从一条规则变成了对一桩被记住的烦恼的解决。
发展:三个问题,三种机制,然后是命令。 本章接着把每个所述目标映射到一种具体机制,来拆解模块。依赖管理得到 go.mod,声明所需的包和版本(与 Node 的 package.json 相比);版本选择得到最小版本选择,这条确定性规则选择满足每项要求的最低版本,使结果可预测;可复现构建得到 go.sum 及其加密哈希,带着尖锐的细节:只要远程文件变了,构建就直接失败。第四点解释了对 GOPATH 的逃离:go.mod 文件锚定项目,工具链相对于该模块解析导入,所以项目可以放在任何地方。只有在这番概念性的打底之后,文本才转向实践——mkdir、cd、go mod init,一个示例 go.mod——并滑入一个读起来像本章小小隐藏陷阱的警告:go 指令不是装饰,因为工具链用它来选择语言语义。给出的例子是 Go 1.22 对 for 循环变量的改变,本书后面的并发一章依赖于它;声明一个旧版本,你会从看似正确的代码得到令人困惑的行为。
转折:从工具链到使用它的人。 在对 lint 的简短说明之后(GolangCI-Lint 作为对默认的升级),本章从配置转向手艺,而这才是它真正的论证。因为重构是本书的核心重点,你的编辑器必须让重构变得廉价:提取和内联变量,提取方法和函数,跨文件重命名符号,每次保存时运行 go fmt,并在其后立即重跑测试——全靠按键,而不靠费力。一份配套清单涵盖阅读而不是改动代码:查看函数的签名和文档,跳转到它的定义,找出一个符号的每处用法。声明的回报是注意力上的而不是技术上的——精通工具,让你的心思放在代码上,而不是放在工具上。
收尾:一个刻意谦逊的检查点。 总结没有宏大的主张。你现在应当装好了 Go,开着一个编辑器,并配置了一些基本工具;本章承认它只点出了一个庞大的第三方生态里的几个有用部分,并把读者交给 awesome-go 去找其余的。整体的形状因此是一个在末端变宽的漏斗:极简的安装话题,对环境中真正令新人困惑的那一块的深潜,转向编辑器的流畅作为本书方法的准备,然后是一扇敞开的门,而不是一个结论。
关键论点,以及支撑它们的原文
模块,而不是旧的 GOPATH 惯例,才是如今 Go 项目应当组织的方式。
作者对比两个时代:在 Go 1.11 之前,每个项目、依赖和编译好的包都必须放在同一个目录之下,所以编译器根本无法解析在别处创建的文件夹的导入。模块通过在每个项目各自的 go.mod 中声明依赖和版本,去除了这一约束,而自 Go 1.16 起,这是默认构建模式。
“Go 1.11 引入了模块。自 Go 1.16 起,这种方式是默认构建模式,因此不建议使用 GOPATH。”
“Go 1.11 introduced Modules. This approach is the default build mode since Go 1.16, therefore the use of GOPATH is not recommended.”
Install Go, set up environment for productivity › Go Modules查看原文 →
确立了 go.mod 是项目的锚之后,作者强调其中的 Go 版本行实际上支配着语言行为,而不仅仅是记录一个数字。
工具链读取那一行来决定适用哪种语言语义,即使本地安装了更新的 Go。给出的例子是 Go 1.22 对 for 循环变量工作方式的改变:后面并发一章里依赖新行为的代码,只有在 go.mod 声明 1.22 或更高时才会如预期运转,这就是作者建议让该行保持相当新的原因。
“这里声明的 Go 版本不只是供参考:工具链用它来决定适用哪种语言语义,即使你安装了更新版本的 Go。”
“The Go version declared here isn't just informational: the toolchain uses it to decide which language semantics apply, even if you have a newer version of Go installed.”
Install Go, set up environment for productivity › Go Modules查看原文 →
从工具链转向使用它的人,作者把对编辑器的熟练当作环境搭建的一部分,而不是可有可无的附加品。
因为本书严重依赖重构,作者列出了你应当能用一个组合键触发的具体操作——提取或内联变量,提取函数,跨文件重命名符号,保存时运行 go fmt,并在其后立即重跑测试——外加导航辅助,如查看函数的签名和定义、查找符号的用处。声明的回报是保持专注于代码,更少切换上下文。
“你应当对编辑器足够熟悉,能用一个简单的组合键完成以下操作:”
“You should be familiar enough with your editor to perform the following with a simple key combination:”
Install Go, set up environment for productivity › Refactoring and your tooling查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
go.mod 如何让代码摆脱一个文件夹
一旦 Go 在某个目录里发现 go.mod 文件,该文件就成为项目的锚,每一个导入都相对于那个模块解析。因为不再需要通过一个全局目录查找任何东西,放在机器上任何位置的项目文件夹仍能编译。
机制 2
为什么版本冲突能被可预测地解决
当好几个依赖要求同一个包的不同版本时,Go 既不猜,也不取最新的;它应用最小版本选择,取仍能满足每项要求的最低版本。因为规则是固定的,相同的输入总是得到相同的所选版本。
机制 3
为什么每个人都得到完全一样的构建
与 go.mod 并行,Go 写下一个 go.sum,保存每个依赖的加密哈希。此后的每次构建都重新下载这些文件并对照记录的哈希检查,所以如果远程服务器上有任何改变,构建就会停下,而不是悄悄使用不同的代码。
常见误读
go.mod 里的 go 1.24 一行只是记录作者碰巧用了哪个 Go 版本的注记,所以保持生成时的任何值就行。
那一行选择编译器应用哪种语言语义,与你机器上安装的 Go 版本无关。作者自己的例子是 Go 1.22 对 for 循环变量的改变:如果 go.mod 没有声明 1.22 或更高,依赖它的代码将不会如预期运行。
“这里声明的 Go 版本不只是供参考:工具链用它来决定适用哪种语言语义,即使你安装了更新版本的 Go。”
“The Go version declared here isn't just informational: the toolchain uses it to decide which language semantics apply, even if you have a newer version of Go installed.”
Install Go, set up environment for productivity › Go Modules查看原文 →
既然模块让代码可以放在任何地方,GOPATH 就没了,项目文件夹放哪儿也不要紧了。
GOPATH 仍然存在;变的是不再推荐依赖它。作者的指示很明确:选一个 GOPATH 之外的目录作为项目根,并在那里运行 go mod init。
“Go 1.11 引入了模块。自 Go 1.16 起,这种方式是默认构建模式,因此不建议使用 GOPATH。”
“Go 1.11 introduced Modules. This approach is the default build mode since Go 1.16, therefore the use of GOPATH is not recommended.”
Install Go, set up environment for productivity › Go Modules查看原文 →
Go 基础的核心概念
- Go 模块
- 以 go.mod 文件为根的项目,它声明自己的路径、Go 版本和依赖,所以代码可以放在任何地方。
- 适用处: 自 Go 1.16 起为默认构建模式;项目根必须在 GOPATH 之外。
- 最小版本选择
- 当各依赖对某个版本意见不一时,Go 确定性地选取满足所有依赖的最低版本。
- 适用处: 适用于模块要求之间的版本选择,不适用于哈希验证。
- 重构
- 安全地重塑代码;本章把它框定为一项工具技能,而不仅是一种编码美德。
- 适用处: 依赖快速的测试来确认没有东西坏掉。
另外 0 个概念
Go 基础划线与批注AI 陪读
“因此不建议使用 GOPATH”
“therefore the use of GOPATH is not recommended”
查看原文 →说得平淡,但这一句埋着 Go 整整一个时代的痛苦。本章只在下一段才让你感受到这一点。
“你不能简单地在电脑上随便哪个地方建一个文件夹就开始写 Go 代码”
“you could not simply create a folder anywhere on your computer and start writing Go code”
查看原文 →这才是对 GOPATH 的真正控诉——一门语言规定你的文件放在哪里。奇怪的是,它竟被当作正常忍受了这么久。
“Go 总是选择能让所有人满意的最小版本”
“Go always picks the minimum version that satisfies everyone”
查看原文 →反直觉,而且是故意的——大多数生态系统抓最新的,并称之为进步。最小意味着无聊,无聊意味着可复现。
另外 5 条批注
“如果远程服务器上有任何改变,构建会立刻失败”
“If anything changes on the remote server, the build fails immediately”
查看原文 →大声失败就是这个功能。一个悄悄被改变的依赖是更糟的结果,不过任何与 go.sum 不匹配作过斗争的人,在当下也许不会觉得感激。
“这里声明的 Go 版本不只是供参考”
“The Go version declared here isn't just informational”
查看原文 →本章最尖锐的实用警告。一个你会略过的数字,悄悄改变了循环的语义——正是那种你永远想不到去找的缺陷。
“brew install golangci-lint”
查看原文 →一条 macOS 命令,被当作放之四海而皆准地提供。一章安装文字,悄悄假定了你的机器。
“你永远不该对在 Go 里怎么调用一个函数感到不确定”
“You should never be unsure how to call a function in Go”
查看原文 →一个关于确定性的大胆主张,实际上是关于工具的主张,而不是关于语言。这份自信是外包给你的 IDE 的。
“精通你的工具会帮助你专注于代码”
“Mastering your tools will help you concentrate on the code”
查看原文 →本章的命题,也是它并非填充的原因:按键的流畅,使重构廉价到真的会去做。
读后感AI 陪读
安装并不真是安装:它在检验重构是否毫无摩擦
一个其实是论证章的安装章。有两步很突出。第一,模块一节不肯只说一句“运行 go mod init”——它解释了 GOPATH 夺走了什么,这样做使模块文件感觉像一次解放,而不是样板。依赖管理、版本选择、可复现构建这三重问题被逐条回答,而最小版本选择(Minimum Version Selection)是悄悄的宝石:一个反对新奇、选择可预测性的选择。第二,工具清单是在为 TDD 打地基。全书的方法取决于重构毫无摩擦,所以作者把一个抽象的价值(“重构很重要”)转化成一个具体的要求:你能用一个组合键提取一个函数吗?这种重新框定才是本章真正的贡献。薄弱之处在边缘——一条 brew 命令代表了所有平台,而推荐了 golangci-lint,却没说默认的 linter 究竟缺什么。而 go.mod 的版本说明是这里承重最多的一段,很容易被略过,却是它能让读者免于在并发那一章里度过一个真正令人困惑的下午。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:你好,世界
子测试如何改变需求的表达方式?
Go 基础是怎样展开的AI 辅助
本章以最常规的起点开篇,并把它当作诱饵。你创建一个文件夹,写一个六行的 hello.go,用 go run 运行它,然后快速游览你刚敲下的东西——main 包,func 关键字,导入的 fmt。几乎立刻,本章从“它能跑吗”转向“你能测试它吗”,而这一转向承载着真正的论证:打印到标准输出是副作用,而被打印的字符串才是领域。于是代码被拆开——Hello() 返回一个字符串,main 把它打印出来——旁边出现了一个 hello_test.go 文件。随后是关于 Go 模块的一段实用的岔路,因为在 Go 1.16 及以后,go test 会直接失败,直到你运行 go mod init example.com/hello;文本解释模块文件是干什么的,为什么名字里该带一个点,以及模块名与 package main 无关。然后测试通过了,作者邀请你故意把它弄坏——第一个暗示:看到失败是方法的一部分,而不是意外。
从这里起,本章进入靠积累来教学的状态。它陈述测试的机械规则(xxx_test.go,一个 Test 前缀,唯一的 t *testing.T 参数,导入 testing),拆解一路遇到的小语法——if,:= 声明,带 %q 动词的 t.Errorf——然后岔入 Go 的文档工具,离线的 go doc 和浏览器版本的 pkgsite。转折出现在“Hello, YOU”:事后写了一个测试来演示测试的形状之后,作者宣布从此以后测试先行。每个新需求随后都走同一个循环。向特定的人问候,起初是一个编译错误——参数太多——文本把它当作编译器在告诉你下一步怎么走;你放宽签名,看测试因内容而失败,拼接,通过,然后把字面量重构成常量。关于源码管理的一段说明夹在其间:提交可工作的版本,但别推送,因为接下来要重构。
这个循环随后以升高的赌注再重复两次。把空名字默认为“World”,引入了经由 t.Run 的子测试,一旦它们通过,作者就重构测试——把断言拉进一个接受 testing.TB 而不是 *testing.T 的辅助函数,使基准测试也能用,并调用 t.Helper(),使失败报告调用者的行号。添加一个语言参数又一次破坏了每个现有的调用点,修复遵循同样的节奏:编译,失败,用一个 if 通过,然后重构掉魔法字符串。法语作为练习交给读者,它产生的第二个 if 促成了 switch——附带一段区分 := 与 = 的旁白。最后的重构提取出 greetingPrefix,它引入了具名返回值 prefix string,零值,裸 return,switch 里的 default,导出名称的大小写约定,以及成组的 const 块。
本章以点名它一路上所实践的纪律作结——写一个失败的测试,满足编译器,看到失败并检查它的信息是否易读,只写刚好够通过的代码,重构——并为每一步辩护,尤其是坚持要亲眼看到失败,因为不可读的测试输出本身就是一种债。总结清点了涉及的 Go 语法和 TDD 的理由,然后承认一个显而易见的事实:Hello() 长成 Hello(“name”, “French”),比起真实的软件来微不足道。它的主张是,课程在这些增量里,而不在这个例子里。
关键论点,以及支撑它们的原文
只有当决定说什么的部分,与同外部世界对话的部分分开之后,代码才变得可测试。
作者从一个整个函数体就是一个 print 调用的程序出发,表明测试无处下手。把它改写成一个函数返回问候、main 负责打印,给了测试可以捕获和比较的东西,而此后每一个需求——名字、空输入、其他语言——都完全是通过那个返回值来驱动的。
“fmt.Println 是副作用(打印到标准输出),而我们传进去的字符串才是我们的领域。”
“The fmt.Println is a side effect \(printing to stdout\), and the string we send in is our domain.”
Hello, World › How to test查看原文 →
在写代码之前看着测试失败,是一次真正的检查,而不是仪式。
作者从开发者自己的经验出发论证:一个失败的测试说不清问题的代码库,工作起来很痛苦,所以红色的运行是评判信息是否有用的时刻。同一次运行也证明测试接到了新需求上,而不是碰巧通过。
“看到测试失败是一项重要的检查,因为它也让你看到错误信息是什么样子。”
“Seeing the test fail is an important check because it also lets you see what the error message looks like.”
Hello, World › Discipline查看原文 →
紧接着,测试应当在代码之前写,而不是事后重建。
作者只把第一个测试事后写出来作为教学示例,随后调换了顺序,并警告说,为配合已经能工作的代码而写的测试,即使行为错误也可能继续通过——这个测试是被实现,而不是被需求塑造的。
“看到测试失败是一项重要的检查,因为它也让你看到错误信息是什么样子。”
“Seeing the test fail is an important check because it also lets you see what the error message looks like.”
Hello, World › Discipline查看原文 →
另外 2 个论点
编译器是穿过这些小步骤的可用向导,而不是障碍。
每一个新需求首先产生一个编译错误——先是参数太多,然后是参数太少——而作者指出,信息准确地说明了下一步编辑是什么:添加一个参数,或在调用点传一个值。倾听它,意味着每一步的设计不必预先想清楚。
“看到测试失败是一项重要的检查,因为它也让你看到错误信息是什么样子。”
“Seeing the test fail is an important check because it also lets you see what the error message looks like.”
Hello, World › Discipline查看原文 →
重构适用于测试本身,而不仅仅是交付的代码。
空字符串需求通过之后,子测试之间重复的 got/want 比较被拉进一个断言辅助函数,它接受更宽泛的 testing.TB 类型,以便也能服务基准测试,并把自己标记为辅助函数,使失败仍指向正确的场景。生产代码得到同样的处理:重复的字符串字面量变成具名常量,堆叠的语言检查变成 switch,而前缀查找最终被提取到一个小写、因而私有的函数里。
“看到测试失败是一项重要的检查,因为它也让你看到错误信息是什么样子。”
“Seeing the test fail is an important check because it also lets you see what the error message looks like.”
Hello, World › Discipline查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
把输出从逻辑里拉出来
打印到屏幕是测试无法检查的效果,所以只要问候和打印住在同一个函数里,就没有什么可断言的。作者把它们拆开:一个函数返回问候字符串,main 只是把它返回的任何东西打印出来。返回值于是可以与期望值比较,这就是整个测试先行循环得以成立的原因。
机制 2
故意失败一次
在代码存在之前运行测试,迫使两件事浮出水面:测试是否真的在检验新需求,以及它的失败文本是什么样子。如果测试在你写任何东西之前就通过了,它什么也没在测试;如果它带着含糊的信息失败,你现在就知道,而不是几个月后构建坏掉时。这就是为什么作者把红色的运行当作一个步骤,而不是一种形式。
机制 3
为什么辅助函数要标记自己
当一个共用的断言函数报告失败时,测试框架默认指向该辅助函数内部的那一行,每个失败用例都是同一行,什么也没告诉你。把函数标记为辅助函数,让框架改为报告调用行,于是输出指名出是哪个具体场景坏了。作者建议把标记注释掉并让一个测试失败,看看区别。
常见误读
你给 go mod init 起的名字对程序要紧——它大概应该是“main”,以匹配迄今每个文件顶部声明的包。
模块名只是整个项目的一个标签,与包名无关;从那个文件夹运行 go run、go test 或 go build,无论你选什么名字都行。作者唯一实用的约束,是在里面放一个点,如 example.com/hello,这样后面用到的工具表现正常。
“需要说清楚,SOMENAME,即模块的名字,与 package main(本书迄今每个 .go 文件顶部都声明它)无关。”
“It's worth being clear that SOMENAME, the module's name, is unrelated to package main (which every .go file in this book so far declares at the top).”
Hello, World › Go modules?查看原文 →
既然两者都赋值,:= 和 = 就是同一件事的两种写法,你可以任选其一。
它们做不同的活::= 声明一个新变量并赋给它第一个值,而 = 改变一个已存在的变量。在重构后的问候函数里,prefix 在声明处用一次 :=,在 switch 内部用单纯的 =,而 name——本已是参数——用单纯的 =。对同一作用域里已声明的东西用 :=,或对从未声明的东西用 =,都无法编译。
“在同一作用域里对已存在的变量用 :=,或对尚不存在的变量用 =,是编译错误。”
“Using := on a variable that already exists in the same scope, or = on one that doesn't exist yet, is a compile error.”
Hello, World › switch查看原文 →
Go 基础的核心概念
- TDD 循环
- 写一个测试,让它能编译,看它带着清楚的信息失败,只写刚好够通过的代码,然后重构。
- 适用处: 需要快速的测试和小步前进;在代码之后写测试,有测试即使行为错误也会通过的风险。
- Go 模块
- 由 go mod init 创建的项目级标识文件,告诉 Go 工具关于你的代码和依赖的信息。
- 适用处: 从 Go 1.16 起,在 go test 或 go build 之前是必需的;它的名字与 package main 无关。
- t.Helper()
- 一个把函数标记为测试辅助函数的调用,使失败报告调用者的行号,而不是辅助函数的行号。
- 适用处: 只影响测试输出里报告的行号;辅助函数应接受 testing.TB,以同时服务测试和基准测试。
- var vs :=
:=声明一个带初始值的全新变量;单纯的=则对已存在的变量重新赋值。- 适用处: 把它们弄混是编译错误:在同一作用域里对已有变量用 :=,或对未声明的变量用 =。
另外 0 个概念
Go 基础划线与批注AI 陪读
“把你的“领域”代码和外部世界(副作用)分开是好事”
“It is good to separate your "domain" code from the outside world \(side-effects”
查看原文 →全书的整个架构被当作随口的旁白夹带在这里。可测试性不是你以后再加的属性——它是这条接缝之所以存在的原因。
“go: cannot find main module; see 'go help modules'”
查看原文 →罕见而可贵:一份教程展示你真会碰到的错误,而不是假装只有顺利的路径。
“倾听编译器很重要”
“it is important to listen to the compiler”
查看原文 →编译器被重塑为结对编程的伙伴,而不是守门人。这种重新框定才是本章真正的一课,而不是字符串拼接。
另外 7 条批注
“有这样的风险:即使代码没有按预期工作,你的测试也可能继续通过”
“there is the risk that your test may continue to pass even if the code doesn't work as intended”
查看原文 →这里最尖锐的一句。事后写的测试,测试的是你对自己所建之物的记忆,而不是你的需求。
“不过我不会推到 main,因为我计划接下来重构”
“I wouldn't push to main though, because I plan to refactor next”
查看原文 →窥见实际的工作习惯,而不是教条——提交作为安全绳,而不是发布。后来的“修改上一次提交”悄悄承认第一次提交只是脚手架。
“把它注释掉,让一个测试失败,观察测试输出”
“comment it out, make a test fail and observe the test output”
查看原文 →靠刻意破坏来教。比断言 t.Helper() 做什么要好得多——它让读者自己生成证据。
“它默认打印“Hello, World”,而不是“Hello,”
“it defaults to printing "Hello, World", rather than "Hello,”
查看原文 →注意那个悄悄混进来的大写 W——本章开头常量写的是“world”。一个微小的不一致,恰是测试会抓住的那类。
“通过传入空字符串来修复它们”
“Fix them by passing through empty strings”
查看原文 →TDD 代价显现的时刻:添加一个参数,破坏了每一处现有的调用点。书用一行就越过了它,但真实的代码库正是在这里流血。
“我们不希望把算法的内部暴露给全世界”
“We don't want the internals of our algorithm exposed to the world”
查看原文 →把三分支的 switch 称作“算法”有点慷慨。不过要点落地了:在 Go 里可见性只是一个字母,所以封装是你不断在无意间做出的一个打字决定。
“只需调用 return,就能返回它被设成的任何值”
“You can return whatever it's set to by just calling return”
查看原文 →被赞许地引入,但裸返回在 Go 风格指南里广受后悔。这个函数小到足以扛过去;大多数函数不行。
读后感AI 陪读
对初学者最难的一课:把失败当作信息
一章只名义上关于 Hello, World——它其实是一场反馈循环的操练,Go 语法只是借口。它最好的一步,是拒绝干净:它展示模块错误、参数个数错误、错字符串的失败,并告诉你注释掉 t.Helper() 去看行号如何变坏。失败被当作信息而不是难堪,这是教给初学者的最难的一件事。结构上的选择也聪明:每个新的语言特性,都是因为一项需求而到来——用常量消灭魔法字符串,用 switch 消灭堆叠的 if,用提取出的函数消灭膨胀的函数体。没有什么是作为要背的词汇被引入的。我要反驳的地方:摩擦被抹平得太快。“通过传入空字符串来修复它们”把改变签名的真实痛苦一挥而过,而最后的重构把具名返回和裸返回当作清晰来推销,而 Go 社区在很小的函数之外大体把它们当作坏味道。还有一处小小的自伤——“world”在本章中途变成“World”——这在一本以测试恰恰能抓住这类问题为命题的书里很好笑。这一切都不削弱核心论证。本章悄悄的主张是,可测试性是一种设计属性,而不是一种测试活动:最初的一步就是把 fmt.Println 与字符串拆开,此后的一切都是这条接缝的下游。这是值得带着往前走的想法。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:整数
基于性质的测试究竟如何堵上硬编码的漏洞?
Go 基础是怎样展开的AI 辅助
开场——搭一个干净的工作区。 本章一开始就挥手打消任何悬念:Go 里的整数表现得正如你所料,所以真正的主题是练习而不是惊喜。在任何代码之前,它在家务事上停了一下——Go 每个目录只允许一个包,所以读者看到一份示例项目树,helloworld 和 integers 作为同级文件夹,共用一个 go.mod。写下的第一个文件是测试 adder_test.go,声明 package integers 而不是 main,这标志着从可运行的程序转向可复用的库。测试调用 Add(2, 2),期望 4,并用 %d 而不是字符串所用的 %q 报告失败——一个小小的、刻意的提示:格式动词追随被打印的类型。
发展——TDD 循环,以更快的节拍再跑一遍。 本章接着走过熟悉的循环。运行 go test 得到编译错误(undefined: Add),这算作失败的那一步;读者只写刚好能编译的代码——一个返回 0 的桩——这样测试就能因正确的原因失败,打印出期望对比实际的信息。有两个旁白随行:重复参数类型的简写 (x, y int),以及一条说明:上一章介绍过的具名返回值被刻意不使用,因为 Add 的结果含义从上下文里已经显而易见。然后是本章讥讽的转折:一位吹毛求疵的程序员可以靠返回硬编码的 4 来满足测试。作者承认这一点(“TDD 是骗局,对吧?”),拒绝与更多测试用例玩猫捉老鼠,并许诺后面用基于性质的测试作为真正的答案——然后就直接写了 return x + y。
转折——从正确性到沟通。 测试变绿了,重构在这个三行的函数里几乎找不到可改进之处,于是注意力向外转到文档。一条文档注释加在 Add 之上,本章论证更广的原则:读者应当能仅凭签名和文档就理解你的代码。这引向本章实质性的新内容,可测试示例。ExampleAdd 住在 _test.go 文件里,打印一个结果,并带着魔法注释 // Output: 6。回报用一种熟悉的失败模式来解释——README 文件里的示例会过时,因为没有东西检查它们——而示例函数与测试套件一起编译,Output 注释使它们真正执行,所以过时的文档会让构建失败。详细的测试输出显示 ExampleAdd 与 TestAdder 一起运行;去掉注释,它会编译但不运行,这被指出对无法作为单元测试运行的代码很有用,比如任何触碰网络的东西。
收尾——亲眼看文档,然后是一个简短回顾。 本章以实用的方式结束,让文档可见:安装 pkgsite,运行 pkgsite -open .,浏览到 localhost:8080,读者可以从包列表钻进 Integers,再进 func Add,然后展开他们刚写的示例。它指出,发布到公共 URL 会在 pkg.go.dev 上呈现同样的文档。总结列出四点收获:更多的 TDD 练习,整数与加法,为你的用户提供更好的文档,以及由你的测试验证的示例——一个悄悄把本章重新框定为与其说关于算术,不如说关于你能信任的文档的收尾。
关键论点,以及支撑它们的原文
在写真正的实现之前,先写刚好够编译的代码,然后看测试因你预期的原因而失败。
作者把 Add 做成返回 0 的桩,纯粹为了满足编译器,运行测试,得到“expected '4' but got '0'”——这条信息确认测试在真正的逻辑存在之前,就在检验目标函数、比较目标值。
“只写足以满足编译器的代码,仅此而已——记住,我们要检查的是测试因正确的原因而失败。”
“Write enough code to satisfy the compiler and that's all - remember we want to check that our tests fail for the correct reason.”
Integers › Write the minimal amount of code for the test to run and check the failing test output查看原文 →
通过一个测试并不等于正确,所以最少代码这一步是检查测试的纪律,而不是关于代码的证明。
遵循严格的 TDD,吹毛求疵的程序员可以让 Add 干脆返回 4,使套件变绿。作者承认这看上去像是在败坏 TDD 的名声,指出堆上更多手挑的数字是猫捉老鼠的游戏,并把真正的答案推迟到本书后面的基于性质的测试。
“只写足以满足编译器的代码,仅此而已——记住,我们要检查的是测试因正确的原因而失败。”
“Write enough code to satisfy the compiler and that's all - remember we want to check that our tests fail for the correct reason.”
Integers › Write the minimal amount of code for the test to run and check the failing test output查看原文 →
文档应当是可执行的,这样它就不会与代码脱节。
作者的出发点是,保存在代码库之外的示例——比如在 readme 里——会过时,因为没有东西检查它们。补救办法是一个 ExampleAdd 函数,go test -v 运行它,并把它与 TestAdder 一起报告为一个通过的测试;如果代码后来改变,使示例不再成立,构建就会失败。条件是:只有带输出注释的示例才会被执行。
“代码库之外的代码示例,比如 readme 文件里的,往往会随着时间过时、与实际代码不符,因为它们没有被检查。”
“Often code examples that can be found outside the codebase, such as a readme file, become out of date and incorrect compared to the actual code because they don't get checked.”
Integers › Testable Examples查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么一个刻意写错的桩有帮助
只写刚好够编译的代码——Add 返回 0——迫使测试真的运行并打印出一次真实的比较。看到“期望 4 但得到 0”,证明测试接到了正确的函数上、度量了正确的东西,所以以后的通过可以被信任,而不只是被假定。
机制 2
由编译器保持诚实的示例
示例函数住在包的 _test.go 文件里,所以每次运行测试时都被编译,而 // Output: 注释让 Go 执行它们并比较打印的结果。因为一个过时的示例随后要么无法编译,要么不匹配,文档就不会悄悄地偏离它所描述的代码。
机制 3
一个测试只钉住一个输入
一个检查 Add(2,2) 的测试,只在那一个点上约束行为,所以返回常量 4 就完全满足它。用更多手挑的数字去追,是一场没有尽头的猫捉老鼠游戏,这就是作者指向基于性质的测试作为系统性修复的原因。
常见误读
任何名为 ExampleSomething 的函数都会被 Go 自动运行并验证。
编译和执行是两种不同的保证。每个示例都被编译,但只有带 // Output: 注释时才被执行并检查——去掉那一行,示例就悄悄不再运行。没有该注释的示例对无法作为单元测试运行的代码仍然有用,比如触碰网络的代码,因为它们至少保证它能编译。
“如果你的代码改变,使示例不再有效,你的构建会失败。”
“If ever your code changes so that the example is no longer valid, your build will fail.”
Integers › Testable Examples查看原文 →
既然具名返回值会出现在文档和编辑器里,你就该到处给返回值命名,让代码更好用。
作者把命名当作一种清晰度工具,在结果的含义从上下文看不明显时使用。Add 留着一个朴素的 int 返回,恰恰因为一个叫 Add、接受两个整数的函数,让它的结果不言自明。
“它通常应当用在结果的含义从上下文看不清楚的时候,在我们的情形里,Add 函数会把参数相加,这几乎是显而易见的。”
“It should generally be used when the meaning of the result isn't clear from context, in our case it's pretty much clear that Add function will add the parameters.”
Integers › Write the minimal amount of code for the test to run and check the failing test output查看原文 →
Go 基础的核心概念
- 可测试示例
- 在 _test.go 文件中名为 ExampleXxx 的函数,充当文档并被编译,带有 Output 注释时还会被执行。
- 适用处: 没有输出注释的示例只被编译,不被执行。
- 参数类型简写
- 当连续的参数类型相同时,可以只列一次类型,如 (x, y int)。
- 适用处: 只适用于相邻的、类型相同的参数。
- 具名返回值
- 给结果起一个名字,使它出现在文档和编辑器里;只有当结果的含义不清楚时才有用。
- 适用处: Add 里省略了,因为含义从上下文里一目了然。
另外 0 个概念
Go 基础划线与批注AI 陪读
“Go 源文件在每个目录里只能有一个包”
“Go source files can only have one package per directory”
查看原文 →作为注记夹带进来的一条语言规则。它其实是本章真正的一课——目录布局就是包布局,无法选择退出。
“我们用 %d 作为格式字符串,而不是 %q”
“we're using %d as our format strings rather than %q”
查看原文 →漂亮的连贯——上一章的引号习惯被明确地忘掉。拿自己早先的代码作对比来教,被低估了。
“啊哈!又被挫败了,TDD 是骗局,对吧?”
“Ah hah! Foiled again, TDD is a sham right?”
查看原文 →作者让怀疑者说话,而不是压制他们。然后——诚实地——并没有完全回答,只是推迟到后面一章。我尊重这张期票,胜过一次挥手应付。
另外 5 条批注
“那感觉像猫捉老鼠的游戏”
“that feels like a game of cat and mouse”
查看原文 →悄悄的承认:基于示例的测试不能证明普遍性,只能抽样。基于性质的测试是正确的出路,但注意真正的论证是“不要对自己抱对抗态度”,那是社会性的,而不是技术性的。
“它通常应当用在结果的含义从上下文中看不清楚的时候”
“It should generally be used when the meaning of the result isn't clear from context”
查看原文 →好的克制。一份较差的教程会立刻应用每一个新玩具;这里作者拒绝使用刚教的东西,并说明理由。
“变得过时、与实际代码不符,因为它们没有被检查”
“become out of date and incorrect compared to the actual code because they don't get checked”
查看原文 →这是本章最尖锐的想法。文档腐烂是因为它撒谎时没有任何东西失败。示例的做法,是给文档一个失败模式。
“暂时删掉注释 // Output: 6,然后运行 go test”
“temporarily remove the comment // Output: 6, then run go test”
查看原文 →一条改变执行语义的注释,对 Go 来说真的很奇怪。让读者删掉它并观察行为变化,是让它牢牢记住的最廉价办法。
“对演示无法作为单元测试运行的代码很有用”
“useful for demonstrating code that cannot run as unit tests”
查看原文 →所以“能编译”本身就是一个值得买的正确性层级。一个整齐的阶梯:能编译 < 能运行 < 断言输出。
读后感AI 陪读
可测试示例,给了文档与代码同样的失败压力
一章刻意小巧:Add(x, y int) 只是借口。实际教了两件事。第一,TDD 循环经得起与琐碎问题的碰撞——而作者坦率地说,在这个尺度上这种纪律看上去荒唐,任由吹毛求疵的 return 4 作为一个真实的反对意见存在,而不是假装这方法天衣无缝。他没有解决它;他递出一张基于性质的测试的期票,这很诚实,却让本章的核心疑问悬而未决。第二,也更持久:文档应当承受与代码同样的失败压力。可测试示例是这里最好的想法,因为它们指出了 README 片段腐烂的原因——它们撒谎时没有任何东西坏掉——并从结构上而不是靠劝诫来修复它。// Output: 注释,一条支配执行的注释,是 Go 里真正古怪的一角,而让读者删掉它并观察的指示,是本章最好的教学动作。pkgsite 演练是最弱的部分——在一章本来持久的文字里,最带日期戳、最依赖工具版本的材料。整体语气:一位足够信任读者、以至于大声说出读者的怀疑,并拒绝使用刚引入的特性的作者。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:迭代
如果调用者可以选择重复次数,Repeat 会怎样改变?
Go 基础是怎样展开的AI 辅助
开场——一个被当作礼物的约束。 本章一开始就宣布,Go 恰好只有一个循环关键字 for:没有 while,没有 do,没有 until——而作者坚持“这是件好事”。随后它径直把读者交还给前几章已经练习过的 TDD 循环,并提示“到目前为止没有任何新东西,所以试着自己写”。所用的例子刻意琐碎:Repeat(“a”) 应当返回 “aaaaa”。测试先写,且无法编译(undefined: Repeat);修复是尽可能小的桩,返回 “”,选它不是为了通过,而是让读者确认测试本身写得对。作者在这里停下来给一句小小的鼓励——你已经懂得足够的 Go 去测试基本问题——然后才展示那条诚实的失败信息,把 “aaaaa” 与空字符串作对比。
发展——循环,然后是打磨。 通过的实现是一个朴素的 C 风格三段式循环,累积 repeated = repeated + character。行文借它教了两处顺带的 Go 语法:循环头不加括号,花括号始终必需,以及 var repeated string 与迄今所用的 := 简写之间的区别(:= 是声明加初始化一步完成,所以当你只想声明时需要显式的 var 形式)。重构步骤随后做的是本书里重构步骤一贯做的事——不改变任何可观察之物——把魔法数字 5 提取成 const repeatCount,并把拼接换成 += 的“加并赋值”运算符。
转折——从正确性到代价。 本章真正的枢轴是基准测试,它被呈现为一项与测试几乎一模一样的一等语言特性:一个 BenchmarkRepeat(b *testing.B) 函数,其函数体在 b.Loop() 上循环,经由 go test -bench=. 运行。作者解释说,框架替你挑选迭代次数,只有循环体被计时(初始化和清理被排除在外),并读出一千万次运行上约 136 ns/op 的样例结果。这个测量成了性能一课的杠杆:Go 的字符串是不可变的,所以每次拼接都要复制内存。用 strings.Builder 和 WriteString 重写 Repeat(记得最后调用 .String()),并以 -benchmem 重新运行,数字降到约 25.7 ns/op,8 B/op,1 allocs/op——是证据,而不是断言。
收尾——练习与一份简短的账目。 本章以把活儿交还收尾:把测试泛化,让调用者选择重复次数,写一个 ExampleRepeat 当作文档,并去浏览 strings 包,对任何看上去有用的东西写测试,理由是花在学习标准库上的时间会产生复利。总结只有三行——更多的 TDD 练习,for,以及基准测试——对一个用五个字符的字符串夹带了 Go 的测量文化的章节来说,这是一份公道的账目。
关键论点,以及支撑它们的原文
Go 只提供一个循环关键字,是优势而不是缺点。
作者表明普通的三段式循环就能处理整个任务,并指出这种语法对 C 系语言的人来说很熟悉,只是去掉了括号、并始终要求花括号;同一个循环随后经受了把计数提取成常量、并换用加并赋值运算符的重构,所以没有 while、do 或 until,也没缺什么。
“在 Go 里没有 while、do、until 关键字,你只能用 for。这是件好事!”
“In Go there are no while, do, until keywords, you can only use for. Which is a good thing!”
Iteration查看原文 →
基准测试是 Go 测试的内置部分,所以性能问题可以靠测量来定论。
一个写得几乎与测试一模一样的基准测试函数,用 bench 标志运行,报告每次操作的平均时间;再加上内存标志,还会报告每次操作分配的字节数和分配次数。有了这些,作者比较同一函数的两种实现,而不是去推理哪个应该更快。
“Go 中的字符串是不可变的,这意味着每次拼接,比如在我们的 Repeat 函数里,都涉及复制内存以容纳新字符串。”
“Strings in Go are immutable, meaning every concatenation, such as in our Repeat function, involves copying memory to accommodate the new string.”
Iteration › Benchmarking查看原文 →
在循环里朴素地拼接要付出真实的性能代价,而标准库的字符串构建器修复了它。
拼接版本平均每次调用约136纳秒,而构建器版本是25.70纳秒、每次操作8字节和一次分配——是同一个基准测试上直接的前后对比。作者指出这些数字来自他们自己的机器,所以绝对值与机器相关,尽管差距才是重点。
“Go 中的字符串是不可变的,这意味着每次拼接,比如在我们的 Repeat 函数里,都涉及复制内存以容纳新字符串。”
“Strings in Go are immutable, meaning every concatenation, such as in our Repeat function, involves copying memory to accommodate the new string.”
Iteration › Benchmarking查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么反复拼接字符串很慢
因为 Go 的字符串永远无法就地修改,每次拼接都得分配一个新字符串并把旧内容复制进去,所以一个反复追加的循环一直在为复制付费。构建器则把各片段写入一个不断增长的缓冲区,最后交回成品字符串,这就消除了大部分复制。
机制 2
基准测试的循环如何度量代码
基准测试接收一个 *testing.B,并在 b.Loop() 持续返回 true 时循环,由框架决定跑多少轮能得到稳定的读数。只有该循环里面的东西被计时,所以它之前的准备和之后的收尾,永远不会抬高每次操作的数字。
机制 3
先写桩,这样失败才读得出来
函数的第一个版本只返回一个空字符串,别无他物,纯粹为了让包能编译。运行它随后产生一条真实的失败信息,把期望值与实际值作比较,这在尝试任何逻辑之前,就证明了测试本身写得正确。
常见误读
用 var 声明和用 := 声明是两种不同的机制,而简写形式总是该用的那个。
简写形式只是把声明和初始化合在一起做的压缩方式;var 则单独做声明。这里累积变量需要在循环运行前以空字符串开始,所以较长的形式是自然的选择——而且 var 也声明别的东西,比如函数。
“不过,:= 只是两步的简写。这里我们只是声明一个字符串变量。”
“However, := is simply short hand for both steps. Here we are declaring a string variable only.”
Iteration › Write enough code to make it pass查看原文 →
你应该挑选或调整基准测试运行多少次迭代,因为这个数字决定结果。
框架会挑选一个能得到合理测量的次数,而总数只有在事后才可得;要紧的是报告的每次操作时间。你的任务是把要度量的代码放进循环里,把初始化和清理留在外面。
“testing.B 让你能访问循环函数。只要基准测试应当继续运行,Loop() 就返回 true。”
“The testing.B gives you access to the loop function. Loop() returns true as long as the benchmark should continue running.”
Iteration › Benchmarking查看原文 →
Go 基础的核心概念
- for(Go 唯一的循环)
- Go 中唯一的重复关键字;其三部分周围不加括号,花括号始终必需。
- 适用处: 本章只展示三段式的 C 风格形式和 b.Loop();其他变体只给了链接,没有讲解。
- 基准测试
- 一个接受 *testing.B 的、类似测试的函数,用 b.Loop() 循环,框架只给循环体计时。
- 适用处: 用 go test -bench=. 运行;默认情况下基准测试顺序运行,迭代次数由框架选择。
- strings.Builder
- 一种标准库类型,其 WriteString 在构建字符串时避免反复复制内存。
- 适用处: 你必须调用 String 方法才能得到最终结果。
- var vs :=
:=是同时声明并初始化的简写;var声明一个变量(或函数)而不初始化它。- 适用处: 这里只用来在循环之前声明一个空的累积字符串。
另外 0 个概念
Go 基础划线与批注AI 陪读
“在 Go 里没有 while、do、until 关键字,你只能用 for。这是件好事!”
“In Go there are no while, do, until keywords, you can only use for. Which is a good thing!”
查看原文 →乐呵呵的“这是件好事!”靠断言完成了全部论证。只有一个循环关键字是站得住的,但读者应得到理由——表达同一件事的方式更少——而不是拍拍脑袋。
“保持纪律!你现在不需要知道任何新东西,就能让测试正确地失败。”
“Keep the discipline! You don't need to know anything new right now to make the test fail properly.”
查看原文 →整本书真正的一课,藏在一句随手的话里:桩的存在是为了证明测试能够失败,而不是为了近似答案。
“repeat_test.go:10: expected 'aaaaa' but got”
查看原文 →小失误——测试用的是 %q,所以输出会带双引号,而不是单引号。一个小事,但本章的整个教学法就是“信任你看到的输出”。
另外 6 条批注
“:= 只是两步的简写。这里我们只是声明一个字符串变量”
“is simply short hand for both steps. Here we are declaring a string variable only”
查看原文 →作者抓得好,但真正的原因没说出来:你要的是零值 “” 来累积,而 := 需要一个可从中推断类型的东西。
“在 Loop() 返回 false 之后,b.N 包含运行的总迭代次数。”
“After Loop() returns false, b.N contains the total number of iterations that ran.”
查看原文 →这是现代的 b.Loop() 形式,取代了旧的 i < b.N 惯用法——值得注意的是,修复在于把计数从输入移到输出,这样框架拥有计时边界。
“代码运行的次数不该是你关心的”
“The number of times the code is run shouldn't matter to you”
查看原文 →本不该——直到它变得要紧。一个有每次调用的初始化开销或缓存效应的函数,在10次迭代和一千万次迭代下基准测试的结果大不相同,而“别去看”正是人们交付误导性数字的办法。
“strings.BuilderstringsBuilder 类型,它最大限度地减少内存复制”
“strings.Builder[stringsBuilder] type which minimizes memory copying”
查看原文 →文本的一个残缺的人造物,但它指向某个真实的东西:Builder 不是魔法,它只是保持一个可增长的 []byte,并在最后不经拷贝地把它交出去。
“10000000 25.70 ns/op 8 B/op 1 allocs/op”
查看原文 →快5倍,只有一次分配——为了五个字符。注意本章跳过的诚实保留:在这个规模下,收益真实但绝对值很小;Builder 随着循环次数增长才挣回自己的开销。
“写 ExampleRepeat 来为你的函数写文档”
“Write ExampleRepeat to document your function”
查看原文 →悄悄是这里最好的练习。示例函数由 go test 编译并运行,所以它们是这门语言里唯一不会悄悄腐烂的文档。
读后感AI 陪读
在一本教学法是仔细阅读的书里,留下的转录伤疤
一章刻意小巧,一次干三件事:教唯一的循环关键字,几乎不带新语法地演练红-绿-重构的节奏,并把基准测试夹带进来,仿佛它是寻常之事。我欣赏的教学动作,是返回 “” 的桩——作者坚持你在写任何一行真正的逻辑之前,先看测试因正确的原因失败,而这种纪律才是真正的主题,不是 for。strings.Builder 的转折是本章唯一真正的工程内容,处理得带着一种漂亮的经验主义诚实:别声称它更快,去测量。我要反驳的地方:那种啦啦队式的喊话(“这是件好事!”“相当不错!”)在几处取代了推理,认为迭代次数“不该是你关心的”这一说法,只对行为良好的函数成立,而且在一个五个字符的字符串上5倍的提速,是一个真实但适度的结果,却被当作启示来呈现。文本还带着小小的转录伤疤——一个用单引号显示的 %q 输出,一处残缺的 strings.Builder 提及——在一本方法是“仔细阅读输出”的书里,这比平常更要紧。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:数组与切片
append 如何决定何时扩容底层数组?
Go 基础是怎样展开的AI 辅助
开场:数组作为最简单的情形。 本章一开始把数组框定为有序的、同类型的集合,随后立刻让本书的 TDD 循环去处理一个极其具体的目标:一个对一组数字求和的 Sum 函数。测试先行,带着一个五元素的整数数组,期望总数为15,而作者借机在遇到小细节时教它们——两种初始化形式(显式大小与 [...]),在失败信息里打印输入的 %v 动词,以及一段实用的旁白:为什么 go mod init main 会破坏测试导入,以及改名如何修复。从这里起,循环如广告所说走过各个阶段:一个返回 0 的桩,以确认一次有意义的失败,一个基于下标的 for 循环让它通过,以及重构成 range,它引入了下标/值对和 _ 空白标识符。
第一个转折:数组的局限推动了切片。 本章接着点出它一直在铺垫的陷阱——数组的大小是类型的一部分,所以 [4]int 和 [5]int 就像 string 和 int 一样不兼容。这为切片作为实用的替代铺好了路,而作者把过渡上演成一个设计决定,而不是语法课:有了一个针对“任意大小的集合”的新测试,你必须要么破坏现有 API,要么添加第二个函数。选了前者(没有外部调用者),修复不过是改变参数类型,让编译器指出剩下的破损。重构步骤随后瞄准的是测试而不是代码,并变成一段关于测试哲学的短论证:测试有维护成本,目标是信心而不是数量,两个求和测试是冗余的。go test -cover 工具出现在这里,作为找出未测试代码的办法——附带一个提醒:100%的覆盖率是有纪律的 TDD 的副产品,而不是目标本身。
发展:变参、append,以及比较切片。 下一个需求 SumAll,为每个传入的切片返回一个总数,这带进了变参参数(...[]int)。写测试暴露了一个真正的障碍:Go 禁止对切片用 ==,所以本章引入标准库的 slices.Equal(Go 1.21+),并指出它对不可比较元素类型的浅比较限制。第一个通过的实现用 make 预分配,这让作者区分长度与容量;重构用 append 取代它,完全绕开了容量管理,读起来也更简单。
第二个转折:一个运行时错误,以及收紧测试。 SumAll 变成 SumAllTails,引入切片运算符 slice[low:high] 以及头/尾之分。作者随后刻意问:空切片的尾部是什么——结果的测试能干净地编译,却因切片边界越界而 panic。这是本章最尖锐的一点:编译期错误是盟友,因为它们在交付之前抓住错误,而运行时错误则抵达用户。一个长度守卫修好了它。收尾的重构把重复的断言提取成 checkSums,但把它赋给一个变量,而不是声明成包级函数——演示了函数是值,闭包可以捕获局部作用域,而让辅助函数保持不导出,缩小了你的 API 表面,同时在调用点仍给你类型安全。
收尾。 总结清点所涵盖的内容——数组、切片及其构造,append,对切片再切片,len,覆盖率工具,以及 slices.Equal——然后指向外面:这些类型适用于任何元素类型,包括嵌套切片如 [][]string。读者被指向 Go 博客关于切片的文章,Go playground,以及准备好的示例,展示切片如何与原切片共享底层数组(以及为什么从一个非常大的切片取出的切片,复制它往往是明智的)——暗示了本章自己只是擦边而过的别名行为。
关键论点,以及支撑它们的原文
定长数组不适合通用函数,所以切片才是常规选择。
作者针对一个五元素数组写了 Sum,然后添加一个传入三元素切片的测试;代码拒绝编译,信息说切片不能用在期望五元素数组的地方。只有在参数变成切片之后,两种情形才都能工作,这就是离开数组的动机。
“如果你试图把一个 [4]int 传给期望 [5]int 的函数,它将无法编译。”
“If you try to pass an [4]int into a function that expects [5]int, it won't compile.”
Arrays and slices › Arrays and their type查看原文 →
当没有别人依赖一个函数时,改变它的签名,比维护第二个近乎重复的函数更好。
面对切片与数组的编译失败,作者摆出两个选项——破坏 Sum 现有的形状,或添加一个并行的函数——并指出,改变已发布的签名会毁掉任何调用者的一天。因为这个函数没有外部使用者,破坏的代价为零,而两个函数则意味着永远双倍的维护,所以单一的切片版本胜出;付出的代价是重写第一个测试。
“如果你试图把一个 [4]int 传给期望 [5]int 的函数,它将无法编译。”
“If you try to pass an [4]int into a function that expects [5]int, it won't compile.”
Arrays and slices › Arrays and their type查看原文 →
目标是对代码的信心,而不是大量的测试,因为每个测试都带着维护成本。
写了一个针对五元素切片的测试和另一个针对三元素的测试之后,作者判断这一对是冗余的:如果求和对一种大小有效,它几乎肯定对合理范围内的任何大小都有效。保留意见是,测试是唯一的覆盖信号——删掉一个并重跑覆盖率报告会显示下降——所以这笔交易是维护开销对信息,而不是测试对虚无。
“目标不应该是尽可能多的测试,而是对你的代码库尽可能多的信心。”
“It should not be a goal to have as many tests as possible, but rather to have as much confidence as possible in your code base.”
Arrays and slices › Refactor查看原文 →
另外 2 个论点
由编译器抓到的错误,胜过在程序运行时才浮现的错误。
空切片的测试干净地编译,随后因切片越界而 panic,作者借此作对比:编译错误只是拦住作者,而运行时错误才是真正抵达用户的东西。这就是为什么修复是一个显式的长度检查,而不是寄望于调用者永远不传空输入。
“编译期错误是我们的朋友,因为它们帮助我们写出能工作的软件;运行时错误是我们的敌人,因为它们影响我们的用户。”
“Compile time errors are our friend because they help us write software that works, runtime errors are our enemies because they affect our users.”
Arrays and slices › Try and run the test查看原文 →
把测试辅助函数定义成测试内部的局部变量,使它不出现在包的表面上,并白送类型检查。
延伸前面关于编译期安全的一点,作者把重复的断言赋给测试内部的一个函数值变量。因为它活在那个作用域里,包里没有别的东西能调用它;又因为它的参数是有类型的切片,误传字符串“dave”的调用会被编译器以类型不匹配拒绝,而不是以后才失败。
“编译期错误是我们的朋友,因为它们帮助我们写出能工作的软件;运行时错误是我们的敌人,因为它们影响我们的用户。”
“Compile time errors are our friend because they help us write software that works, runtime errors are our enemies because they affect our users.”
Arrays and slices › Try and run the test查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
类型里的大小阻碍复用
数组的长度烘焙进它的类型,所以为五元素数组声明的函数,其参数类型就是与四元素或三元素数组不同的类型,编译器拒绝这个调用。切片把大小排除在类型之外,所以一个函数签名能接受任意长度的集合——这就是本章改用它们的原因。
机制 2
为什么 append 胜过手工定大小
切片带着一个长度和一个底层容量,对超出该容量的下标赋值,会在程序运行时炸掉,而不是在编译时。append 绕开了这个问题:它接受一个切片加一个值,交回一个已经容纳了一切的切片,所以代码从不必推理还剩多少空间。
机制 3
对空切片切片会 panic
对一个没有元素的集合索取“第一个元素之后的一切”,是一个越界的请求,而 Go 只有在值存在之后才能发现这一点,所以测试能编译,然后 panic。用长度检查来守卫,并让空输入贡献零,就把崩溃转成了有定义的行为。
常见误读
既然本章展示了一份读数100%的覆盖率报告,高覆盖率就是要瞄准的目标,测试越多代码越好。
覆盖率工具被呈现为发现无测试触及的代码的办法,而真正的目标是信心;作者说,力求完全覆盖不该是最终目标,有纪律的测试先行工作往往会把近乎完整的覆盖率当作副产品产生出来。那些不增加信心的多余测试——比如不同大小上的第二个求和测试——被算作纯粹的维护成本。
“目标不应该是尽可能多的测试,而是对你的代码库尽可能多的信心。”
“It should not be a goal to have as many tests as possible, but rather to have as much confidence as possible in your code base.”
Arrays and slices › Refactor查看原文 →
切片的行为像数组或其他值,所以在测试里两个切片可以用相等运算符比较。
Go 拒绝对切片使用相等运算符——编译器报告切片只能与 nil 比较——所以本章求助于标准库自 Go 1.21 起提供的 slices.Equal,它做逐元素的浅比较。该函数要求元素可比较,所以不适用于二维切片之类的东西。
“Go 有切片,它们不编码集合的大小,而是可以有任意大小。”
“Go has slices which do not encode the size of the collection and instead can have any size.”
Arrays and slices › Arrays and their type查看原文 →
Go 基础的核心概念
- 切片
- 建立在数组之上的可变长度集合,声明时不带大小。
- 适用处: 不能用 == 比较;需要 slices.Equal 和可比较的元素。
- range
- 每次迭代产生下标和值的循环形式。
- 适用处: 下标可以用空白标识符 _ 丢掉。
- 变参函数
- 接受数量可变的参数的函数,如 SumAll。
- 适用处: 参数在函数内部以切片的形式到达。
- 切片容量
- 底层数组能容纳多少个元素,有别于长度。
- 适用处: 超出容量的索引在运行时 panic;append 则安全地扩容。
- 测试覆盖率工具
- go test -cover 报告测试执行了哪些语句。
- 适用处: 100%的覆盖率不是目标;信心才是。
另外 0 个概念
Go 基础划线与批注AI 陪读
“package main 将只包含其他包的集成,而不包含可单元测试的代码”
“package main will only contain integration of other packages and not unit-testable code”
查看原文 →这被当作 Go 的福音来呈现,但它其实是披着规则外衣的惯例。大量真实项目把可测试的代码放在 main 里——编译器的限制关乎导入,而不是逻辑应该放在哪里。
“for i := 0; i < 5; i”
查看原文 →硬编码的 5 就是整个课程,藏在眼皮底下。大小存在于三处——类型、循环、测试——而这种三重重复,恰恰是切片将要消解的东西。
“大小被编码在它的类型里”
“the size is encoded in its type”
查看原文 →关于 Go 数组最令人惊讶的一个事实,作者一句话就扔了出来。[4]int 和 [5]int 是互不相干的类型,这就是为什么几乎没有人在 Go 里用数组。
另外 9 条批注
“我们可能毁掉某个人的一天,因为他的另一个测试将不再能编译”
“we will potentially ruin someone's day because our other test will no longer compile”
查看原文 →好招:破坏 API 的决定被框定为社会性的,而不是技术性的。然后那个逃生舱口——“没有别人在用我们的函数”——诚实地承认了这种自由是玩具代码的奢侈。
“目标不该是拥有尽可能多的测试”
“It should not be a goal to have as many tests as possible”
查看原文 →本章最有价值的一段,并且从内部略微削弱了 TDD 的正统。删掉你刚写的测试,比写它需要更多纪律。
“coverage: 100.0% of statements”
查看原文 →值得注意这个数字看不到什么:Sum 没有任何分支,所以这里的100%几乎是白送的。作者诚实地作了保留,但这个指标仍在奉承。
“它不能应用于元素不可比较的切片,比如二维切片”
“it can't be applied to slices with non-comparable elements like 2D slices”
查看原文 →一个悄悄的活板门。本章后来愉快地建议 [][]string,却没有重新接上你刚刚为它失去了 slices.Equal 这件事。
“在这个实现里,我们少担心容量。”
“In this implementation, we are worrying less about capacity.”
查看原文 →“少担心”在这里承担了一些活。对 nil 切片 append 时,它在增长中会重新分配;而已知长度的 make 则不会。这次重构用一点性能换来了很多清晰——是公平的交易,但它是一笔交易。
“编译期错误是我们的朋友”
“Compile time errors are our friend”
查看原文 →本章是挣得这句话,而不是断言它——它刚刚让一个通过编译的东西先在运行时炸掉。整篇里教学上最强的一拍。
“if len(numbers) == 0”
查看原文 →注意这个修复编码的是一个决定,而不是一个事实:空切片的尾部之和是0,只是因为我们这么说。返回错误,或跳过它,同样站得住脚。测试把意见冻结成行为。
“把一个函数赋给一个变量”
“assigning a function to a variable”
查看原文 →被推销为类型安全和作用域的胜利,但作者跳过了一项代价:闭包无法在测试函数之间复用,所以下一个测试文件要重新发明它。“缩小的 API 表面”同时也是缩小的复用。
“改变切片会影响原数组;但切片的一个“拷贝”
“changing the slice affects the original array; but a "copy" of the slice”
查看原文 →切片真正危险的东西——共享底层数组——在最末尾只得到一句话,作为一个链接。这本该有自己的一个红色失败的测试。
读后感AI 陪读
底层数组与别名造成真实的切片缺陷,却几乎没被审视
一章节奏良好,其真正的主题不是切片,而是让编译器和测试驱动设计的纪律。它最好的动作是结构性的:早早埋下的硬编码的 5,好让切片之后可以去掉它;一次刻意上演的运行时 panic,使“编译错误是朋友”这句话落地成证据而不是口号;以及——对一本 TDD 文本来说最不寻常的——一个主张:你应当删除测试,覆盖率是诊断而不是目标。这种自我批评是本章智识上的高点。缺口大多关乎切片共享什么,而不是持有什么。别名和底层数组,是生产环境中切片缺陷的真正来源,却只出现在结尾的一句话和一个外链里,而 append 对比 make 的重构被框定为“少担心容量”,没有点出它所接受的重新分配。slices.Equal 对不可比较元素的警告被陈述,随后被结尾轻松提到的 [][]string 悄悄否定。而空切片的修复被呈现为让一个测试通过,其实它是一个 API 决定——0 是约定,不是发现的真理;测试只是把它冻住了。逆着纹理读,本章很好地演示了 TDD 不会告诉你正确答案是什么,只是要求你必须公开地承诺一个。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:结构体、方法与接口
指针接收者如何改变方法能做的事?
Go 基础是怎样展开的AI 辅助
本章的开头小得几乎让人放下戒心——要求写一段几何代码,由宽和高计算矩形的周长。作者刻意倚重读者的熟悉感:默认你现在已经知道 TDD 循环,所以开头几步(写出失败的测试,看到“undefined: Perimeter”的编译器抱怨,先返回 0,再返回真正的公式)进展很快,主要用来引入 %.2f 格式动词之类的附带细节。随后 Area 作为自主练习交给读者,这就确立了本章的节奏:作者先示范,再要求你自己重复这个循环。
在继续之前,有一小段关于浮点比较的旁白——对浮点数用 == 有风险,这是人人熟悉的警告,不过有个转折:字面表达式 0.1+0.2 == 0.3 打印 true,而同样的和通过变量计算却打印 false,因为 Go 以任意精度计算无类型常量表达式。这段离题的要点是宽慰而不是警报:因为本章的测试值都是 float64 能精确表示的整数,所以此处精确比较是合法的;基于容差的办法(go-cmp 的 EquateApprox)则被标出,作为不合法时使用的工具。
展开:从零散参数到具名类型
第一个真正的转折是一次出于意义而非正确性的重构。这些函数能用,却对矩形只字未提,所以有人可能把三角形的尺寸喂给它们,得到一个看似合理的错误答案。作者没有改名为 RectangleArea,而是引入 struct——字段的命名捆绑——并围绕一个 Rectangle 类型重写测试和函数。编译器的“not enough arguments”信息被用作教学手段,强化先跑测试再动手修的习惯。
加入圆是迫使下一个概念台阶的原因。一旦 Circle 存在,Area(Circle) 和 Area(Rectangle) 就无法共存——Go 没有函数重载,编译器说得明明白白。作者权衡了把它们放进不同的包,认为小题大做而否决,转向方法:带接收者的函数,在实例上调用。测试先被改写成 rectangle.Area() 和 circle.Area(),随后引入接收者语法 func (r Rectangle) Area() float64,同时引入以类型首字母命名接收者的惯例,并指出 Go 的显式接收者取代了其他语言里隐式的 this。返回 0 的桩实现让代码能编译;r.Width * r.Height 和 math.Pi * c.Radius * c.Radius 让它们通过。
转向接口,再转向测试设计
本章的核心思想经由测试重复而到来。想要一个能接受两种形状、又能在编译期拒绝非形状的 checkArea 辅助函数,作者针对一个 Shape 参数来写这个辅助函数——只要加上一段三行的、声明 Area() float64 的 interface,测试就通过了。“等等,什么?”这一拍是本章真正的回报:没有 implements 关键字,具体类型也没有任何意图声明。满足关系是结构性的、隐式的,由此得出的教训关乎解耦——这个辅助函数从不知道自己拿到的是矩形还是圆,只知道可以向它要面积。
从这里起,焦点从语言特性转向测试技艺。表驱动测试作为结构体的一种应用出现:一个由“形状加期望值”配对组成的匿名 []struct,像任何切片一样被遍历。加入 Triangle 以示范扩展变得多么便宜,仍是让编译器带着读者走完从“undefined: Triangle”到“does not implement Shape”再到通过的 (base * height) * 0.5。然后表本身分阶段打磨——用具名结构体字段取代裸的位置值,用 %#v 让失败时打印出肇事的结构体,把 want 改名为 hasArea,并用 t.Run 给每个用例包一层并起名,使输出能标明失败的是哪个用例,go test -run TestArea/Rectangle 也能用。引了一句肯特·贝克来框定目标:测试应读起来像对真理的断言,而不是一串操作。作者小心地补了一句提醒:只有真正在测试多种实现或多种输入时,表才配得上它多出来的噪音。
收束
收尾列出四项收获——结构体、接口、方法、表驱动测试——并把它们重新框定为一个里程碑:这一章是读者开始设计自己类型的地方,而在静态类型语言里这是代码易懂、易组装、易测试的基础。最后一句指向前方,承诺让 Shape 起作用的那个隐式满足的技巧,同样会让读者自己的类型接入广泛使用的标准库接口,白得许多功能。
关键论点,以及支撑它们的原文
把宽和高包进 Rectangle 类型,说明了这段代码是干什么的,这是两个裸 float64 参数做不到的。
作者指出,一个接收两个普通数字的函数,会欣然收下三角形的底和高,并返回一个信心十足的错误答案;它的签名里没有任何地方提到矩形。给函数改名会有帮助,但定义类型把这个想法一次性封装起来,让每个调用点都表明自己的意图。
“一个不留神的开发者可能试图把三角形的宽和高提供给这些函数,而没意识到它们会返回错误的答案。”
“An unwary developer might try to supply the width and height of a triangle to these functions without realising they will return the wrong answer.”
Structs, methods & interfaces › Refactor查看原文 →
既然 Go 不允许把同一个函数名用于不同类型,把行为作为方法附着在类型上就是顺理成章的出路。
在同一个包里同时写 Area(Circle) 和 Area(Rectangle),会因重复声明而编译失败——一些其他语言是允许的。作者指出可以把它们各放进自己的包,称这是小题大做,转而给每个类型各自的带接收者的 Area 方法,此后 rectangle.Area() 和 circle.Area() 便可共存。
“我们可以改为在新定义的类型上定义方法。”
“We can define methods on our newly defined types instead.”
Structs, methods & interfaces › Write the minimal amount of code for the test to run and check the failing test output查看原文 →
只声明你真正需要的那一个方法的接口,能让函数与它所服务的具体类型解耦。
承接方法这一步,重复的断言收拢成一个要求 Shape 的 checkArea 辅助函数。它从不知道自己拿到的是矩形、圆还是三角形——只知道怎样向它要面积——而传入非形状仍会在编译期失败。作者标明,完整的设计回报要到后面几章才来。
“通过声明一个接口,辅助函数与具体类型解耦,只拥有它完成工作所需的方法。”
“By declaring an interface, the helper is decoupled from the concrete types and only has the method it needs to do its job.”
Structs, methods & interfaces › Decoupling查看原文 →
另外 2 个论点
用 == 比较浮点数,恰恰在所涉及的每个值都能被精确表示时才是可接受的。
作者对比了两行:写成字面量的 0.1+0.2 == 0.3 报告 true,因为 Go 以任意精度计算常量表达式,而通过 float64 变量做同样的加法则报告 false。随后他解释为什么面积测试不受影响——12、6 和 72 都是整数——并建议一旦出现不精确的值或除法,就改用 go-cmp 的 EquateApprox 之类的近似比较。
“只有当你引入无法精确表示的值或计算——比如 0.1,或除法的结果——时,精确比较才变得不安全。”
“Exact comparison only becomes unsafe once you introduce values or calculations that aren't exactly representable, like 0.1, or results of division.”
Structs, methods & interfaces › Write enough code to make it pass查看原文 →
表驱动测试只有在每个用例都可读、每次失败都能自报身份时才划算。
像 {Rectangle{12, 6}, 72.0} 这样的裸列表让读者去猜数字是什么意思,所以作者给结构体字段命名,把 want 改名为 hasArea,用 %#v 打印用例,并用 t.Run 给每个用例包一层。于是失败时报告 FAIL: TestArea/Rectangle 并带出结构体的字段值,而不是二十个用例里一个匿名的 got/want——并且那个用例可以通过 go test -run 单独重跑。
“一时看不清所有这些数字代表什么,而你应当力求让测试易于理解。”
“It's not immediately clear what all the numbers represent and you should be aiming for your tests to be easily understood.”
Structs, methods & interfaces › Refactor查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
仅由方法决定是否适配接口
Go 里的接口只是列出调用者需要的方法。编译器检查某个类型是否已经有这些方法,有就接受它——所以添加一个四行的 Shape 接口,就让 Rectangle 和 Circle 都能使用,两个类型都不必改动,而辅助函数保持类型安全,同时对具体形状一无所知。
机制 2
整数相等为何安全
float64 精确存储整数,而两个精确存储的数相乘,只要不越界,得到的仍是精确结果,所以用 == 比较在这里是可靠的。麻烦只出现在像 0.1 这样的值或除法上,那里存下的值本来就和你写的不一样。
机制 3
编译错误作为下一步
每个测试都针对尚不存在的代码来写,所以编译器会准确指出缺了什么——先是未定义的类型,再是缺失的方法,然后是错误的值。作者把读这些信息本身当作设计循环:你只添加错误要求的最小东西,结构体、方法、真正的公式就是这样依次出现的。
常见误读
一个类型必须在某处声明它实现了某个接口,就像在其他语言里写“implements”那样。
Go 是隐式解决的:任何方法与接口所要求的相匹配的类型都满足它,由编译器检查。Rectangle 和 Circle 在有了 Area() float64 的那一刻就成了 Shape,它们的声明里什么都没加;string 不是,只因为它缺那个方法。
“通过声明一个接口,辅助函数与具体类型解耦,只拥有它完成工作所需的方法。”
“By declaring an interface, the helper is decoupled from the concrete types and only has the method it needs to do its job.”
Structs, methods & interfaces › Decoupling查看原文 →
测试里永远不该用 == 比较浮点数,所以这里的面积断言是个潜在 bug。
危险来自 float64 无法精确存储的值,而不是运算符。12、6 和 72 这样的整数被精确存储,它们的乘积也保持精确,所以相等成立;该换成基于容差的比较的时候,是出现 0.1 这样的输入或除法的时候。
“只有当你引入无法精确表示的值或计算——比如 0.1,或除法的结果——时,精确比较才变得不安全。”
“Exact comparison only becomes unsafe once you introduce values or calculations that aren't exactly representable, like 0.1, or results of division.”
Structs, methods & interfaces › Write enough code to make it pass查看原文 →
Go 基础的核心概念
- 结构体
- 一个具名的字段捆绑,把相关数据存放在一起,并让意图明确。
- 适用处: 只把数据分组;行为必须另外作为方法添加。
- 方法
- 带接收者的函数,在某个类型的实例上调用,而不是随处可调。
- 适用处: 需要接收者基类型的实例;不能独立调用。
- 接口
- 对你所需方法的声明,任何拥有这些方法的类型都自动满足它。
- 适用处: 满足关系是隐式的、由编译器在编译期检查;不用写任何意图声明。
- 表驱动测试
- 一个用例结构体的切片,在同一个循环里遍历,所以添加新用例成本很低。
- 适用处: 主要在对一个接口的多个实现、或多样的输入时,才值得多出这份噪音。
- TDD 循环
- 写一个失败的测试,让编译器引导你,让它通过,然后重构。
- 适用处: 这里每一轮由一条编译器错误驱动,而不是事先做出的设计决定。
另外 0 个概念
Go 基础划线与批注AI 陪读
“第二个比较是假的,因为 0.1 和 0.2 无法被精确表示”
“The second comparison is false because 0.1 and 0.2 can't be represented exactly”
查看原文 →这段岔路是本章最诚实的一段——多数教程对浮点比较只是挥手当作禁忌。这里规则得到了真正的理由,外加你可以安全忽略它的条件。
“一个不留神的开发者可能试图提供三角形的宽和高”
“An unwary developer might try to supply the width and height of a triangle”
查看原文 →引入类型的真正动机不是优雅,而是下一个人的错误。好的是,重构由失败模式而不是品味来证明正当。
“f 已被换成 g,是有理由的”
“the f has been replaced by g, with good reason”
查看原文 →一个微小的格式选择被当作设计决定:一个对自己的数字撒谎的测试,比没有测试更糟。容易略过,值得不略过。
另外 6 条批注
“Area redeclared in this block”
查看原文 →作者让 Go 缺少重载的刺痛先落下,再提供方法。注意这个修辞动作:一项语言限制被重新框定为惯用特性的动机。
“在 Go 里,接口解析是隐式的。”
“In Go interface resolution is implicit.”
查看原文 →整章的回报,却几乎是随口交出的。也是最让新人不安的部分——任何地方都没有声明这种关系,所以编译器的沉默是唯一的确认。
“一旦你把这个加到代码里,测试就会通过。”
“Once you add this to the code, the tests will pass.”
查看原文 →写下一个类型声明,让先前失败的代码能编译,感觉像魔法,直到你看出这只是编译器在检查方法集。好在“等等,什么?”紧随其后。
“但要确保你确实需要测试里多出来的那些噪音”
“but be sure that you have a need for the extra noise in the tests”
查看原文 →在一本教授某项技术的书里,难得的克制时刻。表测试是本章的奖杯,而作者仍警告不要条件反射地去用它们。
“把 want 字段改名成更具描述性的东西,比如 hasArea”
“rename the want field into something more descriptive like hasArea”
查看原文 →把 want 改名为 hasArea,把一行测试变成一个句子。很小,却是本章所作“真理的断言”(肯特·贝克)这一主张最清楚的演示。
“辅助函数与具体类型解耦,只拥有它需要的方法”
“the helper is decoupled from the concrete types and only has the method it needs”
查看原文 →“只声明你需要的”才是这里真正的 Go 之课——接口由消费者而不是实现者定义。它被扔在一段里,推迟到后面各章,这低估了它。
读后感AI 陪读
Go 的隐式接口只得到一句话,表测试的格式却得到整整几章
一章看似平常却构造精良:它用一个琐碎的问题——图形的面积——走完从自由函数到结构体、到方法、到隐式接口的整条弧线,而每一步都由一个具体的不适来推动,而不是一场特性之旅。最强的教学动作是让编译器来论证:每一条错误信息都被引用并大声读出,所以读者学会把编译器输出当作指令而不是障碍。浮点比较的岔路,对一本入门文本来说异乎寻常地诚实,给出的是理由和边界条件,而不是一种迷信。最弱之处在于比例:结构化类型——真正与众不同的想法,实现一个接口无需说任何话——只落在一句话里,而让 Go 的设计哲学连贯起来的、由消费者定义接口的原则被提及后又推迟了。相比之下,表驱动测试的外观得到几页篇幅。对初学者的顺序来说站得住脚,但这意味着读者读完本章,对测试的格式很流畅,却只是隐约知道 Go 的接口为什么要紧。结尾的克制——除非需要,表测试就是噪音——是一位见过自己的建议被过度应用的作者的标志。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:指针与错误
值接收者什么时候其实是更好的选择?
Go 基础是怎样展开的AI 辅助
本章以刻意轻松的口吻开篇——金融科技、比特币、“我们能造出多么了不起的银行系统”——并借这个框架,从上一章的结构体走向受管理的状态这个想法:一个 Wallet,余额被隐藏,只能通过方法触及。TDD 循环照常运行(未定义的类型,未定义的方法,一个失败的断言),作者停下来指出 Go 的可见性规则:小写标识符对其所在的包是私有的,这恰恰使 balance“安全”。随后是本章第一个真正的教学时刻,被布置成一次意外:代码看起来正确,Deposit 往 w.balance 里加,测试却仍然报告 got 0 want 10。
诊断是前半部分的核心。Go 会复制参数,所以方法的接收者 w 是调用者钱包的一份副本。文字没有直接断言这一点,而是让你在两处都打印 &wallet.balance,并观察到两个不同的地址——变更发生在一份用完即弃的副本上。修复是指针接收者 *Wallet,随后作者回答那个显而易见的后续问题:为什么不用写 (*w).balance,w.balance 也能用?因为 Go 会自动解引用结构体指针;显式写法是合法的,但被认为太繁琐。一条惯例说明为这条线索收尾——Balance 技术上并不需要指针接收者,但要保持接收者类型一致。
接着是一轮重构,从机制转向表达力。int 能用,却什么也没说,所以本章引入类型定义(type Bitcoin int),展示可以在其上声明方法,并实现 fmt.Stringer,让 %s 打印出“10 BTC”。这里有一小段诚实的自我纠正:作者承认先加了 String 而没有先写测试,指出 go test -cover 本会标出它,并回头补写测试——同时提醒不要为覆盖率而覆盖率。Withdraw 随后通过同样的循环到来,测试重复被提取成一个 assertBalance 辅助函数。
后半部分从指针转向错误,由一个需求问题引出:如果取出的比持有的多怎么办?Go 的惯用法是返回一个 error 让调用者检查,所以测试被写成期望一个非 nil 的错误并且余额不变。这引入一小段关于 nil 的离题——接口可以是 nil,解引用 nil 会 panic,所以要检查——而一条编译器的提示(used as value)逼着改了函数签名。错误信息随后被迭代:errors.New(“oh no”) 变成真正的信息,t.Fatal 保护辅助函数不在 nil 上调用 .Error(),最后本章点出关于错误是值的关键:重复的信息字符串被提取成包级的 ErrInsufficientFunds 哨兵,于是测试比较的是值而不是措辞。作者补了一句漂亮的元观察——测试揭示你的 API 用起来实际会是什么感觉。绕道 errcheck linter 抓到了那个唯一未测试的情形:成功取款从未验证没有错误返回,由此得到 assertNoError。
收尾部分把错误的故事向外延伸。用 == 比较哨兵,只在一个函数能产生该错误时有效;在分层的程序里它会丢失上下文,所以本章引入带 %w 的 fmt.Errorf,把一个错误包进一条点明账户和操作的信息里。这种包装会破坏 ==,由此引出 errors.Is,其解释是不断解包直到匹配或解无可解——即便对未包装的错误也是安全的默认做法——并提到 errors.As 是按类型提取的对应物,留到后面的章节。结尾随后把本章的线索收在几个标题之下:指针用于变更、也用于避免复制大对象或唯一实例,nil 既是编译器抓不到的隐患、又是表达“无”的一种方式,错误是作为返回值、最好表达为有意义的值而不是字符串,新类型则是承载领域含义和满足接口的工具——最后以一句宽慰作结:只要你仔细读,编译器通常会为你指路。
关键论点,以及支撑它们的原文
如果一个方法必须改变结构体的状态,它就必须接收指向该结构体的指针。
作者的存款方法看起来是对的,余额却停在零。在测试里和方法里分别打印余额字段的内存地址,看到了两个不同的地址,证明方法改的是副本;把接收者改成指针,测试就通过了。只读方法严格来说并不需要这样,不过作者为了一致性让接收者保持统一。
“所以当我们在代码里改变余额的值时,我们操作的是从测试传来的东西的一份副本。”
“So when we change the value of the balance inside the code, we are working on a copy of what came from the test.”
Pointers & errors › That's not quite right查看原文 →
测试应当把错误当作值来比较,而不是对它们的措辞做断言。
本章先写了一个辅助函数,把错误的文本与字面量“cannot withdraw, insufficient funds”相比较,随后注意到同一句话如今同时存在于测试和实现里——所以仅仅改写措辞就会让测试失败,尽管行为毫无变化。把错误提取成包级变量,就有了唯一的真相来源,测试对这个变量做断言。听从那个别扭的测试,正是推出更好设计的动力:API 的调用者现在可以与同一个值比较。
“如果有人想改写错误信息,测试就失败,那会非常恼人,而且这对我们的测试来说细节太多了。”
“It would be really annoying for the test to fail if someone wanted to re-word the error and it's just too much detail for our test.”
Pointers & errors › Refactor查看原文 →
在分层的代码里,你应当给错误添加上下文,同时保留其成因的身份。
承接哨兵错误的设计,作者指出它的局限:如果一个钱包在银行之下、银行又在请求处理器之下,而每一层都原样返回这个错误,顶层就只会得知“余额不足”,却不知道哪个账户或哪个操作失败了。一个用账户 id 包装钱包错误的处理函数,会在一条信息里同时打印上下文和原始成因。因为包装会产生新的值,针对哨兵的普通相等检查现在会失败,所以改用沿链条遍历的匹配器——它对从未被包装的错误同样有效,是稳妥的默认做法。当目标是从链条里取出一个有类型的错误,而不是匹配一个已知的值时,配套的提取函数才是合适的工具。
“这正是 errors.Is 解决的问题——它检查一条被包装的错误链,而不只是最外层的那个:”
“This is exactly the problem errors.Is solves - it checks a chain of wrapped errors, not just the outermost one:”
Pointers & errors › Checking wrapped errors查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么被复制的接收者会丢掉你的修改
Go 在调用时复制参数,所以声明在普通结构体类型上的方法,操作的是钱包自己的那份副本。给那份副本的余额加钱,调用者的钱包毫发无损,这就是为什么读回来的余额仍然是零。把接收者声明为指针,传递的是地址,于是方法修改的是原件。
机制 2
被包装的错误如何保住它的成因
用 %w 格式化一个错误,做的不止是把它的文本贴进一条新信息:它把原始错误存进新错误里,并给结果一个把它交还出来的方法。于是匹配器可以一层一层剥掉外层,直到找到你问的那个错误,所以添加的上下文从不会遮住底层成因。
机制 3
具名类型可以教会打印
由现有类型声明一个新类型,就能在它身上挂方法。定义一个 String 方法,使该类型满足格式化包里的打印接口,于是 %s 以领域术语而不是裸数字来渲染它——测试失败读起来就是货币金额,而不是含糊的整数。
常见误读
用指针接收者时,你必须写 (*w).balance 才能访问字段,否则你碰的是指针而不是值。
两种写法都合法,显式写法也是对的——但 Go 的设计者觉得它笨拙,所以指向结构体的指针会被自动解引用,w.balance 的意思完全一样。
“区别在于接收者类型是 *Wallet 而不是 Wallet,你可以把它读作“指向 wallet 的指针”。”
“The difference is the receiver type is *Wallet rather than Wallet which you can read as "a pointer to a wallet".”
Pointers & errors › That's not quite right查看原文 →
错误一旦成了包级的值,用 == 检查它们就是你永远所需要的全部。
相等只匹配最外层的错误。一旦外层用额外上下文包装了成因,返回的就是另一个错误,所以仍需要沿链条遍历的检查,才能认出原始成因。
“这正是 errors.Is 解决的问题——它检查一条被包装的错误链,而不只是最外层的那个:”
“This is exactly the problem errors.Is solves - it checks a chain of wrapped errors, not just the outermost one:”
Pointers & errors › Checking wrapped errors查看原文 →
Go 基础的核心概念
- 指针接收者
- 写成 (w *Wallet),使方法修改的是原来的结构体而不是副本。
- 适用处: 只读方法并不需要它,但按惯例为一致性而保留。
- 哨兵错误值
- 一个包级的错误变量,调用者拿它做比较,而不是匹配消息字符串。
- 适用处: 一旦错误被外层包装,直接用 == 比较就失效。
- 用 %w 包装错误
- 带 %w 的 fmt.Errorf 把原始错误存进一个携带额外上下文的新错误里。
- 适用处: %v 只复制消息;只有 %w 能让链条保持可检视。
- 基于现有类型的类型声明
- type Bitcoin int 创建一个具名类型,可以带有自己的方法和含义。
- 适用处: 行为与底层类型相同;价值在于领域含义和对接口的支持。
另外 0 个概念
Go 基础划线与批注AI 陪读
“金融科技爱 Go,呃,还有比特币?”
“Fintech loves Go and uhhh bitcoins?”
查看原文 →这耸肩是有意为之,且在起作用:它表明这个领域只是一身戏服,所以你不必把注意力浪费在银行业的真实感上。
“运行测试套件,沉浸在通过的测试里”
“run the test suite and bask in the passing test”
查看原文 →本章最好笑的那个笑话的铺垫——书让你多感觉一行的称职,然后抽走地毯。以失败为教学。
“两个余额的地址不同”
“the addresses of the two balances are different”
查看原文 →这就是整整一章。它没有断言“Go 会复制值”,而是让你打印两个数字,注意到它们不一致。证据胜过教条。
另外 9 条批注
“Go 的创造者认为这种写法太繁琐”
“the makers of Go deemed this notation cumbersome”
查看原文 →对语法糖很坦诚。多数教程把自动解引用藏起来,让学习者弄不清指针什么时候“表现得像”值。
“为了一致性,你应当让方法接收者的类型保持一致”
“you should keep your method receiver types the same for consistency”
查看原文 →惯例紧跟在一条与之相矛盾的技术论断之后被夹带进来。值得一问:为谁的一致?真正的理由是,混用的接收者集合会让值/指针的区别在调用处漏出来。
“我们在这里缺乏纪律:我们加了一个方法,却没写测试”
“We lacked discipline here: we've added a method without writing a test”
查看原文 →这份自白比这个方法更有教益。还要注意它拒绝把 100% 覆盖率当目标——覆盖率是促人思考的提示,而不是指标。
“非常重要的是,只写刚好能满足编译器的代码”
“it is very important to just write enough code to satisfy the compiler”
查看原文 →在 Go 里,编译器在某种程度上就是个测试运行器;“红”常常意味着“构建不了”。TDD 循环悄悄地把类型错误吸收为一等的失败状态。
“如果有人想改写措辞,测试就会失败,那就太烦人了”
“It would be really annoying for the test to fail if someone wanted to re-word”
查看原文 →本章最好的论证:让测试的痛苦告诉你 API 应该是什么样。哨兵错误的正当性不来自风格,而来自测试的脆弱。
“我们没有检查,如果 Withdraw 成功了,是否没有返回错误”
“we have not checked that if the Withdraw is successful that an error is not returned”
查看原文 →敏锐的发现——未测试的情形,是顺利路径的沉默。一个 linter 在按 TDD 写成的文件里找到漏洞,是对“光靠纪律不等于覆盖”的一次不错的承认。
“上面好几层的调用者最终看到的只有余额不足”
“all a caller several layers up ever sees is insufficient funds”
查看原文 →这是包装的真正动机,被表述为信息的丢失,而不是特性清单。哨兵在多层之间扩展得很差,本章也承认这一点。
“它把 err 包进 fmt.Errorf 返回的新错误里”
“it wraps err inside the new error fmt.Errorf returns”
查看原文 →%v 与 %w 的对比是恰当的支点:打印出来的字符串相同,结构不同。关于 errors.Is 的一切都由这一个差别推出。
“这是个安全的默认做法,对从未被包装过的错误也适用”
“it's a safe default that also works for errors that were never wrapped”
查看原文 →悄悄地成了这里最可操作的一句——始终用 errors.Is 就好,因为 == 什么好处也没有,而且只要有人在上游添加上下文它就失效。
读后感AI 陪读
就连先写测试的代码,也可能让人看不到未测试的路径
一章有两半,彼此却不太承认对方。前半是本书最好的时候:它没有断言 Go 会复制值,而是递给你两个内存地址,让矛盾自己落地,于是指针接收者作为你本就想要的补救出现。错误这半沿用同样的方法——“oh no”字符串是故意写坏的,比较字符串的断言是故意写得脆弱的,而哨兵值 ErrInsufficientFunds 的出现,是测试所要求的,而不是作者颁布的。这才是本章真正的教训,胜过指针或错误:让测试的不适感来决定你的 API。有两件事对它构成压力。第一,包装那一节,显然是后来添加的,削弱了前面几页刚教过的相等检查——并且值得称道地承认了这一点,最终落在 errors.Is 作为稳妥的默认做法。读者若在那一节之前停下,带走的建议会在有人于上游添加上下文的那一刻悄悄失效。第二,errcheck 那一刻是 TDD 叙事里一道小而诚实的裂缝:一个工具在先写测试写成的代码里发现了未测试的路径,因为顺利路径上“没有错误”这件事很容易忘了去断言。本章关于写了 String() 而没写测试的自白,以及它明确拒绝追求 100% 覆盖率,属于同一种成年人的语气——纪律是一种你会偏离、又会回归的实践,而不是一枚徽章。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:映射
为什么 Delete 没有指针接收者也能改动字典?
Go 基础是怎样展开的AI 辅助
开篇:最小的问题。 本章接在数组与切片之后,把映射框定为有序存储的补充——按键而不是按位置查找。它没有抽象地概览映射的语法,而是挑了一个具体的载体,一部字典,并从能想到的最琐碎的行为开始:给定一个已经包含一个词的映射,搜索这个词应当返回它的释义。第一个测试在任何实现之前写成,而对映射声明语法的解释(map 关键字,方括号里的键类型,后面的值类型)附在那个测试上,而不是作为序言交付。一个概念要点被早早埋下,日后才要紧:键必须可比较,而值可以是任何东西,包括另一个映射。实现循环短得近乎滑稽——一个返回空字符串的桩,用来确认干净地失败,然后是一行 return dictionary[word]——而重构一步把断言逻辑抽进一个 assertStrings 辅助函数。第二次重构把自由函数转成一个具名类型 type Dictionary map[string]string 上的方法,这个结构上的举动让之后的一切都成了方法调用。
初步展开:缺失问题与双值查找。 本章接着问:遇到不存在的词会怎样。诚实的回答——你悄悄得到零值——被视为可以接受却不够好,因为调用者无法区分“没有这个词”和“词没有释义”。这推动了第二个返回值和错误类型。测试被重组为子测试(known word、unknown word),错误断言遵循一条具体的纪律:先检查错误非 nil,再调用 .Error()。实现引入了对映射索引的 comma-ok 惯用法,文中把它呈现为让这一区分得以可能的机制。重构随后把信息提到 ErrNotFound,并添加一个使用 errors.Is 的 assertError 辅助函数,声明的回报是措辞变了测试也不会坏。
转折:CRUD,以及反复出现的“但如果已经存在呢?”这一拍。 从这里起,本章落入一个重复三次的节奏。Add 写成,用 d[word] = definition 通过,然后作者指出缺陷:映射会悄悄覆盖,所以一个叫 Add 的函数悄悄表现得像“set”。修复是让 Add 先查询 Search,并通过 switch 对返回的错误分支——遇到 ErrNotFound 就赋值,遇到 nil 就返回 ErrWordExists,任何意外情形在 default 分支原样放行,文中明确称之为安全网。Update 用对调的分支和一个新的 ErrWordDoesNotExist 重复这一模式;本章停下来为这个多出来的错误变量辩护,而不是复用 ErrNotFound,用一个 Web 应用的例子论证:不同的错误让调用者在一种情况下重定向、在另一种情况下显示消息。Delete 第三次重复,引入 Go 内置的 delete,到这时“测试,桩,switch”的序列已经足够熟悉,行文便压缩了。
两处承载真正教学的离题。 穿插在 CRUD 循环之间的,是两段做着测试循环做不到的概念工作的旁白。“指针、副本,及其他”一节纠正了一个自然的推断——从“映射值上的方法无需取地址就能修改它”这一事实出发:映射值是指向内部结构的指针,所以传递一个映射复制的是指针,不是数据——它不是引用类型。由此引出关于 nil 映射的实用警告:读起来像空映射,写入却会 panic,以及始终用字面量或 make 初始化的规则。第二段旁白在 Add 的重构里,用实现了 Error() 的 DictionaryErr 字符串类型取代 errors.New 变量,于是哨兵可以声明为 const——不可变,并且按所引的论点,更可复用。
收束。 收尾是一份朴素的清单:一套完整的字典 CRUD API,加上列出的技能——创建、搜索、添加、更新、从映射中删除,以及错误技术(常量错误、错误包装)。于是本章的形状是:一个极小的读取操作,通过反复追问当前实现错在哪里而向外生长,映射语义和错误设计的旁白恰好落在代码本会误导人的地方。
关键论点,以及支撑它们的原文
查找函数应当明白说出值不存在,而不是悄悄交回一个空结果。
作者指出,什么都不返回会让调用者分不清是词缺失、还是词只是没有释义,于是把 Search 改写成返回第二个错误值,借助查找的成功布尔值来返回 ErrNotFound。他承认这对一部字典无关紧要,但在别的用途里可能起决定作用。
“这样,用户就不会纳闷:是这个词不存在,还是只是没有定义(对一本词典来说,这也许看起来没什么用。”
“This way, the user isn't left wondering if the word doesn't exist or if there is just no definition (this might not seem very useful for a dictionary.”
Maps › Write the test first查看原文 →
把写死的错误信息提到一个具名变量里,会让代码及其测试都更结实。
作者用 ErrNotFound 和一个共享的 assertError 辅助函数取代了内联的信息,后者比较的是错误值而不是文本,所以以后措辞改了,测试照样通过——这也缩短了测试本身。
“这样,用户就不会纳闷:是这个词不存在,还是只是没有定义(对一本词典来说,这也许看起来没什么用。”
“This way, the user isn't left wondering if the word doesn't exist or if there is just no definition (this might not seem very useful for a dictionary.”
Maps › Write the test first查看原文 →
既然映射会悄悄覆盖,Add 操作就必须先检查,否则它的名字就在谎报它所做的事。
作者指出,向已存在的键存值,只会悄无声息地替换旧值,一般来说很方便,但对一个叫 Add 的东西来说却是错的。他针对一个已存在的词写了一个失败的测试,然后让 Add 复用 Search 并对其错误分支:没找到就插入,找到就返回 ErrWordExists,任何意外的都往外传——这个 switch 充当对他没料到的错误的安全网。
“如果值已经存在,映射不会抛出错误。相反,它们会径直用新提供的值覆盖该值。”
“Map will not throw an error if the value already exists. Instead, they will go ahead and overwrite the value with the newly provided value.”
Maps › Refactor查看原文 →
另外 3 个论点
错误用自定义类型的常量来表达,比用普通变量更好。
随着错误值越来越多,作者定义了一个带 Error 方法、基于字符串的 DictionaryErr 类型,使错误值可以声明为常量,论证这让它们可复用、且无法被重新赋值。
“如果值已经存在,映射不会抛出错误。相反,它们会径直用新提供的值覆盖该值。”
“Map will not throw an error if the value already exists. Instead, they will go ahead and overwrite the value with the newly provided value.”
Maps › Refactor查看原文 →
一次失败的更新值得拥有自己的错误,而不是并入“未找到”错误。
作者承认可以复用 ErrNotFound,却引入了 ErrWordDoesNotExist,理由是精确的错误能携带更多关于出了什么问题的信息;他用 Web 应用来说明:一种情形可以触发重定向,另一种则向用户显示一条消息。
“这样,用户就不会纳闷:是这个词不存在,还是只是没有定义(对一本词典来说,这也许看起来没什么用。”
“This way, the user isn't left wondering if the word doesn't exist or if there is just no definition (this might not seem very useful for a dictionary.”
Maps › Write the test first查看原文 →
Update 和 Delete 应当拒绝字典里没有的词,与 Add 所需的守卫对应。
修好 Add 之后,作者展示了对称的缺陷:Update 里一次光秃秃的赋值会插入一个全新的词,而一次光秃秃的 delete 调用在不存在的键上会悄悄成功。两者都围绕同样的“Search 加 switch”形状重写,只是把分支对调:词存在时才执行写入,不存在时返回 ErrWordDoesNotExist。
“如果值已经存在,映射不会抛出错误。相反,它们会径直用新提供的值覆盖该值。”
“Map will not throw an error if the value already exists. Instead, they will go ahead and overwrite the value with the newly provided value.”
Maps › Refactor查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
双值查找如何区分缺失与为空
Go 里读映射可以交还第二个布尔值,说明这个键是否真的存在。有了这个额外的信号,字典就能把“没有这个词”和“词被存了、释义为空”当作不同的情形,只在第一种情况下返回专门的错误。
机制 2
为什么传递映射之后你仍能改动它
映射变量持有的是指向拥有数据的运行时结构的指针,所以把映射交给函数,复制的只是那个指针,而不是它背后的桶。这就是为什么字典上的方法无需取地址就能插入或删除条目,尽管 Go 对一切都按值传递。
机制 3
为什么 nil 映射读取安全、写入致命
只声明而没有赋值的映射读起来像个空映射,所以查找悄悄成功、掩盖了问题,但任何写入都会在运行时让程序崩溃。预先用空字面量或 make 创建映射,就让变量指向一张真正的哈希表,于是写入总有地方落脚。
常见误读
既然你能在函数内部向映射添加和删除而不传指针,映射一定是引用类型。
映射值是指向保存数据的运行时结构的指针,所以每次调用复制的是指针,底层的表仍然共享——修改之所以有效是这个缘故,而不是因为 Go 把映射当作引用。
“所以当你把映射传给函数/方法时,你确实在复制它,但只复制了指针部分,而不是包含数据的底层数据结构。”
“So when you pass a map to a function/method, you are indeed copying it, but just the pointer part, not the underlying data structure that contains the data.”
Maps › Pointers, copies, et al查看原文 →
声明一个映射变量就足以开始使用它,就像声明一个切片变量通常那样。
仅仅声明的映射是 nil:读取的表现像空映射、不给任何警告,而第一次写入会在运行时 panic。要用空的映射字面量或 make 初始化它,使真正的哈希表在你存任何东西之前就已存在。
“nil 映射在读取时表现得像空映射,但试图向 nil 映射写入会引发运行时 panic。”
“A nil map behaves like an empty map when reading, but attempts to write to a nil map will cause a runtime panic.”
Maps › Pointers, copies, et al查看原文 →
Go 基础的核心概念
- 映射
- 一种把值存放在键之下以便快速查找的存储,比如一个词和它的释义。
- 适用处: 键必须是可比较的类型;对 nil 映射写入会 panic。
- comma-ok 查找
- 读映射时可以返回第二个布尔值,告诉你这个键到底存不存在。
- 适用处: 只能区分键缺失与键存在,不能区分空值与错误。
- 常量错误类型
- 自己定义一个基于字符串的错误类型,使错误值可以声明为常量。
- 适用处: 需要实现 Error() 的自定义类型;普通的 errors.New 值不能是常量。
- 映射的复制语义
- 传递映射只复制它内部的指针,所以函数仍能改动共享的数据。
- 适用处: 感觉像引用类型,其实不是;nil 映射读起来像空映射,写入则 panic。
另外 0 个概念
Go 基础划线与批注AI 陪读
“它只能是可比较的类型,因为如果无法判断两个键是否相等”
“It can only be a comparable type because without the ability to tell if 2 keys are equal”
查看原文 →好在这个约束是用机制来论证的,而不是靠颁布。不过 Go 里的“可比较”自有它的暗门:带切片字段的结构体看起来可比较,直到编译器说不行;浮点数则会让 NaN 键变得无法触及。
“这很好,因为程序可以继续运行,但有更好的办法。”
“This is good because the program can continue to run, but there is a better approach.”
查看原文 →零值作为悄无声息的默认值,是 Go 最双刃的礼物。作者把它当作一次险些出事,而不是一种设计哲学——真正的要点是,缺失与空无无法区分,而是否要紧只有你自己知道。
“第二个值是个布尔值,表明是否成功找到了这个键。”
“The second value is a boolean which indicates if the key was found successfully.”
查看原文 →comma-ok 惯用法悄悄做着别处一整个 Optional 类型所做的事。值得注意的是,它是语法上的特例,不是你自己能写出来的东西——Go 给了运行时一些它不给你的代码的特权。
另外 8 条批注
“这样将来改了错误文本,我们的测试也不会失败”
“so our test doesn't fail if we change the error text in the future”
查看原文 →这是本章最可迁移的一课,却被埋在一句重构的旁白里:断言身份,而不是断言行文。每个对错误信息做字符串匹配的测试,都是将来的一次误报失败。
“映射值是指向 runtime.hmap 结构的指针。”
“A map value is a pointer to a runtime.hmap structure.”
查看原文 →诚实的表述。它也解释了人们常绊倒的那种不对称:你可以通过副本来修改,却不能向副本里 make——在函数内部赋一个新映射,在外面是看不见的。
“因此,你永远不该这样初始化一个 nil 映射变量:”
“Therefore, you should never initialize a nil map variable:”
查看原文 →措辞失误——var m map[string]string 恰恰不是初始化。而“永远”太强了:nil 映射是个完全可用的只读空映射,这也是 nil 切片和 nil 映射在用处上分道扬镳的原因。
“switch err { case ErrNotFound:”
查看原文 →在测试刚被教会使用 errors.Is 之后读到这个,很刺耳。实现用 == 比较,而断言却容忍包装——本书自己的工具已经超越了这段代码。
“有这样一个 switch 提供了一层额外的安全网”
“Having a switch like this provides an extra safety net”
查看原文 →这是为一个不可能发生的事准备的安全网。Search 恰好只有两种结果,所以 default 永远到不了——作为面向未来的保险尚可辩护,但称之为安全就夸大了,而且它如今是个无法测试的分支。
“err := d.Search(word) switch err { case ErrNotFound: return ErrWordDoesNotExist”
查看原文 →三个方法现在共享同一个形状,只是换了函数体。这正是提取一次检查的教科书时刻,而本章却宣称“没什么可重构的”——三重重复正是它走过去而没教的那一课。
“遇到 ErrNotFound 时你可以重定向用户,但遇到 ErrWordDoesNotExist 时显示一条错误信息”
“You can redirect the user when ErrNotFound is encountered, but display an error message when ErrWordDoesNotExist”
查看原文 →这个理由自己拆了自己的台——这里两个错误都意味着同样的缺失,由同一次查找发现。设立一个不同错误的真正理由是调用者的意图(更新还是读取),而不是新的信息。
“它让错误更可复用、更不可变”
“it makes the errors more reusable and immutable”
查看原文 →实际的收益要窄得多:const 不能被别的包重新赋值。但一个有类型的字符串错误也会丢掉身份——两个文本相同的不同错误会变得 == 相等,这悄悄削弱了哨兵。
读后感AI 陪读
本章真正的主题:让缺失变得明确,而不是映射
一次节奏得当的 TDD 演练,它真正的主题根本不是映射——而是让“缺失”变得明确的纪律。关于映射的材料很单薄,但选得对:comma-ok、delete、对 nil 映射写入会 panic,以及 Cheney 那个澄清——映射是被复制的指针,这是唯一真正承重的事实。本章的长处是它的节奏;每条失败信息都展示出来,所以你学会把编译器当作合作者而不是障碍来读。它的弱点是 CRUD 的展开靠堆叠来教学。Add、Update 和 Delete 最终成了同样的九行,只是挪动了一个子句,而作者在读者本该感到发痒的那一处说“没什么可重构的”。更尖锐的是:测试升级成了 errors.Is,而实现仍然用 == 比较,所以本书在一个文件里示范现代的错误处理,在另一个文件里却是包装出现之前的风格,却没有指出这一差距。常量错误的收尾被呈现为纯粹的收益,其实是一种取舍——不可变、可复用,没错,但基于字符串值的错误在相等性上会碰撞,拿它来给一章关于精确错误身份的内容收尾,是个古怪的音符。读它是为了循环,不是为了结论。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:依赖注入
一旦没有标准库的 Writer 合适,mock 如何延伸这一点?
Go 基础是怎样展开的AI 辅助
本章开头把依赖注入框定为一个被广泛误解的话题,并预先摆出四项主张:你不需要任何框架,你的设计不会变得更复杂,测试变得更容易,你的函数变得更通用。随后它回溯到本书最初的练习——一个打印问候的 Greet 函数——并提出驱动此后一切的问题:fmt.Printf 写向 stdout,而测试框架无法轻易捕获。提出的修复是“注入”这个打印依赖,也就是把它传进去,并接受一个接口而不是具体类型,使输出的去向不再被写死。
展开部分是一次短短的练习,读的是标准库,而不是发明抽象。作者顺着 fmt.Printf 往下走到 fmt.Fprintf,发现 Printf 不过是提供了 os.Stdout 的 Fprintf,而 Fprintf 的第一个参数是 io.Writer——一个只有 Write 方法的接口。推论是 os.Stdout 满足该接口,并指出 io.Writer 在 Go 里反复出现,是表达“把这些数据放到某处”的通用方式。从这里起,照常的先写测试循环展开:一个测试写入 bytes.Buffer(它同样满足 Write),并把其内容与期望的问候比较;测试编译失败;写出一个接收 *bytes.Buffer 的最小 Greet,让编译器满意,但测试仍然失败,问候明显地去了 stdout 而不是缓冲区;把 Printf 换成针对该 writer 的 Fprintf,就通过了。
转折出现在重构中。传 *bytes.Buffer 不过是编译器要求的,本章通过尝试用 os.Stdout 把 Greet 接进真正的程序,暴露了这有多无用——它通不过类型检查,因为 os.Stdout 是 *os.File。把参数放宽到 io.Writer,即这两个类型都实现的接口,一个函数就同时服务于测试和应用。本章接着用第二个情境把这一点推得更远:一个小小的 HTTP 服务器,Greet 在处理器内部被调用,写向同样满足 io.Writer 的 http.ResponseWriter——HTTP 的细节推迟到后面的章节,要点是复用。
收尾点出这次重构买到的三项回报。可测试性,附带更广的诊断:难测试的函数通常暗示依赖被写死或存在全局状态——共享的数据库连接池是常见例子,DI 推着你把它注入到一个接口后面并加以 mock。关注点分离,把数据去向与数据如何产生解耦,当一个函数同时干太多活时就该用这个工具。以及跨情境的可复用性,测试只是其中的第一个。两段收尾旁白作结:mock 被推迟到后面的章节,并被辩护为不是邪恶,只是在这里没必要,因为标准库已经提供了可用的 writer;而标准库值得研读,因为熟悉像 io.Writer 这样的接口,才使这类复用成为可能。本章把Go 程序设计语言列为这个例子的出处。
关键论点,以及支撑它们的原文
直接打印到标准输出的函数难以测试,所以它写向的地方应当从外部传入。
作者顺着标准库往下追:熟悉的打印函数只是一层薄包装,转发给一个接收 writer 的变体,并把标准输出作为那个 writer 提供。这就揭示出 writer 接口是可以接入的接缝,而 bytes 的缓冲区类型因为有所需的 Write 方法,也算这样一个 writer——于是测试可以捕获问候并检查它。
“但我们怎么测试这个呢?调用 fmt.Printf 会打印到 stdout,这用测试框架很难捕获。”
“But how can we test this? Calling fmt.Printf prints to stdout, which is pretty hard for us to capture using the testing framework.”
Dependency Injection查看原文 →
被注入的参数应当是通用的 writer 接口,而不是具体的缓冲区类型。
这直接承接上一步:编译器起初把作者推向缓冲区指针,它通过了测试,但真正的程序试图传入标准输出——另一个具体类型——时就拒绝编译了。把参数放宽到两者都满足的共享接口,一个函数就同时在测试和应用中工作。作者指出,狭窄的具体类型是跟随编译器时一个正当的中途站,只是不是终点。
“如果我们把代码改成使用更通用的接口,现在就可以在测试和我们的应用里同时使用它。”
“If we change our code to use the more general purpose interface we can now use it in both tests and in our application.”
Dependency Injection › Refactor查看原文 →
一旦依赖是接口,同一个函数就能在它从未为之编写的情境里复用。
作者一行也不改问候函数,就把它丢进一个 Web 请求处理器:交给处理器的响应写入器同样满足 writer 接口,于是访问运行中的服务器,看到的是同样的问候,经由 HTTP 送达,而不是送到终端。
“如果我们把代码改成使用更通用的接口,现在就可以在测试和我们的应用里同时使用它。”
“If we change our code to use the more general purpose interface we can now use it in both tests and in our application.”
Dependency Injection › Refactor查看原文 →
另外 2 个论点
如果一个函数难以测试,原因通常是写进去的依赖,或是对全局状态的依赖。
作者的例子是一个依赖单个全局数据库连接池的服务层:对该层的测试既难搭建又跑得慢,直到数据库通过接口注入,并在测试里换成某种可控的东西。作者把这呈现为一种典型模式,而不是实测结果。
“测试我们的代码。如果你无法轻易测试一个函数,通常是因为硬连线进函数的依赖或全局状态。”
“Test our code If you can't test a function easily, it's usually because of dependencies hard-wired into a function or global state.”
Dependency Injection › Wrapping up查看原文 →
对标准库的熟悉,才让这种解耦变得廉价。
作者从未定义新的抽象:通用的 writer 接口早已存在,缓冲区类型早已为测试实现了它,而标准输出和 HTTP 响应写入器为生产实现了它。认识这些部件,是多加一个参数就足以让函数既可测试又可复用的唯一原因。
“但我们怎么测试这个呢?调用 fmt.Printf 会打印到 stdout,这用测试框架很难捕获。”
“But how can we test this? Calling fmt.Printf prints to stdout, which is pretty hard for us to capture using the testing framework.”
Dependency Injection查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
把目的地传进来,输出就可检查
调用 Printf 的函数把文本送到 stdout,测试无法读回。如果把目的地作为参数传进来,测试就提供一个缓冲区,函数往那里写,测试只需读缓冲区的内容并与期望的字符串比较。
机制 2
一个方法就足以适配许多目的地
标准的写入接口只要求一个 Write 方法,所以任何有它的类型都符合。把参数声明为该接口而不是缓冲区指针,意味着测试的内存缓冲区、程序的标准输出、以及 Web 服务器的响应写入器,都被同一个函数接受。
机制 3
测试的摩擦指向写死的依赖
当一个函数伸手去够它内部固定的东西——标准输出、共享的数据库连接池——测试就无法替换任何东西,于是既别扭又慢。把依赖挪到接口后面的参数上,就把产生数据和决定数据去向拆开,两半随后便可各自被单独检验。
常见误读
依赖注入意味着采用某个注入框架或容器,并让你的设计变得更繁复。
这里它不过是一个多出来的函数参数,类型是一个现有的标准库接口——没有库,没有配置,函数体和以前一样短。作者在章首就直言不需要框架,这项技术也不会让设计复杂化。
“我们需要做的是能够注入(这只是传入的一个花哨词)打印这个依赖。”
“What we need to do is to be able to inject \(which is just a fancy word for pass in\) the dependency of printing.”
Dependency Injection查看原文 →
只要注入依赖,你就注定要写 mock,而 mock 是应该避开的坏味道。
mock 只是把一个可控的替身换进被注入的真东西里,让测试能检查它,作者把它当作正当的工具而不是罪过。它也并非总是必需:在本章里,标准库的内存缓冲区已经充当了替身 writer,所以没有什么东西需要手工造假。
“你用模拟来把你注入的真实东西,替换成一个你能在测试里控制和检查的假版本。”
“You use mocking to replace real things you inject with a pretend version that you can control and inspect in your tests.”
Dependency Injection › What about mocking? I hear you need that for DI and also it's evil查看原文 →
Go 基础的核心概念
- 依赖注入
- 把函数的依赖从外部传入,而不是在内部写死。
- 适用处: 不需要框架;它只是传递一个值,通常藏在接口后面。
- io.Writer
- 一个只有一个方法的标准接口,意思是“把这些数据放到某处”。
- 适用处: 只覆盖写入字节;实现者包括 os.Stdout、bytes.Buffer、http.ResponseWriter。
- 模拟
- 在测试中,把被注入的真实依赖换成可控的假冒版本。
- 适用处: 并非总是需要;这里标准库的 bytes.Buffer 就够了。
另外 0 个概念
Go 基础划线与批注AI 陪读
“我们需要做的是能够注入(这只是传入的一个花哨词)打印这个依赖。”
“What we need to do is to be able to inject \(which is just a fancy word for pass in\) the dependency of printing.”
查看原文 →整章的论点就在这个括号里。去掉企业式的词汇,DI 就是函数参数。这就是标题所承诺的消肿。
“如果你去看 fmt.Printf 的源码,就能看到一种让我们接入的办法”
“If you look at the source code of fmt.Printf you can see a way for us to hook in”
查看原文 →真正的教学:答案不是发明出来的,而是从标准库里读出来的。可测试性早已被设计进 Printf,出自把它与 Fprintf 拆开的那个人。
“func Greet(writer *bytes.Buffer, name string) { fmt.Printf("Hello, %s", name”
查看原文 →一个刻意荒唐的中间步骤——接受一个 writer,然后无视它。先逐字服从编译器,再服从测试,一个笑话就道尽了整个 TDD 纪律。
另外 5 条批注
“Hello, Chris di_test.go:16: got '' want 'Hello, Chris'”
查看原文 →可爱的细节:问候出现在测试输出里,断言却什么也没看到。失败信息就在字面上向你展示了数据去了哪里。
“这在技术上是对的,但不太有用。”
“This is technically correct but not very useful.”
查看原文 →编译器是个好校对,却是个糟糕的架构师。它会乐呵呵地把你钉在一个具体类型上;放宽到 io.Writer 是它没法替你做的判断。
“你大概能猜到 http.ResponseWriter 也实现了 io.Writer”
“You can probably guess that http.ResponseWriter also implements io.Writer”
查看原文 →net/http 里没有人向 bytes 或 os 登记过什么。结构化类型意味着复用是追溯性的——接口收集到了它从不知道的实现者。
“比如,如果你有一个全局的数据库连接池”
“If you have a global database connection pool for instance”
查看原文 →诚实的升级。换一个 Writer 是免费的;注入一个数据库则意味着要自己掌管事务、生命周期,以及一个会与现实漂移的 mock。本章赢得轻松,因为它选了最容易的依赖。
“不过在我们的情形里,标准库已经备好了现成的东西可用”
“In our case though, the standard library had something ready for us to use”
查看原文 →悄悄地成了反对 mock 框架的最有力论据:真正的 bytes.Buffer 既是替身,又是断言。你测试的是行为,而不是对调用的录像。
读后感AI 陪读
读一读 Printf 是怎么解决的,这才是这里的要点
一章短短的内容同时干着两件事,而第二件更好。表面上,它用最小的例子教依赖注入:一个打印的函数变成一个写出的函数,于是它突然可测试了,可嵌入命令行工具,一行不改就能丢进 HTTP 处理器。论证无懈可击,因为所选的依赖是能想到的最友善的那一个——io.Writer 只有一个方法,没有生命周期,没有值得争论的错误语义,标准库里的替身(bytes.Buffer)根本不是替身,而是一个真正的实现。作者足够坦率,点到了那个难的情形——全局数据库连接池,但本章从不必为这笔账买单:没有搭建/拆除,没有 mock 漂移,也没有“多少接缝才算太多”的问题。以怀疑的眼光读,这是简单模式下的 DI,从中推而广之的读者,第一次遇到注入意味着要把一个依赖穿过六层构造函数时,会大吃一惊。更深的一课,也是我会留下的那一课,是方法论上的:这个解法是通过读 fmt 的源码找到的。Printf 是对 Fprintf 的一层薄包装,因为很久以前有人恰好做过本章所走过的这个重构,而这个抽象一直在那里等着。再加上 Go 的隐式接口——net/http 里从未有人声明过对 io.Writer 的效忠,而 ResponseWriter 却满足它——复用就成了你发现的东西,而不是你计划的东西。本章悄悄提出的主张是:在 Go 里,设计品味在很大程度上就是对标准库的熟悉。还值得注意的是,有多少教学活在失败输出里:“Hello, Chris”打印到终端、而断言报告的却是一个空字符串的那一刻,比任何一段文字都更生动地定义了什么是不可测试的依赖。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:模拟
你如何判断 sleep 的顺序是行为还是实现细节?
Go 基础是怎样展开的AI 辅助
开篇:一个琐碎的任务,刻意切片。 本章设了一个玩具需求——倒数 3、2、1,然后“Go!”,一次一行,中间带停顿——并立刻用它来论证迭代的纪律,而不是论证聪明。作者把这个小活也分成三片(打印 3;打印整个序列;加上停顿),理由是开发者在写“经过一番折腾理论上会工作”的代码时会掉进兔子洞。第一片由一个写入 bytes.Buffer 的测试驱动,这让本章得以重温前一节的依赖注入:函数接收一个 io.Writer,所以生产代码可以交给它 os.Stdout,而测试捕获输出。红-绿-重构循环是按编译器错误的粒度走完的——未定义的函数,参数个数不对,断言错误——直到测试通过之后,参数才从 *bytes.Buffer 泛化为 io.Writer。这一片随后被接进 main,于是有了端到端可工作的软件,作者推荐“任何项目”都养成这一习惯。
展开:循环,以及循环造成的问题。 第二片很便宜,因为管道已经在那里:测试的期望输出变成反引号多行字符串,一个倒着走的 for 循环加上 Fprintln 和最后一个 Fprint 让它通过,重构一步只是把 “Go!” 和 3 提成具名常量。然后放进 time.Sleep,程序的行为恰如规定——这是本章的支点。作者列出这段可工作的代码带来的代价:测试现在要花三秒(慢测试会“毁掉开发者的生产力”,而且这代价随每个新用例累积),而且停顿本身没有被测试。修复是本章标题所指的那一招:把睡眠定义为只有一个 Sleep() 方法的 Sleeper 接口,注入它,在 main 里用真实实现,在测试里用间谍——一种记录自己如何被使用的测试替身。一个设计决定被明确点出:Countdown 不拥有睡眠时长,把它留给调用者。
转折中的转折:只数调用次数不够。 让测试断言了对 Sleep 的三次调用之后,作者拒绝就此止步,因为顺序这一性质——睡眠穿插在打印之间——仍未被验证。证明是一次刻意的破坏:把函数拆成一个睡三次的循环和另一个打印的循环,测试仍然通过。这条建议可以推广(“如果你不确定测试给了你足够的信心,就把它弄坏,”在提交之后)。修复是单个间谍 SpyCountdownOperations,它同时满足 io.Writer 和 Sleeper,并把 “write” 或 “sleep” 标签追加到同一个切片里,于是针对期望序列做 reflect.DeepEqual,就钉死了穿插顺序。测试套件随后重组为两个子测试——一个管输出,一个管顺序——现在多余的第一个间谍被删除。最后的扩展让睡眠器可配置:ConfigurableSleeper 持有一个时长和一个与 time.Sleep 签名相同的函数,所以测试注入一个记录所请求时长的 SpyTime,而 main 注入真东西;DefaultSleeper 成了死代码,被删掉。
收束:告诫,以及 mock 的坏名声。 最后三分之一丢开练习,与读者可能的反对意见争辩:mock 是邪恶的。作者的立场是,这项技术无辜,但它给出的反馈是真实的:复杂的 mock 或一大堆 mock,通常意味着一个单元做得太多、依赖被切得太细、或测试与实现而非行为纠缠在一起——“设计良好的代码易于测试”,所以令人痛苦的 mock 是 TDD 在报告设计问题,而不是制造问题。随后是具体的启发:重构理想情况下不应要求改测试,所以要问你测的是行为还是内部;避免测试私有函数(Sandi Metz 所说的“较不稳定”的代码);一个测试里超过三个 mock 就是红旗;谨慎使用间谍,因为记录算法的内部步骤会把测试与这些步骤耦合。mock 框架得到一个有分寸的评语——手写教你更多,也练习接口实现,而自动生成的替身在团队里靠强制一致性赢得位置,前提是工具针对接口生成、且不规定测试该怎么写。收尾重申两条线索(薄的垂直切片,引了福勒关于迭代开发的话;以及 mock 是在没有缓慢、脆弱的真实依赖的情况下测试失败、时序和特定系统状态的唯一切实办法),指出间谍只是测试替身的一种,与桩和真正的 mock 并列,并以一个 Go 1.23 的加餐作结:一个返回 iter.Seq[int] 的 countDownFrom 迭代器,产出递减的数字,让 Countdown 里的循环读起来就是对一个富有表现力的自定义序列做 range。
关键论点,以及支撑它们的原文
以已经能工作、已经被测试的、薄薄的端到端切片来构建软件,而不是一次大冲刺。
作者刻意先发布一个只打印数字 3 的版本,把它接进一个可运行的程序,然后才扩展到打印 3、2、1 和最后那个词,再后来加入停顿。每个阶段都是带测试的可工作软件,所以管道在任何逻辑生长其上之前就已被证明,这正是后来的重构保持廉价的原因。
“取一小片功能,让它端到端地跑通,由测试支撑。”
“Take a thin slice of functionality and make it work end-to-end, backed by tests.”
Mocking › Refactor查看原文 →
生产代码里的真实停顿,必须抽到一个可注入的依赖后面。
循环里有一个真正的一秒等待,测试套件就花三秒什么也不干,而且这个停顿从未被真正验证。用注入的睡眠器和一个做记录的替身取而代之,让套件瞬间完成,同时终于使等待行为可测试——停顿从一个未测试的副作用,变成了一条被断言的性质。
“我们对 Sleeping 有一个依赖,需要把它提取出来,这样才能在测试中控制它。”
“We have a dependency on Sleeping which we need to extract so we can then control it in our tests.”
Mocking查看原文 →
检查某个依赖被调用了正确的次数,并不能说明它是何时被调用的。
作者故意破坏实现:先睡三次,再打印全部三个数字。基于计数的测试仍然通过,尽管输出已不再是一场戏剧性的倒数。这种“该失败而没失败”,正是写第二个测试的理由——它比较所记录的写入与睡眠的序列,并附带一条提醒:这类顺序检查会把测试更紧地绑到算法上。
“我们最新的改动只断言它睡了3次,但这些睡眠可能是乱序发生的。”
“Our latest change only asserts that it has slept 3 times, but those sleeps could occur out of sequence.”
Mocking › Still some problems查看原文 →
另外 3 个论点
当 mock 让你痛苦时,这条信息说的是你的设计,而不是测试驱动开发。
作者把复杂或蔓延的假对象当作一种诊断:这通常意味着单元的依赖太多、应当拆分,或依赖太细、应当合并,或测试在追逐实现细节而不是行为。于是人们所称的 TDD 的弱点——那种不适——被重新框定为它的优点:设计良好的代码易于测试。
“人们在这里看到的是 TDD 的弱点,但它其实是一个优势。”
“What people see here is a weakness in TDD but it is actually a strength.”
Mocking › But isn't mocking evil?查看原文 →
让测试瞄准可观察的行为,这样重构就不会拖着一堆测试修改一起来。
既然重构意味着代码变而行为不变,作者主张原则上你应该能在不碰测试的情况下提交一次重构;需要大量改测试是测试规定过度的症状。同样的逻辑排除了对私有函数的测试,它们被描述为公共行为的较不稳定的支撑,并且这暗示一个测试里超过三个假对象,是该重新思考设计的信号。
“人们在这里看到的是 TDD 的弱点,但它其实是一个优势。”
“What people see here is a weakness in TDD but it is actually a strength.”
Mocking › But isn't mocking evil?查看原文 →
你可以手写自己的测试替身;框架是可选的,不是前提。
整章 mock 的不过是带计数器或切片的结构体,作者借此说明 mock 不需要魔法,同时也练习了实现接口。生成的替身在团队里仍被承认有价值,那里由工具来强制一致性——但只限于针对接口生成、且不规定测试该怎么写的工具。
“人们在这里看到的是 TDD 的弱点,但它其实是一个优势。”
“What people see here is a weakness in TDD but it is actually a strength.”
Mocking › But isn't mocking evil?查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
把依赖藏在方法集后面
停顿被重新命名为一份单方法契约,倒数函数只管调用它。生产环境传入一个真正等待的睡眠器;测试传入一个只做记录的假货,所以同一份代码运行时没有真实时间的延迟,而停顿本身也成了测试可以断言的东西。
机制 2
一个记录器,两个协作者
由于单个假对象同时实现了 writer 和 sleeper,每一次打印和每一次停顿,都按发生的顺序落进同一份共享列表。把这份列表与期望序列比较,就能抓到一个光秃秃的睡眠调用计数器看不见的穿插错误。
机制 3
mock 数量作为设计读数
你必须伪造的每一样东西,都对应单元所伸手去够的一个依赖,所以搭建测试的工夫随单元的耦合程度而增长。当假对象堆积或变得盘根错节时,这种困难就是在度量设计:职责太多、依赖切得太细、或断言瞄准了内部。
常见误读
mock 是不良实践,所以有纪律的开发者应该对着真东西测试。
作者的立场是,mock 是一件可能被误用的中性工具,而跳过它会让重要路径得不到测试——你没法验证两次打印之间的停顿,也不容易测试一个失败的服务或某个特定的系统状态。没有替身,你最后只能为检查简单规则而启动数据库和第三方服务,造出又慢又不稳的测试。
“人们在这里看到的是 TDD 的弱点,但它其实是一个优势。”
“What people see here is a weakness in TDD but it is actually a strength.”
Mocking › But isn't mocking evil?查看原文 →
既然间谍抓到了顺序缺陷,那么尽可能多地记录内部细节会让测试更强。
间谍被明确标为要小心使用的东西:你记录的每一个内部细节,都把测试绑到当前的算法上,所以你应当只对自己真正在乎的细节做断言。过度测试代码怎么工作、而不是它做什么,正是让日后的重构变得昂贵的原因。
“间谍对象(spy)是一种能记录依赖如何被使用的 mock。”
“Spies are a kind of mock which can record how a dependency is used.”
Mocking › Write the test first查看原文 →
Go 基础的核心概念
- 间谍
- 一种记录依赖如何被使用的测试替身,比如调用次数或调用顺序。
- 适用处: 记录内部细节会把测试与实现耦合,所以只对你真正在乎的细节设置间谍。
- 测试替身
- 测试中用来代替生产对象的任何替身对象。
- 适用处: 包括桩、间谍和 mock;本章只演示了间谍。
- 重构
- 在行为保持不变的前提下改动代码,理想情况下不应要求改动任何测试。
- 适用处: 如果重构迫使你大量修改测试,说明这些测试绑在了实现细节上。
- 接口
- 一组具名的方法,使函数可以互换地接受真实或假的实现。
- 适用处: 只有当依赖能以行为来表达时才行得通;具体类型由调用者决定。
- 迭代器
- 形状经过设计、使 for-range 循环可以从中取值的函数,在 Go 1.23 中引入。
- 适用处: 需要符合某一种被接受的迭代器函数签名;这里纯粹用于表现力。
另外 0 个概念
Go 基础划线与批注AI 陪读
“我做了一个设计决定:我们的 Countdown 函数不负责睡多久。”
“I made a design decision that our Countdown function would not be responsible for how long the sleep is.”
查看原文 →值得停一停:这是本章最安静、也最有影响的选择。一个没有时长的 Sleep() 几乎算不上时间抽象——它是个“在此暂停”的标记。这正是接口保持可测试的原因。
“干脆把它弄坏!(不过先确保你已把改动提交到源码管理)”
“just break it! (make sure you have committed your changes to source control first though)”
查看原文 →手工做的变异测试。真正的一课不在这个把戏,而在认识论:一个通过的测试什么也证明不了,直到你亲眼看到它因正确的原因而失败。
“我们的 SpyCountdownOperations 同时实现了 io.Writer 和 Sleeper”
“Our SpyCountdownOperations implements both io.Writer and Sleeper”
查看原文 →巧妙——一个间谍把两个依赖折叠成一份有序的日志。也是测试不再测试行为、开始抄写算法的那一刻,而作者在二十段之后就对此提出了警告。
另外 6 条批注
“没有 mock,你代码里的重要部分将得不到测试。”
“Without mocking important areas of your code will be untested.”
查看原文 →诚实的辩护。但注意,本章从不考虑它刚刚弄得没必要的那个替代方案:注入一个时钟,断言经过的虚拟时间。调用顺序是一种答案,不是唯一的答案。
“你可能会想,为什么要这么麻烦,主程序根本没变”
“you might wonder why all the hassle as the main program didn't change at all”
查看原文 →作者难得把读者的无聊大声说出来。然后回报是……main 里的一行。这里的仪式与价值之比,是本章最弱的一段。
“func (s *SpyTime) SetDurationSlept(duration time.Duration”
查看原文 →一个起得像 setter 的间谍。这里命名把意图泄漏得很糟:它是记录,不是配置。小事,但正是这种模糊,让后来的读者不信任测试代码。
“设计良好的代码易于测试”
“well-designed code is easy to test”
查看原文 →说得好像能干净地反过来。其实不能——易于测试的代码也可能是纯粹为了满足 mock 而被切碎成接口的代码。本章自己的 Sleeper 就沾了点这个边。
“任何过度规定测试该怎么写、或用了大量“魔法”的工具,都可以滚到海里去。”
“Any tool that overly dictates how tests are written, or that use lots of 'magic', can get in the sea.”
查看原文 →声音终于放下了教学腔,你听到的是一位真正有主见的工程师。语气上的解脱——也比前面那段谨慎的段落更有说服力。
“我们可以通过对自定义的 countdownFrom 迭代器做 range,让这段代码看起来更有表现力”
“we can make this code look more expressive by range-ing over our custom countdownFrom iterator”
查看原文 →老实问:它更有表现力,还是只是更新?for i := 3; i > 0; i-- 不需要解释;带“yield 返回 bool”协议的 iter.Seq[int] 却需要一段文档。这个加餐小节悄悄拆了本书自己的极简主义的台。
读后感AI 陪读
教授间谍测试,同时警告它的耦合
本章最强之处是作为迭代纪律的示范——薄薄的垂直切片,把编译器错误当作一场设计对话,为验证测试而刻意搞破坏——最弱之处则是它无法真正调和自己的两半。它前半部分教你监视调用顺序,后半部分又告诉你间谍会把测试与实现耦合在一起,而且超过三个 mock 就是红旗。两者都对;本章从未承认它们在自己的例子里相互冲突。Sleeper 接口经得起审视,只因为作者让它与时长无关,这是一步聪明的棋,却几乎是顺带呈现的;而 ConfigurableSleeper 一节是 TDD 仪式跑在回报前面的最清楚的例子——作者自己都注意到了并这样说。Go 1.23 迭代器的尾声读起来像是附加上去的而非论证出来的:它拿一个不言自明的循环去换一个需要文档的协议,而这本书的全部方法就是最小的有用步骤。我会保留的:故意弄坏你的测试,并问自己你测的是代码做了什么、还是它怎么做的。我会反驳的:本章示范的正是它后来所告诫的那种重度依赖间谍的习惯,却把发现这一点留给读者。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:并发
go.mod 低于 1.22 时会发生什么,为什么把 url 传进去就能修复?
Go 基础是怎样展开的AI 辅助
本章的开头不是一张白纸,而是一次交接:一位同事的 CheckWebsites 函数,它遍历一个 URL 切片,往映射里填入每个 URL 的布尔状态,外加他们已经写好的、使用 mock 检查器的测试。作者们特意夸赞了现有的设计——依赖注入意味着函数无需真实的 HTTP 调用就能测试——让读者明白这段代码已经“能用”,也已经“对了”。唯一的抱怨是速度,而本章的第一步,就是把这个抱怨变成可度量的东西:一个基准测试,喂进一百个 URL 和一个刻意放慢的桩检查器,每次调用睡二十毫秒。用 go test -bench=. 运行,它报告每次操作大约 2.25 秒。问题现在有了一个数字。
从这里起,本章先展开概念,再写代码。并发被朴素地定义为“同时有不止一件事在进行”,用泡茶的类比来说明:你不会站着盯着水壶,而是趁它烧开的时候去拿牛奶和杯子。这就引出了阻塞调用与 goroutine 的区别:前者让你等待,后者由 go 关键字作为一个独立的进程启动——好比第二个读者在代码里往前走,而第一个继续沿着页面往下读。接着是幼稚的应用:把循环体包进一个匿名函数,在前面加上 go。本章还解释了为什么匿名函数适合这一用途,因为它们可以在声明处就被调用,并保留对其外围词法作用域的访问。
转折是一连串失败,每一个都被诊断,而不只是打补丁。先是测试返回一个空映射:CheckWebsites 在任何 goroutine 写入结果之前就结束并返回了。一段旁白提醒读者可能反而看到 panic,并把这种不可预测性重新框定为测试在此至关重要的原因。显而易见的修复——返回之前 time.Sleep(2 * time.Second)——运气好时能让测试通过,运气不好时,程序会以 fatal error: concurrent map writes 死掉。这被命名为数据竞争:两个或更多 goroutine 触碰同一块内存,其中至少一个在写,而 Go 的映射对并发写入并不安全。本章随后教授发现这类缺陷的工具 go test -race,并逐步走读竞争检测器的输出,展示警告如何标出同一地址上的两次冲突写入,以及肇事 goroutine 被创建的那几行。反复出现的建议是:输出看着吓人,但只要放慢速度就读得懂。
解决办法是通道。一个小小的、使用匿名字段的 result 结构体把一个 URL 与它的布尔值配成一对,每个 goroutine 把一个这样的结构体发到 resultChannel 上,而不是直接写映射;第二个循环,每个 URL 跑一次,从通道接收并依次执行映射写入。<- 运算符在两个方向上都得到解释——通道在左是发送,通道在右是接收。由此得出的教训是一种分工:慢的部分并发运行,不能安全同时发生的部分保持线性,而通道承载两者之间的通信。关于循环变量捕获的一则说明澄清:自 Go 1.22 起,循环变量每次迭代都是新的,所以捕获 url 是安全的,但 go.mod 里较旧的 go 指令无论工具链版本如何,都保留旧的共享变量语义,此时你要把 url 作为参数传进 goroutine。基准测试现在报告约 0.023 秒——大约一百倍的提升。
收尾部分把整个练习框定为一次漫长的重构:输入和输出从未改变,所以现有的测试与新的基准测试合起来,既让人确信代码仍然能用,又证明了它变快了。要点被列为 goroutine、匿名函数、通道和竞争检测器。最后一段尾声援引了口号“先让它工作,再让它正确,最后让它快”——注明常被误认为出自肯特·贝克——并强调本章之所以能跳过前两步,只因为继承来的代码已经满足它们。本章以高德纳那句“过早优化是万恶之源”收束,它追溯性地为整个结构提供了正当性:先度量,最后才优化。
关键论点,以及支撑它们的原文
速度来自重叠等待,而不是做更少的工作。
作者把它比作泡茶:你灌好水壶,趁水烧开去拿牛奶、杯子和茶包,而不是盯着水壶、等它烧开之后才开始。用到检查器上,计算机在上一个请求还没返回时就发出下一个请求;对着一个每个 url 睡 20ms 的桩,100 个 url 串行用了约 2.25 秒,重叠之后约 0.023 秒。
“通过把结果发送到通道里,我们可以控制每次写入 results 映射的时机,确保它一次发生一个。”
“By sending the results into a channel, we can control the timing of each write into the results map, ensuring that it happens one at a time.”
Concurrency › Channels查看原文 →
单单启动 goroutine 就会弄坏一个本来能用的函数,因为没人等待它们。
仅把循环体包进 goroutine 之后,同事现有的测试以一个空映射失败——这证明结果在返回之前从未被记录。作者指出结果甚至并不稳定:有些运行这样失败,另一些则 panic。
“for 循环启动的 goroutine 都来不及把结果加到 results 映射里;CheckWebsites 函数对它们来说太快了,它返回的仍是空映射。”
“None of the goroutines that our for loop started had enough time to add their result to the results map; the CheckWebsites function is too fast for them, and it returns the still empty map.”
Concurrency › ... and we're back.查看原文 →
用 sleep 等待不是修复;它只是把一种不可靠的行为换成另一种。
这被安排成空映射失败之后显而易见的下一步,然后被推翻:睡两秒时,测试有时通过,有时因一个关于并发映射写入的致命错误而死,而且与基准测试一起运行时崩溃出现得更频繁。sleep 让 goroutine 得以结束,却对它们中的两个在同一时刻写同一个映射毫无办法。
“Go 的映射对并发写入不安全,所以运行时会抛出致命错误,以防内存损坏。”
“Go maps are not safe for concurrent writes, so the runtime throws a fatal error to prevent memory corruption.”
Concurrency › ... and we're back.查看原文 →
另外 2 个论点
并发缺陷是可以调试的,而不是神秘莫测——只要你用上工具,读它打印的东西。
因为失败取决于时序,作者通过 go test -race 请出了内置的竞争检测器,它的报告点出共享的内存地址、两次冲突的写入,以及写入发生处和 goroutine 启动处的确切源码行。作者的要点是,输出看着吓人,但包含了所需的一切;靠耐心而不是猜测来解决它。
“Go 的映射对并发写入不安全,所以运行时会抛出致命错误,以防内存损坏。”
“Go maps are not safe for concurrent writes, so the runtime throws a fatal error to prevent memory corruption.”
Concurrency › ... and we're back.查看原文 →
让它快放在最后,在代码能工作并已整理之后。
整章被框定为一次漫长的重构:输入和输出从未改变,所以同事先前存在的测试可以证明行为得以保留,而新的基准测试证明了收益。没有这张安全网,重写就会是一场盲赌——这就是作者把敏捷口号与高德纳关于过早优化的警告配成一对的原因。
“在另外两步完成之前,我们绝不应尝试“让它快”,因为”
“We should never try to 'make it fast' before the other two steps have been performed because”
Concurrency › Make it fast查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么 goroutine 版本什么也不返回
在循环体前放一个 go,意味着调用者从不等待那份工作。函数在启动 goroutine 所需的时间内就跑完循环并返回映射,所以写入(如果有的话)落在调用者已经交回一个空映射之后。
机制 2
为什么 sleep 只是掩盖了缺陷
sleep 买来了时间,却不强加任何顺序:每个 goroutine 仍然在碰巧完成的任何时刻,直接写入同一个共享映射。Go 的映射对同时写入并不安全,所以两次写入相撞时,运行时会中止程序,而不是悄悄损坏内存。
机制 3
通道如何把写入串行化
每个 goroutine 不再碰映射,而是向通道发送一个 url/结果对。父函数随后对每个 url 接收一次,所以缓慢的检查仍然并行重叠,而唯一不能同时做的事——更新映射——在单个进程里一次一个值地发生。
常见误读
我运行时并发版本通过了,所以其中没有竞争。
竞争取决于时序,所以一次绿灯只证明那一次碰撞没有发生——基于 sleep 的版本在有些运行里通过,在另一些里中止。信心必须来自消除共享写入(借助通道)和用竞争检测器检查,而不是来自一次走运的运行。
“这是一个数据竞争,一种当两个或更多 goroutine 并发访问同一内存位置、且其中至少一个访问是写入时出现的缺陷。”
“This is a data race, a bug that occurs when two or more goroutines access the same memory location concurrently, and at least one of those accesses is a write.”
Concurrency › ... and we're back.查看原文 →
现在在 goroutine 里捕获循环变量没问题了,所以永远不需要把 url 显式传进去。
这种安全取决于 go.mod 里声明的 go 版本,而不是你的工具链有多新:从 1.22 起每次迭代都有一个新的 url,但声明的版本较旧时,所有迭代共享一个变量,goroutine 很可能全都看到它的最终值。在旧的 go.mod 上,把 url 作为参数传进匿名函数。
“其次,它们保有对定义它们的词法作用域的访问——你声明匿名函数时,所有可用的变量,在函数体里也可用。”
“Secondly they maintain access to the lexical scope in which they are defined - all the variables that are available at the point when you declare the anonymous function are also available in the body of the function.”
Concurrency › Write enough code to make it pass查看原文 →
Go 基础的核心概念
- goroutine
- 在函数调用前加上
go启动的一个独立进程,使调用者不必阻塞等待它。 - 适用处: 启动一个并不保证它在调用者返回之前结束。
- 匿名函数
- 一个没有名字的函数字面量,可以在声明处就运行,并保留对外围变量的访问。
- 适用处: 它对循环变量的捕获取决于 go.mod 中 go 指令的版本。
- 通道
- Go 的一种数据结构,发送和接收值,让进程之间得以通信并轮流行事。
- 适用处: 这里用它把映射写入串行化;接收循环必须对每个预期的值各运行一次。
- 数据竞争
- 两个或更多 goroutine 同时触碰同一块内存,其中至少一个在写。
- 适用处: 取决于时序,所以一次通过的运行并不能证明它不存在。
- 竞争检测器
- 一个内置工具,用 -race 标志启用,报告并发的冲突访问及相关代码行。
- 适用处: 只报告运行期间观察到的竞争,而不是所有可能的竞争。
- 基准测试
- 一种计时测试,这里用来在改动前后度量该函数。
- 适用处: 用的是一个刻意放慢的桩,所以度量的是协调,而不是真实的网络行为。
另外 0 个概念
Go 基础划线与批注AI 陪读
“使用依赖注入使他们得以在不发起真实 HTTP 调用的情况下测试这个函数”
“Using dependency injection has allowed them to test the function without making real HTTP calls”
查看原文 →注意这个铺垫是对先前良好设计的回报:同事的 DI 正是让基准测试诚实的东西。优化之所以便宜,只是因为可测试性早先已经付过账。
“我把水壶放上,然后在等它烧开的时候”
“I put the kettle on and then, while I was waiting for it to boil”
查看原文 →泡茶的类比讲清了并发是重叠,却悄悄跳过了难的部分——两只手伸向同一个杯子。数据竞争恰恰是这个比喻展示不了的。
“如果你没碰上这个也别担心,继续运行测试,直到得到上面的结果为止。”
“Don't worry if you got that, just keep running the test until you do get the result above.”
查看原文 →在一本 TDD 的书里,这是一句惊人的指示:一直运行到你得到想要的失败。它对不确定性很诚实,但也教人明白:一个通过的并发测试几乎什么也证明不了。
另外 6 条批注
“两秒应该够了吧?”
“Two seconds ought to do it, right?”
查看原文 →这个修辞上的眨眼是本章最好的教学——错误的修复先被展示为管用,之后才被展示为失效,这恰恰是基于 sleep 的“修复”在真实代码库里活下来的方式。
“我们只需要深吸一口气,读一读堆栈跟踪”
“all we need to do is take a breath and read the stacktrace”
查看原文 →被低估的一课,比通道的技巧更有价值:终端早已把一切都告诉了你;被教的技能是面对满屏文字的输出不要慌。
“自 Go 1.22 起,这是安全的:语言规范已被修改”
“Since Go 1.22, this is safe: the language spec was changed”
查看原文 →对一个著名陷阱打的补丁,而关于新工具链配旧 go.mod 的警告才是真正的陷阱——语义跟随声明的版本,而不是你安装的编译器。
“同时确保那个不能同时发生的部分仍然线性地发生”
“while making sure that the part that cannot happen simultaneously still happens linearly”
查看原文 →这才是真正的论点,埋在一段中间。并发设计大半是在决定哪一片必须保持串行——通道不过是漏斗。
“大约是原函数的一百倍快”
“about one hundred times as fast as original function”
查看原文 →干净得可疑,因为那个桩只睡觉,别的什么也不做。真实的 HTTP 有连接数限制和 DNS;100 个 goroutine 对 100 个真实主机,不会这样线性扩展。
“过早优化是万恶之源 -- 高德纳”
“Premature optimization is the root of all evil -- Donald Knuth”
查看原文 →作为收尾引用,紧跟在一整章全是优化的内容之后——而高德纳那句话,完整地读,其实论证的恰恰是它通常被使用方式的反面。
读后感AI 陪读
讲提速的故事,教的却是如何安全地失败
一章看似温和:读起来像一个提速的故事,其实是一堂关于如何安全地失败的课。它最强的一招是善意的教学上的不诚实——先展示幼稚的 goroutine 循环,再展示 sleep 的权宜之计,让每一种都先过一次再坏掉,于是读者感受到正确版本为何存在,而不是被告知。最弱的缝隙在于,基准测试里一百倍的提速是一个会睡眠的桩造成的假象;本章从不指出真实网络自有它的串行化,也没有提到 sync.WaitGroup、带缓冲的通道、或对 goroutine 数量的任何限制——对 100 个 URL 无妨,对 100,000 个则是隐患。Go 1.22 循环变量那段说明是实践中最危险的一段,却交付得最安静:一个正确性性质取决于 go.mod 里的一行,而不是你安装的编译器。而高德纳的收尾放在一章致力于让某物变快的内容上显得古怪——它被援引为许可,而不是被审视。留在我心里的是“不能同时发生的部分仍然线性地发生”:整个纪律被压缩进作者懒得强调的一个分句里。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:Select
select 返回之后,落败的 goroutine 会怎样?
Go 基础是怎样展开的AI 辅助
开篇:一份具体的需求,刻意解得很糟。 本章从一份规格说明而不是一个概念开始——写一个函数,接收两个 URL,对两者都发 HTTP GET,返回先响应的那个,如果十秒内两者都没回应就报错。它预先列出工具(net/http、net/http/httptest、goroutine、select),然后立刻写了一个作者公开称之为不完美的测试:它让两个真实的、在线的网站赛跑,并断言更快的那个获胜。这个幼稚的测试驱动了通常的 TDD 循环——一个返回空字符串的桩 Racer,一个失败的断言,然后是做了显而易见的顺序之事的实现。它用 time.Now()/time.Since 给一个请求计时,再给另一个计时,并比较这两个时长。教学要点是:从粗糙的东西开始没关系;代码的缺陷成了本章的议程。
初步展开:问题在测试,不在代码。 本章停在一个担忧上——测试可能通过也可能不通过,因为它依赖真实的互联网。呼应前面的 mock 与依赖注入章节,它点出了针对外部服务做测试的三宗罪:慢、不稳定、以及无法演练边缘情形。修复是 httptest.NewServer,它在一个空闲端口上启动一个真正的 HTTP 服务器,背后是一个匿名的 http.HandlerFunc。一个假服务器在回复前短暂睡一会儿;另一个立刻回复,于是“快”和“慢”成了测试所掌控的性质,而不是关于 Facebook 基础设施的事实。随后一步重构清理两边:生产代码多了一个 measureResponseTime 辅助函数,去掉重复的计时块;测试多了一个 makeDelayedServer(delay) 工厂。这里也是引入 defer 的地方——一种把服务器清理安排在函数退出时执行的办法,同时把这条指令写在与之对应的创建语句旁边,方便后来的读者。
转折:不再计时,开始赛跑。 手里有了整洁但仍是顺序的解法,本章问出它一直在铺垫的问题——既然 Go 是为并发而生,为什么要一个接一个地度量两个站点?既然我们唯一需要的事实是哪一个先回来,又为什么要度量精确的时长?这一重新框定产生了本章的核心。一个 ping 函数启动一个 goroutine 去执行 GET,然后关闭一个通道,而 Racer 把两次 ping 调用包进一个 select,返回其通道先触发的那个 URL。几个小教训挂在这上面:使用 chan struct{},是因为信号不带负载,而 struct{} 不分配任何东西;关闭通道本身就是一个完全可以的“完成”信号;通道必须用 make 创建,因为用 var 声明的通道是 nil,向它发送会永远阻塞;而 select 把并发章节里的阻塞接收推广到同时作用于多个通道。其结果既比它取代的版本更短,也更诚实地表达了意图。
第二段展开:超时,以及超时的代价。 直到现在,本章才回到被推迟的需求。第二个子测试期望在两个服务器都超过十秒时返回错误,这迫使 Racer 长出第二个返回值;最小的版本能编译,却要在十一个真实的秒之后才失败。修复是由 time.After 提供的第三个 select case,被呈现为防止代码否则可能永远阻塞的标准守卫。但通过测试暴露了一个新缺陷——测试套件现在要烧十秒来验证几行逻辑。把超时做成参数,会破坏顺利路径测试的调用签名,本章把这种摩擦当作反馈(“听一听它们”),而不是要糊弄过去的烦恼:顺利路径测试不关心超时,所以它不该被要求提到超时。解决办法是把 API 拆成 Racer——写死需求所规定的十秒默认值——和 ConfigurableRacer——接收一个超时。真实用户和顺利路径测试调用前者;失败路径测试用毫秒量级的值调用后者,并且为求完整,在顺利路径里加了一个 t.Fatalf 的错误检查。
收束。 总结很简短,对应的是本章真正赢得而不只是描述的两件工具:用于等待多个通道的 select,通常带一个 time.After 的 case,使任何东西都不会无限挂起;以及用于可靠、可控的测试服务器的 httptest,它复用你已经熟悉的同样的 net/http 接口。简言之,这条弧线从一个针对真实互联网测试的、缓慢的顺序度量,走到一个针对假货在毫秒内完成测试的并发赛跑——每一次重构,都由上一步的某个具体不适所促成。
关键论点,以及支撑它们的原文
与 HTTP 打交道的逻辑,应当针对你掌控的服务器来测试,而不是针对真实网站。
作者的第一版请求两个真实站点,他指出它可能通过也可能不通过,因为缓慢、不稳定的外部服务还使边缘情形无法测试。用两个本地测试服务器取而代之——一个睡 20ms,一个立刻应答——预先固定了赢家,作者还邀请读者修改 sleep 来故意弄坏测试,表明结果如今由他们掌控。
“问题在于,我们为了测试自己的逻辑,去访问了真实的网站。”
“The problem is we're reaching out to real websites to test our own logic.”
Select › Problems查看原文 →
测试服务器是真实的资源,所以清理应当就放在创建它们的地方。
承接换用假服务器这一步,作者指出每个服务器会一直监听一个端口,直到被关闭,于是在关闭调用前加上 defer,让它们在测试函数结束时运行,同时仍然紧挨着准备代码——对后来的读者可读,也不必在每个出口手动记账而保持可靠。
“问题在于,我们为了测试自己的逻辑,去访问了真实的网站。”
“The problem is we're reaching out to real websites to test our own logic.”
Select › Problems查看原文 →
并发地发出两个请求并选取第一个答复,不仅更快,而且代码比一个接一个地给它们计时更简单。
顺序版本记录起始时间,执行每个 GET,测量经过的时长并比较——这些重复作者不得不提取到一个辅助函数里。并发版本则给每个 URL 一个在其请求结束时关闭的通道,让 select 返回先触发的那个;没有时钟,没有减法,意图直接读得出来。
“经过这些改动,我们代码背后的意图非常清楚,而实现实际上更简单。”
“After these changes, the intent behind our code is very clear and the implementation is actually simpler.”
Select › select查看原文 →
另外 1 个论点
一个要花十秒的测试是设计反馈,而不只是烦恼。
作者没有接受这段等待,而是把超时做成参数——但随后再次倾听测试,注意到顺利路径无权去指定超时。结果是一个朴素的 Racer,为调用者提供十秒的默认值,外加一个 ConfigurableRacer,慢路径测试以二十毫秒调用它,于是用户和测试都得到了各自真正需要的接口。
“我们能做的是让超时可配置。这样在我们的测试里,可以用一个很短的超时,而当代码在真实世界中使用时,可以设成10秒。”
“What we can do is make the timeout configurable. So in our test, we can have a very short timeout and then when the code is used in the real world it can be set to 10 seconds.”
Select › Slow tests查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
关闭一个空通道作为完成信号
每次 ping 调用启动一个 goroutine,执行 GET,然后关闭一个不携带数据的通道。由于对已关闭通道的接收会立即返回,关闭本身就是消息,而空结构体的通道无需分配,因为从不通过它发送任何东西。
机制 2
为什么超时分支能终止无休止的等待
select 语句会阻塞,直到其中一个通道就绪,所以如果两个请求都不完成,函数就会永远挂起。加上一个从定时器通道读取的 case,保证最终总有某个分支触发,把无限期的等待变成一个被返回的错误。
机制 3
假服务器如何使赛跑可确定
测试服务器是绑定在空闲端口上的真正 HTTP 服务器,但它的处理器是你写的代码,所以在某个处理器里插入一次 sleep,就固定了哪一方更慢。结果于是只取决于赛跑逻辑,而不取决于网络状况,这就是测试既可靠地通过、又很快完成的原因。
常见误读
用 var 声明一个通道就足以开始使用它;make 只是一种风格习惯。
通道的零值是 nil,向 nil 通道发送会永远阻塞,所以作者强调通道必须先用 make 创建再使用——不像字符串或 int,它们的零值是可用的。
“为什么用 struct{} 而不是像 bool 这样的别的类型?嗯,从内存角度看,chan struct{} 是可用的最小数据类型,所以相对于 bool,我们不会有分配。”
“Why struct{} and not another type like a bool? Well, a chan struct{} is the smallest data type available from a memory perspective so we get no allocation versus a bool.”
Select › ping查看原文 →
完成后的 Racer 是靠度量每个站点花多长时间并比较这两个时间来工作的。
那只是最初被丢弃的实现。最终版本从不给任何东西计时:它并发地发出两个请求,只对先发出信号的那个通道做出反应,因为需求是知道赢家,而不是响应时间。
“经过这些改动,我们代码背后的意图非常清楚,而实现实际上更简单。”
“After these changes, the intent behind our code is very clear and the implementation is actually simpler.”
Select › select查看原文 →
Go 基础的核心概念
- select
- 一条 Go 语句,等待多个通道,并运行最先就绪的那个通道对应的分支。
- 适用处: 只有在要等待多个通道时才有用;除非加上 time.After 的 case,否则某个 case 可能永远阻塞。
- httptest
- 标准库中的包,为测试启动一个真实但可控的 HTTP 服务器。
- 适用处: 在测试中取代外部服务;它仍是真实的服务器,所以必须关闭以释放端口。
- defer
- 在调用前加 defer,会在所在函数结束时运行它,使清理紧挨着准备。
- 适用处: 在函数退出时运行,而不是代码块退出时;用于关闭服务器或文件之类的资源清理。
- time.After
- 返回一个通道,在给定时长之后收到一个信号,可在 select 中用作超时的 case。
- 适用处: 防止永远阻塞;时长应当可注入,这样测试不必等待生产环境的超时。
- chan struct{}
- 一个不携带数据的信号通道;关闭它就是消息。
- 适用处: 只关心完成、不关心值时合适;通道必须用 make 创建,否则发送会永远阻塞。
另外 0 个概念
Go 基础划线与批注AI 陪读
“我们知道这并不完美,也有问题,但这是个开始。”
“We know this isn't perfect and has problems, but it's a start.”
查看原文 →本章真正的一课被早早夹带进来:获准写一个糟糕的测试。多数 Go 教程会把这份尴尬的初稿藏起来。
“问题在于,我们为了测试自己的逻辑,去访问了真实的网站。”
“The problem is we're reaching out to real websites to test our own logic.”
查看原文 →注意诊断的是归属权,而不是速度——测试越过了它无权越过的边界。不稳定只是症状。
“从内存角度看,chan struct{} 是可用的最小数据类型”
“a chan struct{} is the smallest data type available from a memory perspective”
查看原文 →披着惯用法外衣的微优化。诚实的理由是语义上的:struct{} 说的是“没有负载”,这里恰恰如此。零字节是奖励,不是论据。
另外 4 条批注
“对通道来说,零值是 nil,如果你试图用 <- 向它发送,它会永远阻塞”
“For channels the zero value is nil and if you try and send to it with <- it will block forever”
查看原文 →值得强调:select 的某个 case 里的 nil 通道永远不会触发,这是禁用分支的真正惯用法。陷阱和工具是同一个东西。
“第一个发送值的“获胜”,case 下面的代码被执行。”
“The first one to send a value "wins" and the code underneath the case is executed.”
查看原文 →悄悄地不完整——如果两者都就绪,select 是伪随机挑选。racer 能在此幸存,只因为我们不在乎并列的两个赢家里得到哪一个。
“在急着把这个默认值加进我们的两个测试之前,让我们听一听它们。”
“Before rushing in to add this default value to both our tests let's listen to them.”
查看原文 →本章最好的一句话。把编译失败重新框定为关于 API 设计的反馈,而不是一件要打补丁的杂务。
“case <-time.After(timeout):”
查看原文 →落败的 goroutine 从未被取消——它的 http.Get 仍在运行,ping 的通道被遗弃。这里无妨,但在真实代码里,这恰恰是 context.Context 登场的地方。
读后感AI 陪读
把失败直白地摆出来,比直接给出答案教得更好
一章几乎是顺带地教了 select;它真正的主题是让测试对设计施压。这条弧线——真实网站,然后 httptest,然后顺序度量,然后并发 select,然后可配置的超时——是一连串倾听的行为,每次重构都由测试里的一处不适触发,而不是由功能需求触发。它拒绝说的两件事:select 在多个同时就绪的 case 之间的选择是伪随机的,而超时情形里落败的 goroutine 只是被抛弃,留下一个在途的 HTTP 请求和一个被遗弃的通道。两者在一个 racer 里都无害,也都恰好是读者下一章必须去的地方(context、取消、errgroup)。我还要反驳 struct{} 的理由:内存的论据是真的,却微不足道,而语义的论据——一个不带负载的值的已关闭通道就是纯粹的信号——才是该说服人的那个。留在我心里的是作者愿意展示幼稚的版本再把它拆解;measureResponseTime 的重构是刻意走进死胡同,而直白地说出来,比直接抵达 select 更有教益。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:反射
什么时候基于方法的接口在这里真正比反射更可取?
Go 基础是怎样展开的AI 辅助
开篇:框定问题与警告。 本章从社交媒体上发布的一个挑战出发——写 walk(x interface{}, fn func(string)),对任意结构体内部的每个字符串字段递归地调用 fn——并立刻说明这项任务的工具是反射,即程序检视自身类型的能力。在碰代码之前,作者先花一节讲 interface{}(any),解释接受它要付出什么代价:调用者失去类型安全和文档,函数作者则背上在运行时弄清到底传来了什么的负担。结论被预先摆出:反射笨拙、更难读、也更慢,所以先去用普通接口和多态,只有在确实必须时才用反射。这个框定很重要,因为本章随后演示这项技术,同时劝人对它保持克制。
展开:穿过类型系统的、测试驱动的棘轮。 每一步的构建都刻意很小。第一个测试:一个只有一个 Name 字段的匿名结构体,和一个往 got 切片追加的闭包间谍,只断言调用次数——靠一个刻意荒唐的步骤满足,即用一段关于足球比赛结果的写死字符串调用 fn。把断言收紧到实际值,迫使出现第一段真正的反射:reflect.ValueOf、Field(0)、String()——作者公开称这段代码不安全且天真,理由是在“红”阶段你写尽可能少的代码,然后加测试来表达你的担忧。测试随后被重构为表驱动测试,成为本章其余部分的引擎:两个字符串字段推出 NumField 和一个循环;一个非字符串字段(Age int,会把 <int Value> 漏进输出)推出 Kind() == reflect.String 的检查;嵌套结构体推出对 field.Interface() 的递归调用,成对的 if 塌缩成一个 switch。指针输入在 NumField 上 panic,这引入了 Elem(),被提取到一个小小的 getValue 辅助函数里——代码更多了,但抽象层次正确:给我一个 Value,然后遍历并按种类行动。沿途,本章还整理了测试本身,用具名的 Person 和 Profile 类型取代嵌套的匿名结构体字面量,以求可读。
转折:一次有意为之地出错的重构。 切片带来同样的 panic,先用一个作者称之为“恶心”的提前返回特例打补丁,随后重新组织,使 switch 按值本身的种类分派——结构体、切片、字符串。注意到结构体与切片的迭代在概念上相同,作者更进一步,提取出 numberOfValues 加一个从 val.Field 或 val.Index 赋值的 getField func(int) reflect.Value。数组随后白送,用逗号并进切片的 case——看似得到了证明。映射把它打破了:按设计你不能按位置索引映射,只能按键,所以这个优雅的抽象不得不别扭地与一个对 MapKeys 做特例处理的循环并排放着。本章把这当作教训而不是尴尬——重构是一段旅程,错误会发生,而测试让撤退变便宜——并回退到先前的形状,加上一个小小的 walkValue 闭包,去掉每个分支里重复的 walk(value.Interface(), fn) 调用。
收束:测试真正可知的东西,再加两种种类。 映射还暴露了一个测试问题:Go 不保证迭代顺序,所以映射的情形不能放进对顺序敏感的表里,转而放到它自己的子测试,使用 assertContains 辅助函数。作者随后把关于弱断言的观点说得更尖锐——单靠 assertContains,如果 walk 访问了某个条目两次或漏掉一个,仍会通过,所以既然长度无论顺序如何都是可预测的,就并排加一个 assertLength 检查。读者还被推着去故意弄坏测试,看看它的失败信息,使 TDD 的纪律即便在非表驱动测试里也保持完整。接着是通道,用 Recv() 抽干直到关闭(if/else 循环后来收紧成单个 for,接收同时出现在初始化和后置语句里),最后是函数:只限零参数,通过 Call(nil) 调用,并对每个返回值应用 walk。总结列出所涵盖的内容——reflect 包的一小片,对任意结构的递归,以及一次不动声色地熬过去的糟糕重构——并指向 Go 博客以了解更多,最后以与开头相同的调子结束:既然你已经知道了反射,就尽力避免它。一则收尾附录点出一项留作练习的真实局限——带环的指针结构会栈溢出——并勾勒修复办法:通过 reflect.Value.Pointer() 在 map[uintptr]bool 里跟踪已访问的地址,穿过一个内部辅助函数,由公开的 walk 包装它。
关键论点,以及支撑它们的原文
使用 interface{} 以放弃类型安全换来灵活性,对调用者和对函数作者都一样。
作者的例子:如果你本想传入一群动物的物种(字符串),却不小心传了它的数量(int),编译期没有任何抱怨。调用者还失去了精确参数类型的文档价值,而作者则要在运行时弄清传来的是什么。
“作为一个接受 interface{} 的函数的使用者,你失去了类型安全。”
“As a user of a function that takes interface{} you lose type safety.”
Reflection › So why not use interface{} for everything and have really flexible functions?查看原文 →
先判定一个值是什么、再决定怎么处理它,才让遍历易于扩展。
这是先前那些失败的后续:指针,然后是切片,都在 NumField 上崩溃,迫使作者用 Elem() 解包,然后按种类分支,而不是假定它是结构体。switch 一旦存在,数组就不花任何代价——只需把 reflect.Array 与 reflect.Slice 并排加进同一个分支。
“让我们改写代码,改为先检查类型,再做我们的工作。”
“Let's rework the code so instead we check the type first and then do our work.”
Reflection › Refactor查看原文 →
测试让一个错误的抽象很容易撤回,所以值得一试。
作者把循环提取成一对共享的“计数加取值函数”,然后撞上映射,它的值根本无法按下标获取。因为测试套件已经是绿的,这个抽象就被直接回退到先前的 switch 加一个小小的 walkValue 辅助函数,而本章以事后看来那次重构很糟收尾,却不把它当作挫折。
“重构是一段旅程,有时我们会犯错。TDD 的一个主要意义,就是给了我们自由去尝试这些事。”
“Refactoring is a journey and sometimes we will make mistakes. A major point of TDD is it gives us the freedom to try these things out.”
Reflection › Refactor查看原文 →
另外 2 个论点
学了反射之后,正确的结论是敬而远之。
本章一路展示代价来赢得这一点——失去编译期保证,运行时检查,难以阅读的代码——并承认自己只展示了 reflect 包的一个小角落,指引读者去 Go 博客了解其余,而不是鼓励更广泛地使用。
“既然你已经知道了反射,就尽力避免使用它。”
“Now that you know about reflection, do your best to avoid using it.”
Reflection › Wrapping up查看原文 →
完成的 walk 仍然不完整:遇到指回自身的数据就会死。
一个 Person,其 Friend 字段被设为它自己的地址,会让函数无限递归,以致命的栈溢出收场。作者勾勒了标准的补救办法——一个已访问指针地址的映射,由公开的 walk 建立的内部辅助函数携带——却把它留作练习,而没有实现。
“因为这个映射需要在整个遍历期间存在,你会想要一个把它作为参数携带的内部辅助函数,由公开的 walk 创建映射并调用进去。”
“Because the map needs to live across the whole traversal you'll want an internal helper that carries it as a parameter, with the public walk creating the map and calling in.”
Reflection › Known limitation: circular references查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
接受任何类型会把检查推到运行时
声明为 interface{} 的参数接受每一个值,所以编译器再也无法验证调用者传了什么、函数体能拿它做什么。作者因此必须在程序运行时检视值的类型,这就是这类函数最终笨拙、更难读、更慢的原因。
机制 2
先按种类分支,再干活
每个 reflect.Value 方法只对特定种类有效——NumField 在指针或切片上会 panic,映射只能按键读取。先读 val.Kind() 并对它做 switch,就把每种类型的访问模式放进各自的分支,所以支持新种类意味着添加一个分支,而不是改造整个遍历。
机制 3
为什么指针环永不终止
递归遍历顺着指针字段回到一个它已进入过的值,于是同样的字段被反复访问,直到栈耗尽。把每个指针的数值地址记在映射里,并跳过已见过的地址,就能切断循环,但这个映射必须穿过每一次递归调用。
常见误读
interface{} 就是你在 Go 里写多态函数的方式,所以每当函数应当接受几种类型,就用它。
当几种类型应当共用一个函数时,定义一个列出函数所需方法的真正接口;调用者通过实现这些方法来加入,并保住编译期检查。把 interface{} 和反射留给没有这样的方法集能表达需求的情形。
“作为一个接受 interface{} 的函数的使用者,你失去了类型安全。”
“As a user of a function that takes interface{} you lose type safety.”
Reflection › So why not use interface{} for everything and have really flexible functions?查看原文 →
既然 Go 不保证映射的顺序,映射的测试就只能检查某些值在某处出现过。
迭代唯一变得不可预测的只有顺序;无论什么顺序,收集到的值的数量是固定的。所以作者保留对每个值的存在性检查,并加上长度断言,因为光靠存在性检查,在某个条目被访问两次或漏掉一个时仍会通过。
“如果 walk 访问了某个映射条目两次,或漏掉一个而只检查其余条目,单靠 assertContains 仍然会通过——只要我们搜索的那些特定值在某处出现,不管出现了多少次。”
“assertContains alone would still pass if walk visited a map entry twice, or missed one out and only checked the remaining entries - as long as the specific values we search for are present somewhere, however many times.”
Reflection › One final problem查看原文 →
Go 基础的核心概念
- 反射
- 程序在运行时检视自身结构、尤其是类型的能力。 通过 reflect 包在运行时检视值的类型和字段,有用但最好避免。
- 适用处: 笨拙、难读且更慢;只有普通接口无法表达需求时才用。 本章只涉及它的一小个方面;Go 官方博客讲得更深。
- 接口
- interface{}(别名 any)接受任何值,代价是放弃编译期的类型检查。
- 适用处: 要实现多态,应优先用真正基于方法的接口,而不是 interface{}。
- 表驱动测试
- 一个测试遍历一个具名用例的切片,所以添加新场景很便宜。
- 适用处: 顺序没有保证时不适用,比如映射,它需要一个单独的子测试。
- 循环引用
- 一个结构体持有指回自身的指针,于是递归遍历永远不会结束。
- 适用处: 只影响指针环;walk 会栈溢出,而不是优雅地报错。
- 已访问指针跟踪
- 保存一份已见过的指针地址的映射,使遍历跳过重复。
- 适用处: 需要一个携带该映射的内部辅助函数,由公开的 walk 创建它。
另外 0 个概念
Go 基础划线与批注AI 陪读
“简言之,只有在你真正需要时才使用反射。”
“In short only use reflection if you really need to.”
查看原文 →本章花了好几页教一件工具,同时反复恳求你别去用它。这种矛盾心态很诚实,而且在教程里比它该有的稀少得多。
“fn("I still can't believe South Korea beat Germany 2-0 to put them last in their group")”
查看原文 →笑话也是教训:一个刻意荒唐的假货,逼得下一个测试必须存在。如果你写的是看似合理的东西,你可能会相信自己已经做完了。
“我们看第一个也是唯一的字段。不过,可能根本没有字段,那会引发 panic。”
“We look at the first and only field. However, there may be no fields at all, which would cause a panic.”
查看原文 →注意他点名了自己明知要交付的缺陷,而不是把它们藏起来。红阶段的代码可以获得赦免,但前提是你把这份清单留着。
另外 6 条批注
“只是键在编译期是未知的”
“it's just the keys are unknown at compile time”
查看原文 →优雅的抽象就死在这一行。映射不能按下标寻址,而“numberOfValues + getField”这个把戏暗地里押的是一切都能。
“重构是一段旅程,有时我们会犯错。”
“Refactoring is a journey and sometimes we will make mistakes.”
查看原文 →本章最有价值的一段,而它说的是感受,不是代码。回退一次聪明的重构之所以便宜,只因为测试让它变便宜。
“如果 walk 访问了某个映射条目两次,单靠 assertContains 仍然会通过”
“assertContains alone would still pass if walk visited a map entry twice”
查看原文 →敏锐的区分:放弃顺序,并不意味着放弃基数。多数人一见映射就把两者都丢了。
“在这个场景里,非零参数的函数似乎没什么意义。”
“Non zero-argument functions do not seem to make a lot of sense in this scenario.”
查看原文 →披着推理外衣的手一挥。val.Call(nil) 在任何带参数的函数上都会 panic——真正的答案是“我们选择不处理”,而没有任何测试守住这个边缘。
“既然你已经知道了反射,就尽力避免使用它。”
“Now that you know about reflection, do your best to avoid using it.”
查看原文 →一句不错的结束语,但它没说出更深的一点:walk 是个单向的访问者,没有错误返回,没有深度限制,对未处理的种类悄悄跳过。反射代码烂在边缘,而不是中心。
“标准做法是跟踪哪些指针地址已经被访问过”
“The standard approach is to track which pointer addresses have already been visited”
查看原文 →诚实地标出这一点值得称许,但环导致的崩溃不是脚注——它是现实世界的数据递给你的第一件东西。还要注意,未导出的字段会在 .Interface() 上 panic,而本章对此只字未提。
读后感AI 陪读
被保留下来的失败,比任何打磨过的成功都教得更多
一章节奏得当的内容,它真正的主题根本不是反射,而是迭代设计的心理学:写下荒唐的假货,点名你已知的罪过,尝试聪明的抽象,在映射拒绝被索引时毫无羞愧地回退。那次失败的重构恰恰因为被保留下来、而不是从历史里编辑掉,才成为本章最好的教学时刻。它最弱的地方在边界——val.Call(nil) 在带参数的函数上会 panic,.Interface() 在未导出字段上会 panic,通道作为副作用被抽干,未处理的种类悄悄消失,环会把栈撑爆(被打发成一道练习)。这些不是吹毛求疵;它们恰恰是证明本章自己结尾那条“避免使用反射”的建议成立的运行时意外。作者通过展示现实一旦到来代码多快就不再漂亮,赢得了说“别用这个”的资格,但他恰好在展示现实充满敌意时它会败得多惨之前停了下来。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:Sync
你会怎样保护两个必须保持一致的字段?
Go 基础是怎样展开的AI 辅助
开篇:一个极其简单的计数器,以 TDD 的方式构建。 本章设定了一个刻意谦逊的目标——一个可安全并发使用的计数器——并预先宣布了自己的计划:先构建一个不安全的版本,证明它在单线程下可行,然后暴露它的不安全并修复。第一轮循环纯属例行,这正是要点。一个测试断言三次 Inc() 调用后值为 3;编译器的错误带着读者从一个未定义的类型,到一个空结构体,到桩方法,到一个真正的失败(got 0, want 3),最后到显而易见的修复:一个每次调用都自增的 int 字段。一次简短的重构把断言提到 assertCounter 辅助函数里,看起来像是整洁,其实是铺垫——这个辅助函数后来成了陷阱。
展开:失败的并发测试与竞争的解剖。 单线程版本转绿之后,所述的新需求到来,本章写出了弄坏它的测试:一千个 goroutine 各自调用 Inc(),由 sync.WaitGroup 协调(Add 设定计数,每个 goroutine Done,Wait 阻塞直到全部结束),使断言只在每一次自增都尝试过之后才发生。结果是计数偏少——文中的例子是 939,并附带提醒你的数字会不同。解释是本章概念上的核心:c.value++ 不是一个操作,而是读、增、写,运行时可以在其中任意两步之间切换 goroutine。一个走读的穿插示例显示两个 goroutine 都读到 0、都算出 1、都写入 1,于是一次自增消失了。修复是 sync.Mutex,在自增外面包上 Lock() 和延迟的 Unlock(),使每个 goroutine 排队等候。
转折:关于如何接入修复的两个警告。 解决了问题之后,本章立刻让两条诱人的捷径复杂起来。第一是把 sync.Mutex 嵌入结构体,这样就能直接写 c.Lock();文中承认这读起来很优雅,随后断然拒绝,因为嵌入会把被嵌入类型的方法提升进你的公共 API,让调用者可以锁住和解锁你的内部状态,并招来你从未想要的耦合。第二个更微妙:go vet 报告 assertCounter 复制了一个锁的值,因为 Mutex 在首次使用后不得被复制。补救是改为接收 *Counter,这又促成了一个 NewCounter() 构造函数,向用户表明这个类型应当如何创建。
一个更轻的替代,然后是总结。 收尾之前,本章为它一直在处理的狭窄情形提供了另一个答案:sync/atomic。因为计数器只是一个整数,一个带 Add(1) 和 Load() 的 atomic.Int64 字段彻底去掉了互斥锁和加锁/解锁的纪律,而同一个测试原封不动地通过——通常更快,也比较旧的、传指针的 atomic 函数更不容易误用。它划出的分界线很明确:一个值用原子操作,一旦你必须让多个字段或它们之间的不变量保持一致,就用互斥锁。总结随后回顾 Mutex、WaitGroup 和 sync/atomic,并扩展到锁与通道的问题,引用 Go wiki,并警告不要仅仅因为可以就过度使用通道和 goroutine——通道用于传递数据的所有权,互斥锁用于管理状态。它以本章中段所赢得的两个实用习惯作结:在你的构建脚本里保留 go vet,以及不要仅仅因为方便就嵌入一个类型。
关键论点,以及支撑它们的原文
一条看似不可分割的自增语句其实并不是,所以并发使用会悄悄丢失更新。
作者把自增分解成读、加、写,指出运行时可以在这些步骤之间穿插 goroutine,并走读两个 goroutine 各读到零、都写入一的情形——两次调用得到的总数是一。他随后用实验演示:启动一千个 goroutine,得到的计数像 939 而不是 1000,并附带提醒确切的缺口每次运行都不同。
“c.value++ 看起来像一个单一的、不可分割的操作,但其实不是。”
“c.value++ looks like a single, indivisible operation, but it isn't.”
Sync › Why does this happen?查看原文 →
用互斥锁守住自增,通过强迫 goroutine 轮流来修复问题。
先展示了失败之后,作者添加一个互斥锁字段,并用 Lock 和延迟的 Unlock 把自增括起来;先前失败的千个 goroutine 的测试现在通过了,因为每个 goroutine 必须先等锁,才能碰那个值。
“这意味着,任何调用 Inc 的 goroutine,只要排在最先,就会获得 Counter 上的锁。所有其他 goroutine 都必须等它被 Unlock 之后才能获得访问。”
“What this means is any goroutine calling Inc will acquire the lock on Counter if they are first. All the other goroutines will have to wait for it to be Unlocked before getting access.”
Sync › Write enough code to make it pass查看原文 →
把互斥锁嵌入结构体可能看起来更整洁,但那是错误的选择。
作者承认嵌入版本读起来更优雅,随后反对它:嵌入会把被嵌入类型的方法提升进你自己的公共接口,于是 Lock 和 Unlock 成了你 API 的一部分。任何公共的东西都招来调用者与之耦合,而调用者在错误的时刻释放你的锁,会造成难以预料、极难追踪的缺陷。
“暴露 Lock 和 Unlock,往好了说是令人困惑,往坏了说,如果你类型的调用者开始调用这些方法,就可能对你的软件非常有害。”
“Exposing Lock and Unlock is at best confusing but at worst potentially very harmful to your software if callers of your type start calling these methods.”
Sync › I've seen other examples where the sync.Mutex is embedded into the struct.查看原文 →
另外 2 个论点
即使锁已经就位,把计数器按值传递仍然不安全;要传指针,并通过构造函数来创建它。
go vet 报告断言辅助函数按值复制并传递了一个锁,而互斥锁的文档声明互斥锁一旦使用就不得复制——按值传递结构体会复制那把锁。作者把辅助函数改为接收指针,并添加一个返回指针的构造函数,这也向读者表明他们不应手工初始化这个类型。
“当我们把 Counter(按值)传给 assertCounter 时,它会试图创建互斥锁的一份拷贝。”
“When we pass our Counter (by value) to assertCounter it will try and create a copy of the mutex.”
Sync › Copying mutexes查看原文 →
按你所保护的数据的形状来挑选并发工具,而不是凭习惯。
作为对锁版本的反衬,作者用一个原子整数重写了计数器:锁消失了,同一个并发测试仍然通过,更新由底层的 CPU 指令变得不可分割,通常也更快。他明确划定了建议的边界——原子操作适合单个值,互斥锁用于多个字段或必须在它们之间保持成立的不变量,而通道用于移交数据的所有权。
“传递数据所有权时用通道”
“Use channels when passing ownership of data”
Sync › When to use locks over channels and goroutines?查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
一次自增是如何丢失的
对一个字段自增其实是三个独立的步骤:读当前值,在本地副本上加一,写回去。运行时可以在其中任意两步之间切换 goroutine,所以两个 goroutine 可能读到同一个起始数字,然后都写回同样的结果。两次调用,只有一次可见的增长——另一次被悄悄丢弃。
机制 2
为什么锁能让它正确
互斥锁同一时刻只让一个 goroutine 持有;先到者获得它,其他人阻塞直到它被释放。把“读-改-写”序列包在 Lock 和延迟的 Unlock 之间,就把这三步变成不可分割的一段,于是没有其他 goroutine 能在别人更新的中途读到陈旧的值。
机制 3
WaitGroup 如何让测试有意义
测试预先宣布它期望多少个 goroutine,每个 goroutine 结束时报告完成,主 goroutine 阻塞直到计数归零。没有这道屏障,断言可能在自增仍在途中时就运行,所以这种等待让一个错误的总数成为竞争的真正证据,而不是过早度量的证据。
常见误读
一旦加上互斥锁、并发测试转绿,这个计数器就可以像其他结构体一样安全地复制和传递了。
通过的测试并不涵盖复制:复制结构体就复制了锁,而互斥锁的文档禁止在首次使用后这样做,go vet 恰恰在测试辅助函数里标出了这一点。作者的补救是传指针,并通过构造函数创建计数器,使调用者永远不会复制它。
“当我们把 Counter(按值)传给 assertCounter 时,它会试图创建互斥锁的一份拷贝。”
“When we pass our Counter (by value) to assertCounter it will try and create a copy of the mutex.”
Sync › Copying mutexes查看原文 →
goroutine 和通道是 Go 的标志性并发特性,所以退回到互斥锁是不地道的,或者是初学者的捷径。
作者说,新手常见的错误恰恰相反——仅仅因为可以,就去用通道和 goroutine。Go 刻意让两种工具都可用:传递数据所有权时用通道,管理共享状态时用互斥锁。
“传递数据所有权时用通道”
“Use channels when passing ownership of data”
Sync › When to use locks over channels and goroutines?查看原文 →
Go 基础的核心概念
- 竞争条件
- 两个 goroutine 读取、修改并写入同一个值,于是一次更新被悄悄丢失。
- 适用处: 只出现在对共享可变状态的未同步并发访问中。
- 互斥锁
- 一种锁,同一时刻只允许一个 goroutine 触碰被保护的数据。
- 适用处: 首次使用后不得复制,也不应嵌入到公共结构体中。
- WaitGroup
- 一个计数器,阻塞直到每个已启动的 goroutine 都报告自己已结束。
- 适用处: 只协调完成,不保护共享数据。
- sync/atomic
- 像 atomic.Int64 这样的类型,借助 CPU 指令而非锁,使对单个值的更新变得安全。
- 适用处: 适合单个值;多个共享不变量的字段仍需要 Mutex。
- 嵌入
- 把一个类型直接放进结构体,从而把它的方法提升进你的公共 API。
- 适用处: 当被提升的方法(如 Lock 和 Unlock)本应保持私有时,是有害的。
另外 0 个概念
Go 基础划线与批注AI 陪读
“对我们这样的 Go 专家来说,这应该是小菜一碟。”
“This should be trivial for Go experts like us.”
查看原文 →这一眨眼是挣来的——本书从第一章起就一直在营造这个“我们”。它也悄悄为无聊上了保险:对,这一部分很笨,请跟我一起。
“tmp := c.value // 1. read”
查看原文 →这是整章的重心所在。把 ++ 拆成三行,是把一个不稳定的测试数字变成可以推理的机制的唯一一步。
“测试很可能会以一个不同的数字失败”
“The test will probably fail with a different number”
查看原文 →一个每次运行都以不同方式失败的测试,在认识论上很古怪——它不可能是红-绿-重构里的那个红,因为绿也可能只是运气。本章从不承认 go test -race 才是为此而设的真正工具。
另外 5 条批注
“这很糟,而且是错的”
“this is bad and wrong”
查看原文 →直率,我喜欢他不躲在“视情况而定”后面。但所陈述的理由是 API 表面,并非专指互斥锁——真正的教训是关于嵌入,互斥锁只是一个鲜明的最坏例子。
“call of assertCounter copies lock value”
查看原文 →本章最好的一拍:测试通过了,代码依然是坏的。绿色的套件成了虚假的安慰,被一个工具而不是一个断言抓住。
“最好不要自己初始化这个类型”
“it would be better to not initialise the type yourself”
查看原文 →构造函数是靠习惯而不是靠编译器来强制的文档——Counter{} 仍然能编译得好好的。Go 没给你办法真正禁止它,所以补救是社交性的。
“func (c *Counter) Value() int64”
查看原文 →悄悄地是个破坏性变更——返回类型从 int 变了,所以 assertCounter 和每个调用者都得跟着改。被呈现为可直接替换的方案,其实不尽然。
“传递数据所有权时用通道”
“Use channels when passing ownership of data”
查看原文 →本章最有用的一句话,却是作为脚注到来的。互斥锁对通道是个设计问题;计数器的例子太小,让你感受不到差别。
读后感AI 陪读
通过的测试套件是地板,不是天花板
一章刻意写得很小:这个计数器几乎琐碎得不值一提,而这正是要点——它把一个现象(丢失更新)隔离得如此干净,以致“读-增-写”的分解落地为真正的理解,而不是一条要背的规则。本章真正的教学不在 Mutex,那只有三行;而在两个不适的时刻。第一,测试每次运行都以不同的数字失败——一个你无法信任的红灯,本章用了它,却没有真正承认它违反了自己的 TDD 契约(也从未提到 -race,这个正是为此而造的工具)。第二,go vet 在测试早已全绿的代码里抓到了被复制的锁——这里最强的一拍,因为它教会你:通过的测试套件是地板,不是天花板。反对嵌入的论战转折毫不含糊,令人耳目一新,却略微过头:这个论点针对的是一般意义上的公共 API 表面,互斥锁只是其中最吓人的例子。sync/atomic 一节读起来像是后加的,露出了接缝——一旦 Value() 返回 int64,“同一个测试原封不动地通过”就不成立了。而最丰富的想法,用通道转移所有权、用互斥锁管理状态,被停放在收尾的笔记里,在那里一个计数器的例子无法说明它。尽管如此:一章能让你从此对“单个操作”这个词不再信任,就算尽了本分。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:Context
Store 会如何把 context 传播给它自己那些缓慢的下游依赖?
Go 基础是怎样展开的AI 辅助
开篇:一条顺利路径,刻意过于简单。 本章用运维的措辞框定问题——由一个可能被放弃的请求发起的耗时工作,以及若无人告诉那份工作停下来,随之而来的调试之苦。随后它递给你一个最小的可工作系统:一个 Server 函数,接收一个 Store(一个只有 Fetch() string 的接口),返回一个 http.HandlerFunc,并把取到的数据写进响应。一个 SpyStore 测试替身和一个通过的测试确立了基线。作者的要点是,这是容易的情形,有趣的工程是从用户在请求中途走开才开始的。
初步展开:把取消作为显式的方法调用。 遵循严格的 TDD,作者给 Store 接口添加 Cancel(),让间谍睡 100ms 并记录自己是否被取消,并写出一个失败的测试:从请求派生出一个可取消的 context,通过 time.AfterFunc(5ms) 安排 cancel,并用 request.WithContext 把它附上。Go 博客里的一段简短离题解释了为什么 context 构成一棵树,以及为什么派生对传播很重要。然后是本章最有教益的时刻:能通过的最小代码,是在获取之前无条件地调用 store.Cancel()——显然是错的,却是绿的。作者没有把这当作 TDD 的失败,而是当作一次诊断:测试套件规定不足。给顺利路径测试加一条断言,说明存储没有被取消,就迫使出现真正的实现——在一个 goroutine 里运行 Fetch,写入一个带缓冲的通道,然后在该通道与 ctx.Done() 之间 select,让两种结果赛跑。一次重构把取消的断言作为带 t.Helper() 的辅助方法挪到间谍上。
转折:谁应当拥有取消? 让这个办法跑通之后,本章立刻质疑它。Web 服务器应该手动取消它的存储吗?如果存储自己还有缓慢的依赖呢——Cancel 必须沿着整棵树手动传播吗?援引 context 包的文档以及 Google 内部的规则——Context 应是入站与出站请求之间每个函数的第一个参数——作者转向把 context 传入存储,让它及其下游承担责任。值得注意的是,他停下来邀请读者体会对这份人体工学代价的不安,而不是推销它。
第二段展开:在新设计下重建。 Store.Fetch 变成 Fetch(ctx context.Context) (string, error),处理器被掏空,间谍被重写成像真正感知 context 的代码那样行事——一个 goroutine 一次一个字符地组装响应,每一步在 select 里检查 ctx.Done(),外层 select 让完成与取消赛跑。顺利路径先用两行处理器修好。然后,因为 httptest.ResponseRecorder 无法报告是否写入过任何东西,作者手写了一个自定义的 SpyResponseWriter,实现 http.ResponseWriter 的全部三个方法,并翻转一个 written 标志。失败的测试变成“取消时不应写入任何响应”,修复就是检查 Fetch 返回的错误并提前返回。处理器最终比它所取代的版本更小——这是把取消委托给下游的回报。
收束:一份总结,随后一个尖锐的告诫。 总结列出要点(测试被取消的请求,用 context 做取消,用 goroutine/select/通道编写感知 context 的函数,传播请求作用域的 context,手写一个 ResponseWriter 间谍)。本章随后附上一段有态度的尾声,谈 context.Value,引用 Michal Štrba 关于开除任何使用它的人的玩笑:无类型的映射牺牲类型安全,并通过共享的键把模块耦合起来,所以函数需要的值应放在有类型的参数里。作者对像 trace ID 这样的正交数据承认一个狭窄的例外,赞同 Jack Lindamood 的表述:Context.Value 应当告知而不是控制——是给维护者的内容,绝不是必需的输入。它以推荐 Štrba 的《Context should go away for Go 2》作结,同意无处不在的 context 传递闻起来像语言层面的缺口,同时指出在那改变之前,你无论如何都需要 context。
关键论点,以及支撑它们的原文
只写通过测试所需的最少代码,值得忍受那份不适,因为一个显然错误却仍能通过的实现,指向的是测试里的一个漏洞。
为满足取消的测试,作者先写了一个对每一个请求都取消存储的处理器——显然是错的,却是绿的。这暴露出顺利路径测试从未检查存储是否被放过,于是他加了一条断言:没有任何东西被取消;只有两个测试都就位,才逼出一个合理的实现。
“靠着自律,它凸显了我们测试中的一个缺陷,这是件好事!”
“By being disciplined it highlighted a flaw in our tests and this is a good thing!”
Context › Write enough code to make it pass查看原文 →
Web 处理器不该去管手动取消它的协作者;它应该把请求的 context 往下传,让每个依赖自己取消自己。
作者先构建一个自己拥有取消权的处理器,然后质问:如果存储自己还有缓慢的依赖会怎样,从而推翻了自己的设计——它的 Cancel 方法就得手工把取消转发给它下面的一切。Context 已经提供了一种一致的办法,而 Go 自己的文档,加上 Google 内部“入站与出站请求之间的每个函数都以 Context 作第一个参数”的规则,描述的正是这种传播纪律。
“在此之后我们可以看到,服务器代码变简单了,因为它不再显式地负责取消,它只是传递 context,并依赖下游函数遵守可能发生的任何取消。”
“We can see after this that the server code has become simplified as it's no longer explicitly responsible for cancellation, it simply passes through context and relies on the downstream functions to respect any cancellations that may occur.”
Context › Write enough code to make it pass查看原文 →
一旦责任下移,处理器就缩小到几乎什么都不剩。
重新设计之后,处理器只是用请求的 context 调用存储,若返回错误就静默返回,否则打印数据。这条路径的测试需要一个手写的响应写入器替身,因为标准的记录器根本告诉不了你是否写入过任何东西——它也确认了被取消的请求不产生响应。
“在此之后我们可以看到,服务器代码变简单了,因为它不再显式地负责取消,它只是传递 context,并依赖下游函数遵守可能发生的任何取消。”
“We can see after this that the server code has become simplified as it's no longer explicitly responsible for cancellation, it simply passes through context and relies on the downstream functions to respect any cancellations that may occur.”
Context › Write enough code to make it pass查看原文 →
另外 2 个论点
感知 context 的函数的测试替身,必须像真东西那样行事,包括取消,否则测试什么也证明不了。
作者的假存储不是简单地睡觉:它在一个 goroutine 里逐字符追加答案,每次迭代检查 done 通道,被取消时记日志,并用自己的 select 返回完成的字符串或 context 的错误。他指出你自己接受 context 的函数看起来也大同小异,所以这个模式值得理解,而不是照抄。
“在此之后我们可以看到,服务器代码变简单了,因为它不再显式地负责取消,它只是传递 context,并依赖下游函数遵守可能发生的任何取消。”
“We can see after this that the server code has become simplified as it's no longer explicitly responsible for cancellation, it simply passes through context and relies on the downstream functions to respect any cancellations that may occur.”
Context › Write enough code to make it pass查看原文 →
函数真正需要的数据属于有类型的参数,而不是塞进 context。
Context 的值不过是一个无类型的映射,所以编译期什么也检查不了,每次查找都可能落空,而键在模块之间造成一种隐藏的耦合,有人一改就破。有类型的参数受静态检查且自带文档。作者允许一个例外,引用了“context 的值应当告知而非控制”的规则:与请求正交的东西,比如 trace id,可以随行,而不是弄乱每一个签名。
“简言之,如果函数需要一些值,就把它们作为有类型的参数,而不是试图从 context.Value 里取。”
“In short, **if a function needs some values, put them as typed parameters rather than trying to fetch them from context.Value”
Context › What about context.Value ?查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
让缓慢的作业与取消信号赛跑
context 暴露一个通道,请求被放弃时它会关闭,而缓慢的获取在一个 goroutine 里运行,把结果写入另一个通道。select 语句同时等待两者,先结束的那个决定结果:要么响应被写出,要么取消分支运行、不发送任何数据。
机制 2
为什么派生的 context 能让一切停下
context 构成一棵树:每个新 context 都从请求自己的那个派生,所以取消一个祖先就取消每一个后代。因为调用路径上的每一层都把自己的 context 往下传,顶层的一次取消就能到达最深处的工作者,而无需任何一层知道它下面的各层依赖什么。
机制 3
取消只有在工作者检查时才有效
context 并不会强行杀死任何东西;它只是发信号。本章的假存储一次一个字符地构建结果,并在每一步都检查 done 通道,信号到来时提前返回。如果慢循环里没有这样的检查,调用者里的 select 会返回,而工作仍在耗用资源。
常见误读
Context 看起来像个方便的袋子,可以装下更深处的函数可能需要的任何东西,所以通过它传递请求数据,省得到处穿插参数。
把 context 当作传递取消、截止时间和请求作用域附加物的通道,且没有任何有文档记载的结果依赖这些附加物。函数真正需要的任何东西都应是显式的有类型实参,因为值的映射不提供类型安全,可能没有你的键,并通过共享的键名把模块绑在一起,这些键名一改就悄悄失效。
“简言之,如果函数需要一些值,就把它们作为有类型的参数,而不是试图从 context.Value 里取。”
“In short, **if a function needs some values, put them as typed parameters rather than trying to fetch them from context.Value”
Context › What about context.Value ?查看原文 →
把 r.Context() 交给存储就够了——客户端一断开,缓慢的工作就自己停下。
被取消的 context 只是关闭一个通道;停下工作需要缓慢的代码去盯着这个信号并退出。本章里,假存储在自己的循环中检查 done 通道并返回 context 的错误,而处理器仍然得注意到那个错误,并克制着不去写响应。
“在此之后我们可以看到,服务器代码变简单了,因为它不再显式地负责取消,它只是传递 context,并依赖下游函数遵守可能发生的任何取消。”
“We can see after this that the server code has become simplified as it's no longer explicitly responsible for cancellation, it simply passes through context and relies on the downstream functions to respect any cancellations that may occur.”
Context › Write enough code to make it pass查看原文 →
Go 基础的核心概念
- Context
- 沿调用链向下传递的一个值,向从它派生的所有东西发出取消信号。
- 适用处: 适合取消和截止时间;不是偷偷塞入必需值的地方。
- 间谍
- 一个记录自己被做过什么的替身协作者,供测试对其断言。
- 适用处: 行为必须足够像真东西;这里间谍得自己遵守 ctx.Done。
- 通道
- 一根有类型的管道,这里用来让缓慢的获取与取消信号赛跑。
- 适用处: 用 select 赛跑,只有在缓慢的工作者真的检查取消时才有效。
另外 0 个概念
Go 基础划线与批注AI 陪读
“store.Cancel() fmt.Fprint(w, store.Fetch”
查看原文 →刻意荒唐的实现是本章最好的教学举动:一个显然错误的通过测试,证明的是测试套件而不是代码规定不足。
“靠着自律,它凸显了我们测试中的一个缺陷,这是件好事!”
“By being disciplined it highlighted a flaw in our tests and this is a good thing!”
查看原文 →值得注意这种不对称:TDD 没有在代码里发现缺陷,它发现的是缺失的约束。测试是规格,而不是验证。
“我们的 Web 服务器去关心手动取消 Store,说得通吗?”
“Does it make sense for our web server to be concerned with manually cancelling Store?”
查看原文 →这是本章真正的支点。第一个解法能用、也通过了;它仅仅因设计理由就被扔掉。难得的诚实:绿色的测试不是终点线。
另外 6 条批注
“(停一下,想想每个函数都得传入 context 的后果,以及那样的人体工学。)”
“(Pause for a moment and think of the ramifications of every function having to send in a context, and the ergonomics of that.)”
查看原文 →作者在传授这一做法之前先埋下怀疑。教这个惯用法,同时拒绝假装它优雅——对一份教程来说,这是少有的克制。
“觉得有点不安?很好。”
“Feeling a bit uneasy? Good.”
查看原文 →教学上很精明,但回避了显而易见的问题:如果这种“病毒式参数”的代价是真的,那么在今天的 Go 里替代方案是什么?答案是“没有”,而本章直到脚注才几乎说出来。
“data, err := store.Fetch(r.Context()) if err != nil { return // todo: log error however you like”
查看原文 →取消时的静默返回意味着,对于真正的存储失败,客户端也会得到一个正文为空的 200。测试只检查“什么也没写”——它分不清取消和错误。那个 todo 掩盖了一个真实的缺口。
“如果你在我的(并不存在的)公司里使用 ctx.Value,你就被开除了”
“If you use ctx.Value in my (non-existent) company, you’re fired”
查看原文 →引用一个玩笑来制定一条真正的规则,两段之后又用“但 trace id 没问题”收回。真正的原则——告知,而非控制——才是有用的部分;虚张声势只是噪音。
“context.Value 的内容是给维护者的,不是给用户的。”
“The content of context.Value is for maintainers not users.”
查看原文 →Lindamood 的话是本章最锋利的一句——一个你真能应用的检验:如果拿掉这个值会改变有文档记载的行为,它就不该放在 context 里。
“我们用 Go 的并发原语让两个异步进程相互赛跑”
“we use Go's concurrency primitives to make two asynchronous processes race each other”
查看原文 →间谍现在比它所替代的生产代码还要复杂。对编写感知 context 的函数的代价很诚实,但它悄悄承认:测试取消就意味着手工搭建你本想抽象掉的那套机制。
读后感AI 陪读
一份教程,勇敢到以设计为由拆掉一个已通过的测试
一章真正的主题根本不是 context——而是一个通过的测试套件不再是良好设计之证据的那一刻。结构是刻意的两幕背叛:第一幕通过忠实的 TDD 构建出一个可工作的手动取消服务器,第二幕把它扔掉,因为它不地道,且把责任推给了错误的层。这场拆除就是教训,比多数教程敢于做的更勇敢。TDD 的表演(在 Fetch() 之前调用 Cancel() 来通过一个测试)是我见过的“测试即规格”的最佳示范:荒唐的实现不是纪律的失败,而是纪律在起作用,暴露出作者忘了陈述的一条约束。本章最弱之处恰是它最诚实之处:它告诉你,把 context 穿过每个签名会让你不安,引用了 Google 的规定,引用了 Štrba 关于整件事是语言层面缺陷的论点——然后耸耸肩。这份不安从未被解决,因为它无法被解决。有两件事未被充分审视。第一,最终的处理器把取消和真正的存储失败折叠成同一次静默返回,而 SpyResponseWriter 测试在结构上无法察觉;“todo: log error”这条注释承担了大量承重的活。第二,到最后,间谍比被测代码更复杂,这是关于感知 context 的 API 之代价的诚实信号,但也是对可测试性一笔未被承认的税。context.Value 一节是最可引用、也最不严谨的——一个会被开除的玩笑被当成规则来用,随即被一个合理的例外削弱。可挽救的原则属于 Lindamood,而不是作者:context 的值必须告知,绝不控制。那是一条可检验的标准;其余都是态度。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:罗马数字
你会如何在类型系统本身中编码 3999 这个上限?
Go 基础是怎样展开的AI 辅助
本章把罗马数字当作一个刻意“看起来很难”的问题来拿起,并拒绝把它当作难题。第一个测试只要求 1 转换成 I,第一个实现就只是返回 “I”。这个硬编码的答案不是需要道歉的尴尬——它正是本章所主张的那一招。第一轮循环的要点是尽快离开失败状态,使 API 的形状和命名在任何算法出现之前就被钉死。硬编码带来的不适随后被派上用场:因为返回 “I” 显然是错的,它激出下一个测试(2),迫使写出一个单独的 if,这又激出下一个,直到好几轮之后才出现循环。每一步都小到使下一步显而易见,而不断堆高的分支,就是告诉你何时该停止添加、开始重构的信号。
展开:把规则从代码里挪到数据里
实现中的转折出现在不断累积的 switch 语句变得太可疑、没法再留着的时候。作者把罗马数字规则提取成一个由数值与符号组成的小结构体,把这些配对收集进一个从最大值往下排序的切片,让转换只是遍历这个切片——在余数允许时减去数值并追加符号。回报是本章对“这种分离为何重要”最干净的演示:一旦行为不再编码任何特定的数字,支持直到 3999 的整个范围根本不需要改动算法,只需要在表里多加几行。达到像 1984(MCMLXXXIV)这样吓人的数字,既无跳跃也无重写。反方向随后以同样的方式构建——针对同一张表,用 HasPrefix 和 TrimPrefix 把数码解析回数字——这就形成了一对本应互为逆运算的函数。
转折:属性测试攻击作者自己的设计
有了两个通过各自示例测试的转换器,本章刻意向它们发难。一个基于属性的往返测试——把数字转成数码,再解析回去,期望得到原值——被交给 quick.Check,它暴露的不是算法里的差一错误,而是类型签名里的缺陷。把生成的输入记录下来,得到像 6849766357708982977 和 −7028152357875163913 这样的值:负数和远远超出 3999 上限的数,罗马数字完全无法表达它们。(在 Linux 上,把这类输入喂给朴素版本,可能会让机器卡死。)本章最容易被忽略的一点就在这里。修复不是简单地把 int 换成 uint16;那只是缩小了无效范围,因为从 4000 到 65535 的所有值仍然可以用 uint16 表达,却仍然不是罗马数字。要填上剩下的缺口,需要一个真正的领域决定——返回错误,或声明一个专用类型——本章刻意让这个选择开放着,而不是假装类型系统已经解决了它。按这种读法,这些测试的价值来自能够在代码里陈述领域规则,而不是来自随机性发现缺陷。
收束:塑造输入,而不是削弱检查
最后的技术动作是关于约束该放在哪里的纪律。对超出范围的输入,诱人的修复是在属性内部加一个守卫——if arabic > 3999 { return true }——但这只是宣布无效用例通过,并烧掉什么也没测的运行。作者转而提供一个自定义的值生成器,产生 uint16(r.Intn(4000)),使全部 1000 次迭代都落在有效域内,真正地演练转换。是塑造输入,而不是为它开脱。本章随后以它更大的主张收尾:令人生畏的问题,只要你从琐碎的东西开始、以小的增量推进,就变得可以处理,作者说这就是他交付真实系统的方式——从一个“Hello world”网站向外迭代。这最后一部分是作为自述的实践提出的,而不是度量出的证据,应当如此来读。附上的是 Dave 那条吹毛求疵却承重的注记:int 是一个数,不是阿拉伯数字:0xFF == 255 成立,同一个量可以打印成 11111111、255、377、ff 或 FF。表示与数量是不同的,这正是为什么描述这个函数的签名——以及选择它的领域类型——值得本章为之花费的那番细心。
关键论点,以及支撑它们的原文
在测试循环一开始就硬编码一个结果,是正当的一步,因为目的是在 API 和规则被钉死的同时,尽快离开失败状态。
转换器先是只返回 “I”,然后为 2 长出一个单独的 if,直到后来才获得任何循环;每一个令人不适的硬编码答案,都激出下一个迫使写出更诚实代码的测试。
“我知道只是硬编码结果感觉怪怪的,但用 TDD,我们想尽可能长时间地待在“红”之外。”
“I know it feels weird just to hard-code the result but with TDD we want to stay out of "red" for as long as possible.”
Roman Numerals › Refactor查看原文 →
把数码数据与转换行为分开,既去掉了那堆可疑的分支,又让更大的数字白白就能工作。
switch 语句被一个由数值与符号组成的小结构体,加一个从最高值向下遍历的切片取代;作者说,支持直到 3999 的全部符号根本不需要改动算法,只需扩充那个切片。
“我们把一些关于数码的规则声明为数据,而不是藏在算法里,我们可以看到我们只是逐个处理阿拉伯数字,尝试把符号加到结果里,如果它们合适的话。”
“We've declared some rules around the numerals as data rather than hidden in an algorithm and we can see how we just work through the Arabic number, trying to add symbols to our result if they fit.”
Roman Numerals › Refactor查看原文 →
造出一个通过示例测试的转换器之后,作者转而向它发难:一个基于属性的往返测试揭示,int 对这个领域来说就是错误的类型。
把生成的输入记录下来,得到像 6849766357708982977 和 -7028152357875163913 这样的值——负数和远远高于 3999 上限的数,罗马数字无法表示其中任何一个。需要注意的是,换成 uint16 只是缩小了问题,因为 4000 到 65535 仍然能溜过去。(在 Linux 上,用这类输入跑朴素版本,可能会让机器冻结。)
“太好了!我们被迫更深入地思考我们的领域,这是基于性质的测试的真正优势。”
“This is great! We've been forced to think more deeply about our domain which is a real strength of property based tests.”
Roman Numerals › Run our test查看原文 →
另外 2 个论点
面对有效域之外的输入,你应当约束测试生成什么,而不是在属性内部为它们开脱。
像 if arabic > 3999 { return true } 这样的守卫是把问题藏起来,而不是处理它;改为让生成器产生 uint16(r.Intn(4000)),意味着 1000 次运行中的每一次都真正演练了转换,没有浪费的迭代。
“你可以用 if arabic > 3999 { return true } 来防范它,但那只是糊弄问题:那些迭代就变成了空操作。”
“You could guard against it with if arabic > 3999 { return true }, but that just papers over the problem: those iterations become no-ops.”
Roman Numerals › uint16查看原文 →
庞大、令人生畏的问题,只要你从琐碎的东西开始、以小步推进,就变得可行。
作者指出,达到 1984(MCMLXXXIV)既没有大的跳跃,也没有巨大的重写,并说这与他交付真实系统的方式如出一辙——从一个“Hello world”网站向外迭代。这是自述的实践,而不是度量出的结果。
“诀窍一如既往:从简单的东西开始,迈小步。”
“The trick, as always, is to get started with something simple and take small steps.”
Roman Numerals › More TDD practice with iterative development查看原文 →
它是怎样运作的
本章论证所依靠的 2 个机制。
机制 1
规则作为数据,使算法保持固定
作者把数码规则从一个分支繁多的 switch 里挪到一份数值/符号配对的列表里,转换只是从最大值往下遍历这份列表,减去并追加。因为行为不再编码任何特定的数字,覆盖直到 3999 的每个符号,只意味着往表里加行,而不用碰算法。
机制 2
修生成器,而不是修断言
当随机整数超过罗马数字的上限时,诱人的修复是在属性内部提前 return true,但这只是宣布越界用例通过,并烧掉什么也没测的运行。改为提供你自己的值构造器,使参数落在 0–3999,就让全部 1000 次迭代都演练真实的转换,塑造的是输入,而不是削弱检查。
常见误读
属性测试发现的缺陷是个类型缺陷,所以把 int 改成 uint16 就修好了,教训是随机输入能发现缺陷。
收窄类型只是缩小了无效范围——从 4000 到 65535 的一切仍然可以表达,却仍然不是罗马数字——所以剩下的缺口必须由一个领域决定来填上,比如返回错误或声明专用类型,这个选择本章刻意保持开放。这些测试的价值来自能够在代码里陈述领域规则,而不是来自随机性本身。
“太好了!我们被迫更深入地思考我们的领域,这是基于性质的测试的真正优势。”
“This is great! We've been forced to think more deeply about our domain which is a real strength of property based tests.”
Roman Numerals › Run our test查看原文 →
既然这个函数把阿拉伯数字转换成罗马数字,那么 int 参数就是那个阿拉伯数字。
int 是一个数;“阿拉伯数字”指的是写下数的一种方式。作者附上的注记显示 0xFF == 255 成立,同一个值可以打印成 11111111、255、377、ff 和 FF——表示与数量是分开的,这使得函数签名的描述值得谨慎措辞。
“我们可以把同一个整数既写成十六进制,也写成阿拉伯(十进制)数字。”
“We can write the same integer both as a hexadecimal and an Arabic (decimal) numeral.”
Roman Numerals › Postscript查看原文 →
Go 基础的核心概念
- 基于属性的测试
- 向你的代码抛入随机输入、检查领域规则是否始终成立的测试,而不是列举示例。 陈述一条关于你的领域的规则,让库生成许多输入来试图打破它的测试。
- 适用处: 只与你对领域的理解一样好;光有随机数据不是重点。 只有当你能在代码里表达领域规则时才有用;它们补充而不是取代基于示例的测试。
- 表驱动测试
- 一个输入/期望用例的切片,用子测试遍历,所以新增用例几乎不需要样板代码。
- 适用处: 当每个用例都是“给定 X,期望 Y”的同一形状时最合适。
- 罗马数字的减法规则
- 只有 I、X 和 C 可以放在更大符号的左边,并且只能放在它们那一族里更高的后两个符号之前。
- 适用处: 排除了用 IC 表示 99、用 ID 表示 499;与重复三次的限制合起来,把取值上限定在 3999。
- strings.Builder
- 一种用 Write 方法高效构建字符串的类型,把内存复制降到最低。
- 适用处: 通常留给真正的性能问题,但在这里它不多花一行代码。
- quick.Config Values
- 你交给 quick.Check 的一个函数,由你自己构造参数,使随机输入保持在你在意的范围内。
- 适用处: 当默认生成器产生的值落在你的有效域之外时需要;它塑造的是输入,而不是断言。
- 基于现有类型的类型声明
- 定义一个新的具名类型,使无效值在 API 里根本无法被表达。
- 适用处: 这里作为返回错误的替代方案提出;本章让这个选择保持开放。
另外 0 个概念
Go 基础划线与批注AI 陪读
“在 TDD 里,我们想尽可能长时间地待在“红”之外”
“with TDD we want to stay out of "red" for as long as possible”
查看原文 →他一定是想说尽可能短时间地待在红里,或者说:尽可能久地待在绿里。这句话说的恰恰与它所描述的做法相反。
“现在用这种不安的感觉去写一个新测试”
“Now use that uneasy feeling to write a new test”
查看原文 →这才是本章真正的教学。不适作为排期信号——硬编码的返回是一笔你能感觉到的债,而这种感觉告诉你下一个测试该放在哪里。
“我们总可以把代码扔掉,借助手头的测试指引,从头再来。”
“We can always throw our code away and start afresh with the tests we have to guide us.”
查看原文 →先写测试那安静的回报:代码变得可以抛弃。多数对重构的焦虑,其实是对不知道自己何时弄坏了东西的焦虑。
另外 10 条批注
“99 不是 IC(而是 XCIX,90 + 9)”
“99 is not IC (it's XCIX, 90 + 9)”
查看原文 →值得注意,这条被仔细解释的规则,从未出现在算法里。贪心表在结构上使得输出 IC 不可能——这个约束是由数据里缺了什么来编码的,而不是靠任何检查。
“像这样的循环里的重复,通常是一个等着被点名的抽象的迹象。”
“Repetition in loops like this are usually a sign of an abstraction waiting to be called out.”
查看原文 →本章最好的一招:它把重复读成一条信息,而不是一团糟。两个几乎相同的分支不是噪音,而是一个成形一半的概念。
“我们可以通过把数据与行为解耦来重构它。”
“We can refactor this by decoupling the data from the behaviour.”
查看原文 →switch 和表在计算上完全等价;变的是领域知识住在哪里。扩展到 1000 现在意味着编辑一张列表,而不是推敲控制流。
“它很可能会让你的系统冻结(需要强制重启)。”
“It will likely freeze your system (requiring a hard reboot).”
查看原文 →本该是类型的地方却贴了一张警告贴纸。朴素的循环分配 int64 长度的字符串,正是属性测试发现的那个缺陷——而修复是 uint16,不是一个警示框。
“它能通过的唯一可能,是它们有同样的缺陷”
“The only way it could pass is if they have the same kind of bug”
查看原文 →对往返测试的盲点很诚实:对称的缺陷能活下来。如果两个函数共用同一张错误的表,属性完美成立,却什么也没说。
“很多人认为基于属性的测试主要是关于随机数据,但他们错了。”
“A lot of people think property based tests are mainly about random data but they would be mistaken.”
查看原文 →没错,而本章是阴差阳错地证明了这一点:随机性没发现任何关于罗马数字的东西,它发现的是 int 是错误的类型。价值在于被迫陈述一条属性这件事本身。
“那些迭代就变成了空操作”
“those iterations become no-ops”
查看原文 →抓得好,而且可以推而广之:断言内部的一个过滤器会悄悄掏空你的样本量。带 >3999 守卫的 1000 次运行,真正的测试也许只有寥寥几个。
“你能想出一种办法,使得别人不可能用大于 3999 的数字调用我们的代码吗?”
“Can you think of a way of making it so it's impossible for someone to call our code with a number greater than 3999?”
查看原文 →自己给出答案的练习。如果存在一个受约束的类型,Values 生成器和守卫都会消失——测试不需要知道领域的上界,因为类型知道。
“一个数字作为数码的表示,与它作为数的性质无关”
“The representation of a number as a numeral is independent of its properties as a number”
查看原文 →Dave 的吹毛求疵物有所值。ConvertToRoman 命名有误:什么也没转换,只是格式化了一个 int。恰当的类比是 Itoa,这样命名本会让 uint16 的问题早早就变得显而易见。
“但这个 kata 的其他每个版本都不在乎这个区别,所以 🤷”
“But every other version of the kata doesn't care about this distinction so”
查看原文 →一个耸肩,恰好放在本章思想上最认真的一页之后。作者把批评印了出来又拒绝了它——大方,但这个耸肩所做的事,是本章别处不允许的。
读后感AI 陪读
一旦代码变成纯数据,算法就再也不变了
一章真正的主题不是罗马数字,而是拒绝跳跃的纪律。它最强的段落,是那些把代码当作说话者的段落:循环里的重复“在告诉你一些东西”,switch 是“在描述真理,也在描述行为”,硬编码“I”之后那种不安的感觉是下一个测试的排期信号。这是一个真正有用的立场,而从 switch 到表的重构是干净的回报——算法再也不变,变的只有数据。基于属性的那一节,更有意思的是出错之处,而不是它教了什么。随机性没有发现关于罗马数字的任何东西;它发现的是 int 是错误的参数类型,这是设计上的发现,不是测试上的发现。而本章从未真正把它打开的回路闭合:它煞费苦心地解释了减法家族规则(IC 不是 99),贪心表只是靠遗漏而不是靠检查来满足它;它在本该用更窄类型的地方,给代码硬贴了一条系统冻结的警告;它添加了一个上限为 3999 的自定义生成器,然后作为练习问,能不能有一个类型使得 >3999 不可能——而那个类型会同时删掉生成器和早先的守卫。Dave 的附言是最尖锐的一页:这个函数不是转换,而是格式化,若当初就这样命名,类型问题在第一页就会浮现。作者把这则批评重新印了出来,却一耸肩带过,因为“这个 kata 的其他每个版本”都如此——在一章本来建立在认真对待代码的不适之上的内容里,这是一点小小的胆怯。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:数学
SVG 原点位于左上角,如何改变每一次坐标计算?
Go 基础是怎样展开的AI 辅助
本章一开篇就拒绝把绘图当作绘图问题。一个渲染成 SVG 的模拟钟面,从第一页起就被框定为一个真正的数学问题:指针到底指向哪里?作者在泛化之前先钉死一个具体情形——钟面中心位于 (150,150),指针长度为 50、80 和 90 个单位,而像钟面半径或图像大小这样可能的参数,在简单版本跑通之前被明确搁置。针对这些固定的数字,写下一个关于指针坐标的验收测试,从那里起本章自上而下地 TDD:先是 secondsInRadians,然后是单位圆上的 secondHandPoint。本章在这里撞上它的第一个真正的障碍,而它不是设计障碍,而是算术障碍。把一圈切成几分之几再乘以 30 会留下残差,所以乘积不再恰好等于 math.Pi,测试在一个正确的实现上失败。作者的回答刻意取巧:把公式改写成 π/(30/n),使除法留在里面,恢复精确比较;在这种倒置不可能的地方,用 1e-7 的容差吸收剩下的 sin/cos 漂移。像 math/big 这样的任意精度库,被当作画一个钟的小题大做。角度确定之后,单位向量被按一个强制的顺序挪到绘图区上——乘以指针长度来缩放,取反 Y 以补偿 SVG 左上角原点、Y 向下增长,加上圆心来定位——因为在缩放之前翻转或平移,会把那个修正也乘上去。
本章中段从数学转向测试本身的质量。断言输出文本包含某一行,把测试绑在字符而不是含义上:当文档根本不是合法的 SVG 时它欣然通过,而属性之间多出一个无害的空格时它却失败。所以字符串匹配被换成把输出反序列化为有类型的 Go 结构体,这使测试比较的是坐标值,因而只在真正的缺陷上失败。在这个更坚实的立足点上,分针和时针得以构建,方法也从每次重新推导各个角度,转为复用已经完成的指针:分针把 secondsInRadians 除以 60 再加上分钟偏移,因为它每秒走的距离是秒针的六十分之一;时针把 minutesInRadians 除以十二,因为走完一圈要十二小时而不是一小时。重复被提取到 angleToPoint 和 makeHand 里。与此同时,步幅明显变大:作者承认只写了两个用例就对 minutesInRadians 感到厌倦,后来把 hourHandPoint 实现为一行,组合 angleToPoint 和 hoursInRadians。但变的是粒度,而不是顺序——用例仍然写在代码之前,如今设计已被理解,便一次写几个,而小时弧度的测试仍然抓到了一个浮点相等的失败,迫使采用 roughlyEqualFloat64 比较。即便步幅更大,测试也在做真活。
收尾完成钟面,然后退后一步。小时指针的弧度用 roughlyEqualFloat64 比较,画出 hourHandPoint 和 hourHand,沿途积累的魔法数字被命名为常量。最后的反思做了三个动作。TDD 是一件工具,不是要去伺候的义务。程序应当与一个库一同交付,这样这份工作在催生它的那个二进制之外也能用。而且——颠倒通常的强调——关于 SVG 的持久知识住在测试代码里,而不是生产代码里:许多不同的生产技术都能产出同样的图,所以对“正确的 SVG 是什么”的最严格陈述就属于测试。这正是验收测试挣得自己一个文件、而细粒度的角度测试留在包内的原因。代码只记录“怎么做”;测试才是知识继续活着的地方。
关键论点,以及支撑它们的原文
从一个带固定常量的具体用例开始,只在它跑通之后才泛化为参数。
作者把钟面中心钉在 (150,150),指针长度钉在 50、80 和 90,明确搁置钟面半径、图像大小等可能的参数,直到简单版本完成;验收测试针对这些固定数字来写。
“我可以考虑更多参数——钟面圆的半径,SVG 的大小,指针的颜色、形状,等等……但最好是先用一个简单、具体的方案解决一个简单、具体的问题,然后再开始添加参数使之通用化。”
“I could think about more parameters - the radius of the clockface circle, the size of the SVG, the colours of the hands, their shape, etc... but it's better to start off by solving a simple, concrete problem with a simple, concrete solution, and then to start adding parameters to make it generalised.”
Maths › An Acceptance Test查看原文 →
不要为每根新指针从头推导角度,而是复用已经造好的指针,并除以一个固定因子。
完成秒针之后,作者通过把 secondsInRadians 除以 60 再加上分钟偏移得到分针,因为分针每秒走的距离是它的六十分之一;时针则把 minutesInRadians 除以十二,因为走完一圈要十二小时而不是一小时。
“不必从头算出每一秒要把分针在钟面上推多远,这里我们可以直接利用 secondsInRadians 函数。”
“Rather than working out how far to push the minute hand around the clockface for every second from scratch, here we can just leverage the secondsInRadians function.”
Maths › Write enough code to make it pass查看原文 →
顺着这种复用,TDD 步子的大小可以随作者的信心增长,而不必放弃先写测试。
只写了两个用例,作者就承认对 minutesInRadians 厌倦并继续前进,后来把 hourHandPoint 实现成组合 angleToPoint 和 hoursInRadians 的一行——但用例仍然写在代码之前,而小时弧度的测试仍然抓到了一个浮点相等的失败,迫使采用 roughlyEqualFloat64 比较。
“但也要注意:我并没有完全跳过写测试——我仍然先写它们。”
“But also note: I'm not skipping writing the tests entirely - I'm still writing them first.”
Maths › On TDD Zealotry查看原文 →
另外 1 个论点
颠倒通常的强调,关于 SVG 的持久知识住在测试代码里,而不是生产代码里。
作者推理,许多不同的生产技术都能产出同样的图,所以对“正确的 SVG 是什么”的最严格陈述属于测试,这就是为什么验收测试有自己的文件,而细粒度的角度测试留在包内;代码只持有“怎么做”。
“SVG 的“是什么”住在我们的测试里;“怎么做”住在代码里。”
“The what of an SVG lives in our tests; the how in the code.”
Maths › The Most Valuable Test查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么浮点相等会失效
在本章,作者展示了把一圈切成几分之几再乘以 30 会留下残差,所以乘积不再恰好等于 math.Pi。把公式改写成 π/(30/n),使除法留在里面,恢复了精确比较;在这种倒置不可能的地方,1e-7 的容差吸收了剩下的 sin/cos 漂移。
机制 2
把单位向量放上 SVG 绘图区
单位圆上的一个点,是一个长度为 1、角度从 12 点钟方向量起的方向。乘以指针长度来缩放,取反 Y 来补偿 SVG 左上角原点(Y 向下增长),再加上圆心 (150,150) 把它移到位。顺序是被迫的:在缩放之前翻转或平移,会把那个修正也乘上去。
机制 3
为什么字符串匹配会误读 SVG
断言输出文本包含某一行,把测试绑在字符而不是含义上:当文档不是合法的 SVG 时它仍然通过,而属性之间多出一个无害的空格时它却失败。把输出反序列化为有类型的 Go 结构体,使测试转而比较坐标值,于是它只在真正的缺陷上失败。
常见误读
既然浮点运算总是弄坏这些测试,你就需要 math/big 这样的任意精度工具。
两条更便宜的路就够了:重新排列表达式,使除法发生在里面,即 π/(30/n),这就恢复了与 math.Pi 的精确相等;或者为点定义一个阈值为 1e-7 的近似相等。作者把精度库当作画一个钟的小题大做,并指出这种重排之所以有效,只因为那个除法可以倒置。
“有两种办法绕过这个:”
“There are two ways around this:”
Maths › Floats are horrible查看原文 →
当作者说他厌倦了测试一个函数、然后把下一个写成一行时,他已经悄悄放弃了先写测试的开发。
变的是粒度,而不是顺序:用例仍然写在实现之前,只是在设计已被理解之后一次写几个。回报出现在小时指针的测试仍然因浮点相等而失败、并迫使采用近似相等比较的时候,可见测试在做真活。
“但也要注意:我并没有完全跳过写测试——我仍然先写它们。”
“But also note: I'm not skipping writing the tests entirely - I'm still writing them first.”
Maths › On TDD Zealotry查看原文 →
Go 基础的核心概念
- 验收测试
- 只有整个用户可见的功能都工作、而不只是某一个函数工作时才通过的高层测试。 一个驱动整个程序输出的高层测试,这里是给定时间下完整的 SVG。
- 适用处: 它告诉你何时完成,但太粗,无法定位缺陷;那是单元测试的事。 在边缘处有用;细粒度的角度数学仍由低层单元测试承担。
- 单位圆
- 以原点为圆心、半径为 1 的圆,其中 x = cos(角度)、y = sin(角度) 给出坐标。
- 适用处: 这里坐标轴互换了(x = sin,y = cos),因为角度是从 12 点钟方向量起的。
- 弧度
- 一种角度单位,一整圈是 2π 而不是 360 度;Go 的 math.Sin/Cos 要求用它。
- 适用处: 只在与 math 包的边界处才要紧;钟表逻辑本身是一圈的几分之几。
- 浮点数不精确
- 小数运算会漂移,所以先除后乘以 30 不再恰好得回 math.Pi。
- 适用处: 画图时可以容忍;若要求精确相等或高精度则不可接受。
- 表驱动测试
- 一个测试体遍历一个由“时间/期望值”用例组成的切片,子测试以辅助函数命名。 一个测试体遍历一份“时间/期望值”用例的列表。
- 适用处: 一旦存在若干相似用例就有帮助;对单个用例则徒增仪式。 需要像 containsLine 这样的辅助函数,以及跨包复制未导出辅助函数的本地副本。
- XML 反序列化
- 把 XML 字节转成 Go 结构体,使测试比较的是值,而不是字符。
- 适用处: 只与你声明的结构体标签和字段类型一样精确。
另外 4 个概念
- 重构
- 在转绿之后去除重复,例如 angleToPoint 和 makeHand。
- 适用处: 只在测试通过之后做;行为必须保持一致。
- TDD 循环
- 先写测试再写代码的节奏,被当作获取反馈的工具,而不是必须履行的仪式。
- 适用处: 一旦作者已经了解设计,步子可以迈得更大,但测试仍然在先。
- 作为具名常量的魔法数字
- 用像 secondsInHalfClock 这样的名字取代裸数字,使方程陈述自己的含义。
- 适用处: 对具有领域含义的数字有用;也为日后改成参数留着门。
- 程序与库配对
- 把可复用的计算 API 与使用它的可执行程序一同交付。
- 适用处: 只有因为测试驱动的隔离产生了小而具名的函数,才办得到。
Go 基础划线与批注AI 陪读
“但今天不是!今天我们要用数学来解决一个真实的问题。”
“But not today! Today we'll use mathematics to solve a real problem.”
查看原文 →开场的鼓舞在做推销的活——它先承认数学通常与工作无关,然后把本章布置成例外。诚实,但注意这个框定:这里的数学是附带的;真正的一课是分解。
“最好从用一个简单、具体的解法解决一个简单、具体的问题开始”
“it's better to start off by solving a simple, concrete problem with a simple, concrete solution”
查看原文 →本章真正的论点,埋在一句关于参数的旁白里。抵制过早的泛化,比随后的任何三角学都难。
“return Point{150, 60”
查看原文 →硬编码答案看起来像作弊,而这正是要点——它证明测试能失败、然后能通过,所以下一个测试才是做真活的那个。尽管如此,多数读者想跳过这一步的本能,恰恰是 TDD 想要打破的。
另外 17 条批注
“π / (30 / numberOfSeconds)”
查看原文 →聪明,但请注意刚刚发生了什么:作者改写了数学,以讨好浮点表示。这是披着优雅外衣的权宜之计——而且它立刻引入了一个需要自己一段致歉旁注的除以零。
“除以 +Inf 似乎得到零”
“Dividing by +Inf seems to result in zero”
查看原文 →“似乎”在这里承担了大量承重的活。这个修复靠的是 IEEE 754 语义的巧合,而不是设计——把这个模式抄到别处的读者,迟早会被烫伤。
“但鉴于目标是画一个 SVG,而不是登月”
“But given the objective is to draw an SVG and not land on the moon”
查看原文 →本章最好的一句。精度是一项需求,不是一种美德——而知道你处在哪一种情形,就是工程判断。
“是啊,我想我搞砸了。这感觉不对。”
“Yeah, I guess I screwed up. This feels wrong.”
查看原文 →少见而宝贵:作者把错误的转弯留在文本里,而不是编辑掉。把 SecondHand 展示成一个暗地里长成 SVG 形状的函数,然后察觉到那股味道,比一个干净的解法教得更多。
“我是在通过一串字符的表示来测试一个数据结构——XML”
“I'm testing a data structure - XML - by looking at its representation as a series of characters”
查看原文 →这远远超出钟表而适用:对解析后的东西做断言,而不是对它的渲染。同样的错误存在于每个快照测试和每个针对 JSON 的正则里。
“%f 格式指令以默认精度——六位小数——打印我们的坐标。”
“The %f format directive is printing our coordinates to the default level of precision - six decimal places.”
查看原文 →一个因格式而不是逻辑失败的测试。很好地说明了对字符串匹配的验收测试会让你与展示细节耦合——“修复”是固定精度,这是一个被当作测试修补偷运进来的真正决定。
“我真的在乎指针的样式吗?或者说,在乎 zak 生成的那个空 Text 节点?”
“Do I really care about the style of the hand? Or, for that matter, the empty Text node that's been generated by zak?”
查看原文 →这一段里最锋利的想法:测试的数据模型应当只编码你正在断言的东西。从结构体里删字段,就是你声明意图的方式。
“因为它们是未导出的,在声明它们的包之外不可见,所以我们这里需要自己的副本。”
“Since they're unexported, they're not visible outside the package they were declared in, so we need our own copies here.”
查看原文 →测试包之间重复的辅助函数——在整整一节宣讲 DRY 之后,毫无异议地被接受了。黑盒包边界值得这份复制,但作者从未大声说出来。
“写测试,直到恐惧转化为无聊。”
“Write tests until fear is transformed into boredom.”
查看原文 →一条建立在感觉而非指标之上的覆盖启发。诚实,却不可证伪——当你信心满满地错着时,无聊也会到来。
“return (math.Pi / (6 / (float64(t.Hour() % 12))))”
查看原文 →写成除法套除法,是为了呼应前面公式的形状,而 Pi*h/6 本会更朴素——而 h=0 的情形之所以幸存,只因为浮点除以零得 +Inf,而 Pi/Inf 是 0。测试通过;推理是运气。
“浮点运算又一次出手了。”
“Floating point arithmetic strikes again.”
查看原文 →第三次遇到,仍然被动处理——咬到了就放宽比较。复用 minutesInRadians 买来了优雅,付出的代价是累积的误差。
“我不打算为了仪式而硬走一遍 TDD 的仪式。”
“I'm not going to plough through TDD ceremony for the sake of it.”
查看原文 →本章最有用的坦白:仪式只有在它还在教你东西的时候才配得上它的位置。值得注意的是,他之所以能这么说,是因为他在三支指针之前就为这知识付过了账。
“这些常量很容易变成参数。我们是在帮忙把那扇门留着(即使我们永远不会走过去)”
“these constants could easily become parameters. We're helping to leave that door open (even if we never go through it)”
查看原文 →命名作为廉价的可选性——坦承那扇门多半仍会关着,这与通常的过早泛化借口恰好相反。
“我们构建了一个很合理的、用于钟面计算的小 API”
“we've built a very reasonable little API for clockface calculations”
查看原文 →真正的回报主张:可测试性迫使分解,而分解意外地产出了一个库。一个与抓缺陷毫无关系的、支持 TDD 的好论点。
“处理 SVG 的最精密的代码根本不在我们的应用代码里;它在测试代码里”
“the most sophisticated piece of code for handling SVGs isn't in our application code at all; it's in the test code”
查看原文 →他在读者开口之前就把不适点了名。一个修辞动作:先回应反对,再把它重新框定为教训。
“SVG 的“是什么”住在我们的测试里;“怎么做”住在代码里”
“The what of an SVG lives in our tests; the how in the code”
查看原文 →整个小节压缩成了七个词。这是值得偷走的一句——它悄悄颠倒了通常的等级:代码持有真理,测试只是检查。
“我们怎么产生 SVG 并不重要,重要的是我们产生了什么”
“it doesn't matter how we produce our SVG, what is important is what we produce”
查看原文 →只有因为测试严格,这才成立。重构的自由是由测试的严谨买来的——所以他所庆祝的自由,是他所捍卫的劳动的一个后果。
读后感AI 陪读
有诚实局限的 TDD:写测试,直到恐惧化为无聊
本章披着伪装而来。它以一番鼓舞开场——数学通常与你的工作无关,但今天不是!——而这番推销,是本章从未真正兑现的唯一主张。三角学是附带的,近乎装饰;本章真正教的是在压力下的分解,而它真正的论点藏在早先的一句旁白里:从一个简单、具体的问题和一个简单、具体的解法开始。抵制过早的泛化,事实证明比随后任何单位圆恒等式都难得多。
展开是一个螺旋,不是一条直线。一个硬编码的 Point{150, 60} 证明测试装置能失败也能通过;然后是以弧度表示的秒,然后是单位圆上的一个点,然后是把那个向量缩放、翻转并平移到 SVG 空间。每一支指针都以比上一支更少的仪式重复这个循环,直到作者公然拒绝再演这套仪式——这是本章最有用的坦白,也是他挣来的,因为他在三支指针之前就为这知识付过了账。TDD 在这里明确是一件收益递减的工具,而不是信条,而“写测试,直到恐惧转化为无聊”是一条诚实的启发,却也悄悄地不可证伪:当你信心满满地错着时,无聊也会到来。
继续阅读
底下有两条线索,值得点名。第一条是浮点数,碰到三次,每次都被动处理。π / (30 / numberOfSeconds) 是为了讨好浮点表示而改写的数学——一个披着优雅外衣的权宜之计,立刻催生出一个除以零,它之所以幸存,只因为除以 +Inf“似乎”得零。小时的公式重复了这个把戏,到 roughlyEqualFloat64 出现时,模式已经清楚:测试通过了,但推理的一部分是披着聪明恒等式外衣的运气。复用买来了对称,付出的代价是累积的误差。把这些公式抄到别处,你迟早会被烫伤;把放宽的比较抄走,至少你对容差是诚实的。那句救赎的话——“目标是画一个 SVG,而不是登月”——是本章最好的一句,因为它把精度重新框定为一项需求而不是一种美德,而知道你处在哪一种情形,就是工程判断的全部。
第二条线索是比时钟活得更久的那条:不要再对渲染结果做断言。把 SVG 当字符串匹配,会让你与 %f 的六位小数耦合,于是格式变动就弄坏一个逻辑测试;解析成有类型的结构体修好了这点,然后从这些结构体里修剪字段——我在乎指针的样式吗,在乎 zak 生成的空 Text 节点吗?——就成了在数据模型本身里声明意图的办法。每个快照测试和每个针对 JSON 的正则,都犯同样的罪。还值得注意的是,DRY 的说教在包边界处客气地止步了:未导出的辅助函数被不加评论地复制进黑盒测试包。这个取舍是对的;对此保持沉默才是缺口。
结尾是本章挣回本钱的地方,它靠的是预先回应读者的反对,而不是躲着它。是的,最精密的 SVG 代码住在测试里,而不是应用里——而“SVG 的‘是什么’住在我们的测试里;‘怎么做’住在代码里”这七个词的版本值得偷走,因为它颠倒了通常的等级:代码持有真理,测试只是审计。不过请注意其中的循环性,而且那是一种良性的循环:你想怎么重写渲染器都行的自由,完全是由测试的严格买来的。所庆祝的自由,是所捍卫的劳动的一个后果。常量也一样:把魔法数字命名,是廉价的可选性,并附带一句异乎寻常诚实的承认:那扇门多半仍会关着。
该对读者说什么。别为数学而来;为形状而来。可测试性迫使分解,而分解意外地产出了一个小而合理的钟面库——这是支持 TDD 的一个论点,与抓缺陷毫无关系,全在于对设计的压力。把作者愿意把自己的错误转弯留在文本里(“是啊,我想我搞砸了”)当作榜样:一个可见的错误比一个干净的解法教得更多。但读浮点那几节时要带着怀疑而不是敬意,对解析后的结构而不是它们的打印形式做断言,并把每一次放宽的比较都当作你欠自己一个解释的决定——而不是一次修补。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:读取文件
如果许多文件中有一个解析失败,这个包应当怎样表现?
Go 基础是怎样展开的AI 辅助
开篇。 本章的开头不是一个包,而是一种姿态。作者想构建 blogposts,把一个装着 markdown 文件的文件夹变成一个 Post 值的切片,而第一个决定是写它时站在哪里。答案是:站在消费者这一边。作者没有勾勒这个包会需要的抽象再自下而上地装配,而是写下他希望能做出的那个调用,让它驱动身后的一切。理由是一个关于自知的论证——我们在任务开始时的想象力是靠不住的,并援引肯特·贝克的话——乐观是编程的职业病,而反馈是它的疗法——来说明:一个返回真实信息的短回路,胜过建立在猜测之上的长回路。第一个具体后果是测试的搭建:作者没有用真实目录,而是递进一个内存文件系统,一个假装成文件的映射。这一方面关乎速度——回路里没有磁盘,每种行为不需要新的夹具文件——一方面关乎覆盖面,因为在真实文件夹上难以布置的失败模式,在这里变得轻而易举。
展开。 从这里起,本章以仍能产生一个通过的测试的最小切片推进。先是代码只读取目录并数一数找到什么;然后才开始解析。沿途,作者不断追问一个收窄的问题——不是我被交了什么?,而是这段代码实际用到什么? 测试用的文件系统类型是第一个牺牲品:只要函数要求它,这个包就只能在自己的测试里工作,所以参数放宽到 fs.FS。同样的推理随后下行一层,把 fs.File 缩成 io.Reader,因为 io.ReadAll 是唯一的消费者,别无所求。解析逐行进行——标题、描述、标签——本章的第二个机制就在这里显露。行为被绿色测试钉住之后,作者获得了实验的许可:不透明的偏移量 7 和 13,除了把活干成之外什么也没做,变成了具名的分隔符常量,然后是一个建在 strings.TrimPrefix 之上的 readMetaLine 辅助函数。这里的主张不是安全网直接产生好设计,而是它让实验变得便宜,而单位时间内更多的廉价实验,提高了落在好处的概率。
转折。 本章的支点大多是克制的举动。当出现文件系统失败的问题时,作者展示了这个假货会多么容易——桩上一个总是出错的方法——然后拒绝写这个测试,所述条件是代码对错误什么也不做,只是往上传。这个许可明确是有条件的,不是跳过错误测试的通行证。同样的纪律也支配实现工作的收尾:正文解析用一个扫描循环完成,循环被提取到 readBody,端到端的切片跑通。作者随后列出仍未处理的东西——格式错误的文件、非 markdown 扩展名、顺序不同的元数据——并判断这些需要更多测试,但不需要改动已经被验证的设计。这个判断是作为判断提出的,而不是证明,也是本章预料到它最可能被误读的地方:通过的顺利路径是继续前进的理由,不是宣告这个包已完成的理由。
收束。 反思把教训转向外部。作者把完成的生产调用与测试调用并排放着,指出它们读起来几乎一模一样,差别只在交进去的是哪个文件系统。这种对称是本章最后主张的证据:消费者驱动的、自上而下的 TDD,即便对永远不会离开你自己项目的包也值得使用,因为从调用者一侧驱动设计,才把过度想象的抽象挡在门外,并留下真正有用的测试。钢线能工作了,而仍然开放的是实现细节,不是结构。一则收尾的告诫提防对 io/fs 的过度解读:它描述的只是读取,没有写入,也没有操作系统特有的功能,所以任何想把文章送出去的人,都应该看看标准库在那一侧已经提供了什么——io.Writer 及其亲属——并应用同样的习惯:接受工作所需的最窄接口。
关键论点,以及支撑它们的原文
工作应当朝着消费者所需要的东西迭代地切分,而不是从你在开始之前想象出来的抽象自下而上地构建。
作者论证我们在任务开始时的想象力是靠不住的,并借助肯特·贝克的话——乐观是编程的职业病,而反馈是疗法——所以返回真实反馈的短回路胜过推测出来的结构。
“开始工作时,我们不该相信自己过度活跃的想象。我们可能会被诱惑去做某种抽象,而它只有在把一切粘到一起时才得到验证,比如某种 BlogPostFileParser。”
“We should not trust our over-active imaginations when we start work. We could be tempted into making some kind of abstraction that is only validated once we stick everything together, such as some kind of BlogPostFileParser.”
Reading files › Iterative, test-driven development查看原文 →
接受标准库接口而不是具体类型作为参数,能让一个包保持松耦合、可复用。
承接这个迭代的起点,作者展示:只要代码要求测试用的文件系统类型,它就只能在自己的测试里工作,于是通过把参数改为 fs.FS 修复这一点;同样的推理随后把 fs.File 缩成 io.Reader,因为 io.ReadAll 是唯一的消费者,别无所求。
“学会利用 Go 标准库中定义的接口(例如 io.fs、io.Reader、io.Writer),对写出松耦合的包至关重要。”
“Learning to leverage interfaces defined in Go's standard library (e.g. io.fs, io.Reader, io.Writer), is vital to writing loosely coupled packages.”
Reading files › File system abstractions introduced in Go 1.16查看原文 →
让测试通过,就给了你在重构期间做实验的许可,这使好设计更有可能出现。
行为锁定之后,作者重做了元数据解析:那些只是“把活干成”的无法解释的数字 7 和 13,被具名的分隔符常量取代,再被一个使用 strings.TrimPrefix 的 readMetaLine 辅助函数取代——这个改动之所以被尝试,仅仅因为安全网让它变得便宜。
“TDD 的做法给了我们这种经常尝试想法的许可,所以我们有更多机会写出出色的代码。”
“The TDD approach gives us this license to frequently experiment with ideas, so we have more shots at writing great code.”
Reading files › Refactor查看原文 →
另外 3 个论点
并非每条错误路径都需要自己的测试;在代码只是把错误往上传的地方,跳过测试可以是务实的选择。
作者演示了一个失败的文件系统很容易伪造——一个总是出错的桩,一个方法就够了——却仍然拒绝写这个测试,恰恰因为代码对这个错误除了返回它之外没做什么有意思的事。这个许可取决于那个条件。
“在某些情况下,测试错误处理是务实的做法,但就我们的情形而言,我们对这个错误没做任何有意思的事,只是把它往上传,所以不值得费事去写一个新测试。”
“In some cases, testing error handling is the pragmatic thing to do but, in our case, we're not doing anything interesting with the error, we're just propagating it, so it's not worth the hassle of writing a new test.”
Reading files › Error handling查看原文 →
盯住目标,才会产生要紧的那些决定,而不是过早地追逐边缘情形。
作者先构建最短的可工作端到端切片,然后列出仍未处理的东西——格式错误的文件、非 markdown 扩展名、顺序不同的元数据——并判断这些需要更多测试,但不需要改动已经被验证的设计。作者把这呈现为一个判断,而不是已被证明的东西。
“保持专注于目标,意味着我们做出了重要的决定,并对照期望的行为验证了它们,而不是陷入不会影响整体设计的事情里。”
“Keeping focused on the goal means we made the important decisions, and validated them against the desired behaviour, rather than getting bogged down on matters that won't affect the overall design.”
Reading files › Iterating further查看原文 →
消费者驱动、自上而下的 TDD,即便对永远不会离开你自己项目的包也值得使用。
收尾时把教训转向外部,作者指出完成的生产调用与测试调用之间的对称:它们读起来几乎一模一样,差别只在交进去的是哪个文件系统。从调用者一侧驱动设计,防止了过度想象的抽象,并留下了真正有用的测试。
“当你创建包的时候,即便它们只在你的项目内部使用,也请优先采用自上而下、消费者驱动的做法。这会让你不再过度想象设计、不再做出你甚至可能用不上的抽象,并有助于确保你写的测试是有用的。”
“When you're creating packages, even if they're only internal to your project, prefer a top-down consumer driven approach. This will stop you over-imagining designs and making abstractions you may not even need and will help ensure the tests you write are useful.”
Reading files › Wrapping up查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
窄接口松开耦合
作者问的是代码实际需要什么,而不是它碰巧被交了什么:由于读取这一步只喂给 io.ReadAll,参数从具体的测试文件系统放宽为 fs.FS,从 fs.File 放宽为 io.Reader。因为任何满足这些小型标准接口的提供者现在都合用,这个包不再依赖真实的文件夹,在它自己的测试之外也能使用。
机制 2
绿色的测试作为实验的许可
一旦行为被通过的测试钉住,改动内部就不存在悄悄弄坏的风险,所以作者可以放手尝试想法。不透明的偏移量 7 和 13 就是这样变成具名分隔符、再变成一个 strings.TrimPrefix 辅助函数的:单位时间内更多的廉价实验,意味着落在一个好设计上的机会更大。
机制 3
内存文件系统加速反馈
一个假装成文件的映射把磁盘从回路里拿掉了,所以每种行为不需要新的夹具文件,测试也避开了文件系统访问。作者指出了替代方案的代价:额外的文件、更慢的运行,以及在真实文件夹上难以触发的失败模式。
常见误读
端到端的切片通过了它所有的测试,所以这个包基本完成、可以真正使用了。
那个切片刻意只涵盖顺利路径:格式错误的文件、不是 markdown 的文件、以及不同顺序写的元数据,全都仍未处理。作者的要点是,这些缺口需要更多的测试,而不是重新设计——这是继续前进的理由,不是宣告完成的理由。
“保持专注于目标,意味着我们做出了重要的决定,并对照期望的行为验证了它们,而不是陷入不会影响整体设计的事情里。”
“Keeping focused on the goal means we made the important decisions, and validated them against the desired behaviour, rather than getting bogged down on matters that won't affect the overall design.”
Reading files › Iterating further查看原文 →
既然文件系统现在是通过 io/fs 注入的,同一个接口也将涵盖把文章写回去。
io/fs 描述的只是读取;它不提供写入,也没有操作系统特有的功能。对于把数据送出去,作者指引读者去看标准库在那一侧已经提供了什么,比如 io.Writer,并应用同样的习惯:接受窄接口。
“记得持续思考标准库目前提供了什么,如果你在写数据,你大概应该研究利用现有的接口,比如 io.Writer,来保持你的代码松耦合、可复用。”
“Remember to keep thinking about what the standard library offers currently, if you're writing data you should probably look into leveraging existing interfaces such as io.Writer to keep your code loosely-coupled and re-usable.”
Reading files › Writing?查看原文 →
Go 基础的核心概念
- io/fs
- Go 1.16 的一个接口包,描述一个文件系统,使代码可以接受任何提供者,而不是真实的操作系统文件夹。
- 适用处: 只涵盖基本的、面向读取的文件系统操作;它不提供写入或操作系统特有的功能。
- 测试替身
- 测试中使用的替身实现,这里是一个假装成磁盘上文件的内存映射。
- 适用处: 行为停留在接口层面时有用;它无法证明真实操作系统的文件怪癖表现一致。
- 硬编码糊过
- 为了让测试通过而硬编码一个假的返回值,使你可以在逻辑之前先设计形状。
- 适用处: 只在结构仍未知时有帮助;这里结构早已存在,所以跳过了它。
- 迭代器
- 这里是 bufio.Scanner 一次一行文本地遍历文件,使每个元数据字段都能按顺序剥下来。
- 适用处: 只面向行;对文件的结构性校验被推迟到以后。
- 钢线
- 穿过顺利路径的、可工作功能的最短端到端切片,在边缘情形之前构建。
- 适用处: 它还不能用于生产;错误的格式、非 .md 文件和重新排序的元数据仍未处理。
- strings.Builder
- 一个缓冲区,扫描时用 fmt.Fprintln 往里写行,然后修剪末尾的换行。
- 适用处: 之所以需要,是因为 bufio.Scanner 会去掉换行,所以最后多出来的换行必须修剪。
另外 1 个概念
- 依赖注入
- 把文件系统传进来,于是测试交进去 fstest 数据,生产交进去一个真实的目录。
- 适用处: io/fs 只支持读取;写入需要其他接口,比如 io.Writer。
Go 基础划线与批注AI 陪读
“乐观是编程的职业病。反馈是治疗。”
“Optimism is an occupational hazard of programming. Feedback is the treatment.”
查看原文 →本章最好的一句,而且是借来的。它把 TDD 重新框定为针对性格缺陷的医疗方案,而不是质量流程——这大概就是它能打动人的原因。
“我们还在不必要地把自己与文件系统的某个特定实现耦合起来。”
“We're also unnecessarily coupling ourselves to a specific implementation of the file system.”
查看原文 →注意修辞上的先后:作者在揭示 fs.FS 存在之前,先列出真实测试文件夹的种种痛苦。问题被布置成使解法显得不可避免。
“func NewPostsFromFS(fileSystem fstest.MapFS) []Post”
查看原文 →生产签名里出现了一个仅限测试的类型。这是刻意写错的,他也知道——但一个浏览着找模式的读者,很容易照抄并发布出去。
另外 8 条批注
“post := Post{Title: string(postData)[7”
查看原文 →诚实的丑陋。用魔法数字 7 切片,在任何短文件上都会 panic——他赌的是展示这一步尴尬的举动,比藏起来赢得更多信任。
“return nil, err //todo: needs clarification, should we totally fail if one file fails? or just ignore?”
查看原文 →这里最诚实的一行代码。付印的教学材料里留着一条 TODO,承认这种方法浮现了它回答不了的问题——然后本章再也没有回头。
“newPost 一定要与 fs.File 耦合吗?我们用到了这个类型的所有方法和数据吗?我们真正需要的是什么?”
“Does newPost have to be coupled to an fs.File ? Do we use all the methods and data from this type? What do we really need?”
查看原文 →这才是本章真正可迁移的技能,而不是读文件。“要你真正用到的最窄的东西”,就是整个 Go 接口哲学浓缩成的一个问题。
“这个函数永远不会返回错误。”
“This function could never return an error.”
查看原文 →他出于对未来的猜测保留了一个没用的错误返回——正是他在引言里警告过的那种对需求的过度想象。接口稳定是辩护理由,但这是一个没有被承认是例外的例外。
“我们没有对这个错误做任何有意思的事,只是把它往上传,所以不值得费事去写一个新测试”
“we're not doing anything interesting with the error, we're just propagating it, so it's not worth the hassle of writing a new test”
查看原文 →一次少见而受欢迎的承认:TDD 的严格是可以商量的。值得注意的是他仍然写了那个桩——于是读者得到了技术,而不必背负义务。
“如果元数据字段的顺序不同呢?”
“what if the order of the metadata fields is different?”
查看原文 →诚实的坦白是:readMetaLine 是按位置的,而不是按键的——优雅的逐行读取其实是一个伪装起来的僵硬解析器。这里列出的每一个未处理的情形,都是固定顺序设计让它变得更难的。
“请注意生产代码与……之间的对称”
“Notice the symmetry between the production code”
查看原文 →回报是真实的,也很容易错过:os.DirFS 和 fstest.MapFS 嵌进同一个洞里,所以测试不是对用法的模拟,它就是用法。
“需要注意的是,这些新特性只有读取文件的操作。”
“It's important to note that these new features only have operations for reading files.”
查看原文 →好在这点被标了出来,但它被埋在总结之后。io/fs 只读的性质,是你在其上构建抽象之前,而不是之后,就值得知道的一项设计约束。
读后感AI 陪读
优雅的 readMetaLine 闭包其实是个脆弱的位置解析器
一章几乎没教读文件,却大量教了工作的排序。它真正的论点是:抽象应当在重构一步里被发现,而不是在一开始被设计,而最强的时刻是对 newPost 参数的盘问——“我们只把它传给 io.ReadAll,所以就要 io.Reader”——这把整个 Go 接口的精神浓缩成一个习惯。对教学材料来说,这份诚实不同寻常:丑陋的魔法数字切片被展示出来而不是藏起来,一条关于逐文件错误策略的真正的 TODO 活到了付印,而作者明确允许在传播错误并不有趣时跳过一个测试。不过有两道裂缝。在一个不可能失败的函数上保留的错误返回,是作者对未来需求的过度想象,正是引言所警告的那种罪,而它被放行,却没有被点名为例外。更实质的是,结尾那份推迟的需求清单——错误的格式、非 .md 文件、重新排序的元数据——悄悄暴露出,那个优雅的 readMetaLine 闭包其实是个对变化毫无容忍的位置解析器。“我们不必改设计,只需改实现细节”这一说法,对 NewPostsFromFS 的签名成立,却在实实在在地掩盖:newPost 的内部需要重写,而不是扩展。尽管如此,main 里的 os.DirFS 与测试里的 fstest.MapFS 之间的收尾对称,挣得了整套做法:测试演练的是真实的消费者路径,而不是它的 mock。要带走的教训比 TDD 布道更窄——从标准库里拿接口,然后只取你真正触碰的那一小部分。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:HTML 模板
markdown 的正文最终是怎样变成 HTML 的,html/template 会对它转义吗?
Go 基础是怎样展开的AI 辅助
如何开篇。 本章以一个论点而不是代码开篇:并非每个网站都需要 JavaScript 单页应用。作者把这建立在 Go 开发者往往看重的东西上——一个简单、稳定、快速的工具链——并指出沉重的前端构建系统给出的恰恰相反。如果直接由 Go 提供的纯 HTML 和 CSS 就能胜任,标准库就已经足够。这个前提框定了本章:目标是在 Go 里、先写测试,渲染一个博客的 HTML。
如何展开。 忠于本书的方法,第一个绿色的实现刻意很粗糙——一长串带重复错误处理的 fmt.Fprintf 调用。它很丑,作者也直说了,承认随着标记变多它撑不下去。但它是可工作的软件,而可工作的软件是你能从中学习的东西。回报立刻到来:把那一串换成 template.New(“blog”).Parse 加 Execute,是一次纯粹的重构。测试从未改变,一直通过,而代码缩短了。这是本章反复出现的节奏——先粗糙地让它工作,再在测试的保护下改变做法。
第一个转折:你如何断言。 随着 HTML 变多,测试本身成了问题。对确切的标记字符串做断言,意味着每次微调之后都要手工重敲期望的输出,于是测试腐烂。补救是认可测试:期望的输出住在一个文件里;第一次运行失败并写出一个已收到的文件;你读它,把它复制到已认可的文件上,测试就转绿了。回路变成:改模板 → 运行 → 目测 → 认可。作者小心地说,这仍然是把测试当作设计工具,而不是放弃测试。从这里起,工作在结构上推进——模板挪进嵌入的 .gohtml 文件,顶部和底部的局部模板被组合起来——然后进入以性能为形状的设计:每个请求都解析模板会重复同样的工作,所以解析被挪进一个 NewPostRenderer 构造函数,使每次渲染除了填数据无事可做。一个基准测试让浪费看得见,从 53812 降到 3131 ns/op。注意这个框定:作者承认,对于单篇文章,绝对的节省通常可以忽略不计。基准测试是针对一个设计论点的证据,不是性能上的强制要求。
第二个转折:逻辑住在哪里。 渲染一个索引页引入了一个新需求——清理标题以便它们能在 URL 里用——随之而来的是本章最尖锐的教训。通过 template.FuncMap 接入 sanitiseTitle 行得通,但它把逻辑困在模板后面:它的边缘情形只能靠渲染整个页面并比对 HTML 来演练。对一条业务规则来说,这是糟糕的测试方式,也隐含着一个警告,提醒别对刚刚建立的认可测试过于信任——认可文件用于你凭目测认可的、庞大难看的输出,而不是用来钉死边缘情形。所以逻辑被拿出模板。这里最简单的可行修复,是 Post 上的一个 SanitisedTitle 方法:模板回到只负责放置数据,而规则变得可以直接做单元测试。
如何收束。 本章并没有把这个修复当作胜利来结束;它结束在它的代价上。把视图关注点放到核心领域对象上,是通往上帝对象之路的第一步。作者的收尾立场是有条件的:现在一个方法就够了,只有当从领域到视图的转换增长到足以说明它值得,专用的视图模型才配得上多出来的那个类型。本章于是结束在它开始的地方——对工具和结构的判断,而不是一件完成的作品——并带着一条常设的原则:模板应当保持无逻辑,而逻辑应当住在测试能直接够到的地方。
关键论点,以及支撑它们的原文
许多网站不需要 JavaScript 单页应用;Go 可以自己提供 HTML。
作者的论证出发点是 Go 开发者真正看重的东西——简单、稳定、快速的工具链——而沉重的前端构建系统做不到,所以由 Go 提供的纯 HTML 和 CSS 更合适。这开启了本章,并铺垫了随后的一切。
“许多网站并不需要是单页应用(SPA)。HTML 和 CSS 是交付内容的绝佳方式,而你可以用 Go 做一个交付 HTML 的网站。”
“Many websites do not need to be an SPA. HTML and CSS are fantastic ways of delivering content and you can use Go to make a website to deliver HTML.”
HTML Templates查看原文 →
以微小的步子工作:先让粗糙的代码通过,再在重构时改变做法。
第一个绿色的实现是一长串带重复错误检查的 fmt.Fprintf 调用——很丑,作者也承认随着标记变多它撑不下去——但它是你能从中学习的可工作软件。随后把这一串换成 template.New(“blog”).Parse 加 Execute,就成了一次纯粹的重构:测试从未改变,一直通过,而代码缩短了。
“为了在工作中发现和学习,我们需要以一种提供频繁、高质量反馈循环的方式工作,而最容易的办法就是小步前进。”
“In order to discover and learn as we work, we need to work in a way that gives us frequent, high-quality feedback loops, and the easiest way to do that is work in small steps.”
HTML Templates › Write enough code to make it pass查看原文 →
对确切的标记字符串做断言,会让测试成为维护的噩梦;把期望的输出放在认可文件里。
HTML 一变多,每次微调都迫使你吃力地手工重写期望的字符串。有了认可文件,回路变成:改模板、跑测试、看收到的输出、认可它,周而复始——作者指出这仍然是把测试当作设计工具,而不是放弃测试。
“如果每次对标记做一点小改动,我们都得费尽心思去更新断言字符串,维护这个测试将成为一场噩梦。”
“Maintaining this test will become a nightmare if we have to keep painstakingly updating the assertion string every time we make minor changes to the markup.”
HTML Templates › Next: Make the template "nice"查看原文 →
另外 3 个论点
在渲染器类型里只解析一次模板,既更整洁,也比每次渲染都重新解析快得多。
一个基准测试直接度量了差别:一旦 NewPostRenderer 持有已解析的模板,每次渲染的时间从 53812 ns/op 降到 3131 ns/op。作者小心地补充说,对于单篇文章,绝对的节省通常可以忽略不计。
“为了避免我们一遍又一遍地重新解析模板,我们将创建一个持有已解析模板的类型,并给它一个做渲染的方法”
“To stop us having to re-parse the templates over and over, we'll create a type that'll hold the parsed template, and that'll have a method to do the rendering”
HTML Templates › An excuse to mess around with Benchmarking查看原文 →
把逻辑推进模板,会把这些逻辑困在对渲染出的 HTML 的比较后面,这是测试业务规则的糟糕方式。
通过 template.FuncMap 传入的 sanitiseTitle 函数确实可行,但它的边缘情形只能靠渲染页面并比对标记来演练——这是作者偏爱无逻辑模板的理由,也是他此后转向视图模型的理由。
“在当前这种设计下,你测试这条逻辑的唯一办法,就是渲染 HTML 并比较字符串。”
“With this current design, your only means of testing this logic is by rendering HTML and comparing strings.”
HTML Templates › Passing functions into templates查看原文 →
这里清理标题属于 Post 上的一个方法;只有当从领域到视图的转换变复杂时,专用的视图模型才配得上它的位置。
给 Post 加上 SanitisedTitle,让模板回到单纯的数据放置,并使逻辑可以做单元测试——最简单的可行修复。作者也点出了代价:这是视图关注点渗入核心领域对象,是通往上帝对象之路,而这恰恰是独立的视图模型值得多出一个类型的时候。
“当领域对象与视图之间的转换变得更复杂时,专用的视图模型会更有用。”
“dedicated view models become more useful when the transformation between the domain object and view becomes more involved.”
HTML Templates › Create a dedicated view model type, such as PostViewModel with exactly the data we need查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
认可文件取代字符串断言
手写的期望标记在 HTML 每次变化时都得重敲,于是测试腐烂。认可测试则把期望输出存在一个文件里:第一次运行失败并写出一个“已收到”的文件,你目测它并把它复制到已认可的文件上,测试便转绿。
机制 2
解析一次,然后只管执行
每个请求都重新解析模板文本,会重复同样的工作。把解析挪进渲染器类型的构造函数,使每次渲染除了填数据别无他事,基准测试显示每次渲染的时间从 53812 降到 3131 ns/op。
机制 3
模板里的逻辑逃过了单元测试
通过 template.FuncMap 接入的函数,只能靠渲染整个页面并比较 HTML 字符串才够得到,所以它的边缘情形是通过标记来测试的。把它抽成数据类型上的一个方法,或放进视图模型,就恢复了直接的单元测试,让模板只负责放置数据。
常见误读
认可测试涵盖了渲染出的输出,所以也是钉死标题清理边缘情形的好地方。
认可测试是用于你凭目测认可的庞大难看的输出;它们仍比单元测试昂贵,且对标记的频繁变动敏感。像标题清理这样的业务规则应当被挪出模板——挪到一个方法或视图模型上——并直接测试,这正是作者拒绝把逻辑留在 template.FuncMap 里的原因。
“在当前这种设计下,你测试这条逻辑的唯一办法,就是渲染 HTML 并比较字符串。”
“With this current design, your only means of testing this logic is by rendering HTML and comparing strings.”
HTML Templates › Passing functions into templates查看原文 →
53812→3131 ns/op 的基准测试意味着,出于性能原因你必须把解析提到外面。
作者承认,对于单篇文章,绝对的节省通常可以忽略不计。真正的动机是设计:持有已解析模板的渲染器类型,不再让代码重复同样的工作,读起来也更好——基准测试只是让浪费看得见。
“为了避免我们一遍又一遍地重新解析模板,我们将创建一个持有已解析模板的类型,并给它一个做渲染的方法”
“To stop us having to re-parse the templates over and over, we'll create a type that'll hold the parsed template, and that'll have a method to do the rendering”
HTML Templates › An excuse to mess around with Benchmarking查看原文 →
Go 基础的核心概念
- html/template
- 一个标准库包,把模板与数据合并,生成能防范代码注入的 HTML。
- 适用处: 刻意不含逻辑;构建非 HTML 的字符串请改用 text/template。
- 嵌入
- //go:embed 指令把模板文件或整个文件系统烘焙进编译后的二进制。
- 适用处: 文件在编译期就固定了,所以依赖配置的模板仍应从磁盘加载。
- 认可测试
- 把输出与一个保存的已认可文件比较、而不是与内联的期望字符串比较的测试。
- 适用处: 它使工作偏离严格的 TDD,因为期望输出是事后认可的,而不是事先写下的。
- io.Writer
- 一个接口,让渲染器可以同样地把 HTML 写到文件、HTTP 响应或测试缓冲区。
- 适用处: 它只固定了输出目的地;对标记本身如何产生只字未提。
- 基准测试
- 一种 Go 测试,度量函数每次操作花多长时间,暴露像重复解析这样的浪费。
- 适用处: 这里的节省绝对值很小;作者指出真正的动机是让代码更整洁。
- 视图模型
- 一个只为携带模板恰好需要的数据而构建的类型,使业务逻辑留在别处保持可单元测试。
- 适用处: 当从领域到视图的转换很复杂时最有价值;否则领域类型上的一个方法更简单。
另外 3 个概念
- 无逻辑模板
- 只负责放置数据、内部没有分支或计算的模板。
- 适用处: Go 允许你通过 template.FuncMap 传入函数,所以这个约束是一种纪律,而不是强制。
- template.HTML
- 一个包装类型,把字符串标记为可信的 HTML,使 html/template 原样输出而不转义。
- 适用处: 只适用于可信来源;第三方 HTML 必须先清理。
- 认可测试
- 对庞大难看的输出做快照、并要求你认可变更而不是手写期望的测试。
- 适用处: 比以前便宜,但仍比单元测试昂贵,且对标记变动敏感。
Go 基础划线与批注AI 陪读
“许多网站不需要做成 SPA。”
“Many websites do not need to be an SPA.”
查看原文 →整章的政治立场就在这一行。他后来靠从不真正需要 JS 来挣得它——论证是由代码做出的,而不是口号。
“请注意,写这个让人觉得别扭。在测试里看到所有那些标记,感觉很糟”
“Notice that writing this, feels awkward. Seeing all that markup in the test feels bad”
查看原文 →他刻意写出糟糕的测试,让你坐在不适里。以痛苦为教学——重构之所以落地,只因为你感受到了为什么需要它。
“换行!谁在乎呢?嗯,我们的测试在乎,因为它在匹配一个精确的字符串值。它应该在乎吗?”
“New lines! Who cares? Well, our test does, because it's matching on an exact string value. Should it?”
查看原文 →那句“它应该在乎吗?”是整章的合页。缺陷不在代码里,而在测试选择去在意什么。
另外 13 条批注
“人们常常抱怨 Go 里错误处理的冗长,但你可能会发现,可以找到更好的写代码方式,使它一开始就不那么容易出错”
“Frequently people complain about the verbosity of error handling in Go, but you might find you can find better ways to write your code so it's less error-prone in the first place”
查看原文 →漂亮的重新框定:那六个 if err != nil 块不是 Go 的错,而是做法错了的迹象。冗长作为设计坏味道的探测器。
“但那太傻了!TDD 是做事的一种方法,具体来说是设计的一种方法;但这并不意味着我们必须教条地把它用于一切。”
“But that's just silly! TDD is a method for doing work, specifically designing; but that doesn't mean we have to dogmatically use it for everything.”
查看原文 →一本 TDD 的书承认 TDD 在哪里不再划算。可信度买得便宜又好——还要注意他画的那条线:API 形状用 TDD,外观用认可测试。
“如果我们在这里坚持先写代码再补测试的认可测试做法,我们就不会在受控的环境里回答这些问题了。测试给了我们思考的空间。”
“If we had stuck with a test-after, approval tests approach here we would not be answering these questions in a controlled environment. Tests give us space to think.”
查看原文 →对前一个让步的平衡,也是更尖锐的主张:认可测试确认输出,TDD 迫使做决定。他根据哪个问题悬而未决来切换工具。
“不这样做的努力也相当微不足道,而且还应该把代码整理得更整洁一点”
“the effort to not do this is also pretty negligible and should tidy the code up a bit too”
查看原文 →诚实的排序——性能收益是借口,真正的回报是结构。他无论如何都得到的 17 倍提速,几乎是对过早优化说教者的一个玩笑。
“got "<ol><li><a href=\"/post/Hello%20World\">Hello World</a></li”
查看原文 →html/template 悄悄对空格做了 URL 转义——上下文感知的转义不请自来地做着它的工作。这个失败的测试是一堂免费的课:这个包知道自己身处 href 之内。
“这是一个强大的特性,能把函数送进你的模板会让你做一些非常酷的事,但是,你应该这样做吗?”
“This is a powerful feature, being able to send functions in to your template will allow you to do some very cool things, but, should you?”
查看原文 →整章围绕这半句话转向。他先把这个特性好好教完,再问要不要用它——这个次序本身就是教训。
“你测试这条逻辑的唯一办法,就是渲染 HTML 并比较字符串”
“your only means of testing this logic is by rendering HTML and comparing strings”
查看原文 →反对模板逻辑的真正理由不是审美,而是你失去了测试仪器。你只能通过渲染出的字符串来观察的逻辑,是你无法审问的逻辑。
“受 Mustache 影响的模板引擎给你的是一个有用的约束”
“What Mustache-influenced templating engines give you is a useful constraint”
查看原文 →把局限重新框定为礼物。并注意他刚给我们看了逃生舱口——这个约束只有在你知道自己本可以打破它时才有教育意义。
“它现在成了一个核心领域对象的 API 的一部分。这种做法久而久之会导致你创建出上帝对象”
“it now becomes a part of the API for a core domain object. This kind of approach over time can lead to you creating God Objects”
查看原文 →SanitisedTitle 看着无害,却是骆驼的鼻子。领域对象现在知道了 URL。
“两个选项都行,在这种情况下我倾向于选第一个。”
“Both options are fine, in this case I am tempted to go with the first.”
查看原文 →令人耳目一新地不教条——他点出缺点,然后还是选了廉价的选项,并附带一道何时切换的绊线。多数书会选纯粹的答案,假装没有代价。
“但这种一般的本能在这里失效了:gomarkdown 的解析器保持着内部状态”
“But that general instinct breaks down here: gomarkdown's parser keeps internal state”
查看原文 →少见而诚实的脚注:优化的反射通常是对的,而这里它是个缺陷。比本章的主要教训更有价值。
“使用这个类型存在安全风险:被封装的内容应当来自可信来源”
“Use of this type presents a security risk: the encapsulated content should come from a trusted source”
查看原文 →template.HTML 是一份签了字的豁免书。对你自己写 markdown 的博客没问题——但这恰恰是用户提交的内容会在无人察觉中越过的那条线。
“人们喜欢抱怨现代 Web 开发的复杂性,但你确定你不是在给自己强加这种复杂性吗?”
“People like to bemoan the complexity of modern web development but are you sure you're not just inflicting the complexity on yourself?”
查看原文 →挣来的论战——他用整章演示了替代方案,而不是只作断言。不过,当你的例子没有客户端状态时,“这是你自己的错”说起来更容易。
读后感AI 陪读
先展示代码能用,再陈述你的主张:就是这个次序
这一章是一场伪装成教程的论证,而伪装正是要点。它以一句口号开场——“许多网站不需要做成 SPA”——然后在其余篇幅里拒绝用修辞为它辩护,而是靠仅用标准库造出一个可工作的博客渲染器来为它辩护。等到结尾的论战到来(“你确定你不是在给自己强加复杂性吗?”),代码早已把理由讲完;那句话是一张收据,不是一个主张。这种次序——先演示,再断言——是本章最好的结构习惯,并且在每个尺度上反复出现。
脊梁是一连串作者刻意走进去的不适。他写出带六处错误检查的 fmt.Fprintf 版本,然后把这种冗长重新框定为不是 Go 的缺陷,而是设计的坏味道:错误处理之所以嘈杂,是因为做法错了。他写出对确切标记做断言的测试,承认这感觉很糟,并任由一个多余的换行把它弄坏——然后问出整章所系的那个问题:测试该在意这个吗?缺陷从来不在代码里;它在于测试选择去注意什么。认可测试作为答案到来,关键是它不是 TDD 的替代,而是一种分工:当悬而未决的问题是 API 形状时用 TDD,当悬而未决的问题是外观时用认可测试。他慷慨地做出让步(“这并不意味着我们必须教条地把它用于一切”),然后收回恰好合适的那么多地盘(“测试给了我们思考的空间”)。这两步走比单独任何一半都更有价值——它是一个关于工具选择的工作启发,而不是教义。
继续阅读
同样的诚实支配着后半部分。模板函数在被质疑之前先被好好地教过,而反对意见最终是工具性的而不是审美的:你只能通过渲染出的字符串来观察的逻辑,是你无法审问的逻辑。由此,Mustache 著名的局限被重新框定为一份礼物——一个约束,只有因为他先给你看了逃生舱口,才有教育意义。备选方案(领域类型上的一个方法,或一个视图模型)连同被大声说出的缺点一起被权衡:SanitisedTitle 是骆驼的鼻子,是核心对象开始了解 URL、并开始向上帝对象缓慢漂移的那一刻。他还是选了廉价的选项,并为何时切换留下了一道绊线。多数书会选纯粹的答案,假装它是免费的。
细节挣得了它们的位置。html/template 悄悄对 href 里的一个空格做百分号编码,是关于上下文感知转义的一堂免费的课,由一个失败的测试而不是一段文字来传授。template.HTML 被正确地标记为一份签了字的豁免书——对你自己写 markdown 的博客没问题,却恰恰是用户提交的内容会在无人察觉中越过的那条线。只解析一次的重构被推销为先是结构、其次才是速度,然后还是收获了 17 倍的提速,这是对过早优化说教者的一个无声的玩笑。而关于 gomarkdown 有状态解析器的脚注——通常正确的缓存反射在那里成了缺陷——正是那种比本章标题论点活得更久的、辛苦得来的具体知识。
我会对读者说:拿方法,别拿宣言。这里可迁移的技能是:问你的断言究竟在意什么,把错误处理的噪音读作关于设计的信号,并留意逻辑何时迁移到了你再也无法测试的地方。论点本身是真的,但在容易的地盘上受的检验——博客没有客户端状态,所以“你是在给自己强加复杂性”在这里说起来,比在一个有实时交互的应用里便宜。结论要抓得松,实践要抓得紧。本章之所以有说服力,不是因为服务端渲染的 HTML 总是赢;而是因为作者一直让他的测试向他提出令人不适的问题,并一直诚实地作答,包括答案是“这个工具到这里就不再有用了”的时候。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:泛型
对一个新的辅助函数,你会如何在接口与类型参数之间做选择?
Go 基础是怎样展开的AI 辅助
开篇:一个刻意琐碎的起点。 本章开头不是泛型的定义,而是一个微小而具体的需求——测试辅助函数。作者为 int 写下 AssertEqual 和 AssertNotEqual,随后立刻要求它们多做一件事:适用于字符串。编译器拒绝了,而这一拒绝才是真正的开场一步。文字没有把错误当作障碍,而是停下来为它辩护,列出类型约束买来的东西:更简单的实现(你无法把一个 Person 加到一个 BankAccount 上),以及防止意外传入错误的数据。这个框定对随后的一切都要紧,因为它确立了目标不是逃离类型系统,而是在它之内获得灵活性。
展开:interface{} 的弯路,被认真对待、然后被拆解。 泛型出现之前显而易见的答案是 interface{},而本章真的走了这条路——改签名,看着测试通过,指出流行的断言库正是这么做的。然后它探查代价。AssertEqual(1, “1”) 能编译;编译器对你的参数再也无话可说;开发者最终转而求助反射。问题被精确地刻画:错误从编译期迁移到运行时,在那里它们触达用户。只有在这个代价被具体化之后,本章才引入解法——[T comparable]——并按约束而不是机制来解释这种语法:之所以选 comparable,是因为函数需要 == 和 !=,换成 any 会产生一个诚实的编译器错误。一段简短的旁白回答了那个自然的反对(“T any 不就是 interface{} 吗?”),给出干净的区分:interface{} 让你把苹果和橘子混在一起,而 T 把所有参数绑定到同一个类型,所以 GenericFoo(apple1, orange1) 会失败。
转折:同样的论证在数据结构层面重演。 解决了函数之后,本章为类型重新上演整个序列——这种重复是这一篇的结构核心。我们得到 StackOfInts 和 StackOfStrings,近乎相同的孪生兄弟,带着近乎相同的测试。基于 interface{} 的统一先被善意地尝试,并且又一次通过了测试。决定性的一击是一个新测试:弹出两个值并相加。operator + not defined on firstNum——而修复,即对每一个值都做类型断言加 ok 检查,被完整地展示出来,让丑陋被感受到而不是被断言。Stack[T any] 随后以解脱而不是新奇的面貌落地,Pop() (T, bool) 返回一个可用的 int,字符串栈的测试作为多余的被删除,并加上一个构造函数 NewStack[T](),以涵盖推断无能为力、你必须显式写出类型的情形。
收束:从语法到判断。 最后一节退后一步,把精力花在文化而不是代码上,逐一预先回应社区的焦虑。泛型会把 Go 变成 Java 吗?不会——而反驳是“小心使用”对每一种语言特性都成立,包括接口,它们本身也只是描述约束的另一种方式。作者指出,你每次使用切片或映射,就已经在使用泛型了。抽象不是脏话;一个毫无抽象的代码库同样糟糕。真正的告诫留到最后,而且是方法论上的:不要太早泛化。本章指回自己的结构作为证据——它之所以抵达 Stack[T],只因为先构建了具体的、经过测试的 StackOfInts 和 StackOfStrings,并注意到了模式。收尾的 TDD 概述(“一点点重复,胜过与一个糟糕的抽象耦合……睡一觉再说”)把论点说得很明确:泛型是用来整合你已经理解的行为的工具,不是用来事先想象它的。
关键论点,以及支撑它们的原文
用静态类型语言写作,大半是陈述约束的工作,做好它能让代码既更安全、也更容易写。
作者论证,告诉编译器你处理哪些类型,会缩小有效实现的集合:你就是无法把一个人加到银行账户上,也无法把一个数字首字母大写,所以甚至可以被表达的错误程序更少了,对函数的意外误用也会替你捕获。
“使用静态类型语言是一种描述约束的行为。”
“Using statically typed languages is an act of describing constraints.”
Generics › Generics are simpler than using interface{} in most cases查看原文 →
为了获得灵活性而伸手去拿 interface{},会丢掉这些约束,并把失败转移到运行时。
这是上一点的反面。一个以 interface{} 切片为底的栈欣然通过编译,同时允许把苹果压入一个橘子的栈;把整数与字符串 “1” 比较同样能编译,只在运行时以一条没有帮助的信息失败。
“编写接受 interface{} 的函数可能极具挑战且容易出错,因为我们失去了约束,在编译期对我们所处理的数据种类一无所知。”
“Writing functions that take interface{} can be extremely challenging and bug-prone because we've lost our constraints, and we have no information at compile time as to what kinds of data we're dealing with.”
Generics › The problem with interface{}查看原文 →
带 T any 的泛型函数,不仅仅是换了新衣的 interface{}。
作者承认,作为约束,any 只是 interface{} 的别名,然后画出区别:接口版本接受任意混合的参数,而泛型版本把所有参数绑到同一个类型,所以比较两个苹果能编译,苹果对橘子则不能。
“然而 GenericFoo 仍提供一些约束,因为我们已经说过它只对一个类型 T 起作用。”
“However GenericFoo still offers some constraints because we've said that it only works with one type, T.”
Generics › Is a generic function with T any the same as interface{} ?查看原文 →
另外 3 个论点
泛型数据结构去掉了重复的逻辑,而不带 interface{} 给调用者造成的痛苦。
完全相同的 int 栈和 string 栈类型,各自有自己的测试,合并成一个 Stack[T]。用 interface{} 时,一个弹出两个值并相加的测试编译不过,需要对每个值做断言加 ok 检查;用 Stack[int] 时,弹出的值就是普通的 int,加法直接就行,而重复的字符串栈测试可以删掉。
“然而 GenericFoo 仍提供一些约束,因为我们已经说过它只对一个类型 T 起作用。”
“However GenericFoo still offers some constraints because we've said that it only works with one type, T.”
Generics › Is a generic function with T any the same as interface{} ?查看原文 →
只有当具体的、经过测试的行为显示出值得捕捉的模式时,才去泛化。
作者讲述了自己的路径:先有具体的、有测试支撑的 StackOfStrings 和 StackOfInts,等重复变得可见了,再重构成 Stack[T]。他提供了那条熟悉的“等你看到三次再说”作为粗略的指南,指出一点重复胜过与糟糕的抽象耦合,并强调通过的测试才让之后的重构安全——这里没有硬性规则。
“这里没有硬性规定,但在你看出自己有了一个有用的泛化之前,要抗拒把东西做成泛型。”
“There's no hard and fast rules here but resist making things generic until you can see that you have a useful generalisation.”
Generics › Make it work, make it right, make it fast查看原文 →
泛型不是会把 Go 变成 Java 的、独一无二地危险的特性。
作者回应社区的恐惧,指出接口也是描述约束的一种方式,选得糟糕的接口同样令人困惑——所以“小心使用”是适用于每一种语言特性的建议。他补充说,任何使用切片和映射的人,早已在消费泛型代码了,因为你无法把橘子追加到苹果的切片上,而完全没有抽象的代码库也没有好到哪里去。
“使用静态类型语言是一种描述约束的行为。”
“Using statically typed languages is an act of describing constraints.”
Generics › Generics are simpler than using interface{} in most cases查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
interface{} 如何抹去检查
把参数声明为 interface{},什么也没告诉编译器关于数据的事,所以它无从知道哪些运算符合法。本可以在构建时失败的检查被移到运行时,迫使用类型断言或反射,并放过了不匹配的值。
机制 2
一个标签,一种类型
像 T 这样的类型参数是一个单一的占位符,每次调用绑定一次,所以每个声明为 T 的参数都必须共享那一个类型。因为类型通常是从参数推断出来的,调用者获得了跨类型的灵活性,而每一次具体的调用仍受类型检查。
机制 3
有类型的结构体,有类型的方法
把类型参数放在结构体上,使方法接受并返回同一个类型,仿佛为每个实例化各生成了一份单独的实现。于是 Stack[int].Pop 交回一个 int,按类型重复的结构体就合而为一。
常见误读
既然 any 字面上就是 interface{} 的别名,那么用 T any 写泛型函数,相比直接接受 interface{} 并无任何好处。
两者的区别在于类型在哪里被固定。类型参数把所有参数(和返回值)绑到每次调用选定的一个类型,所以像传入 “1” 和 1 这样的混合类型调用会编译失败,调用者无需断言就拿回真实的类型;而 interface{} 参数接受任意组合,返回的东西编译器一无所知。
“然而 GenericFoo 仍提供一些约束,因为我们已经说过它只对一个类型 T 起作用。”
“However GenericFoo still offers some constraints because we've said that it only works with one type, T.”
Generics › Is a generic function with T any the same as interface{} ?查看原文 →
泛型胜过重复,所以一旦出现两个相似的类型,就该把它们做成泛型。
本章建议等一个你真能看见的泛化。作者刻意先构建有测试的具体栈,援引等第三次出现的经验法则,并警告:一点重复,好过把自己耦合到一个在理解行为之前就发明出来的抽象。
“这里没有硬性规定,但在你看出自己有了一个有用的泛化之前,要抗拒把东西做成泛型。”
“There's no hard and fast rules here but resist making things generic until you can see that you have a useful generalisation.”
Generics › Make it work, make it right, make it fast查看原文 →
Go 基础的核心概念
- 类型参数
- 一个带标签的占位类型,比如 T,声明在方括号里,使函数或结构体可以处理由调用者选定的某一个类型。
- 适用处: 通常从参数推断;无法推断时必须显式写出,比如构造函数。
- comparable 约束
- 一个约束,说明该类型支持 == 和 !=,这正是断言辅助函数所需要的。
- 适用处: 改用 any 会编译失败,因为这些运算符并非对每个类型都有定义。
- 泛型数据结构
- 用类型参数声明的结构体,所以 Stack[int] 只存储和返回 int。
- 适用处: 语法与泛型函数一致;栈的类型决定其方法接受和返回什么。
- 接口
- 这里用作 interface{} / any,意思是“任何东西”,它带来灵活性,却抹去了类型信息。
- 适用处: any 在 1.18 中作为 interface{} 的别名加入;与类型参数不同,它允许在一次调用中混用类型。
另外 0 个概念
Go 基础划线与批注AI 陪读
“在软件里,约束往往极有帮助。”
“In software, constraints are often extremely helpful.”
查看原文 →本章真正的论点,埋在一句旁白里。类型不是文书工作,它们缩小了你甚至能写出的错误程序的空间。
“尽管错误信息 got 1, want 1 并不清楚”
“although the error message got 1, want 1 is unclear”
查看原文 →诚实的好细节:失败确实触发了,只是它对原因撒了谎。这比无法编译更糟,也是反对 interface{} 的全部论证浓缩成一行。
“any 在 1.18 中加入,只是 interface{} 的别名”
“any was added in 1.18 and is _just an alias for interface”
查看原文 →所以约束根本不在 any 这个词里——而在那个让两个参数达成一致的单一标签 T 里。力量在绑定,不在边界。
另外 5 条批注
“为简洁起见,我省略了 TDD 过程”
“For the sake of brevity I've omitted the TDD process”
查看原文 →一本 TDD 的书跳过了 TDD。可以理解,但它悄悄削弱了后面“经由循环抵达抽象”的寓意——重复是被预先煮好递给我们的。
“如果你这样做,我们的测试仍然通过。谁需要泛型?”
“If you do this, our tests still pass. Who needs generics?”
查看原文 →本章最好的修辞一拍:让糟糕的版本通过套件,再显示套件从来就不是重点。类型安全关乎你写不出的代码,而测试无法度量它。
“一点点重复,胜过与一个糟糕的抽象耦合”
“a little duplication is better than coupling to a bad abstraction”
查看原文 →对以上一切的诚实刹车。还要注意:本章宣传泛型,却用最后几页告诉你先等一等再用。
“谢天谢地,我这么聪明又好看,因为我用 TDD”
“Thank goodness I am so smart and good-looking because I use TDD”
查看原文 →这个玩笑很迷人,却在做说服的活——自嘲偷运进一个相当强的主张:TDD 才是允许无畏重构的执照。
“你一直都是泛型代码的消费者”
“you've already been a consumer of generic code”
查看原文 →对付恐慌散布最强的化解论据:切片和映射一直都是参数化类型的,只不过这项特权留给了编译器作者。
读后感AI 陪读
出色的泛型章节,但缺少对它的 TDD 论证
一章主要靠布置一次失败、然后拒绝接受它来工作的教学。教学的脊梁很强:int 辅助函数 → string 就坏了 → interface{} “修好”了它 → 修复在调用处烂掉(operator + not defined)→ 泛型。最犀利的一招,是让 interface{} 的 Stack 通过整套测试,然后问“谁需要泛型?”——因为答案不在测试里,而在编译器本会阻止你写出的那些程序里,而这恰恰是一个绿色的测试套件无法展示的。最清晰的概念回报是 GenericFoo/InterfaceyFoo 的对比,它揭示约束从来不在 any(interface{} 的别名)这个词里,而在共享的标签 T 迫使两个参数达成一致——是关系型的类型约束,而不是限制型的。有两处软肋。第一,本章略去了产生出那两个重复栈的 TDD 过程,在一本关于 TDD 的书里,就在争辩只有 TDD 循环才让你有资格泛化之前;重复是预先做好递给我们的,所以我们看到了重构的结论,却没看到证明它合理的证据。第二,最后三分之一把推销话术反转过来——三次法则、睡一觉再说、宁要重复也别要糟糕的抽象——这明智,却让读者手里拿着一件刚被告知要推迟使用的工具。防御性的几节(“泛型会把 Go 变成 Java 吗?不会。”)是这里最政治化的文字,而其中最好的一句最安静:如果你用过切片或映射,你就已经一直在消费泛型了,而这以前是留给语言作者自己的特权。在这一切底下,是我愿意保留的那一句:静态类型是描述约束的行为,更小的解空间是回报,而不是代价。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书Go 基础:用泛型重访数组与切片
Map 和 Filter 如何用同样的两个类型参数的技巧来写?
Go 基础是怎样展开的AI 辅助
本章的开头是回头看,而不是向前看。它重新印出前面数组与切片那一章里的 Sum 和 SumAllTails,请读者找出其中共有的形状:确立一个初始结果,遍历集合并应用一个组合函数,返回结果。作者指出,这个形状在函数式编程里有个名字——fold,或 reduce——他引用一条百科式的定义来说明,这是一族成熟的高阶函数,不是他的发明。Go 一直都有高阶函数,自 1.18 起又有了泛型,所以这些模式现在可以表达了。预料到阻力(“Go 应该是简单的”),他以全篇倚重的两条区分作答:容易不等于简单(暗指 Rich Hickey 的演讲),不熟悉不等于复杂。
从这里起,本章成了一次有纪律的重构练习。作者警告人们对待新语言特性的常见错误——没有具体用例就伸手去拿——并把泛型框定为 TDD 的重构一步的工具,在那里,现有的测试充当安全网,想法可以自由地尝试和回退。他邀请读者先自己尝试 Reduce,就其参数给出提示(集合、一个组合函数,以及——受 fold 文献的提示——一个初始值),然后展示第一遍:一个只有一个类型参数的泛型函数,用 func(A, A) A 折叠切片。Sum 和 SumAllTails 被重写,使每个函数在第一行声明自己的组合逻辑,并把迭代委托给 Reduce。针对乘法和字符串拼接的小测试展示了可复用性,而乘法的情形引出一段关于为何必须提供初始值的简短离题:从零开始会把乘积抹平,所以一才是正确的种子——单位元。
转折出现在模式被拉伸到超出其最初设计之时。把一列 Transaction 值归约成一个 float64 的银行余额无法编译,因为单个类型参数迫使输入和输出相同。修复是一行签名的改动——两个类型参数和 func(B, A) B——函数体和每一个现有调用者都原封不动,作者把这呈现为证据,说明抽象是被放宽而不是被破坏,并且仍然是类型安全的。他先以老实的方式得到余额函数:一个朴素的循环,在测试通过时写下并提交,然后才通过 Reduce 重写。一节“扩展银行”把人体工学推得更远,用 Account、NewTransaction 和 NewBalanceFor,通过 applyTransaction 在一个账户上折叠交易——他主张这样的代码读起来是声明式的,说的是发生什么而不是怎么发生,变更和循环被隔离在 Reduce 里。
收尾一段先泛化、再说教。他说,fold 是普遍的——混色、数票、给购物篮求和、几乎任何处理列表的事——Find 作为第二个更容易的例子随之而来:一个泛型的谓词搜索,返回一个值和一个是否找到的标志,在整数和一小列 Person 上复用(并开了一个关于结果“完美无缺”的拿自己开涮的玩笑)。总结重申了驱动本章的经验法则:让 TDD 浮现出你需要的真实行为,在重构一步发现抽象,在绿灯时提交,使一团糟总是可以恢复。两条最后的注记收了尾——使用既有的名字(把 A 的集合变成 B 的是 Map,不是 Convert),并把“这不地道”当作空洞的反对,除非它附带理由,因为惯用法必须随语言一起变迁。以关于 fold 的资源链接结束本章。
关键论点,以及支撑它们的原文
手写的 Sum 和 SumAllTails 是同一个模式的两个实例,这个模式在 Go 之外已有一个既定的名字:fold,或 reduce。
作者指出这两个函数都是先设一个结果,把每一项组合进去,再返回它,然后通过把它们改写成 Reduce(numbers, add, 0) 和 Reduce(numbers, sumTail, []int{}) 展示回报——此后每个函数的函数体只陈述它自己的计算,迭代消失进了 Reduce。
“这个想法在函数式编程圈子里常被谈论,通常叫作“reduce”或 fold。”
“This idea is commonly talked about in functional programming circles, often times called 'reduce' or fold)”
Revisiting arrays and slices with generics查看原文 →
反对说 Go 应当保持简单,是把容易与简单混为一谈,把不熟悉与复杂混为一谈。
作者的推理是:写循环并复制粘贴它们是容易的,却不简单,而 fold 只是听起来学术:剥掉行话,它不过是拿一个集合并把它组合成一个东西,读者本来就一直在做。使用公认的名字也让从其他语言来的程序员立刻读懂意图。
“不要把容易与简单混为一谈。写循环和复制粘贴代码是容易的,但不一定是简单的。”
“Don't conflate easiness, with simplicity. Doing loops and copy-pasting code is easy, but it's not necessarily simple.”
Revisiting arrays and slices with generics查看原文 →
泛型的回报来自从对已测试代码的重构一步中长出来,而不是来自一个闪亮的特性去找用途。
承接上一点,作者先用普通循环写出银行余额,在测试通过时提交,然后才试验 Reduce 版本——当试验编译失败时,代价是一次类型签名的改动,而不是弄坏的软件。论点是:通过的测试加上已提交的工作使试验可以撤回,这才让抽象是被发现的而不是猜出来的;作者承认循环版本并不是坏代码,重写部分是为了演示。
“我们添加了第二个类型约束,这让我们得以放宽 Reduce 的约束。”
“We've added a second type constraint which has allowed us to loosen the constraints on Reduce.”
Revisiting arrays and slices with generics › Refactor查看原文 →
另外 3 个论点
一旦结果类型不同于元素类型,Reduce 依然有用,而拓宽它几乎不花代价。
从一个交易切片计算余额,意味着把 Transaction 值折叠成一个 float64,单参数版本会以类型不匹配的错误拒绝它。添加第二个参数就修好了,函数体和先前的调用者都不用改,同样的把戏让 NewBalanceFor 把交易折叠进一个 Account,并保持函数类型安全。
“我们添加了第二个类型约束,这让我们得以放宽 Reduce 的约束。”
“We've added a second type constraint which has allowed us to loosen the constraints on Reduce.”
Revisiting arrays and slices with generics › Refactor查看原文 →
以这种方式写的代码,读起来是对发生了什么的描述,而不是怎么发生的。
NewBalanceFor 不过是把 Reduce 应用于交易,加上 applyTransaction 和一个起始账户,所以浏览文件的读者一眼就看到意图,并可以深入 applyTransaction 看业务规则,而无需追踪循环或被改动的状态。作者把这与我们通常读代码的方式联系起来——在文件间飞掠,找的是“是什么”,不是“怎么做”。
“这个想法在函数式编程圈子里常被谈论,通常叫作“reduce”或 fold。”
“This idea is commonly talked about in functional programming circles, often times called 'reduce' or fold)”
Revisiting arrays and slices with generics查看原文 →
借用这些模式已经带有的名字,而不是发明你自己的。
作者的例子:把 A 的集合变成 B 的函数是 Map,所以叫它 Convert 就把它藏起来了。既定的名字降低其他读者的心智成本,并使这个想法可被搜索,这与使用 Reduce 这个词一开始就传达了意图是同一个原因。
“不要把容易与简单混为一谈。写循环和复制粘贴代码是容易的,但不一定是简单的。”
“Don't conflate easiness, with simplicity. Doing loops and copy-pasting code is easy, but it's not necessarily simple.”
Revisiting arrays and slices with generics查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
折叠如何塌缩一个列表
Reduce 持有一个累加器,以提供的初始值开始,并通过依次把一个组合函数应用于累加器和每一项来改写它;处理完最后一项后,累加器就是答案。因为循环和状态更新都住在 Reduce 里面,每个调用者只需提供组合这一步。
机制 2
为什么种子必须是单位元
初始值直接被喂进第一次组合,所以它必须是该运算的中性元素,否则会扭曲结果。加法的中性元素是 0,碰巧与 Go 的零值吻合;乘法的是 1,所以依赖零值会让一切都乘以 0,永远返回 0。
机制 3
第二个类型参数拓宽了 Reduce
只有一个类型参数时,组合函数被推断为 func(A, A) A,所以类型与集合元素不同的银行余额累加器无法编译。声明 Reduce[A, B any] 与 f func(B, A) B,把累加器类型与元素类型分开;循环体和每一个现有调用者都原封不动,因为旧的用法只是 A 与 B 重合的情形。
常见误读
说这些高阶函数“不是地道的 Go”,就足以拒绝它们。
作者认为这个说法,若不加细节,既不可操作也没有用——尤其是对语言刚刚获得的特性而言。惯用法随语言的变化而变迁,所以模式应当按其优劣来论辩,而设计良好的测试意味着,如果某种风格并不合适,团队总可以重构回去。
“不要把容易与简单混为一谈。写循环和复制粘贴代码是容易的,但不一定是简单的。”
“Don't conflate easiness, with simplicity. Doing loops and copy-pasting code is easy, but it's not necessarily simple.”
Revisiting arrays and slices with generics查看原文 →
既然泛型存在了,该做的就是预先构建一个泛型辅助函数库,比如 Reduce、Find 和 Map,并到处使用。
本章的次序恰恰相反:先用 TDD 逼出真实、具体的行为,在绿灯时提交,然后才去找一个能把它整理干净的抽象。没有具体用例就伸手去拿新特性,会让你只能猜,这正是过早抽象混进来的方式——而作者仍然指出,朴素循环写的余额函数完全是可接受的代码。
“我们添加了第二个类型约束,这让我们得以放宽 Reduce 的约束。”
“We've added a second type constraint which has allowed us to loosen the constraints on Reduce.”
Revisiting arrays and slices with generics › Refactor查看原文 →
Go 基础的核心概念
- Reduce(折叠)
- 一个高阶函数,遍历集合,把每一项组合进单一的累积结果,从一个初始值开始。
- 适用处: 适合把列表塌缩成一个值的任何事;它隐藏了循环,所以组合函数必须承载全部真正的逻辑。
- 单位元
- 在该运算下使每个元素保持不变的起始值:加法为 0,乘法为 1。
- 适用处: 取决于运算,所以 Go 默认的零值并不总是正确的初始值。
- Find
- 一个泛型函数,返回集合中第一个匹配谓词的项,以及是否找到。
- 适用处: 只返回第一个匹配;每种用例都需要一个谓词。
- 重构
- 测试通过之后的阶段,在测试套件保护下重塑可工作的代码。
- 适用处: 只有在测试通过且工作已提交之后才安全。
另外 0 个概念
Go 基础划线与批注AI 陪读
“不要把容易与简单混为一谈。”
“Don't conflate easiness, with simplicity.”
查看原文 →本章最强的一句,而且是借来的。不过它是双刃的——Hickey 的观点同样会谴责一个把三个关注点藏在一个名字后面的 Reduce。
“人们对闪亮的新语言特性常犯的一个错误,是在没有具体用例时就开始使用它们。”
“A mistake people often make with shiny new language features is they start by using them without having a concrete use-case.”
查看原文 →作者真正的论点,比周围的泛型材料更好。把重构当作引入抽象的唯一安全之地,是一个值得多于一段的主张。
“return Reduce(numbers, sumTail, []int”
查看原文 →这是本章最弱的例子。累加器在追加——它在构建一个列表,而不是折叠成一个值。那是穿着 Reduce 外衣的 Map,而作者后来恰恰为这类误命名训斥别人。
另外 5 条批注
“如果你想在你的书呆子朋友面前显得聪明,你可以把这叫作单位元。”
“If you wish to sound clever with your nerd friends, you'd call this The Identity Element.”
查看原文 →这个玩笑低估了它。单位元不是派对把戏——它是初始值参数在签名里不可省略的原因,而本章几乎建立了这个联系。
“我们不必改动函数体,也不必改动我们任何现有的调用者。”
“We won't have to change the function body, and we won't have to change any of our existing callers.”
查看原文 →真正优雅——放宽类型约束在这里源码兼容,只因为推断仍能解析旧的调用。值得注意,这是 Go 推断的性质,而不是一条普遍定律。
“func applyTransaction(a Account, transaction Transaction) Account”
查看原文 →悄悄依赖 Go 的值语义:a 是一份副本,所以修改 Balance 是安全的。把 Account 换成指针,这个“声明式”的折叠就会变成共享状态缺陷。纯粹性是偶然的。
“如你所见,这段代码完美无缺。”
“As you can see, this code is flawless.”
查看原文 →作者唯一眨眼的时刻。Find 在没找到时返回零值,而对 Person 来说,那是一个空结构体,与一个真正名字为空的值无法区分——found 这个布尔值是承重的,却容易被忽略。
“没有更多细节,这不是一句可操作的或有用的话”
“Without any more detail, is not an actionable, or useful thing to say”
查看原文 →公平,但这是对反对意见最弱版本的反驳。严肃的惯用法论证关乎团队的可读性和可调试性,而不是教条,本章并没有正面回应它。
读后感AI 陪读
先写测试,后做抽象,这才是真正能迁移的教训
一章同时干两件事,成败不均。作为 TDD 论证,它很出色:先提交可工作的循环,然后在测试覆盖下对抽象做实验,如果它不配留下就回退。这个次序才是真正的教训,也是真正可迁移的。作为泛型教程,它就不那么稳了——SumAllTails 的折叠其实是个 map,Find 示例里的零值返回被一个玩笑带过去,而 Account 折叠表面上的纯粹性,依赖于 Go 的值语义,而文中从未点明。防御性的收尾几节是个破绽:作者知道 Go 的听众会反驳,选择了预先回应而不是正面交锋。“不要把不熟悉与复杂混为一谈”是对的,也恰恰是一个宁愿不去检验复杂性是否真实存在的人会说的话。整篇里最好的建议是最小的那条:在自己命名它之前,先去了解这个模式已经叫什么。
在 FeedLitra 里读Go 基础,每段旁边都有这些卡片。
开始读书测试基础:验收测试入门
当验收测试写在代码之前时,它们如何充当北极星?
测试基础是怎样展开的AI 辅助
开篇:一个职场问题,而不是抽象。 本章从作者工作中的一个具体痛点开篇——运行在 Kubernetes 上的服务需要“优雅停机”——并用一个朴素的类比来锚定它:把一通电话说完,而不是说到一半就挂断。从这里起,它提供刚好足够的 Kubernetes 背景,让利害关系变得可读:频繁部署(由 DORA 指标作为依据)意味着 pod 经常被终止,而终止从一个 SIGTERM 开始,它实际上是在不容申诉的 SIGKILL 之前的一个警告。作者随后展示无视这个警告的代价:在途的 HTTP 请求死掉,自动化的 API 测试间歇性失败,一个告警响起,一位开发者不得不放下手头的工作去调查——而真实的用户看到的会是 5xx 错误。确立了代价之后,本章把期望的行为陈述为三步(停止接受请求,让在途的完成,然后退出),并指出 Go 的标准库已经提供了零件:Server.Shutdown 加 signal.Notify。一个小小的 main 函数演示了作者自己的 gracefulshutdown 包以装饰器模式包装一个 http.Server。
支点:单元测试不够。 本章真正的转折出现在作者承认:尽管这个包有足以让人放心重构的单元测试,他们仍然没有觉得有把握它能工作。他们的替代办法是手工测试——启动一个真实的程序,发一个请求,发一个 SIGTERM,观察——作者把它描述为无聊、无法扩展、不精确且浪费。这种不适推动了本章的真正主题。验收测试随后被定义为黑盒或功能测试,只通过公共接口、像用户那样演练系统,作者把它与熟悉的 Go 习惯联系起来:把测试放在 mypkg_test 而不是 mypkg 里。接着是一份平衡的清单:好处(对整个系统的信心,比手工检查更便宜、更准确,不会漂移的文档,无需 mock)对照缺点(写起来昂贵,慢,与设计耦合,难以调试,对内部质量沉默,且无法触及每一种场景)。得出的结论刻意温和——只靠验收测试是愚蠢的;它们属于测试金字塔,与单元测试并列,而交付周期是惩罚失衡的指标。
展开:展示机制。 本章随后回到优雅停机的例子,把手工仪式变成自动化的:平白地列出步骤:构建二进制,运行它并等它开始监听,发一个请求,在响应能到达之前发 SIGTERM,并检查仍有响应返回。实现是一个 acceptancetests 包,其导出的 LaunchTestProgram 构建一个随机命名的二进制,启动它,轮询端口直到有东西应答,并交回两个闭包——一个清理函数,杀掉进程并删除二进制,和一个中断函数,发送 SIGTERM。作者坦率地说这段代码不漂亮,并告诉读者忽略未导出的样板;回报是准备成本只付一次。由此得到的测试读起来几乎像散文:确认服务器能工作,安排在发出请求后 50ms 发 SIGTERM,断言请求仍然成功,然后断言后续请求失败。像 assert.CanGet(一个 goroutine 加 select 的超时包装)这样的辅助函数让套件保持 DRY,作者强调这一切都不需要框架——标准库就够了。一段粘贴的 go test 输出显示两个并行的示例程序,一个优雅、一个不优雅,作为小投入大回报的证明。
收束:泛化与前瞻。 总结把教训从具体的例子里提升出来:验收测试的机制取决于你在构建什么——网站用 Selenium 这样的无头浏览器,REST API 用 HTTP 客户端——但黑盒原则是不变的,而带数据库的非平凡系统,需要像 docker-compose 这样的东西来搭起本地环境。本章随后点出自己的局限:这里的验收测试是在代码之后写的,而(引用Growing Object-Oriented Software)这类测试可以充当驱动设计的“北极星”——这是下一章的主题,连同阻止验收套件成本失控的技术,作者说这种失败模式曾拖累许多团队。它以两个较软的注记结束:呼吁开源作者随库附上可运行的示例程序和可读的验收测试,作为节省信任和维护的投资;以及为作者雇主做的简短招聘推销。
关键论点,以及支撑它们的原文
单靠单元测试,作者们并不确信停机包能工作,而他们退而求其次的手工检查本应被自动化。
作者描述了推动这项工作的真实故障:一个 pod 在请求中途终止,杀死了服务器,一个自动化的 API 测试没得到响应,由此产生的告警让一位开发者放下了工作——同样的情形下用户则会撞上 5xx。单元测试通过了,所以团队只好手工启动一个演示程序,发一个请求,自己发 SIGTERM;作者称这无聊、无法扩展、不精确且浪费,这正是把这些步骤变成测试的论据。
“你身上的工程师应当对手工测试感到不舒服。它无聊,无法扩展,不准确,而且浪费。”
“The engineer in you should be feeling uncomfortable with manual testing. It's boring, it doesn't scale, it's inaccurate, and it's wasteful.”
Introduction to acceptance testing › Tests and feedback loops查看原文 →
验收测试应当像用户那样,从外部驱动整个运行中的系统。
承接上一点,作者写出了他的测试所执行的配方:构建二进制,运行它,等到端口上有东西在监听,发一个 GET,在处理器能回复之前发 SIGTERM,并检查响应仍然到达——然后检查后续请求被拒绝。什么都不 mock,什么也不伸进包内部,所以断言描述的是调用者可以观察到的行为;那个慢的处理器存在,只是为了打开一个窗口,让信号可以在途中落下。
“你身上的工程师应当对手工测试感到不舒服。它无聊,无法扩展,不准确,而且浪费。”
“The engineer in you should be feeling uncomfortable with manual testing. It's boring, it doesn't scale, it's inaccurate, and it's wasteful.”
Introduction to acceptance testing › Tests and feedback loops查看原文 →
Go 的标准库已经足以写验收测试;不需要专门的框架。
整个测试装置就是一个辅助函数 LaunchTestProgram,由普通的包构成:os/exec 编译并启动二进制,net 在一个重试循环里拨端口直到服务器应答,syscall 发送 SIGTERM,辅助函数还交还给测试一个清理函数,杀掉进程并删除临时二进制。作者承认这套管线不漂亮,但它只写一次,然后被忽略。
“再次强调,Go 开箱即用就有写验收测试所需的一切工具。你不需要特殊的框架来构建验收测试。”
“It's important to note again, Go has all the tools you need to write acceptance tests out of the box. You don't need a special framework to build acceptance tests.”
Introduction to acceptance testing › The acceptance test(s)查看原文 →
另外 3 个论点
搭建验收测试前期成本更高,但回报是可读的测试和快速、可信的反馈。
一旦仪式被藏在启动器和像 CanGet/CantGet 这样的小辅助函数后面——它们在 goroutine 上运行请求,三秒内没有回应就失败——测试体就是对行为的简短、清晰的描述,为没有优雅停机的版本再写第二个也很容易,读者可以对比。作者展示整套测试每个包几秒就跑完,而人手工重复这套仪式则要久得多。
“你身上的工程师应当对手工测试感到不舒服。它无聊,无法扩展,不准确,而且浪费。”
“The engineer in you should be feeling uncomfortable with manual testing. It's boring, it doesn't scale, it's inaccurate, and it's wasteful.”
Introduction to acceptance testing › Tests and feedback loops查看原文 →
只依赖验收测试是个错误;它们属于单元测试这个底座之上。
把先前的热情翻转过来,作者列出这些测试做不到的事:写起来昂贵,运行慢,与系统设计耦合,失败时很少显示根因,对内部质量一言不发,而且因为待在盒子外面,有些场景根本触及不到。单元测试才是让无畏重构和快速反馈成为可能的东西,所以合理的组合由测试金字塔描述——它也保护着团队目标的十分钟以内的交付周期。
“出于这个原因,只依赖验收测试是愚蠢的。它们不具备单元测试的许多品质,而拥有大量验收测试的系统,往往会在维护成本和糟糕的交付周期方面受苦。”
“For this reason, it is foolish to only rely on acceptance tests. They do not have many of the qualities unit tests have, and a system with a large number of acceptance tests will tend to suffer in terms of maintenance costs and poor lead time.”
Introduction to acceptance testing › Potential drawbacks vs unit tests查看原文 →
共享包的作者应当随包提供小型示例程序,并由验收测试覆盖。
作者的推理关乎信任:有了这类测试,读者可以看着示例程序,知道这个示例确实能工作,所以包的主张是可信的。他把这种效果比作可测试的示例——一点额外的开发者体验的投入,买来信心,而且因为示例是被验证的、而不是与现实渐行渐远,也降低了维护者自己的支持负担。
“你身上的工程师应当对手工测试感到不舒服。它无聊,无法扩展,不准确,而且浪费。”
“The engineer in you should be feeling uncomfortable with manual testing. It's boring, it doesn't scale, it's inaccurate, and it's wasteful.”
Introduction to acceptance testing › Tests and feedback loops查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么宽限期能救下在途请求
Kubernetes 先发 SIGTERM,很久之后才发 SIGKILL。如果程序捕获该信号并要求 HTTP 服务器关闭,服务器会关闭它的监听器,使不再有新连接到来,让已在处理的请求写出响应,然后才退出——于是调用者得到的是真实的答复,而不是被断开的连接。
机制 2
为什么黑盒测试无法作弊
因为测试只触碰程序的公共接口,对其内部毫无把手,它无法伸手进去安排一个方便的状态来让自己通过。它断言的一切都必须能从外部观察到,就像真实用户所见,所以一个绿色的测试确实反映端到端的行为。
机制 3
沉重的验收套件如何拖慢交付
每个验收测试都运行一整个真实的系统,所以执行慢,写起来和维持工作都昂贵;失败指向系统而不是根因,所以调试耗时。堆积足够多,每次合并都得等一个漫长而脆弱的套件,把交付周期推远。
常见误读
如果验收测试是绿的,里面的代码一定状态不错。
这些测试只评判外部可见的行为,作者直说你可以写出彻头彻尾的垃圾,仍然让其中一个通过。关于内部质量的判断来自单元测试,它们也正是让重构安全的东西——这就是他两者都保留、而不是用一个取代另一个的原因。
“出于这个原因,只依赖验收测试是愚蠢的。它们不具备单元测试的许多品质,而拥有大量验收测试的系统,往往会在维护成本和糟糕的交付周期方面受苦。”
“For this reason, it is foolish to only rely on acceptance tests. They do not have many of the qualities unit tests have, and a system with a large number of acceptance tests will tend to suffer in terms of maintenance costs and poor lead time.”
Introduction to acceptance testing › Potential drawbacks vs unit tests查看原文 →
接上优雅停机之后,服务器可以花任意长的时间去完成它正在做的事。
这个窗口被两次限定:Kubernetes 在固定的宽限期之后用 SIGKILL 跟进 SIGTERM,而如果停机调用自己的 context 截止时间先到,它也会放弃并返回错误。这就是示例程序把停机的错误视为致命、并警告某些响应可能已丢失的原因——处理器仍必须足够快,才能装进这个窗口。
“优雅停机确保你的系统在被终止之前妥善地完成它的工作。”
“Graceful shutdown makes sure your system finishes its work properly before it is terminated.”
Introduction to acceptance testing查看原文 →
测试基础的核心概念
- 验收测试
- 一种黑盒测试,像真实用户那样,通过公共接口驱动整个运行中的系统。
- 适用处: 无法触及内部,所以有些场景不切实际,失败也很少显示根因。
- 优雅停机
- 收到 SIGTERM 时,停止接受新请求,让在途的请求完成,然后退出。
- 适用处: 受 SIGKILL 之前的宽限期以及停机 context 截止时间的限制。
- 交付周期
- 从合并一个提交到它在生产环境中运行的时间。
- 适用处: 臃肿的验收套件会拉高它;本章的目标是少于 10 分钟。
另外 0 个概念
测试基础划线与批注AI 陪读
“现实世界中的类比,是一个人试图妥善地结束一通电话”
“A real-world analogy would be someone trying to wrap up a phone call properly”
查看原文 →温暖的类比,但它藏起了难的部分:宽限期是一个截止时间。如果你啰嗦到超过 SIGKILL,电话照样会被挂断。
“你身上的工程师应该对手工测试感到不舒服。”
“The engineer in you should be feeling uncomfortable with manual testing.”
查看原文 →漂亮的修辞——以羞耻为动力。它之所以奏效,是因为他自己先坦白了曾亲手做那件手工的事。
“它不能使用任何可能让测试通过的特殊变通办法”
“it can't use any special workarounds that could make a test pass”
查看原文 →黑盒的真正论据:它剥夺了你自己作弊的能力。测试不只是在检查代码,它们在检查作者。
另外 6 条批注
“你可以写出彻头彻尾的垃圾,仍然让验收测试通过。”
“You could write total garbage and still make an acceptance test pass.”
查看原文 →本章最诚实的一句。验收测试证明的是行为,从来不是设计——这正是为什么 TDD 的人如此护卫金字塔。
“诚然,这不是世界上最漂亮的代码”
“Admittedly, this is not the nicest code in the world”
查看原文 →而这正是埋在旁白里的真正教训:测试装置的代码有另一套质量预算,因为它是写一次、从不读。
“Without graceful shutdown, this would fail”
查看原文 →一条注释干了两个测试的活。“有/无”两个程序的配对是这里真正的设计想法——对比本身就是断言。
“Go 开箱即带有写验收测试所需的全部工具”
“Go has all the tools you need to write acceptance tests out of the box”
查看原文 →没错,而且悄悄带着论战意味——矛头对准框架产业综合体。不过 exec.Command 加随机的二进制名就是一个框架,只是一个没有名字、如今由你来维护的框架。
“编写和维护验收测试的成本可能迅速失控”
“the costs of writing and maintaining acceptance tests can quickly spiral”
查看原文 →他肯说出来是好事,但一个停机测试就要 4.8 秒,已经暗示了斜率。乘以一百个场景,十分钟以内的交付周期就没了。
“请在 Twitter 上联系我,也许我们可以一起工作”
“please reach out to me on Twitter, and maybe we can work together”
查看原文 →DORA 指标和 $WORK 的框定忽然读起来不一样了——本章在一定程度上是一则工程文化的广告。这并不使它失效,但值得点明。
读后感AI 陪读
单元测试通过了,他仍然怀疑自己的代码
一章简短而务实的内容,它靠承认弱点挣得了权威:单元测试通过了,作者仍然不相信自己的库,于是他把自己一直在手工进行的、令人尴尬的人工仪式自动化了。最强的材料是对缺点的诚实清单——尤其是承认验收测试可以在垃圾之上保持绿色——这把验收测试重新框定为信心的仪器而不是质量的仪器,也让金字塔的论证显得是推导出来的而不是背诵出来的。有两件事没被充分施压。第一,反对框架的主张:Go 的标准库确实够用,但 LaunchTestProgram 是一个量身定制的测试装置,带着构建、端口轮询、信号和清理这些关切,也就是他说你不需要的那种东西,只是少了社区的维护。第二,成本曲线:一个优雅停机的测试要花约 5 秒的墙钟时间,而本章自己设定的 10 分钟交付周期目标是一份硬预算,这类测试会很快把它花光——被承认了,却推迟到下一章。“有/无”程序配对是页面上最可迁移的想法:一个关于行为的主张,只有放在它的否定旁边才可读。而 $WORK 的框定加上结尾的招聘推销,提醒我们这里的 DORA 词汇既是一种方法,也是一块招牌。
在 FeedLitra 里读测试基础,每段旁边都有这些卡片。
开始读书测试基础:扩展验收测试(以及 gRPC 简介)
一旦 gRPC 成为第二个驱动,规格如何保持稳定?
测试基础是怎样展开的AI 辅助
开篇:一次诊断。 本章不是以代码开篇,而是以对验收测试的一句抱怨——它们维护起来昂贵——然后重新框定这笔开销实际来自哪里。通常的嫌疑犯是抽象,但作者指向的是与实现细节的耦合。锚定的例子是一个 Selenium 测试,当一个 HTML 标签被换成另一个时就坏了:用户能感知到的东西什么也没变,测试却必须修,因为测试知道了它无权知道的内部。从这个诊断出发,引出本章的组织性提议:把验收测试分成三层——规格(想要什么行为)、驱动(怎样与系统交谈)和系统本身。只有驱动知道标记、URL 和协议,所以变动落在那里,而除非行为真的改变,规格保持不动。作者把这一点再推进一步:一份不含实现细节的规格,可以由不同的驱动在不同的环境里运行——通过无头浏览器,或对同一个 API 直接发 HTTP 调用——甚至针对生产环境,并带着诚实的提醒:在预发布环境的一次绿灯,仍然证明不了生产环境怎样。开篇以搭建本章其余部分的载体收尾:一个自上而下构建的 Go“问候器”,配合 Docker 和 Testcontainers,使测试演练的是一个真正在运行的系统。
展开:从规格出发、向外推的改动。 论证了分层之后,本章把它放到负载下。作者添加一个名字参数——问候器应当问候某个人——纪律是先改规格。那次改动立刻使驱动的编译失败,修好驱动又弄坏服务器;每个编译器错误都指向下一件要改的事,所以工作是被期望的行为牵引着走,而不是自下而上猜出来的。这也是本章付出最诚实代价的地方。从验收测试开始,意味着在第一个真正的功能存在之前,就要买下一份规格、一个 HTTP 驱动、一个 Dockerfile 和一个基于容器的测试。理由是被报告的经验,而不是度量:孤立地构建一块代码的工程师常常发现它做了错的事,带着没人要求的工作,抗拒集成,并被重写——而一个失败的验收测试则在任何代码写下之前就陈述了目标。
转折:新协议只是新适配器。 本章的支点是一次重组——领域代码、适配器和 cmd 被拆开——随后是对分层是否真实的检验。作者的立场是,像 HTTP 和 gRPC 这样的传输,是仅仅因为涉及计算机才存在的复杂性;它们不是领域的一部分。所以问候逻辑住在一个核心的领域函数里,而协议住在适配器里。回报是本章最强的一条证据:一个全新的 gRPC 问候器与 HTTP 的相比,恰好只有两处不同——它的 Dockerfile 和它的驱动——而且它运行的是完全相同的问候规格,gRPC 服务器委托给已经为 HTTP 服务的那个领域函数。没有新测试,没有新领域逻辑。Docker 启动是共享的,一个参数化的 Dockerfile 涵盖两者。
收束:让实践可以过日子,并知道何时停手。 最后一个乐章关乎成本控制与判断。首先是机制:缓慢的验收测试挂到 Go 的 -short 标志上,一个 Makefile 暴露出一个快速的单元测试目标,和一个单独的完整构建目标——这之所以行得通,只因为慢测试真的检查那个标志并跳过自己,所以一个全新的检出几乎不需任何搭建就可测试。然后是判断:关于验收测试何时配得上它位置的启发。真正新的、面向用户的行为配得上一个;业务规则的变体和边缘情形属于单元测试,因为在一条已被覆盖的旅程上再堆一个验收测试,花时间而收获很少,而且验收测试对内部设计的反馈很弱。作者把这些框定为要问自己的问题,而不是规则。一个新的“Curse”功能随后演示了这个启发的实际运用——它是真正的新行为,所以规格在先,驱动编译失败,被打上桩,protobuf 代码被重新生成,服务器和驱动被端到端地实现。本章以重新定性它所构建的一切收尾:因为规格一直不含实现细节,它们读起来就是对系统做什么的文档。
一个值得点名的误读。 人们很容易带着这样的想法离开:规格/驱动的分离是不必要的间接层,直接针对真实 UI 或 API 写的测试更简单。本章的整个论证恰恰相反:这层间接正是吸收变化的东西。驱动是唯一知道标签、端点和协议的地方——这就是为什么换 HTML 标签或加 gRPC 只花一个文件而不是一整套测试。
关键论点,以及支撑它们的原文
验收测试变得昂贵,主要是因为它们绑在实现细节上,而不是因为用了太多抽象。
作者的例子是一个 Selenium 测试,当标记从一个标签改成另一个时就坏了:用户能看到的什么也没变,测试却必须修,所以维护成本来自测试知道了内部。
“然而,后者往往正是验收测试不得不改变的原因。以至于工程师甚至因为感觉更新测试的工作量大,而不愿意改动他们的系统!”
“Too often, though, the latter is the reason acceptance tests have to change. To the point where engineers even become reluctant to change their system because of the perceived effort of updating tests!”
Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › Tight-coupling查看原文 →
不含实现细节的规格,可以由不同的驱动在不同的环境里运行,甚至是生产环境。
承接解耦的论证,作者指向一个坐落在 API 之上的网页:同一份规格既可以通过无头浏览器演练,也可以通过直接的 HTTP 调用再演练一遍。作者补充了提醒:在预发布环境通过,仍然不能证明生产环境是正确的。
“把规格说明如何被执行解耦,使我们能在不同场景中复用它。”
“Decoupling how the specification is executed allows us to reuse it in different scenarios.”
Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › Testing on steroids查看原文 →
从验收测试开始、自上而下地工作,可以避免白费的工作。
作者凭经验描述了这样的工程师:孤立地构建一块代码,结果发现它做的并非所要的,带着没人需要的工作,抗拒集成,最后被重写;而一个失败的验收测试则在任何代码存在之前就陈述了目标。这是被报告的经验,而不是度量,在本章里,它意味着在第一个真正的功能之前,要为一份 Greeter 规格、一个 HTTP 驱动、一个 Dockerfile 和一个基于容器的测试付费。
“这是验收测试驱动方法的一个关键特性,用测试来为我们的代码获得真正的验证。”
“This is a crucial property of the acceptance-test-driven approach, using tests to get real validation of our code.”
Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › The perils of bottom-up查看原文 →
另外 3 个论点
增加一整个新的传输,不应需要新的测试或新的领域逻辑。
问候器的 gRPC 版本与 HTTP 版本的区别只在于它的 Dockerfile 和它的驱动,而它调用的是完全相同的问候规格;gRPC 服务器最终委托给已经为 HTTP 服务的那个领域函数。
“这个新功能可以通过创建一个新的适配器与我们的领域代码交互来实现。”
“This new functionality can be accomplished by creating a new adapter to interact with our domain code.”
Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › Write the test first查看原文 →
缓慢的验收测试应当可以被跳过,这样一个全新的检出几乎不需任何搭建就能被测试。
作者把慢测试挂到 Go 的 short 测试标志上,并附带一个 Makefile,其中单元测试目标只运行快速的测试,另有一个目标用于完整构建;这之所以行得通,只因为慢测试真的检查那个标志并跳过自己。
“最好是,运行 go test ./... 时,工程师除了几项关键依赖——比如 Go 编译器(显然)以及也许 Docker——之外,无需任何进一步的搭建。”
“It's preferable that running go test ./... should be runnable with no further set up from an engineer, beyond say a few key dependencies such as the Go compiler (obviously) and perhaps Docker.”
Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › Separating different kinds of tests查看原文 →
全新的、面向用户的行为配得上一个验收测试;业务规则的变体和边缘情形属于单元测试。
新的 Curse 功能是真正的新行为,所以作者先写它的规格,让驱动编译失败,给它打上桩,重新生成 protobuf 代码,然后实现服务器和驱动。相反,在一条已被覆盖的旅程上再堆一个验收测试,花时间而收获很少,所以那里用单元测试。作者把这呈现为要问自己的问题,而不是硬性规则。
“如果你把关注点分得好,业务规则的排列组合和边缘情形应该很容易通过单元测试来驱动。”
“Permutations of business rules and edge cases should be simple to drive via a unit test if you have separated concerns well.”
Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › Enhance both systems by updating the domain logic with a unit test查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
三层使规格保持稳定
本章把验收测试拆成规格(想要什么行为)、驱动(怎样与系统交谈)和系统本身。因为只有驱动知道标记、URL 或协议,实现层面的变动落在那里,而除非行为真的改变,规格保持不动。
机制 2
改动从规格出发,向外推
当需要新行为时,作者先改规格,这立刻使驱动、然后是服务器的编译失败。每个编译器错误都指向下一件要改的事,所以工作是由期望的行为驱动的,而不是自下而上猜出来的。
机制 3
新协议只是新适配器
作者把 HTTP 或 gRPC 这样的传输,视为仅因涉及计算机才存在的复杂性,而不是领域的一部分。把问候逻辑留在一个核心领域函数里、把协议推进适配器,意味着第二种传输只需要一个新的驱动和 Dockerfile,而同样的规格和领域代码被复用。
常见误读
像单独的规格和驱动这样的分层是不必要的间接层;直接针对真实 UI 或 API 写的测试更简单,也更容易维护。
这层间接正是吸收变化的东西:驱动是唯一知道标记、端点和协议的地方,所以重构系统只触及它,而直接针对系统写的测试,会在诸如把一个 HTML 标签换成另一个这样的表面改动上就坏掉。
“然而,后者往往正是验收测试不得不改变的原因。以至于工程师甚至因为感觉更新测试的工作量大,而不愿意改动他们的系统!”
“Too often, though, the latter is the reason acceptance tests have to change. To the point where engineers even become reluctant to change their system because of the perceived effort of updating tests!”
Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › Tight-coupling查看原文 →
既然验收测试演练的是真实的系统,每条新规则和每个边缘情形都该有一个。
验收测试慢,对内部设计的反馈也弱,所以只留给新的面向用户的行为;一旦一条旅程已被覆盖,再来一个验收测试只增加成本而价值很少,业务规则的排列组合更适合通过单元测试来驱动。
“如果你把关注点分得好,业务规则的排列组合和边缘情形应该很容易通过单元测试来驱动。”
“Permutations of business rules and edge cases should be simple to drive via a unit test if you have separated concerns well.”
Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › Enhance both systems by updating the domain logic with a unit test查看原文 →
测试基础的核心概念
- 验收测试
- 从业务角度检查系统外部行为的黑盒测试。 在容器里构建并运行真实程序、并通过规格来驱动它的黑盒测试。 像用户那样,通过驱动、对照规格来驱动整个系统的测试。
- 适用处: 不关心内部质量,所以不能取代单元测试来提供设计反馈。 比单元测试慢,所以要共享样板代码,并让规格不含传输细节。 慢,且对内部设计的反馈弱;边缘情形就跳过。
- 规格
- 对所需行为的陈述,不含传输或实现细节,可跨驱动复用。
- 适用处: 只有当领域逻辑确实在代码里被建模时才有用。
- 驱动
- 把规格调用转成特定于系统的调用(比如 HTTP 请求)的适配器。
- 适用处: 每个系统界面都需要自己的驱动;它是实现层面变动该落到的地方。
- 偶然复杂性
- 仅仅来自使用计算机的复杂性:网络、磁盘、API。
- 适用处: 与本质复杂性相对,后者是先于计算机存在的领域规则。
- 适配器模式
- 一层薄包装,重塑你已有的东西,使它满足调用者所要求的接口。
- 适用处: 只在行为已存在而形状不对时有帮助;它不增加新逻辑。
- 端口与适配器
- 一种布局,把领域代码放在核心,把网络、Docker 和协议的关切推到适配器里。
- 适用处: 本章警告不要有一个倾倒杂物的 domain 文件夹;把相关的想法(如交互)归在一起。
另外 2 个概念
- 测试金字塔
- 一种套件的形状:许多快速的单元测试,只有少数缓慢的端到端验收测试。
- 适用处: 指导比例,而不是任何单个决定的规则。
- short 测试标志
- go test -short 加 testing.Short(),让工程师只运行快速的测试。
- 适用处: 只有慢测试真的检查该标志并跳过时才有效。
测试基础划线与批注AI 陪读
“面对一个没有任何测试的糟糕代码库,请抵制开始重构的诱惑。”
“When faced with a poor codebase without any tests, please resist the temptation to start refactoring.”
查看原文 →开篇里最强的实用建议。没有安全网的重构不是重构,而是重写加祈祷。
“工程师们第十亿次为某个东西该是 <article> 还是 <section> 而争论不休”
“engineers bike-shed over whether something should be an <article> or a <section> for the billionth time”
查看原文 →这个例子选得好,因为它琐碎——这正是重点。在语义上无关紧要的标记上就坏掉的测试,度量的是错误的东西。
“这条陈述在计算机被用于银行之前就是真的!”
“this statement was true before computers were even used in banks!”
查看原文 →检验某物是否本质复杂性的干净办法:你所有的机器都消失之后,它还能存活吗?
另外 17 条批注
“开发/生产环境一致性,充其量是个善意的谎言。我在生产环境里测试。”
“Dev/Prod parity is, at best, a white lie. I test in prod.”
查看原文 →刻意挑衅,而他挣得了它——但请注意其中的障眼法:在生产环境运行只读规格,和多数读者从“在生产环境测试”里听到的大不相同。
“为什么不用同一份规格来测试两者?”
“Why not use the same specification to test both?”
查看原文 →这是整个架构真正的回报——规格成了一项你跨层摊销的资产,而不是每层的成本。
“软件内部的复杂性,大多通常不在于实现细节,而在单元“之间”以及它们如何交互”
“most of the complexity that lives internally within software doesn't usually reside in implementation detail but "between" the units and how they interact”
查看原文 →悄悄地是这里最深的一句。它也解释了为什么只靠单元测试感觉让人安心,却错过了真正发布出去的缺陷。
“他们认为将会解决一项任务,但它:”
“they think is going to solve a job, but it:”
查看原文 →自下而上的批评说中了,但它是靠轶事论证的。诚实的说法是:自下而上在你尚未理解问题时失败——这是大多数时候,不是所有时候。
“为了把我们的规格与实现解耦而付出的这种仪式,可能会让一些人指责我们“过度抽象”
“this level of ceremony to decouple our specification from implementation might make some people accuse us of "overly abstracting”
查看原文 →他自己提出这个反对,因为他知道到目前为止的代码——一句写死的字符串散布在五个文件里——看起来很糟糕。预先回应是聪明的;但它还不是证明。
“只要你没对 err 做任何事,那些说“你得到什么错误就返回什么错误”的测试,价值相对较低”
“so long as you're not doing anything with the err, tests that say "you return the error you get" are relatively low value”
查看原文 →在一本 TDD 的书里,这是一个令人耳目一新的非正统让步。标准是行为,不是覆盖率。
“在我们验证测试按预期失败之前,不要写会让测试通过的生产代码”
“don't write the production code that would make the test pass until we've verified the test fails as we'd expect”
查看原文 →红灯这一步其实是对测试的测试。跳过它,就是人们最终得到无条件通过的套件的方式。
“这个“迭代0”的仪式和搭建,会对后续的迭代大有帮助。”
“This "iteration 0" ceremony and setup will serve us well for further iterations.”
查看原文 →而下一节立刻演示了这一点——改一处规格,编译器指出每一个要改的地方。这就是先前那个承诺所开的支票。
“这本来不错,但它行不通”
“This would be nice, but it doesn't work”
查看原文 →以失败结束这一段是个好的教学直觉——规格想要一个接口,而领域函数只是一个函数。接缝即将显露。
“设计模式的价值不在于具体的实现,而在于一种语言,用来描述工程师所面对的常见问题的具体解法。”
“The value of design patterns is not specific implementations but a language to describe specific solutions to common problems engineers face.”
查看原文 →我所知道的对模式最好的一句话辩护:它们是词汇,不是代码。一半的白眼来自那些交付图表而不是说出那个词的人。
“type GreetAdapter func(name string) string”
查看原文 →Go 的“函数类型上的方法”这个把戏,用三行做了其他语言需要一个类才能做的事。这里的适配器几乎是免费的——值得注意,模式的分量取决于语言。
“现在,想象你的 CTO 已经决定 gRPC 就是未来。”
“Now, imagine your CTO has now decided that gRPC is the future.”
查看原文 →一个漂亮而诚实的框定:这个需求是武断的、自上而下的,而且完全与用户无关。这恰恰是你的架构应当不为所动地吸收的那类改变。
“别偷懒,在你的项目里弄一个装着几百个互不相关的类型和函数的 domain 文件夹。”
“Don't be lazy and have a domain folder in your projects with hundreds of unrelated types and functions.”
查看原文 →这一击说中了,因为 domain/ 是每个 DDD 风味仓库的杂物抽屉。不过 interactions 本身也是个相当单薄的名字——建议比例子更靠谱。
“不是巧合,而是设计”
“not by coincidence but by design”
查看原文 →整章的回报句。看上去重复的测试形状,是规格确实被分解出来的可见证据。
“todo: we shouldn't redial every time we call greet, refactor out when we're green”
查看原文 →把 TODO 留在印出来的代码里,在教学上很勇敢——它显示作者知道得更好,却有意推迟。这种纪律才是真正的教训,而不是 sync.Once。
“我想先运行我们的验收测试,看看传输层面是否一切正常,并验证失败的测试输出”
“I'd like to run our acceptance test first to see if everything is working on a transport level and verify the failing test output”
查看原文 →“fixme”作为曳光弹:在逻辑之前先证明管道。把传输失败与领域失败区分开,才让红色的测试保持信息量。
“这是非计算机人士经常谈论的东西吗?”
“Is this something that the non-computer people talk about a lot?”
查看原文 →页面上最锋利的启发——测试边界按谁在谈论这个行为来划定,而不是按代码结构。
读后感AI 陪读
这一章其实是同一个论证,在逐步升高的赌注下讲了三遍,把它当作一个整体来读,形状就清楚了:验收测试之所以腐烂,是因为它们与系统今天碰巧的构建方式耦合,所以你把它们拆成规格(业务的意思)、DSL/驱动(你怎样触及系统)和实现(它当前是什么)。开篇用精心挑选的琐事挣得了这一主张——<article> 还是 <section> 的自行车棚之争,那条在银行用上计算机之前就成立的银行规则——因为检验本质复杂性的标准,是一条陈述能否在你所有机器消失之后仍然成立。不要重构一个未经测试的代码库的警告,是本章最实用的一句,值得按字面去读:没有安全网,那不是重构,而是重写加祈祷。“在生产环境测试”的挑衅,是我会请读者放慢的唯一地方——针对生产环境运行只读规格,是一种比这句话让大多数人听到的窄得多、也站得住脚得多的做法。
中段是本章不再断言、开始付账的地方。迭代0看起来荒唐:一句写死的问候语散布在五个文件里,仪式多到作者自己预先回应了“过度抽象”的指控——回应得对,但预先回应不是证明。证明在下一拍到来:当加上一个名字参数意味着只需把规格改一次,让编译器指出每一个必须改动的地方。随后领域函数无法满足规格的接口,接缝显露,适配器模式滑了进来——在 Go 里几乎是免费的,因为函数类型上的一个方法用三行就做了其他语言要搭成一个类的事,这是一个关于模式的分量取决于语言的真正提醒。gRPC 这一转折是诚实的高潮:一次由 CTO 驱动的、武断的传输改变,被通过写第二个驱动来吸收,而规格和领域代码原封不动。“不是巧合,而是设计”是本章真正的回报句,而摊销的洞见——一份规格跨层复用,是资产而不是每层的成本——是整套练习值得其仪式的架构理由。最深的一句,几乎被埋没,是软件的复杂性住在单元之间而不是单元内部;这一条观察解释了,为什么一个绿色的单元测试套件会让人感到安心,却仍然错过生产环境中真正出问题的一切。
继续阅读
收尾比多数书做得更扎实。通过 -short 把缓慢的验收测试与单元测试分开,是平凡的管线,却让整套方案在 CI 里活得下去。这些启发比通常的测试金字塔礼拜更好:“这是不是非计算机人士经常谈论的东西?”是按谁在讨论这个行为、而不是按代码结构来划定测试边界,而承认“你得到什么错误就返回什么错误”的测试价值很低,对一位 TDD 作者来说是真正非正统的让步——标准是行为,不是覆盖率。Curse 功能随后把完整的循环端到端地重演一遍作为练习,本章落在作为文档的规格上,这是它多半只是断言而非演示的唯一一个主张。
我要向读者标出的:自下而上的批评是靠轶事论证的,公平的说法是,自下而上在你尚未理解问题时失败——这是大多数时候,不是所有时候。对杂物抽屉式的 domain/ 文件夹的责骂说中了,不过“interactions”本身也是个单薄的名字;采纳建议,而不是例子。还要留意教学上的选择,而不只是代码——把红灯这一步当作对测试的测试,“fixme”这颗曳光弹在逻辑之前先证明传输,使红色的测试能告诉你是哪一层失败了,关于重新拨号的 TODO 明明白白地留在印刷品里。最后一条是安静的教训:知道得更好却有意识地、以书面形式推迟的纪律,胜过它最终变成的 sync.Once。读这一章,是为了把规格与驱动分开,也为了它的词汇——模式作为语言,而不是要交付的图——并接受在一个只有一句问候的程序上,这个结构看起来会像是小题大做。它是一张支票,gRPC 那一节把它兑了现。
在 FeedLitra 里读测试基础,每段旁边都有这些卡片。
开始读书测试基础:不用 mock、stub 和 spy 工作
一个假实现必须复现多少行为,才不再比真实依赖更便宜?
测试基础是怎样展开的AI 辅助
本章以一桩披着战争故事外衣的不满开篇。作者回忆一个团队把他们的系统接入六个外部 API——文档糟糕、不断变动——以及他们为替代这些 API 而构建的测试替身。桩和 mock 与真实 API 渐行渐远;测试变得不稳定;团队的日子都耗在重新发现依赖究竟如何运作,然后在十几个地方重新编辑同样重复的桩代码上。作者注明这段轶事是简化过的,但它承载着本章的框定性主张:mock、桩和间谍把对你依赖的未经测试的猜测散布在整套测试里,所以套件可以保持绿色,而软件却是坏的。随后是第二个更尖锐的局限。桩交回的是一个罐装的值,什么也不记得,所以一个创建资源、保留其 ID、稍后再更新它的流程,没有地方存放那个状态。测试一旦跨越几个单元和几次有状态的调用,桩就跟不上这个故事了——而本章指出,那恰恰是需要集成测试的地方。
从这个诊断出发,本章展开它的替代方案:有状态的内存假实现。因为假实现持有状态,测试可以组装真实的协作者,然后对最终状态而不是对流量做断言。示例把两个假实现放进一个客户服务,并简单地问每个假实现它现在持有什么,以确认社会安全号码已被记录;作者论证,这比断言哪些函数以哪些参数被调用读起来更容易,而且它让服务的内部保持私有,而不是把它们冻结进测试。结果是以接近单元测试的速度获得广泛的集成覆盖。但除非有东西检查它,假实现只是第二套假设,这就是契约登场的地方:一个针对接口写的测试,使同一套测试可以同时对假实现和真实依赖运行。第一个 API 的示例契约捕获了普通的创建/获取/更新行为,以及一条出人意料的真实规则——名字“Dave”被拒绝——内存假实现被迫服从它。关于依赖的新知识成为一次有纪律的编辑,每个测试都继承它,而不是在套件里手工搜寻。两条实用注记为机制收了尾。因为假实现是真正能工作的实现,它的错误分支很难触及,所以本章用一个装饰器把它包起来,装饰器持有假实现外加可选的逐方法函数字段:正常调用直接穿过,而单个覆盖就让依赖按指令失败。数据库代码也以同样方式对待——一份契约,同时对内存实现和真实的、由 SQLite 支撑的实现运行,较慢的真实运行留给验证契约,而不是到处使用。作者坚持说,证明某一条特定的 SQL 字符串被组装出来,对代码是否行为正确什么也证明不了。
转折出现在作者拿起那个显而易见的反对时:这一切都是额外的代码。答案重新框定了账本。自动化测试从不触及客户,所以它们的全部价值在于工程师能多高效地工作——额外的代码是投资,不是浪费。内存假实现加上清晰分开的模块,让你能隔离一个场景里的各个角色,并反复而廉价地重放它;替代方案是在共享环境上缓慢的黑盒或手工测试。作者坦率地说,这一回报是有条件的:代码必须已经足够模块化以便隔离,而契约必须真的能对真实系统运行。一条相关的警告也在这里落下——一旦有了好的假实现,人们很容易不再演练真实依赖,但假实现只有在契约仍对着活的东西执行时才值得信任。放弃了这一点,你就回到了原来那个无人验证假设的陷阱,这也是契约必须针对接口而不是实现的原因。
本章收尾,先把这套实践放进工作流,再放进组织。契约应当自上而下地从工作中冒出来,而不是首先被设计:写一个失败的验收测试,把协作者放到接口后面,用内存假实现满足它,把你做出的假设写成契约,然后让真实依赖通过那份契约并交付。从契约开始是自下而上的设计,招致对可能永远不会到来的需求的过度工程。放大到团队层面,签入构建流水线的契约,做到了维基页面和会议做不到的事。事先商定一个 API 很少站得住,因为写代码这个行为会加深你对领域的理解,并使你早先的假设失效——这随后又迫使另一个团队改变。在两条流水线里都运行的一份契约,把这种变化呈现为一次失败的构建,而不是一条 Slack 讨论串或一次每周会议,从而让模块化的团队能够吸收他们无法预见的跨团队变化。
关键论点,以及支撑它们的原文
mock、桩和间谍把对你依赖的未经测试的猜测散布在整个测试套件里,所以套件可以保持绿色,而软件实际上是坏的。
作者讲述了一个团队接入六个文档糟糕、不断变动的外部 API:替身与现实渐行渐远,测试变得不稳定,团队的大量时间花在重新发现这些 API 的行为,并在许多地方重新编辑同样重复的桩上。这是一段为了叙述而简化的轶事,却显示了代价落在哪里。
“如果替身与真实依赖的行为之间有差异,或者随时间产生了差异(比如真实依赖变了,这是可以预料的),你可能有通过的测试,却有失败的软件。”
“If there is a discrepancy between the behaviour of the double and the real dependency, or if one happens over time (e.g. the real dependency changes, which has to be expected), you may have passing tests but failing software.”
Working without mocks, stubs and spies › The problem with stubs and mocks查看原文 →
承接这个问题,有状态的假实现以接近单元测试的速度给你广泛的集成覆盖,因为你检查的是最终状态,而不是窥探调用。
在示例里,测试把两个假实现组装进客户服务,然后简单地问每个假实现它现在持有什么,以确认社会安全号码已被记录。作者论证,这比断言哪些函数以哪些参数被调用读起来更容易,而且它让服务的内部保持私有,而不是把它们冻结进测试。
“使用假实现,我们可以基于各系统的最终状态作断言,而不必依赖复杂的侦察。”
“Using fakes, we can make assertions based on the final states of the respective systems rather than relying on complicated spying.”
Working without mocks, stubs and spies › Enter: Fakes查看原文 →
关于依赖的假设应当写成契约——一个同时对假实现和真实对象运行的可复用测试——这样新知识就意味着一次有纪律的编辑。
第一个 API 的示例契约捕获了普通的创建/获取/更新行为,以及一条意想不到的真实规则(名字“Dave”被拒绝),内存假实现被迫服从它。作者指出,没有这个,你就得记着手工去搜寻每一个受影响的测试。代价是搭建的工夫,外加真的能对真实系统运行这份契约的能力。
“没有这种做法,你就得记得去找出所有相关的测试并更新那些桩。易错,费力,又无聊。”
“Without this approach, you'd have to remember to find all the relevant tests and update the stubs. Error-prone, labourious and boring.”
Working without mocks, stubs and spies › Evolving software查看原文 →
另外 3 个论点
把反对意见翻过来:假实现和契约的额外代码是投资而不是浪费,因为测试的存在是为了让工程师高效,而不是为了交付功能。
自动化测试从不直接触及客户,所以它们的价值纯粹在于团队能多高效地工作。作者推理,内存假实现加上清晰分开的模块,让你能隔离所涉及的角色,并反复而廉价地重放一个场景,而替代方案是在共享环境上缓慢的黑盒或手工测试。这一好处取决于代码已经足够模块化以便隔离。
“自动化测试并不直接让客户受益,但我们写它们是为了让自己的工作更高效(你写测试不是为了追逐覆盖率分数,对吧?)。”
“Automated tests do not directly benefit customers, but we write them to make ourselves more efficient with our work (you don't write tests to chase coverage scores, right?).”
Working without mocks, stubs and spies › Isn't this extra code waste?查看原文 →
契约应当自上而下地从工作中冒出来,从验收测试和假实现开始,而不是首先被设计。
作者列出次序:写一个失败的验收测试,把协作者放到接口后面,用内存假实现满足它,然后把你做出的假设写成契约,再让真实依赖通过那份契约并交付。改从契约开始则是自下而上的设计,招致对可能永远不会到来的需求的过度工程。
“要推向生产环境,你不能用内存实现!把你针对假实现所做的假设编码进一份契约。”
“To push to production, you can't use in-memory! Encode the assumptions you made against the fake into a contract.”
Working without mocks, stubs and spies › How does this fit into TDD?查看原文 →
在团队之间,签入构建流水线的契约做着维基页面和会议做不到的事:它们让每个团队在共享的边界移动时继续前进。
作者的推理是,事先商定一个 API 很少站得住,因为写代码这个行为会加深你对领域的理解,并使早先的假设失效,这随后又迫使另一个团队改变。一份在两个团队流水线里都运行的契约,把这样的变化呈现为一次失败的构建,而不是一条 Slack 讨论串或一次每周会议。
“不要依赖每周会议或 Slack 讨论串去充实变更。把你的假设写进契约。”
“Don't rely on weekly meetings or Slack threads to flesh out changes. Codify your assumptions in contracts.”
Working without mocks, stubs and spies › Wrapping up查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么桩会在跨单元时失效
桩只交回一个罐装的值,什么也不记得,所以一个创建资源、保留其 ID、稍后再更新它的流程,没有地方存放那个状态。测试一旦跨越几个单元和几次有状态的调用,桩就跟不上这个故事了——而那恰恰是需要集成测试的地方。
机制 2
契约如何让假实现保持诚实
契约是针对接口写的测试,所以同一套测试可以同时对内存假实现和真实依赖运行。当你发现一个出人意料的真实行为,你在契约里把它编码一次;假实现随后必须改变才能通过,每一个使用这个假实现的测试都继承这一更正,而不是悄悄漂移。
机制 3
装饰器触及失败路径
假实现是真正能工作的实现,所以它的错误分支很难从测试里触及。把它包进一个持有假实现外加可选的逐方法函数字段的装饰器,让正常调用直接穿过,而单个覆盖就让依赖按指令失败,无需编辑假实现本身。
常见误读
测试数据库代码意味着 mock 驱动,并检查出来的 SQL 语句是否正确。
要紧的是代码行为是否正确,而证明某条特定的 SQL 字符串被组装出来,对此什么也证明不了。作者转而写一份契约,同时对内存实现和真实的、由 SQLite 支撑的实现运行;因为真实的运行更慢,它们被限定在验证契约上,而不是到处使用。
“证明一条特定的 SQL 语句被编译了,并不能证明你的代码按你需要的方式运作。”
“Proving a specific SQL statement was compiled does not prove your code behaves how you need it to.”
Working without mocks, stubs and spies › Where's the chapter on testing databases?查看原文 →
一旦有了好的假实现,假实现就是测试套件——你可以不再演练真实依赖了。
假实现只有在有东西对照现实检验它时才值得信任,而那个东西就是同时也对着活的依赖执行的契约。跳过这一步,你就回到了无人验证假设的原始陷阱;这也是契约必须针对接口的原因,使两种实现都能通过它。
“没有这种做法,你就得记得去找出所有相关的测试并更新那些桩。易错,费力,又无聊。”
“Without this approach, you'd have to remember to find all the relevant tests and update the stubs. Error-prone, labourious and boring.”
Working without mocks, stubs and spies › Evolving software查看原文 →
测试基础的核心概念
- 测试替身
- 测试被测对象时所用的、任何可控的真实依赖替身。 测试中任何真实依赖的替身;本章更偏好有契约支撑的假实现,而不是临时的桩和间谍。
- 适用处: 当真实依赖很慢、很远、或难以从顺利路径上推开时有用。 桩和间谍仍有一席之地,用来模拟一次性的行为,只要成本保持可控。
- 假实现
- 一种真实但轻量、有状态的依赖实现,常常在内存中。
- 适用处: 比桩花更多代码,且必须手写,因为它编码了真实的行为。
- 契约
- 针对接口编写的、可复用的测试,同时对假实现和真实依赖运行,以验证假设。 一套可复用的测试,任何接口实现都必须通过,使假实现与真实依赖的行为一致。
- 适用处: 只有测试针对接口而不是具体类型时才有效。 本章建议不要从契约开始;在假实现验证了设计之后再推导它。
- 集成测试
- 证明两个或更多单元在组合之后能正确工作的测试,介于单元测试和验收测试之间。
- 适用处: 当单元测试太窄、而完整的黑盒验收测试成本太高时需要。
- 装饰器模式
- 包裹一段代码,以覆盖或扩展其行为,而无需编辑它的源码。
- 适用处: 这里用来迫使假实现进入本来被藏起来的失败路径。
- 模块化系统
- 被拆成测试良好的部分、带有值得信任的假实现的代码,使你可以廉价地重组和实验。
- 适用处: 本章的好处依赖于这种模块化;纠缠的代码无法以这种方式被隔离。
另外 0 个概念
测试基础划线与批注AI 陪读
“这些假设通常除了手工检查外没有被验证,所以它们威胁你测试套件的用处”
“These assumptions are usually not validated beyond manual checking, so they threaten your test suite's usefulness”
查看原文 →整章的论点一句话说完:问题不在 mock 本身,而在于未经验证的信念被硬编码在几十个地方。
“把你的构建与另一个团队的解耦”
“Decoupling your build from another team's”
查看原文 →注意这是披着技术外衣的组织理由。测试替身作为政治上的隔离层。
“如与真实人物(无论在世或已故)有任何相似之处,纯属巧合。”
“Any resemblance to actual persons, living or dead, is purely coincidental.”
查看原文 →一个泄露出真实挫败感的玩笑。这份免责声明保护的,是他显然在 Slack 上追了好几个月的同事。
另外 15 条批注
“真有一种风险:忘了在代表同一依赖的其他桩里更新它”
“there's a real risk of neglecting to update it in other stubs representing the same dependency”
查看原文 →这才是真正的痛点,而且是重复,不是 mock。同样的知识被复制 N 份,没有单一的所有者。
“如果我们手工检查自己的系统,我们会去查询那些 API 来检查它们的状态,而不是检查我们的请求日志”
“if we manually checked our system, we would query those APIs to check their state, not inspect our request logs”
查看原文 →有力的论证:断言一个人会检查的东西。间谍让你断言机制,而不是结果。
“系统不允许你把“Dave”添加为客户”
“the system will not allow you to add 'Dave' as a customer”
查看原文 →本章最好的东西。契约成了真实世界荒唐事的博物馆——这类知识否则会死在 Slack 讨论串里。
“在签入之前运行完整的测试套件,可能会因为假实现的行为不同而导致其他测试失败。这是件好事!”
“Running the full test suite before checking in may result in other tests failing due to the fake having a different behaviour. This is a good thing!”
查看原文 →这里是真正的回报:一个假实现成了爆炸半径探测器。集中的假设意味着一个改动自动传播,而不是被追猎。
“我们常常比其他系统的开发者更早发现问题。”
“We often identified issues before the other systems' developers did.”
查看原文 →随口带出,却是这里最强的经验性主张——契约变成了针对别人 API 的监控工具。
“你正在失去来自契约的保证,因为你在引入临时的、特设的行为”
“you are losing the guarantees from your contract as you are introducing ad-hoc behaviour”
查看原文 →承认这点值得称道:一旦你需要错误路径,桩就通过装饰器溜了回来。这个做法并未完全逃脱它所批评的东西。
“证明一条特定的 SQL 语句被编译了,并不能证明你的代码按你需要的方式运作。”
“Proving a specific SQL statement was compiled does not prove your code behaves how you need it to.”
查看原文 →而这悄悄回答了他为什么五年来拒绝写数据库一章——这个问题本身从一开始就问错了。
“带契约的假实现让你把使用“更重”依赖的需要,限制在仅仅验证契约的时候”
“Fakes with contracts allow you to use restrict the need to use the "heavier" dependency to only when you're validating the contract”
查看原文 →笔误活过了出版,但经济账才是真正的要点:昂贵的依赖在契约边界付一次钱,而不是每次测试运行都付。
“定义一些 API 契约(通常在一个维基页面上!),然后独立工作六个月,再把它们全部拼在一起”
“define some API contracts (often on a wiki page!) and then work independently for six months and stick it all together”
查看原文 →括号里的话干了所有的活。维基页面是一份无法让构建失败的契约——这意味着它不是契约,而是一种希望。
“他们取得进展和改变主意的能力,而无需向他们小团队之外的任何个人或群体请求许可”
“their ability to make progress and to change their minds, without asking for permission from any person or group outside of their small team”
查看原文 →巧妙的安放:这段引文把一个技术性的测试技巧变成了组织论证。假实现是自主权的一种形式。
“不要依赖每周会议或 Slack 讨论串去充实变更。把你的假设写进契约。”
“Don't rely on weekly meetings or Slack threads to flesh out changes. Codify your assumptions in contracts.”
查看原文 →本章最尖锐的一句——沟通无法扩展,但失败的测试可以。不过它悄悄假定另一个团队同意运行你的契约。
“把古怪的、假设的(但可能发生的)场景变成自包含的测试,帮助我们理解问题”
“weird, hypothetical (but possible) scenarios into self-contained tests to help us understand the problem”
查看原文 →在这里,测试不再是验证,而成了思考的仪器。这是廉价替身的真正回报,却被埋在段落中间。
“当然,桩和间谍有它们的位置。”
“Of course, stubs and spies have their place.”
查看原文 →这一让步是在论证获胜之后才到来——修辞上精明,但一章题为“不用……来工作”的内容,欠它不止一句话。
“很难复现一个由其他工程师编写、而且他们同时也在改动的系统的确切行为”
“it's hard to reproduce the exact behaviours of a system written by other engineers, who also change it simultaneously”
查看原文 →这是拆了整套方案台的诚实承认:假实现只与有人记得更新的契约一样新。
“问题在于这不给你隔离的反馈,而且反馈很慢。”
“The problem is this doesn't give you isolated feedback, and the feedback is slow.”
查看原文 →共享的预发布环境作为反派——每个等过轮到自己部署的工程师都会点头。诊断是社会性的,不是技术性的。
读后感AI 陪读
这一章开篇不是一项技术,而是一桩不满,而这正是读懂它的关键。本章一开始就指出传统测试替身的真正缺陷:每个桩或间谍都硬编码了对某个依赖的一种信念,这些信念除了有人在预发布机器上手工戳一戳之外从未被验证,并且在几十个测试文件里被重复,没有单一的所有者。论点句——那些“除了手工检查之外未被验证”的假设威胁着套件的有用性——就是整个论证的浓缩。请注意由此而来的:反派不是 mock,而是未经验证且重复的知识。这个重新框定很要紧,因为它意味着,即便 mock 的人体工学更好,本章的补救办法仍然是需要的。从这里起,全篇以一条干净的弧线展开——诊断分散的假设问题,提出有状态的内存假实现作为那份知识所在的唯一地方,给它加上一份契约,使假实现是对照真实的东西被验证的,而不只是被相信,然后展示这能买到什么:广泛而快速的集成测试,对状态而非调用日志的断言,一个能在笔记本上运行的套件,以及装饰器,用来触及假实现自然无法产生的错误路径。收尾一段把镜头从测试拉宽到组织,为前期成本辩护,并把这套做法植入验收测试优先的 TDD 循环之中。最强的动作是关于认识论的,而不是关于工具的。断言一个人会检查的东西——你会去查询 API 的状态,而不是检查你自己的请求日志——是一条真正有澄清作用的规则,它一下子解释了为什么重度依赖间谍的测试感觉忙碌却空洞:它们证明了机制触发了,而不是世界改变了。“契约即博物馆”的想法是本章最好的礼物:“系统不允许你把 Dave 添加为客户”这条规则,恰恰是那种荒唐的、辛苦得来的、真实世界的知识,通常死在 Slack 讨论串里,而契约给了它一个可以让构建失败的栖身之处。
同样,那句看似随口的承认——假实现改变后其他测试坏掉是“一件好事”——才是真正的回报,一个爆炸半径探测器,假设被集中起来,使一个改动得以传播,而不是被到处追猎。还有那句随意的“我们常常比其他系统的开发者更早发现问题”,是本章最强的经验性主张,几乎被藏起来了:契约悄悄成了别人 API 的监控。所有句子中最尖锐的一句,是那条命令:停止依赖每周会议和 Slack 讨论串,把假设写成契约——沟通无法扩展,失败的测试可以。我看穿的是:这一章有相当一部分是披着技术外衣的组织论证。“把你的构建与另一个团队的解耦”是政治;被引用的、对可以不经许可就改变主意的团队的赞美,是一份自主权宣言;共享的预发布环境被塑造成反派,而那里的诊断是社会性的,不是技术性的。对维基页面的挖苦是破绽——一份无法让构建失败的契约不是契约,而是一种希望——而括号里的话比周围的段落做了更多的活。这不是缺陷;这是本章真正的洞见,而作者把它直说出来会更有力:假实现加契约是一种恰好由测试代码构成的协调技术。但这套方案有接缝,值得称道的是,作者点出了其中大多数,而没有藏起来。一旦你需要错误路径,桩就通过装饰器走了回来——他承认你在那里“正在失去来自契约的保证”,这就承认了这个做法并未完全逃脱它所批评的东西。更具破坏性的是,他承认很难复现一个其他工程师同时在改动的系统的行为。这才是承重的弱点:一个假实现,只与有人记得更新的契约一样新,而本章最尖锐的那句话悄悄假定另一个团队同意拿你的契约去跑他们的服务。
继续阅读
如果他们不同意,你不过是用 Go 重建了那个维基页面——更漂亮,可以针对你自己的假实现执行,而同样会过时。经济账也值得比那句满是笔误的句子给予的更多肯定:昂贵的依赖在契约的边界付一次钱,而不是每次测试运行都付。是这个,而不是纯粹性,让这套方案能扩展。对写作的两点批评。“桩和间谍有它们的位置”这一让步,是在论证已经获胜之后以一句话到来的——修辞上精明,但一章题为“不用……来工作”的文字,欠它的对立面一次真正的倾听,包括当一个假实现根本造价太高的时候。而最有意思的想法被埋在段落中间:把古怪的假设场景变成自包含的测试,使测试成为思考的仪器,而不是验证的苦差。这是让替身变便宜的最深后果,本该有个标题。五年来拒绝写数据库一章,也在这里几乎是顺带地得到了回答:证明一条特定的 SQL 语句被编译了,对行为什么也证明不了,所以这个问题从一开始就问错了。我会对读者说:别把这当作对 mock 的禁令。拿走它的诊断。对你代码库里的每个测试替身问:谁拥有这个假设,存在多少份副本,如果现实漂移了什么会失败?如果答案是“没人,很多,没东西”,那么不管你用什么工具,你都有这一章所描述的问题。然后问本章没有逼得足够紧的那个更难的问题:你是否有组织上的共识来保持契约的诚实?有的话,就造假实现。没有的话,也还是造契约,让它的失败成为杠杆,帮你争取到共识——但要带着明白进去:一个无人验证的假实现,不过是礼貌了一点的桩。
在 FeedLitra 里读测试基础,每段旁边都有这些卡片。
开始读书测试基础:重构步骤,起步清单
清单里的哪一项最能改善你今天碰过的代码?
测试基础是怎样展开的AI 辅助
本章开篇把重构框定为一项技能——随着练习近乎成为第二天性——并立刻划出组织其后一切的界线:重构在不改变行为的前提下改进现有代码,所以你的测试不应当需要改变。这个定义就是它位于 TDD 循环第三步的原因,它也兼作诊断:如果你在“重构”时改测试,你做的就是别的事。作者随后在一个具体案例里逐步展开这一区分,一个参数列表过长的生日问候方法,人们可能想把它折叠成单一的 Person 参数。因为测试与行为和方法签名耦合,那是设计,不是重构——而推荐的回应不是放弃 TDD,而是运用它,以先写测试的方式、小步驱动这一改动(把几个受影响的测试中除一个之外的都注释掉,然后逐个处理其余的)。从这里起,本章短暂地拓宽到“大设计”,它更长、更像对话、更主观,但仍值得拆成降低风险的步骤。
中段提供了为什么所有这些小尺度纪律要紧的论证,用的是“只见树木不见森林”这个习语:只有底层代码结构良好,你才能就整体设计进行良好的对话,因为否则每个人都把注意力耗在心里解析一团糟上,而结构糟糕的代码会在复杂性堆到摇摇欲坠的地基上时不断累积恶化。这一动机引向本章的实用核心——每个 TDD 循环都要过一遍的心理清单,并附有作者为培养肌肉记忆而教给新工程师的 IntelliJ/GoLand 快捷键注解。清单上的条目是:内联那些只是把值传递下去的变量(提醒是:当一个名字承载了含义时,不要去追求聪明的单行);提取变量以去除重复的值;然后是关于 DRY 本身的、相当平衡的一段离题——误用时,它产生为减少行数而追逐的纠缠的抽象,但拒绝对任何东西做 DRY 同样有害,所以真正的目标是把一个想法收在一个地方,逐案判断,而混乱的参数列表通常就是你走偏了的迹象。
清单继续,把“魔法”值提取成具名的变量或常量,以三个共享一秒超时的 HTTP 客户端为例——作者借这个例子指出,提取也会引入耦合,因为这三个客户端现在必然共享一个值,而内联可以撤销它。接着是让公共方法易于浏览:一个创建部件的例子,通过内联一个载荷变量,并把构建 JSON 的噪音提取到辅助函数里而得到收紧,使公共方法说的是做什么而不是怎么做。福勒被引用以示支持,而作者直率地回答了那个标准的反对(“现在我看不出它是怎么工作的”)——学会用你的工具导航;跳到符号,再跳回来。一个相关的动作随后到来:不依赖方法参数的值,比如构造出来的 URL,可以变成在构造时计算的字段。一个前后对比显示了累积的回报——在有测试提供安全网时,大约一分钟就能做到的、虽小但真实的改进——而清单的最后一项建议写一个方法而不是一条注释。
本章随后转向它自己的例外和工具。有些改动确实触及测试——重命名一个公共符号显然是——而作者乐意仍称这些为重构,因为自动化的 IDE 操作不会把你拖进测试和生产代码同时被弄坏的漩涡。这引出了实践建议:每做一次小改动就运行单元测试(毫秒级的反馈回路,正是所有那些可测试性投入所买到的),并依靠源代码控制,使实验感觉廉价而不是重大。作者尖锐的主张是,那些说自己没时间重构的开发者,缺的是练习和纪律,而一个让提取变得费力的工具,会悄悄让你更少重构。
它以许可和精神收尾。重构不该被请求或推迟;少量而经常地做,它不是时间黑洞,而跳过它则会侵蚀内部质量、团队能力和日程余量。结尾的福勒引文颠倒了通常的借口——一阵重构往往会提高生产力,而觉得时间不够本身就是你需要重构的信号。总结承认这份清单只是开始(指向福勒的《重构》第2版),重申太多人把重构当作别人的决定,而不是要学习的技能,并以把本章串在一起的回路作结:好的重构让代码可理解,可理解的代码让更好的设计显现,所以频繁的小重构是好设计的前提条件。
关键论点,以及支撑它们的原文
如果改进某些代码迫使你同时编辑它的测试,你做的就不是重构,而是别的事。
作者从耦合推导出这一点:测试是针对行为和签名写的,所以真正的重构只能触及内部——私有方法、字段、新类型——因此按构造就让测试保持不动。他允许一个狭窄的例外:IDE 自动化的公共符号重命名确实会同时改变生产代码和测试代码,却算作重构,因为工具安全地执行它,不会演变成连锁的破坏。
“如果你在“重构”某些代码时不得不同时修改测试,那你做的是别的事情。”
“You're doing something else if you are "refactoring" some code and having to change tests at the same time.”
Refactoring step, starting checklist › Refactoring vs other activities查看原文 →
改变方法的签名是设计变更,应当得到与任何新行为一样的先写测试的纪律。
作者用的是一个参数列表很长的生日问候方法,他想把它折叠成单一的 Person 参数。因为依赖那个签名的不只是测试,还有真实的调用者,他以先写测试的方式小步驱动这一改动,并建议把调用它的测试中除一个之外都注释掉,让那个通过,然后逐个处理其余的——TDD 通常的降低风险手段,用在一个只是感觉不同的改动上。
“如果你在“重构”某些代码时不得不同时修改测试,那你做的是别的事情。”
“You're doing something else if you are "refactoring" some code and having to change tests at the same time.”
Refactoring step, starting checklist › Refactoring vs other activities查看原文 →
公共方法应当宣告它们做什么,把怎么做留给私有辅助函数。
他走读一个创建部件的方法,里面塞满了 JSON 字符串和字节缓冲区的仪式:内联载荷变量,再把载荷的构建提取到自己的函数,留下的方法体读起来就是三个具名的意图。把不依赖任何参数的 URL 挪进由构造函数赋值的字段,又去掉了一行。作者的要点,呼应福勒,是必须动脑子才能理解代码,这本身就是该重构的信号。
“如果我们不去解决那些细小的设计问题,就会发现很难设计我们更庞大系统的整体。”
“If we fail to address the minor design issues, we'll find it hard to engineer the overall design of our more extensive system.”
Refactoring step, starting checklist › Seeing the wood for the trees查看原文 →
另外 2 个论点
结构良好的代码,是就更大设计进行有用对话的前提条件。
这个论证经由可读性展开:每次打开文件都要解码一团糟的同事,没有余力去推理结构,而结构糟糕的代码会随着复杂性堆在薄弱的地基上而指数级地恶化。小清理还直接浮现设计问题——一旦步骤有了名字,你就可以问这个类型为什么要做 X,或者这活儿是否属于别处。
“如果我们不去解决那些细小的设计问题,就会发现很难设计我们更庞大系统的整体。”
“If we fail to address the minor design issues, we'll find it hard to engineer the overall design of our more extensive system.”
Refactoring step, starting checklist › Seeing the wood for the trees查看原文 →
没有时间不是跳过重构的理由;它通常是重构已经拖欠的症状。
部件的例子展示了经济账:整个改进只是一小撮由工具驱动的动作,练熟了几乎只要一分钟,单元测试在毫秒内运行作为安全网。作者补充说,抱怨重构慢的开发者之所以慢,恰恰因为他们很少做、且没有纪律,并引用福勒说一阵重构一再提高了生产力——唯一的让步是非常接近截止日期的时候。
“时间不够,通常是需要做些重构的迹象。”
“Not having enough time usually is a sign that you need to do some refactoring.”
Refactoring step, starting checklist › Don't ask permission to refactor查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么测试保持不动
测试只绑定两样东西:可观察的行为和方法签名。只要改动不动这两者,你就可以自由地添加私有辅助函数、字段或类型,重写公共方法的内部,而现有测试继续通过——这正是这项活动位于 TDD 循环第三步的原因。
机制 2
命名一个值也把它的使用者绑在一起
把重复的字面量提到一个具名变量里,去掉了无法解释的魔法,但如今每一个读这个名字的地方都共享一个值。作者那三个 HTTP 客户端,在提取出共享的超时变量之后,除非再次内联,就无法独立调整了——所以含义上的收益,伴随着新的耦合,你应当有意识地选择。
机制 3
小混乱挤掉大设计
如果每个文件光是在脑子里解析就要花力气,就没有注意力留给关于整体结构的问题。更糟的是,新的复杂性被堆在那些摇摇欲坠的地基上,于是衰败是复利式的,而不是均匀累积——所以持续的小清理,才是让更大的设计工作得以可能的东西。
常见误读
DRY 已经名誉扫地,所以安全的做法是放着重复的代码和值不管。
DRY 真正的想法是把一个想法收在一个地方;更短的代码是副产品,不是目标。为省行数而建的纠缠的抽象是误用,但一概拒绝合并,会留下噪音和更高的维护成本,作者称之为另一个问题。他建议逐案判断——问提取出来的函数的参数列表读起来是否合理、它是否命名了一个真正的想法——并且就去试,因为版本控制和快速的测试让你可以廉价地回退。如果合并感觉很难,那就是该停手的信号。
“减少代码行数往往是 DRY 的副作用,但它不是真正的目标。”
“Reducing the number of lines of code is often a side-effect of DRY, but it is not the actual goal.”
Refactoring step, starting checklist › DRY up stuff in general查看原文 →
把一个方法拆成小的私有函数,会藏起代码实际如何工作,让它更难跟随。
细节被刻意挡在叙述之外,因为它在多数时候是无关的噪音;当读者确实在意时,跳转到符号一下子就进入辅助函数,再按一个键就回来。作者把这个反对大体当作工具熟练度的缺口,并指出每当你想写一条解释性注释时,同样的提取方法动作是更好的答案。
“如果你练得熟,这类改进几乎只要你一分钟,只要你好好运用了 TDD,你就有测试这张安全网来确保你没弄坏任何东西。”
“If you are well-practised, this kind of improvement will barely take you a minute, and so long as you have applied TDD well, you'll have the safety net of tests to ensure you're not breaking anything.”
Refactoring step, starting checklist › Comparing and contrasting CreateWidget查看原文 →
测试基础的核心概念
- 重构
- 在不改变行为的前提下改进现有代码,所以测试保持不动。
- 适用处: 改变行为或方法签名是设计工作,不是重构;安全的自动化重命名是被接受的例外。
- 大设计
- 更大的、部分主观的结构性改动,确实需要改测试,应当由 TDD 小步驱动。
- 适用处: 当签名或行为必须改变时适用;仍需要降低风险的小步。
- 作为具名常量的魔法数字
- 用具名变量或常量取代无法解释的重复值,赋予它们含义。
- 适用处: 提取也会把该值的每个使用者耦合在一起,这未必是想要的。
- DRY
- 把一个想法收在一个地方;更少的行数是副作用,不是目标。
- 适用处: 过度应用的 DRY 会造成纠缠的抽象;完全拒绝 DRY 同样有损维护。
另外 0 个概念
测试基础划线与批注AI 陪读
“如果你在“重构”某些代码时不得不同时修改测试,那你做的是别的事情。”
“You're doing something else if you are "refactoring" some code and having to change tests at the same time.”
查看原文 →一个干脆的、可证伪的定义——测试套件成为你被允许用哪个词的仲裁者。多数关于重构的争论,一旦你接受这点就烟消云散了。
“你可以在这是否算“行为”改变上咬文嚼字,但你想要你的方法表现得不同”
“You can split hairs over whether this is a "behaviour" change, but you want your method to behave differently”
查看原文 →诚实的含糊其辞。他知道“行为”在他的分类里干着许多无酬的活,并选择了务实而不是精确。
“我建议把除一个之外的所有测试都注释掉,把改动驱动出来”
“I'd suggest commenting out all but one of the tests, driving out the change”
查看原文 →本章最实用的一句,也是唯一承认重构途中你的套件暂时在对你撒谎的一句。如果你忘了取消注释,这建议有风险。
另外 6 条批注
“结构糟糕的代码会随着工程师在摇摇欲坠的地基上不断堆叠复杂性而指数级地变得更糟”
“badly-factored code gets exponentially worse as engineers pile on complexity on top of shaky foundations”
查看原文 →“指数级”是修辞,不是度量。但他所点名的机制——因为解析吃掉了一切,心智预算里不剩设计的余地——才是真正的论证,它不需要那个数学。
“我们也使得所有三个客户端共享同一个超时”
“we have also made it so all three clients share the same timeout”
查看原文 →本章最好的一招:他在推荐 DRY 的同一口气里,展示了 DRY 如何造成耦合。提取从来不是免费的——它断言两个值是同一个想法,而不只是同一个数字。
“你是否学会了如何用你的工具有效地在代码库中导航?”
“Have you learned how to navigate codebases using your tooling effectively?”
查看原文 →他称之为直率;它也是一种回避。工具回答“它在哪里”,却不回答“我得追这个追多少层深”。对小函数的反对关乎工作记忆,不是快捷键。
“任何你能用 IDE/编辑器安全完成的改动,我仍然乐意称之为重构”
“any changes you can safely perform with your IDE/editor, I would still happily call refactoring”
查看原文 →所以真正的标准从来不是“测试不变”——而是“风险被机械地限定”。承重的想法是自动化,而不是测试稳定性。先前的规则只是个替身。
“他们要花那么多时间,是因为他们没有带着纪律去做——而且他们练得还不够”
“it takes so much time for them because they don't do it with discipline - and they have not practised it enough”
查看原文 →以一种有用的方式循环:没练过的重构很慢,慢又让人不愿练习。他描述的是一个陷阱,尽管他把它框定为性格缺陷。
“不过,除非你非常接近截止日期,否则你不应该推迟重构”
“Other than when you are very close to a deadline, however, you should not put off refactoring”
查看原文 →注意福勒自己的逃生条款,被原样引用。即使是守护神也承认截止日期是真实的——这比“不要请求许可”所允许的让步更多。
读后感AI 陪读
本章的力量在于它的狭窄:它拒绝让“重构”变成“我想做的任何代码改动”,并给出一个机械的检验——你的测试是否不得不改变?——来强化这条界线。正是这种纪律让其余部分可以教。但到最后,本章悄悄修正了自己的标准:重命名一个公共符号确实会改变测试,却仍被称为重构,因为 IDE 保证了它。所以真正的原则是有界的机械风险,测试稳定性只是一个方便的替身;把这点事先说出来,会比把它作为“例外”抵达更有力。最精彩的一段是 HTTP 超时的例子,在那里,提取被推荐了,同时它隐藏的代价——三个客户端如今耦合进了一个决定——在同一个动作里被展示出来;这是关于抽象的、少见而诚实的教学。最弱的是对“现在我不知道它怎么工作”的回应,一个关于工作记忆和调用深度的合理抱怨,被一句关于快捷键的挖苦打发了。而结尾对“没有时间”的开发者的说教,描述了一个自我强化的陷阱,却把它归因于缺乏纪律,这比那个循环本身更没用。总的来说:一份好清单,一个关于为何微小的设计债会阻断重大设计思考的更好论证,以及一个没能完全活过自己这一章的定义。
在 FeedLitra 里读测试基础,每段旁边都有这些卡片。
开始读书构建一个应用:构建一个应用
产品负责人不断变化的要求,如何塑造测试的设计?
构建一个应用是怎样展开的AI 辅助
如何开篇。 本章不是以代码开篇,而是以对读者进度的一次检查点。它假定你已经“消化”了前面的 Go 基础部分,并因此把两样东西记在你名下:对该语言大多数特性的可用掌握,以及对如何实践测试驱动开发的把握。这个假定是整个部分所系的合页——开篇的存在,是为了标出一条界线,一边是在孤立练习中学习 Go,另一边是把它付诸使用。随后的宣告刻意朴素:接下来这一部分将涉及构建一个应用。
如何展开。 本章中段解释的是方法,而不是题材。每一章都要在上一章的基础上迭代,“按我们的产品负责人所要求的”扩展应用的功能——一小段框定,把读者塑造成接收不断变化的需求的开发者,而不是在大纲上逐项学习的学生。文字随后设定对内容的期待:新概念会在有助于你写出好代码的地方出现,但大部分新材料将关乎 Go 标准库已经使之成为可能的事。这是本章安静的论证——在 Go 里构建真实的东西,与其说是积累语言技巧,不如说是学会伸手去拿随语言一同发布的东西。所述的回报紧随其后:到最后,你应当理解如何在 Go 里迭代地增长一个应用,并以测试为基础。
转折与收束。 本章随后整个转换了语域,从散文变成一张地图。其余部分是前方六章的清单,每章附一行描述它构建什么、教什么:一个监听请求并作出应答的 HTTP 服务器;JSON 响应连同路由和嵌入;在磁盘上读写数据,外加排序;命令行输入和一个能从同一份代码库支持多个应用的项目结构;用 time 包调度活动;最后是编写并测试一个 WebSocket 服务器。次序本身就是论证——每一项都以上一项为前提,从光秃秃的请求处理器向外推进到持久化,到打包和输入,到时间,最后落在最难测试的东西上。本章在那条 WebSocket 条目上收束,作为一扇门而不是终点:它给了读者一个前提、一种方法和一份行程,仅此而已,把真正的工作留给它所引入的各章。
关键论点,以及支撑它们的原文
本书的这一部分,主要是发现 Go 捆绑的包已经能做什么,而不是堆叠更多的语言特性。
作者说新概念只会为帮助写出好代码而出现,而大部分材料是标准库使之成为可能的事——章节清单也印证了这点,因为每个列出的主题都点出一项库的能力:处理 HTTP 请求,返回 JSON 和路由,读写磁盘并排序,命令行输入,用于调度的 time 包,以及 WebSocket。
“会引入新的概念来帮助写出优秀的代码,但大部分新内容将是学习用 Go 标准库能完成什么。”
“New concepts will be introduced to help facilitate writing great code but most of the new material will be learning what can be accomplished from Go's standard library.”
Build an application查看原文 →
应用不是一次预先设计好的;它随着所述需求的变化,一章接一章地扩展。
作者把每一章框定为在上一章之上迭代,功能按产品负责人的要求添加,而路线图具体地显示了这种累积:它从一个监听并回复的服务器开始,然后叠加 JSON 响应和路由,持久化到磁盘,从同一份代码库支持多个程序外加命令行输入,调度活动,最后是 WebSocket。
“每一章都将在前一章的基础上迭代,按产品负责人的要求扩展应用的功能。”
“Each chapter will iterate on the previous one, expanding the application's functionality as our product owner dictates.”
Build an application查看原文 →
承诺的回报是一种可用的方法,而不只是一个完成的程序。
作者把目标陈述为读者最终牢牢掌握如何在 Go 里迭代地、有测试支撑地编写一个应用——这是从刚才描述的结构自然得出的,因为各章中“扩展再测试”的重复,正是读者本应带走的东西。
“每一章都将在前一章的基础上迭代,按产品负责人的要求扩展应用的功能。”
“Each chapter will iterate on the previous one, expanding the application's functionality as our product owner dictates.”
Build an application查看原文 →
它是怎样运作的
本章论证所依靠的 2 个机制。
机制 1
为什么一步步增长的同一个应用能教会迭代
不是每次都从头开始,而是每一章都扩展同一个程序,同时一位想象中的产品负责人不断要求更多。因为读者要一再把新需求安放到已经存在、并有测试覆盖的代码上,到最后留下来的就是以带测试支撑的小步增长软件的习惯。
机制 2
先基础,后库的广度
作者假定前面的基础材料已经解决了语言本身和测试纪律。有了这层基础,每个新需求都可以通过伸手去拿一项打包好的能力来满足——HTTP 服务器、JSON 编码、文件 IO、调度——于是学习从语法转向知道随 Go 发布了什么。
常见误读
学完基础之后,应用这一部分一定是教 Go 语言中更难或更高级部分的地方。
作者认为语言本身大体已被基础部分解决,并把这里的新概念定位为帮助写出好代码的次要辅助;主要的学习目标,是 Go 已经随附的那一组包。
“会引入新的概念来帮助写出优秀的代码,但大部分新内容将是学习用 Go 标准库能完成什么。”
“New concepts will be introduced to help facilitate writing great code but most of the new material will be learning what can be accomplished from Go's standard library.”
Build an application查看原文 →
这一部分的每一章都是单独的、自成一体的练习,可以按任意顺序拿起来读。
它们构成一次连续的构建:每一章都在上一章的基础上迭代,并往同一个应用里添加功能,所以从 HTTP 服务器到 WebSocket 的次序是累积的,而不是一份菜单。
“每一章都将在前一章的基础上迭代,按产品负责人的要求扩展应用的功能。”
“Each chapter will iterate on the previous one, expanding the application's functionality as our product owner dictates.”
Build an application查看原文 →
构建一个应用的核心概念
- 迭代式应用开发
- 一步步增长同一个程序,每一章都在上一章之上添加功能,底下有测试支撑。
- 适用处: 这里被描述为一种学习结构,而不是正式的方法论。
- Go 标准库
- 随 Go 发布的包,提供应用所需的大部分东西。
- 适用处: 新的语言概念仍会出现,但相对于学习库而言是次要的。
另外 0 个概念
构建一个应用划线与批注AI 陪读
“按我们的产品负责人所要求的”
“as our product owner dictates”
查看原文 →一次悄悄的承认:课程是由不断变化的需求驱动的,而不是一份干净的规格。这才是这里真正的教学——各章迭代,因为需求在迭代。
“大部分新材料将是学习用 Go 标准库能完成什么”
“most of the new material will be learning what can be accomplished from Go's standard library”
查看原文 →整页最强的主张,却近乎致歉地被丢了出来。学习一门语言的标准库就是学习这门语言的文化。
“你对 Go 的大多数语言特性有扎实的基础”
“you have a solid grounding of a majority of Go's language features”
查看原文 →“希望已消化”加上“大多数”——谨慎地含糊其辞。诚实,却让读者不确定缺口会不会在下一节咬到自己。
另外 3 条批注
“JSON、路由和嵌入”
“JSON, routing and embedding”
查看原文 →嵌入被夹带在两件 HTTP 杂务旁边。它是那份清单里唯一真正特属于 Go 的想法,却排在第三位。
“命令行与项目结构”
“Command line & project structure”
查看原文 →唯一一条关乎代码形状而不是某个功能的条目。它出现在课程中段,很能说明问题——结构是挣来的,不是预先宣布的。
“学习如何编写和测试一个使用 WebSocket 的服务器”
“learn how to write and test a server that uses WebSockets”
查看原文 →注意措辞:不是“使用 WebSocket”,而是“编写和测试”。这本书连最花哨的话题也保持着测试这个子句。始终如一直到最后。
读后感AI 陪读
这是一页路标,不是一章,它的有趣几乎全在于它所揭示的这本书的方法,而不是任何内容。有三个动作引人注目。第一,一位“发号施令”的“产品负责人”这一虚构——迭代由外部驱动,这既对真实的工作很诚实,也是不预先设计应用的一个方便借口。第二,明确地把语言特性降级,让位给标准库:作者押的赌注是,Go 的胜任主要是对 net/http、encoding/json、io、time 的熟练——一个站得住的赌注,也是一个悄悄表明立场的赌注。第三,次序:HTTP 服务器,然后 JSON 和路由,然后持久化,然后项目结构,然后时间,然后 WebSocket。结构出现在第五位,在代码有时间变乱之后——刻意拒绝在尚无东西可布局之前就教布局。反复的含糊其辞(“希望已消化”、“大多数”、“应该有扎实的掌握”)是唯一的弱音;它是对读者友好的谦逊,但也意味着这一页从未明确你必须已经知道什么。我希望这样的目录能有的,是一句关于这个应用究竟是什么的话——各章是按技术列出的,从不按产品,对一个围绕产品负责人搭建的部分来说,这是一点小小的讽刺。
在 FeedLitra 里读构建一个应用,每段旁边都有这些卡片。
开始读书构建一个应用:HTTP 服务器
下一段如何监视 RecordWin 来验证存储?
构建一个应用是怎样展开的AI 辅助
本章一开篇就拒绝设计任何东西。目标——一个跟踪玩家胜场的 HTTP 服务器——被陈述出来,随后第一个测试只要求从一个 GET 请求里返回一个分数。答案是硬编码的:处理器写出一个固定的数字,测试转绿。这是刻意的戏剧,因为第二个测试立刻戳破这层假象,要求另一个玩家的分数。现在那个常量活不下去了,处理器被迫读取请求 URL 并剥掉路径前缀,才知道被问到的是谁。作者承认这种解析并不健壮,但眼下够用了。被埋下的教训是:下一步设计的动作不是预见到的;它是被一个先前代码无法满足的测试挤出来的。
从这里起,本章通过提取接缝展开。分数查找被拉进自己的函数,而一旦它独立出来,设计中的缺口就变得可见:这个函数想成为别人的责任。于是 GetPlayerScore 成了 PlayerStore 接口上的一个方法,处理器成了 PlayerServer,一个持有存储并实现 ServeHTTP 的结构体。关键点在于发现的方向——接口不是先画好再去实现的。处理器自己的测试陈述了处理器需要从存储得到什么,而那份对需要的陈述就是方法集。一个预先填好分数的罐装桩存储喂给 GET 测试,这使得读可以在写根本还不存在时就先建好。针对未知玩家的 404 随后到来,本身也给出了一个小而尖锐的教训:写最偷懒的代码——在每个响应上都返回 404——仍然让套件保持绿色,这暴露出没有任何测试曾对一个确实存在的玩家断言过 200。测试是不完整的,只有偷懒才把它揭了出来。
转折随 POST 而来。路由拆成 showScore 和 processWin,而记录一次胜利带来一个鸡与蛋的问题:GET 需要存储的数据,POST 需要一种读回它的办法。本章用第二种替身打破了僵局——一个只数 RecordWin 调用次数的间谍。保存是通过观察到的调用而不是之后的读取来验证的,所以写的一半不再等读的一半。把 RecordWin 加进 PlayerStore 随后让 main 无法编译,而作者把这种坏掉的状态视为可以接受而不是令人警觉:处理器已经告诉了他正确的契约,而编译器会逐一列出每个必须跟上的具体存储。沿途,被提取了两次的玩家名字,被提升到 ServeHTTP 里只取一次。
本章以拉远镜头收尾。一个集成测试针对 InMemoryPlayerStore 驱动真实的 PlayerServer:记录三次胜利,然后一次分数请求,返回的是 “123” 而不是 “3”——想要累加的地方发生了拼接——而这个失败把存储推向一个以玩家名为键的映射。作者对这个测试的框定很谨慎,也是双刃的。它买来对整个流程的信心,而不必为一个注定要被扔掉的组件写单元测试,并且当真实的数据库取代内存存储时,它依然保有这份价值。但它只证明处理器和存储一起能工作;它更难写,更慢,出问题时也更难定位。这恰恰是快速而聚焦的处理器测试仍留在它下面、而不是被退役的原因。
收尾的教训把本章的方法与它的主题连起来。http.Handler 是让这一切变得可测试的抽象,而依赖注入让一个桩、一个间谍和一个真实的存储能占据同一个位置。然后是方法论上的主张:编译失败与测试失败是同样的紧急情况。两者都意味着你在盲飞,红灯期间所做的每一个改动都未经验证——一次堆上一个桩存储和一个新的接口方法,你得到的就是几个纠缠在一起的问题,而不是一个。用尽可能少的代码脱离红灯,然后在通过的测试支撑下重构。纪律迫使测试小,小测试迫使改动小,而小改动正是复杂系统保持可操作的方式。
关键论点,以及支撑它们的原文
测试替身是打破僵局的东西:GET 需要存储的数据,而 POST 需要一种读回它的办法。
作者用一个预先填好的假存储来服务 GET,通过观察它对存储的调用来检查 POST,所以存储从不通过读取来验证,两个功能不再互相阻塞。
“对于 POST,我们可以侦察它对 PlayerStore 的调用,确保它正确存储了玩家。”
“For POST we can spy on its calls to PlayerStore to make sure it stores players correctly.”
HTTP Server › Chicken and egg查看原文 →
下一步设计,是由测试和重构揭示出来的,而不是预先规划的。
加上第二个玩家的分数使硬编码的答案失败,这迫使处理器读取请求 URL 并剥掉前缀——作者承认这种做法不健壮,但眼下够用。把那个分数查找拉出到自己的函数里,随后使接缝变得明显,从那里起,PlayerServer 成了一个持有存储并实现 ServeHTTP 的结构体。
“这个测试迫使我们真正去看请求的 URL 并作出决定。”
“This test has forced us to actually look at the request's URL and make a decision.”
HTTP Server › Write enough code to make it pass查看原文 →
只写最偷懒的、能通过的代码是有用的,因为它暴露出测试忘了做的断言。
在每一个响应上都返回 404,仍然让套件保持绿色,这揭示出没有任何测试曾对确实存在的玩家断言过 200。
“通过只做让测试通过的最起码的事,它能突出你测试中的缺口。”
“By doing the bare minimum to make the tests pass it can highlight gaps in your tests.”
HTTP Server › Write enough code to make it pass查看原文 →
另外 3 个论点
接口应当在测试处理器时被发现,而不是预先设计。
承接先前的提取,作者只在处理器的测试要求 RecordWin 时才扩展存储接口;main 随即无法编译,而他把这种坏掉的状态视为可以接受,因为处理器已经告诉了他正确的契约,而编译器会列出要修的地方。
“如果我们要能够调用 RecordWin,就需要通过修改接口来更新 PlayerServer 对 PlayerStore 是什么的认识。”
“We need to update PlayerServer's idea of what a PlayerStore is by changing the interface if we're going to be able to call RecordWin.”
HTTP Server › Write enough code to make it pass查看原文 →
一个覆盖整个流程的测试,可以在不为一个一次性组件写单元测试的情况下给系统带来信心,而当真实的数据库取代那个组件后,它依然保有价值。
记录三次胜利再发一次分数请求,返回的正文是“123”而不是“3”,这把存储推向一个以玩家为键的映射。作者指出,这类测试只是端到端地检查这一对组合,并警告它们更难写、更慢、更难诊断。
“这将让我们达到对应用能工作有信心的目标,而不必直接测试 InMemoryPlayerStore。”
“This will let us get to our goal of being confident our application is working, without having to directly test InMemoryPlayerStore.”
HTTP Server › Refactor查看原文 →
编译失败与测试失败算同样的紧急情况:用最少的代码脱离,然后清理。
在本章收尾处,作者论证,长时间待在红灯里会积累未经测试的改动和兔子洞,而这条纪律迫使测试小,因而改动小,这正是复杂系统保持可操作的方式。
“你需要把编译失败或测试失败当作一个你需要尽快脱离的红色状况。”
“You need to treat having failing compilation or failing tests as a red situation that you need to get out of as soon as you can.”
HTTP Server › Commit sins, then refactor (and then commit to source control)查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么短的红灯阶段保持安全
当代码无法编译或测试失败时,每一个额外的改动都未经验证,所以一次堆上像桩存储这样的新部件,会成倍增加你必须同时理清的问题。先用最少的代码脱离红灯,让每一步保持小,并让随后的重构有通过的测试支撑。
机制 2
桩与间谍把读和写分开
GET 和 POST 各自需要对方才有用,所以作者用仅限测试的版本替换了存储:一个罐装存储给 GET 处理器提供分数,而一个记录的替身为 POST 数 RecordWin 的调用次数。因为保存是通过观察到的调用而不是之后的读取来检查的,两半可以独立构建。
机制 3
处理器测试浮现出接口
针对处理器写测试,迫使你准确陈述它需要从存储得到什么,而那个需要就成了方法集。把 RecordWin 加进接口随后弄坏了别处的编译,而编译器本身会指出哪个具体存储还得跟上。
常见误读
你应当先敲定存储接口,然后再针对它写处理器。
作者是反过来到达接口的——测试处理器,直到它陈述出自己需要什么——而且他乐意让更大的应用暂时无法编译,让编译器指出每一个必须被调整到位的地方。
“如果我们要能够调用 RecordWin,就需要通过修改接口来更新 PlayerServer 对 PlayerStore 是什么的认识。”
“We need to update PlayerServer's idea of what a PlayerStore is by changing the interface if we're going to be able to call RecordWin.”
HTTP Server › Write enough code to make it pass查看原文 →
端到端测试一通过,它就是值得保留的测试,小的处理器测试就多余了。
那个测试只显示处理器和存储一起能工作;作者标明它更难写、更慢,出问题时更难定位,这就是快速而聚焦的测试仍留在它下面的原因。
“这将让我们达到对应用能工作有信心的目标,而不必直接测试 InMemoryPlayerStore。”
“This will let us get to our goal of being confident our application is working, without having to directly test InMemoryPlayerStore.”
HTTP Server › Refactor查看原文 →
构建一个应用的核心概念
- TDD 循环
- 写一个失败的测试,做最少的工作让它通过,然后安全地清理。
- 适用处: “犯下罪过”的许可之所以成立,只因为重构这一步有测试支撑。
- 接口
- 一组类型可以满足的具名方法,这里是用于分数查找的 PlayerStore。
- 适用处: 本章只是在一个具体函数使接缝变得明显之后,才把它提取出来。
- 模拟
- 用一个简单的、仅限测试的版本替换真实依赖。
- 适用处: 以映射为底的桩足以应付读取;检查写入则需要间谍。
- httptest
- Go 的包,为处理器测试提供现成的响应间谍。
- 适用处: 它在进程内记录响应;并不演练真实的监听端口。
- 处理器
- 任何带 ServeHTTP 的类型;HandlerFunc 把普通函数适配成一个。
- 适用处: 本章只在服务器不持有状态时才使用 HandlerFunc。
- 间谍
- 一种记录对它所做调用的测试替身,使测试可以对其断言。
- 适用处: 对像 RecordWin 这样的副作用有用;对真实的持久化则什么也说明不了。
另外 4 个概念
- 验收测试
- 这里是一个把 PlayerServer 与 InMemoryPlayerStore 接起来、检查整个流程能否工作的集成测试。
- 适用处: 更难写,更慢,失败也更难定位。
- 测试金字塔
- 指导原则:让许多快速的单元测试位于较少的缓慢集成测试之下。
- 适用处: 作为延伸阅读被提及,而不是在本章里推导出来。
- 依赖注入
- 把存储传入 PlayerServer,使处理器可以在没有真实存储的情况下开发和测试。
- 适用处: 需要一个由处理器拥有的接口;替换实现必须保持同样的契约。
- 互斥锁
- 加上的一把锁,使并发请求不会同时读写分数映射。
- 适用处: 本章只指向 sync 那一章了解细节。
构建一个应用划线与批注AI 陪读
“尽快让测试通过,过程中犯什么罪都行。”
“Make the test work quickly, committing whatever sins necessary in process.”
查看原文 →犯罪的许可之所以行得通,只因为重构这一步没得商量。许多团队只引用前半句,跳过后半句——这就是你得到那个每个人都怪罪 TDD 的代码库的方式。
“所以我们现在只是想打破那个常量”
“so we're just trying to break the constant for now”
查看原文 →“打破常量”是对第二个测试最诚实的描述。这个测试根本不是关于 Floyd 的;它是一根瞄准硬编码的撬棍。
“下一个合乎逻辑的步骤实际上似乎是关于路由”
“the next logical step actually seems to be about routing”
查看原文 →迄今最美好的一刻:测试与作者自己的计划相矛盾。他事先宣布了 mock 和 PlayerStore,然后最小的一步却原来是 URL 解析。
另外 12 条批注
“我做了最起码的事(明知它不正确),也就是在所有响应上写 StatusNotFound,但我们所有的测试都通过了!”
“I have done the bare minimum (knowing it is not correct), which is write a StatusNotFound on all responses but all our tests are passing!”
查看原文 →他把刻意写错的代码当作缺失断言的探测器——测试套件被测试了。这种倒转的价值,比 404 功能本身更大。
“if score == 0 { w.WriteHeader(http.StatusNotFound”
查看原文 →一个真实的、零胜场的玩家现在得到 404——而且正文仍然在头部之后被写出。测试之所以通过,只因为桩的映射把“不存在”与“零”混为一谈。一个本章内置而没有在这里标出的缺陷。
“你应当养成习惯:优先拥有能编译的代码,然后是能通过测试的代码。”
“You should be in the habit of prioritising having code that compiles and then code that passes the tests.”
查看原文 →被低估的纪律:编译器是比测试运行器更便宜的反馈回路,所以在你推理行为之前,先清空类型错误的队列。
“p.store.RecordWin("Bob")”
查看原文 →硬编码“Bob”暴露出,只对调用次数做断言是个弱间谍。这个假答案就是下一个测试的论据——再一次把犯罪当作诊断。
“即使我们的测试通过了,我们其实并没有可工作的软件。”
“Even though our tests are passing we don't really have working software.”
查看原文 →本章最诚实的一句。绿色的测试作为局部麻醉剂——作者在别人之前先点出了自己这套方法的失效模式。
“通过聚焦于我们的处理器,我们识别出了我们需要的接口,而不是试图预先设计它”
“by focusing on our handler we have identified the interface that we need, rather than trying to design it up-front”
查看原文 →整个论证浓缩在一个分句里:接口是使用的被发现的残余,而不是计划。在这里有说服力,因为处理器有一个真实调用者的需要——在还没有人调用你的地方就没那么有说服力。
“而不必直接测试 InMemoryPlayerStore”
“without having to directly test InMemoryPlayerStore”
查看原文 →注意这个动作:一个一次性的组件被侧面覆盖,而测试比它所围绕而写的那个组件活得更久。这种可复用性才是真正的回报,而不是覆盖率。
“我打算在这里放纵一下,写下比你在不写测试的情况下可能觉得舒服的更多的代码。”
“I am going to take some liberties here and write more code than you may be comfortable with without writing a test.”
查看原文 →一位老师承认他即将打破自己所教的规则——更有用的是,说出了万一出错的逃生口。TDD 是一个旋钮,不是一个誓言。
“response body is wrong, got '123' want '3'”
查看原文 →“123”是个漂亮的缺陷——桩是追加而不是计数,而失败信息精确地告诉你你一直活在哪种虚构里。好的测试失败是一个句子,而不是一份堆栈跟踪。
“犯下罪过,然后重构(然后提交到源代码控制)”
“Commit sins, then refactor (and then commit to source control)”
查看原文 →这个双关承载着纪律:只有因为忏悔是被排进日程的,犯罪才被允许。多数代码库只做了前半。
“你需要把编译失败或测试失败当作一个你需要尽快脱离的红色状况”
“You need to treat having failing compilation or failing tests as a red situation that you need to get out of as soon as you can”
查看原文 →把红灯重新框定为滋生利息的债务,而不是要栖身的阶段。给出的理由是认知上的,不是道德上的——问题在复利,而不是做法差。
“通过添加互斥锁,我们强制了并发安全”
“By adding mutexes, we enforce concurrency safety”
查看原文 →奇怪的是,运行中的服务器真正会撞上的那一个缺陷,只得到一句话和一个链接——没有测试,没有红灯。本章的纪律恰好在并发使测试变得困难的地方悄悄松懈了。
读后感AI 陪读
当作一个整体来读,这一章与其说是关于 HTTP 服务器的教程,不如说是一次关于设计如何被发现而不是被规划的演示。它从一个写死的“20”出发,用第二个玩家打破了这个常量,然后——违背作者自己宣布的引入 mock 和存储的计划——才发现最小而诚实的下一步是 URL 解析。那一刻就是本章论点的缩影:决定工作次序的是测试,而不是架构师。从那里起,PlayerStore 作为处理器实际所需之物的残余浮现,PlayerServer 成了实现 http.Handler 的结构体,404 处理到来,POST 把路由拆成 showScore 和 processWin,一个间谍把 RecordWin 逼进了接口,一个集成测试终于把真实的 InMemoryPlayerStore 与服务器连了起来。这条弧线真正是累积的:每个组件的存在,都因为下游的某样东西需要它。
我最敬重的是本章愿意把错误的代码当作工具。在每个响应上都写 StatusNotFound,看套件是否察觉,是对测试的测试;硬编码 RecordWin(“Bob”) 暴露出,一个只数调用次数而不检查参数的间谍,几乎算不上间谍;“123”的失败信息是诊断上的一件小杰作。“犯下罪过,然后重构”之所以行得通,只因为忏悔是被排进日程的——作者也知道这一点,所以他还直白地说,通过的测试并不意味着可工作的软件。把红灯当作复利式的债务,而不是要栖身的阶段,并把可编译置于正确性之前,因为编译器是更便宜的反馈回路,这些都是多数 TDD 文章略去的操作细节。
继续阅读
它失足的地方,失足得富有教益。零分的 404 是文中内置的真正缺陷,而且没有被标出:一个零胜场的玩家与一个不存在的玩家无法区分,因为桩的映射把二者混为一谈,而且正文仍然写在头部之后。而并发——运行中的服务器真正会遇到的那一种失败——得到的是一把互斥锁、一句话和一个链接,没有红灯,没有测试。纪律恰恰在测试困难的地方松懈了,而那恰恰是它本该证明自己的地方。
对读者:拿方法,别拿仪式。这里有价值的动作是:诊断性的罪过,那个审计你的断言的、故意写坏的实现,从一个已经存在的调用者里提取出来的接口,以及比它所围绕而写的那个一次性组件活得更久的集成测试。把 TDD 当作一个作者自己都承认在拧的旋钮——而当你来到测试变得别扭的地方,留意本章在那里畏缩了,而你不要。
在 FeedLitra 里读构建一个应用,每段旁边都有这些卡片。
开始读书构建一个应用:JSON、路由与嵌入
排序和持久化会怎样改变 PlayerStore 接口?
构建一个应用是怎样展开的AI 辅助
本章接着上一章停下的地方——一个记录并报告玩家赢了多少局的小型 Web 服务器——并引入产品负责人的新要求:一个 /league 端点,以 JSON 返回所有已存储的玩家。在碰 JSON 之前,作者迈出最小的一步,写了一个测试,只断言 GET /league 返回 200。它以 404 失败,原因富有教益:ServeHTTP 一开头就把路径中的 /players/ 修剪掉,所以服务器在结构上假定每个请求都是关于某个具名玩家的。作者把这个失败的测试框定为:它不只是报告一个缺陷——它在告诉我们,应用没有路由的概念。
展开:路由,然后绕道嵌入
修复出现在标准库的 ServeMux 里。作者先刻意“犯点罪”:在 ServeHTTP 内部构建一个路由器,挂上两个匿名处理器(/league 和 /players/),并委托给路由器——难看但是绿的。然后是分阶段的重构,每一步都由一个具体的不适推动。匿名函数变成具名方法 leagueHandler 和 playersHandler。每个请求都构建一个路由器很浪费,所以路由设置挪进新的 NewPlayerServer 构造函数,把 mux 存在结构体上。最后,具名的 router *http.ServeMux 字段被一个无名的 http.Handler 取代,构造函数用路由器去填充它——而 PlayerServer 自己的 ServeHTTP 方法可以整个删掉。
最后这一步引发了本章主要的概念性旁白:嵌入。Go 没有子类化,但结构体可以借用被嵌入类型的方法,所以 PlayerServer 白得了 ServeHTTP。作者随后在代价一侧逗留,那才是真正的教训:嵌入暴露了被嵌入类型里的一切公共成员。嵌入 http.Handler 接口只暴露 ServeHTTP;嵌入具体的 http.ServeMux 则会让任何调用者都能在你的服务器上注册新路由。建议是把嵌入当作一个公共 API 的决定,而不是一种便利。
转折:从“它有响应”到“它以正确的数据响应”
结构就位之后,注意力转向内容。下一个测试把响应正文解码成 []Player,而不是比较 JSON 字符串,作者停下来为这一选择辩护:字符串断言脆弱、难以调试、表达了错误的意图,并且等于重新测试标准库的编码器。他指出 encoding/json 令人愉快的对称——Encoder 需要一个 io.Writer(http.ResponseWriter 就是),Decoder 需要一个 io.Reader(记录器的 Body 就是)。处理器先用一个写死的单元素联赛通过,然后测试被收紧,要求特定的桩玩家,这迫使 GetLeague() 进入 PlayerStore 接口。编译器照例在五个地方抱怨,而作者接受 InMemoryPlayerStore 里一个刻意空洞、返回 nil 的 GetLeague()——转绿的最小代码,并把不适明确记为日后的工作。一次测试重构提取了 newLeagueRequest、getLeagueFromResponse 和 assertLeague,再一个小循环添加了 content-type: application/json 头以及一个 assertContentType 辅助函数。
收束:偿还搁置的债,然后总结
本章清偿了自己的债。因为只要 InMemoryPlayerStore.GetLeague() 仍返回 nil,/league 就会让演示出丑,作者扩展了集成测试——复用服务器测试的辅助函数,这一回报他指出可作为重构测试的理由——并看着它以 got [] want [{Pepper 3}] 失败。真正的实现是四行:遍历映射,每个条目追加一个 Player。总结按出现顺序点出三个要点:ServeMux 路由(简单,完全遵从 http.Handler,但没有路径变量,所以若解析成了苦差,就去找第三方路由器),类型嵌入(强大,但只暴露合适的东西),以及 JSON 序列化(近乎琐碎且可配置)。结尾一句铺垫续篇:持久化和给联赛排序。
关键论点,以及支撑它们的原文
路由和依赖的接线属于构造函数,而不是逐请求的处理器。
作者先用一个在 ServeHTTP 里构建的路由器让 /league 测试转绿,然后称这种安排古怪而浪费,并把它重构进 NewPlayerServer,后者只组装一次 ServeMux,并把它留给之后每个请求。
“我们把路由的创建从 ServeHTTP 挪进了 NewPlayerServer,这样它只需要做一次,而不是每个请求都做。”
“We have moved the routing creation out of ServeHTTP and into our NewPlayerServer so this only has to be done once, not per request.”
JSON, routing & embedding › Refactor查看原文 →
嵌入时,要权衡的是它给你的公共 API 增加了什么,而不是嵌入本身的机制。
嵌入会提升被嵌入类型里的所有公共成员,所以作者只嵌入窄的 http.Handler 接口;若他嵌入了具体的 http.ServeMux,PlayerServer 的调用者就能调用它公共的 Handle 并注册自己的路由。本章把更深的机制留给 Effective Go,并把自己的教训归结为只暴露合适的东西。
“嵌入类型时,要真正想一想这对你的公共 API 有什么影响。”
“When embedding types, really think about what impact that has on your public API.”
JSON, routing & embedding › Any downsides?查看原文 →
对 JSON 端点的测试,应当把响应解码成程序自己的类型,而不是比较原始的 JSON 文本。
作者列出字符串断言的四项代价:数据模型一变就坏,给出的差异没有帮助,表达的是数据如何被编码而不是数据是什么,实际上是在重新测试已被测试过的标准库编码器。所以他的测试把正文解码成 Player 切片再比较。
“相反,我们应当把 JSON 解析成对我们测试有意义的数据结构。”
“Instead, we should look to parse the JSON into data structures that are relevant for us to test with.”
JSON, routing & embedding › Why not test the JSON string?查看原文 →
另外 1 个论点
标准库的路由器是个很好的起点;只有当它的缺口真正造成痛苦时,才去找第三方的。
总结指出 ServeMux 缺少诸如 /users/{id} 这样的路径变量,你可以自己解析。因为路由器本身就是 http.Handler,而流行的库遵循同样的理念,之后再切换成本很低,所以作者建议只有当缺失的功能成为负担时才去别处找。
“你可以很容易地自己解析这些信息,但如果它成了负担,你可能想考虑看看其他路由库。”
“You can easily parse this information yourself but you might want to consider looking at other routing libraries if it becomes a burden.”
JSON, routing & embedding › Wrapping up查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
在构造时只建一次路由器
ServeMux 把每个 URL 路径映射到一个处理器,所以在 ServeHTTP 里创建它,就会在每个请求上重建同一张表。把这套接线挪进 NewPlayerServer,就成了一次性的设置,每个进来的请求只需委托给存好的路由器。
机制 2
嵌入借来了处理器方法
Go 没有子类化,但嵌入某个类型的结构体会获得该类型的方法。嵌入 http.Handler 给了 PlayerServer 一个 ServeHTTP,而把路由器赋给那个字段,就让路由器来应答请求,所以手写的 ServeHTTP 可以删掉。
机制 3
JSON 里写入器与读取器的对称
json.Encoder 需要一个 io.Writer,而 http.ResponseWriter 就是一个,所以联赛切片可以直接编码进响应。解码与之镜像:json.Decoder 需要一个 io.Reader,被记录的响应正文恰好提供,让测试能读回同样的数据。
常见误读
测试 JSON 端点最直接的办法,是断言正文等于一个期望的 JSON 字符串。
把正文解码成领域类型再比较。字符串比较在数据模型变化时很脆弱,难以调试,而且实际上测试的是标准库的编码器而不是你自己的数据。
“相反,我们应当把 JSON 解析成对我们测试有意义的数据结构。”
“Instead, we should look to parse the JSON into data structures that are relevant for us to test with.”
JSON, routing & embedding › Why not test the JSON string?查看原文 →
嵌入只是复用的捷径,所以嵌入具体的 http.ServeMux 和嵌入 http.Handler 一样好。
嵌入具体类型会把它所有的公共方法和字段提升到你的类型上,所以嵌入的 ServeMux 会让外部调用者使用 Handle,往服务器上添加路由。只嵌入 http.Handler 接口则只暴露 ServeHTTP,这就是它在这里是稳妥选择的原因。
“嵌入类型时,要真正想一想这对你的公共 API 有什么影响。”
“When embedding types, really think about what impact that has on your public API.”
JSON, routing & embedding › Any downsides?查看原文 →
构建一个应用的核心概念
- 嵌入
- 把一个类型放进结构体,使外层类型借用它的方法,例如 PlayerServer 从 http.Handler 得到 ServeHTTP。 把一个类型放进另一个类型,使外层类型直接获得它的行为。
- 适用处: 嵌入具体类型会暴露它所有的公共方法和字段,所以最好只嵌入你想暴露的那个接口。 有用,但它把被嵌入类型的方法提升进你的公共 API,所以只暴露合适的东西。
- ServeMux
- Go 内置的请求多路复用器,把 URL 路径映射到 http.Handler。
- 适用处: 应当在构造时构建一次,而不是每个请求都建,因为每个请求都设置既奇怪又低效。
- io.Writer
- 标准的写入接口;http.ResponseWriter 实现了它,所以 json.Encoder 可以直接写入响应。
- 适用处: 对称的对应物是 io.Reader,json.Decoder 需要它。
- http.Handler 路由器
- 标准库的路由类型,把路由映射到处理器,其本身也是一个处理器。
- 适用处: 它缺少像 /users/{id} 这样的路径变量;你得自己解析,或转用路由库。
- JSON 反序列化与序列化
- 用标准库把 Go 数据变成 JSON,再变回来。
- 适用处: 默认设置涵盖大多数情形,需要时还可以定制转换。
另外 0 个概念
构建一个应用划线与批注AI 陪读
“让我们犯点罪,用最快的办法让测试通过”
“Let's commit some sins and get the tests passing in the quickest way we can”
查看原文 →这里的诚实就是教学。每个请求创建一个路由器确实是糟糕的代码,一开始就把它称为罪过,才让后来的重构感觉是挣来的而不是随意的。
“注意到 ServeMux 也是一个 http.Handler 了吗?”
“notice how ServeMux is also an http.Handler?”
查看原文 →这个括号注就是整章的缩影——路由器是处理器,所以路由器可以与处理器组合。标准库的自相似性在做重活。
“我们只嵌入了我们想暴露的接口 (http.Handler)”
“we embedded just the interface that we wanted to expose (http.Handler)”
查看原文 →嵌入接口而不是具体类型,是本章最锋利的单个决定。ServeMux 本可以同样地工作,却会把 Handle() 泄露给每一个调用者。
另外 5 条批注
“重新测试标准库。没有必要”
“Re-testing the standard library. There is no need”
查看原文 →给出四个理由,而这是唯一能推广到 JSON 之外的一个。我见过的多数脆弱测试,其实都是对别人的序列化器的断言。
“json.NewEncoder(w).Encode(leagueTable) w.WriteHeader(http.StatusOK)”
查看原文 →这是本章从未点名的一个潜伏缺陷:写入正文会隐式发送 200,所以其后的 WriteHeader 是空操作。两次重构之后,当 content-type 的修复重写这个函数时,它悄悄消失了——测试从没抓到它,因为 200 反正就是想要的状态。
“忍受一个不完整实现带来的不舒服感”
“live with the uncomfortable feeling of an incomplete implementation”
查看原文 →为了安抚编译器而返回 nil,是多数人会悄悄略过的动作。承认这种不适,才使后来的集成测试感觉像是在偿还一笔债,而不是一个新功能。
“展示了重构测试的重要性”
“showing the importance of refactoring tests”
查看原文 →为单元测试写的辅助函数被集成测试整体复用。把测试代码当作真正的代码对待,在单单一章之内就收到了回报——很少看到这被演示出来而不只是被断言。
“league = append(league, Player{name, wins”
查看原文 →Go 里映射的迭代顺序是随机的,所以这个联赛是无序的。集成测试之所以通过,只因为恰好只有一个玩家——再加一个就会不稳定。下一章的“排序”其实是一个披着功能外衣的缺陷修复。
读后感AI 陪读
一章看似整洁,真正的主题却根本不是 JSON——JSON 是容易的部分,标准库用四行就处理了。真正的教训有两重:ServeMux 是一个 http.Handler,以及接口才是该嵌入的东西。这两个事实合起来,成就了优雅的最终形态:PlayerServer 通过借来一个处理器而本身就是处理器,而作者关于嵌入 ServeMux 而不是 http.Handler 的警告,是文中最可迁移的建议。TDD 的方法诚实地展示了自己,包括它的盲点:两个真正的缺陷从这里的绿色测试里溜了过去。Encode 之后再 WriteHeader 的顺序缺陷,被后来的一次重构悄悄抹掉而不是被诊断;而 GetLeague 的映射迭代使联赛的顺序不确定——之所以看不见,只因为集成测试里只有一个玩家。两者都是测试大到足以通过、又小到不足以揭示的情形,这比总结所主张的任何东西都更尖锐地教了测试驱动开发。关于为何不对 JSON 字符串做断言的那一节,是最强的独立论证:测试数据,而不是编码,并且不要重复测试你不拥有的代码。
在 FeedLitra 里读构建一个应用,每段旁边都有这些卡片。
开始读书构建一个应用:IO 与排序
tape 类型如何保证旧字节被截断?
构建一个应用是怎样展开的AI 辅助
开篇:一个新需求,用能行得通的最窄接口来满足。 本章接手一个一直只存在于内存里的玩家存储,要求它在重启后依然存活,所以数据必须写到一个 JSON 文件里。作者没有去拿文件名和 os.Open,而是从标准库的 IO 抽象出发,取其中能胜任这件事的最小的一个:io.Reader。存储被交予一个可以读取的东西,把 JSON 解码成一个玩家切片,第一个测试通过。引入了一个 League 类型,给那个切片一个名字和行为的归宿,带一个 Find 方法,使调用者不必再手写遍历玩家的循环。
展开:设计被一次次失败推得更宽。 本章不断改变接口,原因是一个具体的机械事实,而不是品味。从读取器读取会消耗它;解码器一旦走到文件末尾,下一次读取就从 EOF 开始,什么也看不到。所以一旦某个测试要求第二次读取,朴素的 Reader 就不够了,参数放宽到 io.ReadSeeker,再解析前用 Seek(0, io.SeekStart) 倒回。当必须记录胜利时,同样的压力再次出现:写入需要 io.ReadWriteSeeker。这里出现了第二个更微妙的陷阱。对切片做 range 产出下标加上每个元素的一份副本,所以对循环变量增加 Wins 改的是一个即将被丢弃的值;写入必须经过 league[i] 或指向切片内部的指针,这正是为什么 Find 要返回指针。作者随后注意到,当存储才是拥有这份状态的东西时,每个请求都重新解析文件是浪费的,于是把 seek 和解码挪进构造函数:联赛在启动时读取一次,保存在字段里,只有在确有改变时才触碰磁盘。
转折:残留字节缺陷,以及一个包含它的私有类型。 持久化一次胜利暴露出本章最恶劣的缺陷。Seek 到位置零再写入,只覆盖你实际写入的那么多字节——旧文件里任何更长的部分都原样留存。把 “abc” 写到 “12345” 上,文件读作 abc45,这是损坏的 JSON。修复不是一行补丁,而是关注点的分离:写什么与怎么写被拆开。一个小小的、未导出的 tape 类型接管了机制——截断文件,然后 seek 到开头,再写入——这样这个有风险的行为就可以由它自己的测试来演练,而不是与 JSON 编码纠缠在一起。作者坦率地说,测试一个未导出的类型是一种妥协,并狭窄地为之辩护:测试的存在是为了买来信心,这里这是唯一实际的办法,而它只是一个测试,设计变了的话,删掉它不花任何代价。在此期间,它为维护代码的人记录了“先截断再写入”的要求。这个教训被框定为一个刻意的例外,而不是许可证——针对内部的测试确实把你与实现耦合起来,让重构更难。
收束:还清债务,以及回报。 有了 Truncate,后半部分整理了早先仓促留下的东西。json.Encoder 被创建一次并缓存,而不是每次写入都重建。此前被悄悄丢弃的错误被浮现出来,并从构造函数返回——这样做立刻揭示了一个一直藏在明处的崩溃。一个全新的、零长度的数据库文件不是合法的 JSON,所以对它解码确实失败;这件事之所以看起来无害,只因为解析错误被扔掉了。补救是显式初始化:用 file.Stat 检测零大小的文件,往里写一个空的 [],再 seek 回开头,使解码成功。把这一切清理完毕,本章以兑现回报结束。最后一个需求——按胜场数排序提供联赛——变得几乎微不足道:在恰当的位置一个 sort.Slice 调用,没有新的管线。这个对比就是本章的收尾论证,也是排序功能被留到最后的原因:重构和错误处理不是功能工作的绕路,它们才是让最后这个功能变便宜的东西。
关键论点,以及支撑它们的原文
存储拥有联赛的状态,所以它应当在被构建时一次性加载文件,只在有变化时才触碰磁盘。
作者把解析挪进构造函数:它 seek,把联赛解码一次,保存在一个字段里,此后读取只提供内存里的副本——磁盘的成本在启动时只付一次,而不是每个请求都付。
“它应该只在程序启动时读取文件,并且只在数据变化时更新文件。”
“it should only need to read the file when the program starts up and only need to update the file when data changes.”
IO and sorting › More refactoring and performance concerns查看原文 →
承接残留字节缺陷,修复办法是把写什么与怎么写拆开。
作者把倒回再覆盖的行为重构进它自己的小类型,使这个关注点可以在测试里被直接演练,而不是与产生 JSON 纠缠在一起。
“不出所料!它写入了我们想要的数据,但把原始数据的其余部分留了下来。”
“As we thought! It writes the data we want, but leaves the rest of the original data remaining.”
IO and sorting › Try to run the test查看原文 →
当这是对某个有风险的行为建立信心的唯一实际办法时,用测试伸进私有类型是可以接受的。
作者的理由是,测试的存在是为了给人信心,而且它只是一个测试:设计以后变了,删掉它不花任何代价,而在此期间它为维护代码的人记录了编辑/删除的要求——并承认这类测试确实把你与实现耦合起来。
“然而,我们不能忘记,测试应当给我们信心。”
“However, we must not forget that tests should give us confidence.”
IO and sorting › On testing private types查看原文 →
另外 1 个论点
被悄悄丢弃的错误会变成真正的缺陷,所以构造函数必须把它们返回,并应付一个全新的空文件。
空文件崩溃之所以看不见,恰恰因为解析错误被吞掉了;一旦错误被浮现出来,就用 file.Stat 发现零长度的文件,往里写一个空的 JSON 数组,并 seek 回开头,使解码成功。
“我们无法解析联赛,因为文件是空的。我们之前没得到错误,是因为我们总是直接忽略了它们。”
“We cannot parse the league because the file is empty. We weren't getting errors before because we always just ignored them.”
IO and sorting › Error handling查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么读取器会枯竭
读文件会把它消耗到末尾,所以下一次读取从 EOF 开始,什么也找不到。这就是为什么存储不能接受朴素的 Reader:它需要一个 ReadSeeker,并必须在再次解析前调用 Seek(0, io.SeekStart) 来倒回。
机制 2
range 给你的是副本
对切片做 range 给出下标加上每个元素的一份副本,所以对循环变量增加 Wins,只改变那份用完即弃的副本。要记录一次胜利,你必须经过 league[i] 或指向切片内部的指针来写入,这就是 Find 返回指针的原因。
机制 3
短写入留下残余
Seek 到开头再写入,只覆盖你写入的那么多字节;旧文件里任何更长的部分都原样留存。把 “abc” 写到 “12345” 上,剩下 'abc45',所以文件必须先被截断,然后才 seek 并重写。
常见误读
空的数据库文件是无害的——对它解码应该只会得到一个空联赛。
空文件不是合法的 JSON,所以解码确实失败;这件事之所以看起来没问题,只因为错误被忽略了。代码必须检测零大小的文件,用 “[]” 给它播种,并在解析前倒回。
“我们无法解析联赛,因为文件是空的。我们之前没得到错误,是因为我们总是直接忽略了它们。”
“We cannot parse the league because the file is empty. We weren't getting errors before because we always just ignored them.”
IO and sorting › Error handling查看原文 →
既然本章测试了一个未导出的类型,那么针对内部写测试一般是可以的。
作者把它当作一个刻意的例外:这类测试把你绑在实现上,让重构更难,所以只有当没有别的办法能让你对行为有信心时才站得住脚,而且只因为它是一个廉价、可随手扔掉的单个测试。
“然而,我们不能忘记,测试应当给我们信心。”
“However, we must not forget that tests should give us confidence.”
IO and sorting › On testing private types查看原文 →
构建一个应用的核心概念
- io.Writer
- 写字节的标准接口;这里与读取和 seek 一起合并在一个像文件一样的接口里。
- 适用处: strings.Reader 无法满足它,因为它没有 Write 方法。
- Find
- 新 League 类型上的一个方法,返回指向匹配玩家的指针,或 nil。
- 适用处: 玩家不存在时返回 nil,所以调用者必须处理新玩家的情形。
- 基于现有类型的类型声明
- 把 []Player 命名为 League,使有用的方法可以挂在上面。
- 适用处: 改变 PlayerStore 的返回类型,会迫使各调用者做小的编译修复。
- t.Helper()
- 标记像 createTempFile 和 assertScoreEquals 这样的测试辅助函数,使失败指向调用者。
- 适用处: 只影响报告的行号,不影响行为。
- 接口
- 一组方法要求;本章主张,当永远只会传入 *os.File 时,不要去发明一个。
- 适用处: 对多态和 mock 有用;当唯一现实的参数是单个具体类型时则毫无意义。
- 用 %w 包装错误
- 本章用 %v 包装,但坚持错误要携带你当时在做什么的上下文。
- 适用处: 上下文对运维人员很重要;光秃秃的
return err会丢掉线索。
另外 1 个概念
- sort.Slice
- 使用你提供的 less 函数就地对切片排序。
- 适用处: 本章把排序放在 PlayerStore 里,因为真正的数据库会用 ORDER BY。
构建一个应用划线与批注AI 陪读
“她也不高兴,因为我们没有把 /league 端点解释为应当按胜场数排序返回玩家!”
“She is also not pleased that we didn't interpret the /league endpoint should return the players ordered by the number of wins!”
查看原文 →产品负责人作为反复出现的叙事手法——她不是角色,而是一个压力来源,用来为下一次重构提供理由。廉价却有效的教学。
“问题在于我们的 Reader 已经读到了末尾,所以没有更多可读的了。”
“The problem is our Reader has reached the end so there is nothing more to read.”
查看原文 →这才是本章真正的教训:接口不只是一个方法集,它带着隐藏的状态。Reader 看起来是纯的,直到你调用它两次。
“幸运的是,我们在测试里用的 strings.NewReader 也实现了 ReadSeeker,所以我们不必做任何其他改动。”
“Happily for us strings.NewReader that we used in our test also implements ReadSeeker so we didn't have to make any other changes.”
查看原文 →运气,而作者也知道。放宽所需的接口,你的测试替身通常会坏——这次没有。值得注意这是运气,不是方法。
另外 11 条批注
“我不认为这里有特别错的答案,但选用第三方库的话,我就得去解释依赖管理了!”
“I don't think there's an especially wrong answer here, but by choosing to use a third party library I would have to explain dependency management!”
查看原文 →少见的诚实:这个设计决定是由本书的大纲驱动的,不是由代码。我宁愿他这样说,也不愿把它包装成工程判断。
“修改副本的 Wins 值,不会对我们正在迭代的联赛切片产生任何影响。”
“Changing the Wins value of a copy won't have any effect on the league slice that we iterate on.”
查看原文 →空洞看起来的实现与“能工作”的实现只差一个字符的唯一地方。range 复制,是 Go 程序员永远在重新发现的缺陷。
“我们不应该那么做,因为 FileSystemStore 完全负责联赛的状态”
“We should not have to do that because FileSystemStore is entirely responsible for the state of the league”
查看原文 →注意缓存买到了什么、又花掉了什么:文件不再是真相之源,内存才是。有两个写入者往那个文件写,整个设计就悄无声息地崩溃。
“在我们当前的情形里,这是不可能的。我们从不编辑或删除分数,所以数据只会变大。”
“In our current case, this is impossible. We never edit or delete scores so the data can only get bigger.”
查看原文 →他修复了一个刚刚证明尚不可能发生的缺陷——然后发明一个测试来强迫它现形。这是本章最成熟的动作:写出让潜伏的缺陷变得可观察的测试。
“tape.Write 每次调用都会 seek 到文件开头,所以值得停下来检查这种组合是否真的安全”
“tape.Write seeks to the start of the file on every call, so it's worth pausing to check this combination is actually safe”
查看原文 →行文中一道奇怪的接缝——突然是一段关于 Encode 恰好调用一次 Write 的、仔细的不变量证明。正确,但它建立在标准库一个无文档的实现细节上,而不是契约上。每次 Write 都 Truncate 加 Seek,是埋给下一个换掉编码器的人的地雷。
“最后,这只是一个测试!如果我们决定改变它的运作方式,直接删掉这个测试也不会是灾难”
“Finally, it's just one test! If we decide to change the way it works it won't be a disaster to just delete the test”
查看原文 →诚实的辩护不靠原则,而靠廉价——一个可逆的决定。这才是真正的论证,比任何对纯粹性的诉求都强。
“但这到底给了我们什么?请记住我们没有做 mock,而且文件系统存储接受 *os.File 以外的任何类型都是不现实的”
“But what is this really giving us? Bear in mind we are not mocking and it is unrealistic for a file system store to take any type other than an *os.File”
查看原文 →漂亮的拒绝。他把那个聪明的接口完整写出,然后拒绝它——表明诱惑就是整个教训。
“网上的人开玩笑说,大多数 Go 代码是:”
“People on the internet jokingly say that most Go code is:”
查看原文 →一个被用作教学楔子的玩笑:这个梗就是反模式。光秃秃的 return err 丢掉了运维人员唯一需要的东西——它发生在哪里。
“我们无法解析联赛,因为文件是空的。我们之前没得到错误,是因为我们总是直接忽略了它们。”
“We cannot parse the league because the file is empty. We weren't getting errors before because we always just ignored them.”
查看原文 →这一段里最好的一句。处理错误并没有制造缺陷;它揭示了一个一直在那里、悄悄通过的缺陷。
“如果我们用的是“真正的”数据库,我们会用 ORDER BY 之类的东西,这样排序超级快。出于这个原因,感觉应当由 PlayerStore 的实现来负责。”
“If we were using a "real" database we would use things like ORDER BY so the sorting is super fast. For that reason, it feels like implementations of PlayerStore should be responsible.”
查看原文 →位置是从一个我们没有的数据库出发论证的——为我们将要迁移到的存储做设计。不过每次读取都就地排序,是他没标出的偷懒捷径。
“为了能够打破规则,你必须先理解它们。”
“In order to be able to break the rules you must understand them first.”
查看原文 →吉他的类比使这不至于变成一张许可证。打破规则作为素养而不是品味——多数人跳过的那个区分。
读后感AI 陪读
这是本书第一次出现持久化的一章,它到来的方式与真实项目中一样:不是作为一个设计,而是一连串的让步。存储从映射移到一个 JSON 文件,每一步都是被某样东西弄坏所迫,而不是事先选好的。io 接口的阶梯——Reader,然后 ReadSeeker,然后 ReadWriteSeeker,然后拒绝再往上爬——是整件事的脊梁,它教的比“接受接口”更有价值:接口不只是一个方法集,它带着隐藏的状态。Reader 看起来是纯的,直到你调用它两次,第二次读不到任何东西,突然你就需要 Seek。这一个失败催生了本章的大部分内容。后半部分是随之而来的烂摊子:在较长的 JSON 之上写入较短的 JSON 会留下尾部垃圾,于是出现一个 tape 类型来 Truncate 并回绕;编码器被缓存;错误不再被忽略;联赛终于被排序,因为产品负责人——一个叙事上的压力来源而不是角色,廉价却有效——指出没人要求玩家是无序的。
我最敬重的是本章愿意展示随后又被丢弃的工作。那个聪明的泛型文件系统接口被完整写出,又被拒绝,理由是传进来的永远只会是 *os.File;诱惑就是教训。损坏缺陷是在作者证明它尚不可能发生之后被修复的,然后一个测试被发明出来,强迫潜伏的失败现形——这是这里最成熟的动作,也是我最希望读者照抄的。为尾随换行测试的辩护是廉价,而不是原则:它只是一个测试,以后删掉它不花任何代价。这个可逆性的论据比任何对纯粹性的诉求都强,而本书因为这样说而更好。同样,承认回避第三方库是一个教学大纲的决定而不是工程判断——这种少见的诚实,我随时愿意拿来换一个包装过的理由。
继续阅读
我要反驳的,是这个设计悄悄买到了什么、又悄悄付出了什么。在构造函数里缓存联赛,使内存成为真相之源,文件只是一个投影;有两个写入者往那个文件写,整件事就悄无声息地崩溃,任何地方都没有错误。每次 Write 都做 Truncate 加 Seek 的组合,之所以可证明地安全,只因为 json.Encoder 碰巧只调用一次 Write——那是实现细节,不是契约,是埋给换掉编码器的人的地雷。每次读取都就地排序是一条文中没标出的捷径,而位置论证(“真正的数据库会用 ORDER BY,所以存储应当负责排序”)是在为一个我们没有的数据库做设计。与此同时,处理错误并没有引入空文件缺陷;它揭示了一个一直在悄悄通过的缺陷——本章最好的一句话,也是为什么光秃秃的 return err 和被忽略的返回值是同一种失败换了不同外衣的最清楚的例子。关于 Go 不过是错误检查的梗被用作楔子:这个笑话本身就是反模式,因为未经包装的错误丢掉了运维人员唯一需要的东西,也就是它发生在哪里。
对读者:把这一章当作一次真实重构的记录,而不是要照抄的蓝图。遵循方法——让测试迫使出下一个接口,勉强地放宽,把聪明的抽象写下来然后拒绝它,并构建那个让你尚无法触发的缺陷变得可见的测试。但要带着怀疑去读最终的设计,因为作者自己的结尾注记同样适用于他:你必须先理解规则,才能打破规则,而这里有几条捷径,是他没有宣布的打破。能辨认出哪些是哪些,恰恰是本章想给你的能力。
在 FeedLitra 里读构建一个应用,每段旁边都有这些卡片。
开始读书构建一个应用:命令行与项目结构
什么时候把测试桩作为公共 API 暴露是个错误?
构建一个应用是怎样展开的AI 辅助
本章以产品负责人的转向开篇:现有的扑克项目,到目前为止只构建了一个 HTTP 服务器,现在还需要一个命令行应用,当用户输入类似“Ruth wins”的内容时记录一次胜利——而关键是,两个应用必须共享同一个数据库,联赛才能保持一致。作者通过回顾 PlayerStore 接口(分数查找、胜利记录、联赛获取)以及上一章构建的 FileSystemPlayerStore 实现来落脚,后者是值得复用的部分。
在写任何新功能之前,本章绕道去重构项目结构,因为 Go 要求每个二进制的入口点都位于 package main。所有领域代码被抽出到项目根目录下一个名为 poker 的包里,现有的 main.go 移到 cmd/webserver/,遵循常见的惯例:cmd 存放 main 包,而可导入的库代码住在根目录。一份 tree 清单展示了结果,main.go 现在以完整的仓库路径导入这个库,并调用诸如 poker.NewFileSystemPlayerStore 之类的东西。作者停下来讲了一个实际的小皱褶:dbFileName 是相对路径,所以 JSON 数据库是相对于工作目录解析的——这意味着除非你从同一个地方运行它们,两个二进制会悄悄使用各自独立的文件,“最终检查”一节建议的做法是把两个二进制都构建出来,并从项目根目录运行。
开发阶段是经典的小步 TDD。一个行走骨架 cmd/cli/main.go 打印一句问候,然后根目录下的 CLI_test.go 断言一个尚不存在的 CLI 类型的 PlayPoker 针对共享的 StubPlayerStore 记录一次胜利。第一遍硬编码一个赢家;第二轮迭代添加一个 io.Reader 依赖,并通过 strings.NewReader 喂入输入,仍然靠一个硬编码的名字满足;一次重构提取出与服务器测试共享的 assertPlayerWin 辅助函数;直到两个子测试要求不同的赢家,才出现真正的代码——一个读取一行的 bufio.Scanner,以及剥掉“ wins”后缀的 extractWinner。
转折出现在作者把命令行接进 main 的时候。从 package main 里构造 poker.CLI{store, os.Stdin} 失败了,因为该结构体的字段是未导出的:测试之所以能够到它们,只因为住在同一个包里。这被框定为一个设计教训——依赖被正确地保持为私有,却没有提供构造函数——并引出一个更广泛的论证:把测试包命名为 foo_test,使测试只消费导出的 API,恰如真正的导入者。把 CLI_test.go 切换成 poker_test 立刻弄坏了桩和辅助函数,引出一个主观问题:测试替身是否应该是公共的。援引 HashiCorp 的做法(经由 Mitchell Hashimoto 的一次演讲),作者选择把它们导出到一个新的 testing.go 里,使未来的用户不必重新发明一个桩。一个 NewCLI 构造函数解决了最初的错误,而且因为它在构造时就把 reader 包进扫描器,PlayPoker 简化成一个 readLine 加一次记录调用。
最后的一次重构注意到两个 main 都重复了“先打开文件再构建存储”这一套,于是 FileSystemPlayerStoreFromFile 把它封装起来,返回存储、一个关闭函数和一个错误;两个应用缩成几乎相同的设置代码,作者把这解读为对设计的验证。总结把线索收拢:重构为独立的 cmd 文件夹,使得从共享的领域代码产出两个二进制成为可能;未导出字段的冲突显示了以薄切片集成、以及 mypackage_test 纪律的价值;os.Stdin 容易用,因为它满足 io.Reader,而 bufio.Scanner 负责逐行读取;简单的抽象加上公开暴露的桩,使新应用的构建和测试几乎毫不费力。
关键论点,以及支撑它们的原文
只有当项目确实需要时,才加上复杂的包结构。
作者指出,把一切放在一个包里直到现在都好好的,并以 Go 自己的标准库为证据,说明好代码需要的文件夹脚手架很少。只有当项目必须产出两个二进制时才引入结构,此时 cmd 惯例把每个 main 包与根目录里可复用的库代码分开。
“注意这种对称:尽管是不同的用户界面,设置几乎一模一样。这感觉像是对我们迄今设计的良好验证。”
“Notice the symmetry: despite being different user interfaces the setup is almost identical. This feels like good validation of our design so far.”
Command line and project structure › Web server application code查看原文 →
如果你自己的代码从外部很难构造,导入者也会觉得难——所以做一个构造函数。
把命令行的存储和 reader 设为私有是正确的决定,但在 main 里把它接起来却因“implicit assignment of unexported field”错误而编译失败,而同样的失败在测试挪到包外之后再次出现。加上 NewCLI,它同时在构造时把 reader 包进扫描器,一下子修好了两个调用者,也让读取代码得以简化。
“我们理所当然地把 CLI 的依赖设成了私有(因为我们不想把它们暴露给 CLI 的使用者),却没有给用户提供构造它的办法。”
“We rightfully made the dependencies of our CLI private (because we don't want them exposed to users of CLIs) but haven't made a way for users to construct it.”
Command line and project structure › Write enough code to make it pass查看原文 →
只通过公共 API 测试,能抓到同包测试所掩盖的集成问题。
命令行的测试最初住在 package poker 里,所以它欣然触碰私有字段和包内的辅助函数——并且没有给出任何警告说明 main 做不到同样的事。把它改名为 poker_test,桩存储和胜利断言立刻产生了未定义符号的错误,揭示出包的真实表面究竟缺了什么。作者的经验法则:你难以测试的代码,是别人难以集成的代码。
“TDD 有一句格言:如果你无法测试你的代码,那么你代码的使用者很可能也难以与之集成。”
“An adage with TDD is that if you cannot test your code then it is probably hard for users of your code to integrate with it.”
Command line and project structure › package mypackage_test查看原文 →
另外 3 个论点
把桩和断言辅助函数作为公共 API 发布,是一个站得住的选择,而不是要不惜一切代价避免的污染。
作者承认这个取舍是主观的——仅供测试的代码确实扩大了包的表面——但引用了 Mitchell Hashimoto 的《Advanced Testing with Go》中描述的 HashiCorp 做法,以及他在共享包上的个人经验:导出一个桩玩家存储,意味着导入者永远不必为了使用这段代码而自己写一个假对象。
“TDD 有一句格言:如果你无法测试你的代码,那么你代码的使用者很可能也难以与之集成。”
“An adage with TDD is that if you cannot test your code then it is probably hard for users of your code to integrate with it.”
Command line and project structure › package mypackage_test查看原文 →
小接口让同样的领域代码几乎不费额外工夫就能服务截然不同的前端。
因为存储只是一个 PlayerStore,输入只是一个 io.Reader,生产环境中的 os.Stdin 与测试中的 strings.NewReader 可以互换地放进去。把打开文件的动作提取到一个辅助函数之后,两个 main 都塌缩成同样的三步——从文件加载一个存储,defer 它的关闭,把它交给 NewPlayerServer 或 NewCLI——作者把 HTTP 服务器和终端应用之间的这种对称视为设计站得住的确认。
“注意这种对称:尽管是不同的用户界面,设置几乎一模一样。这感觉像是对我们迄今设计的良好验证。”
“Notice the symmetry: despite being different user interfaces the setup is almost identical. This feels like good validation of our design so far.”
Command line and project structure › Web server application code查看原文 →
以薄切片工作并立刻集成,才是让设计缺陷浮现出来的东西。
当测试住在包内时,私有字段的破裂是看不见的;它只在新命令行被接进真正的 main 的那一刻才出现。作者从这一插曲得出一般教训:尽早追求完全集成的、可工作的软件,因为那时打包和可见性的错误才会显形。
“我们理所当然地把 CLI 的依赖设成了私有(因为我们不想把它们暴露给 CLI 的使用者),却没有给用户提供构造它的办法。”
“We rightfully made the dependencies of our CLI private (because we don't want them exposed to users of CLIs) but haven't made a way for users to construct it.”
Command line and project structure › Write enough code to make it pass查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
私有字段阻止外部构造
小写开头的结构体字段只在它们自己的包内可见,所以别的包里的代码无法用结构体字面量填充它们,编译器会拒绝这一尝试。把依赖保持为私有仍然是对的——你只需要提供一个导出的构造函数,为调用者组装出值。
机制 2
_test 包模仿导入者
给测试的包名加上 _test 后缀,就把测试放在了被测包之外,所以它只能够到导出的名字。这就把每一处对包内部的意外依赖变成编译错误,恰是外部用户会撞上的那堵墙——在写测试时发现,而不是发布之后。
机制 3
相对的数据库路径把共享文件拆开了
数据库名是一个光秃秃的相对路径,所以它是相对于进程启动时所在的目录解析的。因此,从各自的 cmd 文件夹启动 Web 服务器和命令行,会让它们各得到一份私有的文件副本,而且不出现任何错误——胜利只是永远不会出现在另一个应用里。从同一个目录运行两个二进制,才让它们用同一个数据库。
常见误读
既然独立的 _test 包是更好的纪律,内部测试就是该被消灭的坏习惯。
作者为它们留了余地:当你确实需要演练某个内部的东西时,在与代码同一个包里写第二个测试文件。_test 包是默认选项,因为它强制测试公共 API,而不是禁止永远伸进里面。
“TDD 有一句格言:如果你无法测试你的代码,那么你代码的使用者很可能也难以与之集成。”
“An adage with TDD is that if you cannot test your code then it is probably hard for users of your code to integrate with it.”
Command line and project structure › package mypackage_test查看原文 →
让两个应用都指向同一个文件名“game.db.json”,就足以让它们共享一个数据库。
那个名字是相对的,所以它是相对于每个进程的工作目录解析的——从 cmd/cli 启动命令行,从 cmd/webserver 启动服务器,你就得到两个互不相关的文件,而且没有警告。共享需要从同一个地方启动两者,例如构建两个二进制并从项目根目录运行。
“既然我们的产品负责人希望命令行和 Web 服务器共享同一个数据库,这就很要紧:从不同的目录运行两者,会悄悄地让各自得到自己独立的 game.db.json。”
“Since our product owner wants the CLI and web server to share the same database, this matters: running both from different directories will silently give each one its own, separate game.db.json.”
Command line and project structure › Some project refactoring first查看原文 →
构建一个应用的核心概念
- cmd 目录惯例
- 顶层的 cmd 文件夹,存放每个应用的 main 包,与项目根目录里可导入的库代码分开。
- 适用处: 只有当项目构建不止一个二进制时才值得加;Go 的标准库表明,通常只需要很少的文件夹结构。
- package mypackage_test
- 给测试文件的包名加上 _test 后缀,使测试只能触碰导出的标识符,就像真正的导入者一样。
- 适用处: 如果你确实需要测试内部,另外保留一个同包的测试文件。
- 导出的测试辅助函数
- 把桩和断言辅助函数(testing.go)作为公共 API 发布,使导入者无需重建假对象就能针对你的包测试。
- 适用处: 主观的取舍:它用仅供测试的代码污染了包的 API。
- io.Reader
- 让 main 里的 os.Stdin 和测试里的 strings.NewReader 可以自由互换的读取抽象。
- 适用处: 每次 Scan 调用,Scanner 只读到一个换行为止。
另外 0 个概念
构建一个应用划线与批注AI 陪读
“cmd 是一个广泛使用的 Go 惯例,用来存放 main 包”
“cmd is a widely used Go convention for holding the main packages”
查看原文 →值得注意这是惯例,不是工具——Go 工具链并未赋予 cmd/ 任何特殊含义。本书对它是社区习惯这一点很诚实。
“好的做法是不要把包结构搞得过头”
“it is good practice not to go over-the-top with package structure”
查看原文 →本章最有用的一句,却是作为旁白到来。结构是由第二个二进制挣来的,而不是预先强加的。
“从不同的目录运行两者,会悄悄地让各自得到自己独立的 game.db.json”
“running both from different directories will silently give each one its own, separate game.db.json”
查看原文 →这则提醒读起来像是编辑后来打的补丁——原来的流程愉快地说“go run main.go”。抓得好,但它暴露出共享数据库的需求从未被真正测试过。
另外 7 条批注
“记住先做最简单的事”
“Remember to do the strictly easiest thing first”
查看原文 →硬编码“Chris”是看起来愚蠢却是整套方法的一步。这个假货迫使下一个测试去挣得真正的解析器。
“这凸显了集成你工作成果的重要性。”
“This highlights the importance of integrating your work.”
查看原文 →未导出字段的错误在叙事里不是失误——它是被布置好的。作者故意领着我们撞上一堵墙,让教训落在身体上。
“如果你无法测试你的代码,那么你的代码的使用者大概也很难与之集成”
“if you cannot test your code then it is probably hard for users of your code to integrate with it”
查看原文 →这里最强的主张,而且一身二用:可测试性作为 API 质量的替身。_test 包后缀把这一信念变成编译器强制的约束。
“有人可能会争辩说,你不想用为方便测试而写的代码来污染你包的 API。”
“One could argue that you do not want to pollute your package's API with code to facilitate tests.”
查看原文 →他提出反对,然后用诉诸 HashiCorp 加轶事将其压倒。注意发布 testing.go 的代价:StubPlayerStore 的字段保持私有,所以导入方得到的是一个无法配置的桩。
“我们还会修改 CLI,让它存储一个 bufio.Scanner,而不是那个 reader”
“We'll also change CLI so it stores a bufio.Scanner instead of the reader”
查看原文 →安静的设计转移:构造函数不再是一种形式,而开始拥有一个决定。这也是为什么 in io.Reader 从来不需要留在结构体里。
“返回一个关闭函数,这样我们就可以关闭底层的文件”
“returns a closing function, so we can close the underlying file”
查看原文 →返回 (store, closeFunc, err) 很务实,但有点失衡——在错误路径上它交回一个 nil 的关闭函数,所以调用者必须在 defer 之前检查 err。很容易踩的陷阱。
“请注意这种对称:尽管是不同的用户界面,搭建过程几乎相同。”
“Notice the symmetry: despite being different user interfaces the setup is almost identical.”
查看原文 →他把对称称为对设计的验证。公平——但 PlayPoker 恰好只读一行就退出,所以命令行还算不上一个真正的界面。对称在一定程度上是因为构建的东西太少。
读后感AI 陪读
一章真正的主题根本不是命令行——而是包的边界,命令行只是揭示它们的压力。教学的脊梁很出色:作者刻意写出在测试里能编译、在 main 里却失败的代码,然后用这个失败来论证 package foo_test 是一道结构性的保障,而不是风格偏好。最强的想法是把可测试性当作 API 的信号;最有争议的是在公共包里附带测试辅助函数,他靠权威和轶事为之辩护,却留下一个真正的疙瘩(一个带未导出字段的导出桩,导入方无法填充)。有两处文字比思想薄弱:产品负责人真正的需求——两个二进制共享一个数据库——只靠一条工作目录的提醒来满足,从未靠测试或设计决定;而结尾对“对称”的赞美,抬举的是一个只读一行就退出的命令行。尽管如此,从“一切都在 package main”到共享一个库的两个二进制,把集成的痛苦展示出来而不是描述,大概已是一份教程所能做到的、对渐进式结构最诚实的演示。
在 FeedLitra 里读构建一个应用,每段旁边都有这些卡片。
开始读书构建一个应用:时间
把 Game 注入 CLI,如何改变测试的接缝?
构建一个应用是怎样展开的AI 辅助
本章从上一章停下的地方接着开始,一个看起来很小、结果暴露出设计问题的功能:在扑克命令行里调度盲注提醒。障碍立刻出现。在 Go 里调度未来工作的自然办法是 time.AfterFunc,但测试无法检查交给它的是什么,因为 Go 无法比较函数值——没有办法断言“这个回调,在这个延迟之后”。作者的招数是停止测试计时器,开始把调度本身当作数据来测试。调度被放到一个可注入的 BlindAlerter 接口之后;一个间谍实现记录每次 ScheduleAlertAt 调用的时长和盲注金额,测试以表驱动的形式对这份列表做断言。生产环境保留真实的计时器,它真的输出;测试保留一个做记录的替身。与此同时,命令行长出一个询问有多少玩家在玩的提示,使盲注计划可以由输入推导,而不是写死。
转折来自测试,而不是功能。为了只检查盲注提醒被调度了,作者发现自己要接上四个独立的依赖,而且一个测试同时对输入解析、调度和记录的赢家做断言。他把这种杂乱当作证据,而不是不便:一长串依赖是一个缺失的类型,需要四个协作者的组件扛着好几份工作。值得注意的是,他说他起初刻意容忍了这团乱一阵——他据以行动的信号是积累起来的具体摩擦(断言堆进一个测试,仅为构造它就需要的搭建),而不是第一个多出来的构造函数参数。这次提取给了提醒器和存储一个家,放进新的 Game 类型,由它拥有 Start 和 Finish,让命令行只负责解释用户输入。关键是,重构是分阶段的:NewCLI 的外部接口在内部挪动时保持固定,所以现有测试保持绿色,充当对这次改动的检查;只有之后才重写测试本身。他论证,在一次动作里同时改生产代码和测试代码是危险的,恰恰因为它拿走了你用来分辨哪一半坏了的唯一仪器——因此要小步,每一步之后都重跑套件。
后半部分完成解耦,然后收尾。NewCLI 变瘦;命令行被改成依赖一个只声明 Start 和 Finish 的 Game 接口,具体实现被改名为 TexasHoldem,于是命令行对扑克规则一无所知。一个记录 StartedWith 和 FinishedWith 的 GameSpy,让命令行的测试只需断言 Start 是以七个玩家调用的,而错误输入被明确地处理。这里值得分开说:那些间谍断言证明的是命令行把输入正确地接了过去,而对规则什么也没证明——这恰恰是盲注计划和赢家记录的检查迁移到 TestGame_Start 和 TestGame_Finish、针对真实实现运行的原因。作者把这次迁移描述为复制旧的命令行测试,但依赖更少,而缩小的搭建就是回报:它追溯性地证明那些断言一直放错了地方。本章以它的主题收尾——通过注入的辅助函数测试依赖时间的代码,关注点分离,非结构体类型上的方法,以及核心的纪律:当测试很乱时,先重构测试,只有之后仍然乱,才怀疑设计。
关键论点,以及支撑它们的原文
调度未来工作的代码,只有当调度是被注入而不是被直接调用时才能测试。
作者的出发点是 Go 无法比较函数,所以没有办法断言交给计时器的是哪个回调;他于是把调度经由一个依赖路由,并提供一个间谍,其 ScheduleAlertAt 记录时长和盲注金额,让测试把整个计划当作一列值来检查,而生产环境仍使用真实的计时器。
“你在 Go 里无法比较函数,所以我们无法测试传进来的是哪个函数。”
“You cannot compare functions in Go so we'd be unable to test what function has been sent in.”
Time › time.Duration查看原文 →
一长串被注入的依赖是设计的坏味道,暗示缺了一个抽象。
为了只测试盲注提醒被调度了,作者不得不搭建四个独立的依赖,那个测试里的杂乱就是他认为该组件做得太多的证据;他随后提取出一个 Game,接收提醒器和存储,暴露 Start 和 Finish,让命令行只负责解释用户输入。他指出自己刻意容忍这团乱一阵再重构,而不是对第一个多出来的依赖就起反应。
“每当你系统里的某个东西有很多依赖,这就暗示它做得太多了。”
“Whenever you have a lot of dependencies for a thing in your system, it implies it's doing too much.”
Time › Refactor查看原文 →
承接那次提取,命令行应当依赖一个 Game 接口,而具体实现被改名为 TexasHoldem,所以命令行对真正的规则一无所知。
接口只用 Start 和 Finish 声明,命令行内部对具体游戏的每一处引用都换成它;一个 GameSpy 随后记录 StartedWith 和 FinishedWith,所以命令行的测试可以简单地断言 Start 是以 7 个玩家调用的,而不涉及任何扑克逻辑。
“这忠实于这样一个观念:我们的 CLI 对我们正在玩的实际游戏,以及 Start 和 Finish 时发生什么,毫不知情。”
“This keeps faithful to the notion that our CLI is oblivious to the actual game we're playing and what happens when you Start and Finish.”
Time › Refactor查看原文 →
另外 3 个论点
一旦游戏成了自己的类型,针对游戏的断言就属于游戏的测试,而命令行测试只应涵盖读取输入和写出输出。
作者把盲注计划和赢家记录的检查挪进 TestGame_Start 和 TestGame_Finish,把这一挪动描述为复制旧的命令行测试,但依赖更少——缩小的搭建就是回报,显示这些断言曾放错了地方。
“既然我们已经把 Game 提取出来,就应当把针对游戏的断言挪到与命令行分开的测试里。”
“Now that we have extracted out Game we should move our game specific assertions into tests separate from CLI.”
Time › Refactor查看原文 →
杂乱的测试是关于设计的诊断信号,而不只是审美问题。
他指出自己的套件里显而易见的毛病——一个测试同时检查输入、计划和赢家,外加要构造的依赖太多——并论证正确的回应是重构测试;如果之后它们仍然一团糟,其背后的设计多半有缺陷。
“如果你做了这些而它们仍然一团糟,那很可能指向你设计里的缺陷”
“If you've done this and they're still a mess it is very likely pointing to a flaw in your design”
Time › More examples of good separation of concerns查看原文 →
以保持套件通过的小步重构,绝不在同一个动作里同时改生产代码和测试代码。
他称同时改两者是危险的,并建议每次小改动后重新运行测试,这样你就永远不会困在一个坏掉的构建里;具体的纪律是在提取期间保持 NewCLI 的外部接口不变,恰恰是为了使客户端代码和测试暂时不必改变。
“同时重构测试代码和生产代码会是危险的,所以我们先重构了生产代码(在当前状态下我们没法对测试做太多改进),而不改变它的接口,这样我们在改动的同时尽可能依赖我们的测试。”
“It would've been dangerous to refactor both the test code and the production code at the same time, so we first refactored the production code (in the current state we couldn't improve the tests much) without changing its interface so we could rely on our tests as much as we could while changing things.”
Time › More examples of good separation of concerns查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么计时需要接口
Go 无法比较函数值,所以测试没有办法检查交给 time.AfterFunc 的是哪个回调。把调度放到一个可注入的接口后面,让一个做记录的替身转而捕获每个时长和金额,于是计时行为成了测试可以断言的普通数据,而真实的输出仍然经过真实的计时器。
机制 2
依赖太多意味着缺了一个类型
当一个组件仅仅为了测试提醒的调度就需要接上四个协作者时,这个数量就是证据,说明它同时扛着好几份工作。把提醒器和存储挪进一个拥有 Start 和 Finish 的 Game,给每份职责一个自己的家,这就是为什么提取之后长长的列表缩短、测试也变小了。
机制 3
一次只改一边
测试是重构唯一的检查,所以把生产代码和测试代码一起重写就拿走了这道检查,让你分辨不出是哪个改动弄坏了东西。在内部被重新组织时保持 NewCLI 的外部接口固定,让现有测试保持绿色,只有之后才重写测试本身,并在每个小步骤之后重跑套件。
常见误读
如果间谍确认 Start 和 Finish 是以正确的值调用的,游戏逻辑本身就被测试过了。
间谍只记录自己如何被调用,所以那些断言证明的是命令行把输入正确地接了过去,而对规则是否正确什么也没说;这就是盲注计划和赢家记录的检查被挪进游戏自己的、针对真实实现的测试的原因。
“既然我们已经把 Game 提取出来,就应当把针对游戏的断言挪到与命令行分开的测试里。”
“Now that we have extracted out Game we should move our game specific assertions into tests separate from CLI.”
Time › Refactor查看原文 →
一旦测试看起来丑陋,设计就是错的,应该重新设计。
作者的规则在那之前还有一步:先重构测试本身,之后如果它们仍然一团糟,才把设计当作罪魁。他真正据以行动的信号是具体的——断言堆进一个测试,以及仅为构建它就需要依赖。
“如果你做了这些而它们仍然一团糟,那很可能指向你设计里的缺陷”
“If you've done this and they're still a mess it is very likely pointing to a flaw in your design”
Time › More examples of good separation of concerns查看原文 →
构建一个应用的核心概念
- 依赖注入
- 把协作者从外部传入,使测试可以换成假对象。 传入协作者,而不是去拿全局变量,使依赖列表成为设计的文档。
- 适用处: 有帮助,直到被注入的列表长到暗示设计有问题。 只有当被注入的列表真正与职责相符时,它才记录意图。
- 间谍
- 一种记录对它所做调用的测试替身,使你可以对其断言。 一个记录自己如何被调用的替身对象,使测试可以对这些调用断言。
- 适用处: 只做记录;它不会调度真正基于时间的工作。 适合验证像 Start/Finish 这样的交互;对真实的游戏逻辑是否正确则什么也说明不了。
- 测试替身
- 真实协作者的替身对象;哑对象只是填一个参数槽位。
- 适用处: 哑对象绝不能被断言,因为它从未被真正使用。
- 表驱动测试
- 一个测试体,遍历一份输入/期望配对的列表。
- 适用处: 当每个用例演练同一逻辑、只是值不同时最合适。
- 接口的函数适配器
- 一个带有接口方法的具名函数类型,使普通函数就能满足一个单方法接口。 围绕函数或映射定义一个新类型,使你可以在上面挂方法并满足接口。
- 适用处: 主要对单方法接口是惯用法。 适用于任何 Go 类型,不只是结构体;对单方法接口最顺手。
- 接口
- 调用者所依赖的、代替具体类型的一组具名方法。
- 适用处: 这里它只涵盖 Start 和 Finish,所以命令行对真实游戏的内部运作一无所知。
另外 1 个概念
- time.After
- 标准库的计时辅助函数,在一段时长之后触发,或在其上重复触发。
- 适用处: 本章只指向文档;它不涵盖对已调度工作的取消。
构建一个应用划线与批注AI 陪读
“你在 Go 里无法比较函数,所以我们无法测试传进来的是哪个函数。”
“You cannot compare functions in Go so we'd be unable to test what function has been sent in.”
查看原文 →一个语言的局限成了设计的驱动力。包装接口不是出于优雅而选的——而是因为否则测试什么也看不到。至少他对此是诚实的。
“把它标为“dummy”,测试的读者就清楚它并不重要。”
“By labelling it as a "dummy" it is clear to the reader of the test that it is not important.”
查看原文 →把命名作为缺席的文档。一个微小的动作,却是读者追问“这在这儿干嘛?”与一滑而过之间的区别。
“60 * time.Minute, 800”
查看原文 →注意这张表悄悄与上面的规格相矛盾——需求说的是 100、200、400、600、1000、2000。代码写的是 100…600、800、1000。没人标出来。测试如今是真正的规格。
另外 10 条批注
“记住,我们可以随意犯下任何必要的罪过来让它工作。”
“Remember, we are free to commit whatever sins we need to make this work.”
查看原文 →写糟糕代码的许可本身就是纪律——它之所以行得通,只因为重构这一步没得商量。拿了许可却不跟进,你就只剩罪过。
“每当你系统里的某样东西有很多依赖,这意味着它做得太多。从视觉上,我们可以从测试的杂乱看出来。”
“Whenever you have a lot of dependencies for a thing in your system, it implies it's doing too much. Visually we can see it in how cluttered our test is.”
查看原文 →这是本章真正的论点:测试是设计的压力计。丑陋不是要绕开的测试问题——它是一条信息。
“我们不想同时改动测试代码和客户端代码,因为那样要兼顾的太多了”
“we don't want to change the test code and the client code at the same time as that is too much to juggle”
查看原文 →这里最安静的智慧。重构的安全来自把一边固定住——你需要一个不动的参照点,否则你分辨不出什么坏了。
“记住,任何类型都可以实现接口,不只是结构体。”
“Remember that any type can implement an interface, not just structs.”
查看原文 →BlindAlerterFunc 这个把戏,是 Go 的 http.HandlerFunc 惯用法的缩影。值得注意它的存在是为了免去调用者一个空结构体的仪式——纯粹的人体工学。
“这保持了对这一观念的忠实:我们的命令行对我们正在玩的实际游戏一无所知”
“This keeps faithful to the notion that our CLI is oblivious to the actual game we're playing”
查看原文 →这次重命名就是整个设计论证压缩进一个措辞。“Game”作为接口,“TexasHoldem”作为细节——名字告诉你它们各自住在边界的哪一边。
“wantPrompt := poker.PlayerPrompt + "you're so silly”
查看原文 →刻意的占位措辞,使测试钉住的是行为,而不是文案。坦承此刻他还不知道消息该说什么。
“你能不能重构/删掉其中一些,而仍然有信心一切正常?”
“can you refactor/remove some of them and still be confident it all works”
查看原文 →在 TDD 的文字里,难得看到有人告诉你要删测试。测试也是库存——保留它们的成本在每一次未来的改动上都要付。
“我们能直观地看到它很杂乱;倾听你的测试是如此重要。”
“We could visually see it was cluttered; it is so important to listen to your tests.”
查看原文 →本章最强的主张:杂乱的测试是设计的坏味道,不是测试问题。前面的一切都是这句话的铺垫。
“所以我们先重构了生产代码(在当前状态下我们没法对测试做太多改进),而不改变它的接口”
“so we first refactored the production code (in the current state we couldn't improve the tests much) without changing its interface”
查看原文 →这个次序才是真正的手艺教训——绝不要让安全网的两边同时移动。多数人跳过这一点,然后分不清是哪一半坏了。
“在 Go 里,你可以给类型添加方法,而不仅仅是结构体”
“in Go you can add methods to types, not just structs”
查看原文 →BlindAlerterFunc 这个把戏和 Blog 映射是同一个想法的两次出现:接口要的不是一个类,只是行为。可爱的是,作为光秃秃映射的 Blog 变成了一个 HTTP 服务器。
读后感AI 陪读
本章看起来像一章功能章——在扑克命令行里调度盲注提醒——其实它是一章关于让测试告诉你设计在哪里走偏了的内容,它靠先走进那团乱麻而不是绕着它做架构,挣得了这个论点。开头的动作是书中最诚实的:在调度函数之上包一层接口,不是出于优雅,而是因为 Go 无法比较函数,没有这层包装,测试就什么也看不到。一个语言的局限成了设计的驱动力,而作者直白地这么说了。从那里起,手艺都在小处——一个起名“dummy”的间谍,让读者不再追问它为何存在;占位的提示措辞(“you're so silly”)钉住的是行为而不是文案,并承认他还不知道消息该说什么;time.AfterFunc 在接缝后面做真正的工作。值得注意的是,提醒的表悄悄与所述的需求相矛盾(出现了规格里没提过的 800),却没人标出来:到这个时候,是测试,而不是行文,在充当规格。那就是 TDD 的现实与风险,被压在一行无人评说的文字里。合页出现在 NewCLI 积累了四个依赖、测试明显杂乱的时候。本章的回答不是整理测试,而是读它:很多依赖意味着这东西做得太多,而丑陋是一条信息,不是一种不便。出来的是 Game,以及它背后的 TexasHoldem——一次重命名,把整个边界论证压缩进措辞:接口用角色名,具体用细节名——外加一个 GameSpy 来验证 Start/Finish 以及对错误输入的正确处理。后半部分最尖锐的教学是程序性的,而不是架构性的:先重构生产代码而不改变其接口,再重构测试,因为你需要一个不动的参照点,否则无法分辨是哪一半坏了。多数人两边都动,然后盲目调试。还有两份小礼物:“我们可以随意犯下任何必要的罪过来让它工作”之所以能作为纪律起作用,只因为重构这一步没得商量——拿了许可却不跟进,你就只剩罪过;而 BlindAlerterFunc 这个把戏,是 Go 的 http.HandlerFunc 惯用法的缩影,提醒你任何类型都可以满足一个接口,这就是一个光秃秃的映射为何能成为一个博客服务器。纯粹是人体工学,免去调用者一个空结构体。最少见的是,本章告诉你要删测试——你能不能删掉一些而仍然有信心?——把测试当作库存,其持有成本在每一次未来的改动上都要付。我会对读者说:别略过那个杂乱测试的时刻去赶往干净的 Game 接口。接口是答案;杂乱才是方法。学会把一个测试的别扭感受为关于生产代码的信息,是这里可迁移的技能,它在你忘了盲注提醒器是什么之后依然存活。
在 FeedLitra 里读构建一个应用,每段旁边都有这些卡片。
开始读书构建一个应用:重访时间,借助 testing/synctest
为什么持有 sync.Mutex 不算持久阻塞?
构建一个应用是怎样展开的AI 辅助
开篇:为旧解法留下的缺口命名。 本章从回头看早先的“时间”一章开始,那里的扑克命令行用 time.AfterFunc 调度盲注上涨的提醒。因为函数不能比较,而回调在真实时间过去之后跑在自己的 goroutine 上,最初的修复是依赖注入:一个 BlindAlerter 接口,加一个 SpyBlindAlerter,它只记录被要求调度的时长和金额。作者承认这个设计是健全的,测试也很快,然后指出它留下了什么未测试——那一小段真正调用 time.AfterFunc 并打印东西的代码,根本从未在测试里运行过。这一遗漏被框定为一个刻意的取舍(要么等真实的几分钟,要么缩短时长并祈祷机器跟得上),由此引出 Go 1.25 的 testing/synctest 作为第三种选择。随后是一份简短的入门:一个气泡,带着从 2000-01-01 午夜开始的假时钟,只有当每个 goroutine 都持久阻塞(睡眠、气泡内的通道接收、Cond.Wait、WaitGroup.Wait——但不包括互斥锁的加锁)时时间才前进,synctest.Test 等待所有派生的 goroutine,synctest.Wait 让后台工作沉淀下来。
展开:通过感受痛苦来挣得这件工具。 本章没有直接跳到 synctest,而是先跑了普通的 TDD 循环。一个真实时间的测试调度一个 5 秒的提醒,睡 6 秒,并对一个 bytes.Buffer 做断言;它先编译失败,随后在 BlindAlerter、BlindAlerterFunc 和 StdOutAlerter 存在之后通过。它也花了六个真实的秒——乘以游戏的十一个提醒,其中一些相隔一个多小时,这是无法忍受的,这恰恰是 synctest 要解决的问题。第一次 synctest 的尝试刻意天真:去掉睡眠,指望假时钟干活。它失败了,教会关键的一点:气泡的时钟不会自己往前走;只有当里面有东西阻塞着等待它时才会动。恢复睡眠,让测试瞬间通过——看上去大功告成。
转折:两次竞争检测器失败,重新框定教训。 本章的支点是一次习惯性的 -race 运行,它报告缓冲区读取与回调写入之间存在数据竞争。堆栈跟踪显示写入的 goroutine 早已结束,所以断言实际上永远不会失败——但竞争检测器问的是有没有保证次序的东西,而睡眠,不管多么慷慨,什么也不保证。作者指出,这个竞争在最初的真实时间版本里也潜伏着,没被察觉,因为没人会对一个六秒的测试反复运行 -race。把 synctest.Wait() 作为真正的同步点加进去,使运行可重复地干净。然后第二个测试——断言还什么都没发生——触发了第二个更微妙的竞争:没有别的 goroutine 来约束它、也没有即将到来的截止时间,Wait() 让运行时为了取得进展而触发待处理的 5 秒计时器,于是回调与“还什么都没有”的检查并发地跑在一个未受保护的缓冲区上。
收束:修设计,而不是修症状。 一把锁或 atomic.Pointer 就能让竞争闭嘴,但本章转而运用本书反复出现的原则——测试的痛苦是设计的信号。StdOutAlerter 在做两份工作,决定消息和执行副作用,而调度一个盲注提醒根本不需要知道 io.Writer。重构用 NewAlerter 取代它,返回提醒器加一个 <-chan string;接口类型原封不动,打印成了 main 的关切。援引“通过通信来共享内存”的格言,由此得到的测试去掉了 Wait()、睡眠和任何保护类型:一个带 default 的非阻塞 select 确认在虚拟时间零点还没有任何东西到来,而一次朴素的阻塞接收让气泡跳到计时器触发的时刻。它在反复的 -race 下保持绿色,因为不再有共享内存。本章把 synctest 定位为与旧的间谍互补而不是取代——间谍检查调度的算术,synctest 检查机制是否工作——然后回顾教训并列出一些坑(仍阻塞的 goroutine 导致死锁,绑定于气泡的通道和计时器,不算持久阻塞的真实 I/O,以及 AfterFunc 回调对共享状态需要自己的锁)。它以链接和一则坦率的说明收尾:这是本书第一个在 AI 辅助下写成的章节,强调论断是通过运行真实的测试验证的,而设计在草稿中途变得更好了。
关键论点,以及支撑它们的原文
只通过间谍测试提醒器,会让真正调度和发出提醒的代码未经测试。
作者指出,基于间谍的测试只断言记账,比如“ScheduleAlertAt 以 10 分钟和 200 调用”,所以真正调用 time.AfterFunc 并产生输出的那部分——缺陷会住的地方——从未运行过。他把这框定为一个刻意的取舍而不是疏忽,因为一个现实的真实时间测试会花几分钟,或迫使采用不现实的极小时长。
“那一小段真正调用 time.AfterFunc 并打印东西的代码,也就是真正有缺陷可能的部分,从未被测试演练过。”
“The one piece of code that actually calls time.AfterFunc and prints something, the part with the real bug potential, never gets exercised by a test.”
Revisiting time, with testing/synctest查看原文 →
synctest 气泡不会自己快进,所以测试仍然得说明它在等什么。
作者刻意写了一个过于乐观的版本,一旦测试在气泡中运行就完全去掉睡眠;缓冲区返回为空,测试瞬间失败。把六秒的睡眠放回去,它几乎在零真实时间内通过,表明时钟之所以跳变,只因为有东西要求它这么做。
“气泡的假时钟不会靠自己的计时器向前走。只有当气泡里的某个东西持久地阻塞、等着它时,它才推进。”
“A bubble's fake clock doesn't run itself forward on a timer of its own. It only advances when something in the bubble durably blocks, waiting for it to.”
Revisiting time, with testing/synctest › Introducing synctest查看原文 →
睡得够久不等于同步了,所以通过的测试里仍含有一个真实的数据竞争。
在 -race 下运行绿色的气泡测试会产生警告,而报告甚至把写入的 goroutine 标注为已完成——每次运行写入确实先于读取。作者借此区分两个问题:这一次是否出了错,与有没有什么东西保证它不可能出错。他补充说,最初朴素的真实时间版本一直有同样的竞争;没人想到要对一个六秒的测试反复运行 -race。
“但竞态检测器问的不是“这一次出错了吗?”;它问的是“有没有什么东西保证它不会出错?”time.”
“But the race detector isn't asking "did this go wrong this time?"; it's asking "is there anything that guarantees it can't?" time.”
Revisiting time, with testing/synctest › Introducing synctest查看原文 →
另外 3 个论点
synctest.Wait() 不是一张包治百病的安全网:气泡里没有别的东西约束它时,它可能让时钟跑得足够远,以致触发你正断言尚未触发的那个计时器。
承接上一点,作者加上 Wait() 来修复竞争,它对“提醒已到达”的检查有效——但当他写第二个检查,断言还没有打印任何东西时,-race 稳定地标出了第一行。理由是:没有别的 goroutine、也没有即将到来的截止时间,触发待处理的五秒计时器是运行时取得进展的唯一办法,所以回调 goroutine 与断言并排运行。
“问题在于,单独的 Wait() 对它愿意让假时钟走多远,没有任何限制。”
“The issue is that Wait() alone has nothing bounding how far it's willing to let the fake clock run.”
Revisiting time, with testing/synctest › Write the test first查看原文 →
痛苦的测试指向的是一个设计缺陷,所以修复办法是重新设计提醒器,而不是外加一把互斥锁。
一把锁或一个原子指针本可以让竞争闭嘴,但作者指出,决定消息和执行打印是两份工作,而调度一个扑克盲注根本不需要知道 io.Writer。把提醒器改成把完成的消息沿通道送出,就完全去掉了共享缓冲区;由此得到的测试不需要 Wait(),不需要保护类型,也不需要睡眠,并在反复的 -race 运行下保持绿色。
“这就是本书不断回到的那种张力的形状:如果你的测试让你痛苦,请倾听那个信号,并思考你代码的设计。”
“That's the shape of tension this book keeps coming back to: if your tests are causing you pain, listen to that signal and think about the design of your code.”
Revisiting time, with testing/synctest › Write the test first查看原文 →
间谍和 synctest 回答的是不同的问题,所以新办法是补充旧办法,而不是取代它。
作者保留间谍,作为检查算术的合适工具——对于给定的玩家人数,是否在正确的偏移量调度了正确的金额——在那里真实的计时无关紧要。synctest 则填补这类间谍总会留下的缺口:调度机制本身是否工作。
“那一小段真正调用 time.AfterFunc 并打印东西的代码,也就是真正有缺陷可能的部分,从未被测试演练过。”
“The one piece of code that actually calls time.AfterFunc and prints something, the part with the real bug potential, never gets exercised by a test.”
Revisiting time, with testing/synctest查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
假时间只在每个人都卡住时才移动
在 synctest 气泡内,时钟是虚拟的,自己从不走动;只有当气泡里的每个 goroutine 都被阻塞、且只有另一个气泡内的 goroutine 才能解除时,它才向前跳,比如睡眠或通道接收。所以测试仍然得表达它在等什么,而回报是这种等待不花任何真实的秒。
机制 2
为什么慷慨的睡眠仍是竞争
一个 goroutine 上的睡眠,对另一个 goroutine 对共享内存做完了什么不作任何承诺;它只是让走运的次序压倒性地可能。竞争检测器检查的是次序是否被保证,而不是它是否发生了,所以拉长睡眠只是在 -race 之外把问题藏起来。
机制 3
无界的 Wait 让计时器触发
synctest.Wait() 在其他每个 goroutine 都持久阻塞时返回,但在它做决定期间,没有任何东西限定时钟可以跑多远。没有别的 goroutine、也没有待处理的截止时间,取得进展的唯一办法就是触发排队中的计时器,这会与本该什么也没看到的断言并发地派生出回调 goroutine。
常见误读
一旦测试在 synctest 气泡内运行,假时钟会自己跳过去,所以你可以删掉睡眠,直接断言。
只有当气泡里的每个 goroutine 都持久阻塞、且有东西在等待时间时,时钟才前进;测试仍然得表达这个等待,只是它不再花真实的秒。作者用一个没有睡眠、因缓冲区为空而失败、恢复睡眠后又通过的版本来演示。
“气泡的假时钟不会靠自己的计时器向前走。只有当气泡里的某个东西持久地阻塞、等着它时,它才推进。”
“A bubble's fake clock doesn't run itself forward on a timer of its own. It only advances when something in the bubble durably blocks, waiting for it to.”
Revisiting time, with testing/synctest › Introducing synctest查看原文 →
如果实践中写入总是先于读取完成,竞争检测器的警告就是可以忽略的技术细节——或者至多是更长的睡眠就能治好的东西。
警告关乎的是缺乏被保证的次序,而不是这一次运行发生了什么,所以更长的睡眠只是减少它在 -race 之外出现的频率。真正的同步——在恰当时刻的一次 Wait(),或者更好的,把值经由通道交出去——才是真正消除它的东西。
“但竞态检测器问的不是“这一次出错了吗?”;它问的是“有没有什么东西保证它不会出错?”time.”
“But the race detector isn't asking "did this go wrong this time?"; it's asking "is there anything that guarantees it can't?" time.”
Revisiting time, with testing/synctest › Introducing synctest查看原文 →
构建一个应用的核心概念
- testing/synctest 气泡
- 函数的一次隔离运行,其中 time 包使用一个从 UTC 2000年1月1日午夜开始的假时钟。
- 适用处: 真实的网络和文件 I/O 不算持久阻塞,所以无法由假时钟驱动。
- 持久阻塞
- 以只有同一气泡内的另一个 goroutine 才能解除的方式阻塞;假时间只在那时才前进。
- 适用处: 对 sync.Mutex 加锁不算,因为互斥锁通常只被短暂持有。
- 数据竞争
- 两个 goroutine 触碰共享内存,没有任何东西强制次序,即便其中一个实际上几乎总是赢。
- 适用处: 更长的睡眠永远修不好它;它只是在 -race 之外把它藏起来。
- 通道
- 把完成的消息交过 goroutine 边界,而不是写进共享状态。
- 适用处: 通道绑定于创建它们的气泡;从气泡外使用会 panic。
另外 0 个概念
构建一个应用划线与批注AI 陪读
“它们从不让真正的提醒器运行。”
“They never let a real alerter run.”
查看原文 →使本章值得读的诚实坦白。多数书会让 DI 的凯旋巡游原封不动;点出未被测试的那个洞才是真正的教训。
“这不是疏忽,而是一种取舍。”
“That's not an oversight, it's a trade-off.”
查看原文 →好的纪律:旧的那一章没有错,只是受约束。工具改变的是约束,不是推理。
“假时钟现在肯定会替我们处理这个吧?它不会”
“surely the fake clock handles that for us now? It doesn't”
查看原文 →先刻意走进错误的直觉,是正确的教学动作——“假时间是快进”这个心智模型必须先破碎,“假时间是按需驱动的”才能落地。
另外 9 条批注
“出于习惯: go test -race ./...”
“out of habit: go test -race”
查看原文 →随意的框定在做实际的活。本章最尖锐的一点是作为事后的想法到来的,而这正是这类缺陷在现实中到来的方式。
“它问的是“有没有什么保证它不可能发生?”
“it's asking "is there anything that guarantees it can't?”
查看原文 →我读过的对竞争检测器最干净的一句话定义。睡眠是概率论证;同步是证明。
“谁会对一个已经要花六个真实秒的测试,反复运行 -race”
“who runs -race, repeatedly, on a test that already takes six real seconds”
查看原文 →可爱的倒转:慢测试不只是浪费时间,它通过劝退唯一能发现缺陷的工具,把缺陷藏了起来。慢作为认识论上的代价,而不只是不便。
“单靠 Wait(),没有任何东西约束它愿意让假时钟往前跑多远”
“Wait() alone has nothing bounding how far it's willing to let the fake clock run”
查看原文 →这才是真正出人意料的地方——Wait() 可以为了取得进展而推进时间,所以断言“暂时什么也没发生”是气泡里最危险的断言。我希望这一点比段落中间的一句话更响亮。
“如果你的测试让你痛苦,请倾听那个信号”
“if your tests are causing you pain, listen to that signal”
查看原文 →整章为了挣得而搭建的支点。注意被拒绝的是什么:互斥锁的修复本会管用,却什么也教不了。
“而这,意味深长地,已不再与 stdout 有任何关系”
“which, tellingly, no longer had anything to do with stdout”
查看原文 →这个名字一直在撒谎,直到竞争迫使人们看一眼,才有人注意到。一个误导性的名字,作为放错了地方的职责的先行指标。
“注意哪些不见了:哪里都没有 synctest.Wait()”
“Notice what's gone: no synctest.Wait() anywhere”
查看原文 →回报是做减法——新的设计需要这个新工具更少。一章介绍 synctest,却以几乎不怎么用它来结束,这是有自信的一步。
“synctest 没有取代那个需要;它填补了覆盖缺口”
“synctest didn't replace that need; it fills the coverage gap”
查看原文 →拒绝升级叙事。间谍回答“我要的对不对”,synctest 回答“要了之后管不管用”——不同的问题,两个都需要。
“这是本书第一章在 AI 辅助下写成的”
“This is the first chapter in the book written with AI assistance”
查看原文 →这份披露之所以挣得可信度,恰恰因为它描述的是验证而不是生成——那些坑之所以存在,是因为真实的测试失败了。不过“如果有什么读起来奇怪,那是我的责任”是一种略嫌太整齐的责任承担。
读后感AI 陪读
一章的真正主题不是 testing/synctest,而是一个通过的测试与一个真正证明了什么的测试之间的差距。它的结构悄悄地出色:测试的每个版本看起来都完成了,通过了,然后被一次习惯性的 -race 运行单独拆掉——两次,包括在作者明确说自己有把握的那条断言上。最有价值的洞见不是 API,而是认识论:睡得足够久是一个概率论证,不是同步保证,而一个慢测试之所以危险,不是因为浪费几秒,而是因为它的慢劝退了本可以暴露它缺陷的那个工具。第二个竞争——synctest.Wait() 在气泡里没有任何别的东西来约束它时,让假时钟往前跑,并与断言并发地触发计时器——真正违反直觉,而且就它多容易咬到读者而言,路标太少;它值得比段落中间的一句解释更多。重构为通道,是本书家法的最佳体现:互斥锁的修复本会管用,却什么也教不了,而 StdOutAlerter 已不再与 stdout 有关这一点,从头到尾是一个名字在悄悄标出一份放错了地方的职责。结尾抵挡了两个值得一提的诱惑——它没有宣布旧的 SpyBlindAlerter 过时,而且它以一个几乎不用本章表面上在推销的那个工具的设计收尾。关于 AI 辅助的说明放得恰当,也能落地,因为它描述的是验证而不是生成,尽管它最后对责任的承担略嫌太整齐。
在 FeedLitra 里读构建一个应用,每段旁边都有这些卡片。
开始读书构建一个应用:WebSocket
本章最终如何从 WebSocket 测试里去掉那个武断的 time.Sleep?
构建一个应用是怎样展开的AI 辅助
开篇——同一场游戏,一扇新门。 本章开头把迁移到浏览器框定为换风景而不是换实质:扑克应用的需求被宣布与终端版本相同,只有输入到达的通道不同。这个框定做着真正的活,因为它许可原封不动地复用上一章构建的 Game 抽象。所以最初几步是管线。HTML 通过 html/template 提供,作者在这里停下来,就测试标记做了一个明确的成本论证:驱动真实的浏览器是不成比例的机械,对渲染出的 HTML 逐字符断言会让测试对任何表面编辑都很脆弱,而仅仅验证模板被要求渲染,几乎证明不了页面的任何东西。项目里只有一个模板时,最便宜的赌注是跳过测试——这个决定被呈现为关于回报的判断,而不是留下代码不测试的普遍许可。然后是 /ws 上的 WebSocket,由一个刻意镜像终端版本的测试驱动:发送玩家人数,发送赢家的名字,断言游戏以这些值启动并结束,复用与之前相同的替身游戏。
展开——丑陋的代码,被一个测试按住。 第一个能工作的处理器公然很糟。它吞掉每一个错误,而钉住它的测试靠一个写死的十毫秒停顿,作者在写下它的那一刻、而不是事后,就把它标为不良做法。这里要说的是次序:先到绿灯,才使清理安全,因为测试就是会在重构期间抓住错误的东西。所以这团乱只保留到刚好够达到通过的状态,然后被拆解——一个共享的升级器被提取出来,错误处理挪进公共的辅助函数。那个武断的停顿被标为仍未解决并推迟,这为本章的最后一个乐章铺好了路。
转折——目的地无法预先知道。 结构上的问题在盲注提醒需要到达浏览器时到来。每个进来的请求都产生自己全新的响应写入器,然后被升级成自己的套接字;在服务器的依赖被组装的那一刻,根本不存在单一的输出目的地。解决办法是把目的地从构造中挪出来、放进调用里:BlindAlerter 和 Game 被重构为接受一个 io.Writer,于是提醒触发时哪个连接是活的,哪个就收到它。Game 随后被接进 PlayerServer,套接字被包进一个 playerServerWS。桥接被证明是微不足道的——游戏逻辑只需要一个发送字节的地方,所以编译器对缺失 Write 方法的抱怨就是全部的障碍,而提供那个方法就是全部的改动。缩短盲注上涨的间隔并打开页面,显示金额实时攀升。作者小心地标明这是什么:一次手工目测,不是测试。
收束——断言消息,而不是调用。 只是目测过之后,作者主张去测试服务器实际通过连接发送的东西,而不是监视产生它的那次函数调用,理由是钉在内部调用上的测试,一旦实现被重新排布就会坏,即使可见的行为没变。(这与早先拒绝“检查模板被要求渲染”是同一种推理:一次被记录的调用,只确认了一段代码以某种特定方式对另一段代码说了话。)替身游戏仍在使用,但现在给了一个罐装的提醒,它写到交给它的任何目的地,所以测试等待那段确切的文本经由套接字到达。这立刻暴露了异步的隐患:从套接字读取会永远阻塞,等一条可能永远不来的消息,使运行冻结而不是失败。修复按情形分开。对必须到达的东西,断言在自己的 goroutine 里运行,并与一个计时器通道赛跑,于是缺席成了一个被报告的失败。对只是迟到的状态,一个重试辅助函数重新检查,直到截止时间。回报闭合了本章中途打开的那个回路:写死的停顿现在可以从共享的断言辅助函数里删掉。本章承认的两条捷径——被吞掉的错误和猜出来的睡眠——到结尾都不见了,这才是被演示的真正论证。
关键论点,以及支撑它们的原文
为终端构建的游戏抽象可以原封不动地搬到浏览器,因为变的只是输入到达的方式。
作者指出需求相同,只有输入通道不同,然后用一个镜像终端版本的测试来驱动浏览器版本:它通过套接字发送玩家人数,再发送赢家的名字,并检查游戏是以这些值启动并结束的,复用了上一章的同一个替身游戏。
“这和我们现在的需求是一样的,只是获取输入的方式不同;所以只要可能,我们就应当考虑复用这个概念。”
“This is the same requirements we have now, just a different way of getting the inputs; so we should look to re-use this concept if we can.”
WebSockets › Refactor查看原文 →
有些标记就是不值得一个测试,因为每一种测试它的办法,花费都超过回报。
作者权衡了三个选项并逐一否决:驱动真实的浏览器,比这个问题配得上的机械多得多;对渲染出的 HTML 逐字符匹配,会让测试在任何表面编辑上就坏;而仅仅检查模板被要求渲染,几乎证明不了页面的任何东西。项目里只有一个模板,跳过测试是更便宜的赌注。
“所以在《Learn Go with Tests》这本书里,我们第一次不打算写测试。”
“So in the book "Learn Go with Tests" for the first time, we're not going to write a test.”
WebSockets › How do we test we return the correct markup?查看原文 →
只要有测试按住,交付刻意写得丑陋的代码也无妨,因为测试正是让清理安全的东西。
第一个能工作的版本吞掉每个错误,测试靠一个写死的十毫秒停顿;作者把两者都点名为不良,只为到达绿灯才保留它们,然后在那个测试的保护下提取出一个共享的升级器和辅助函数。那个武断的停顿被公然承认暂时未解决,留待以后处理。
“我们有了丑陋、可怕、却能工作并由测试支撑的软件,所以现在我们可以自由地把它变漂亮,并知道不会意外破坏任何东西。”
“We have nasty, horrible, working software backed by a test, so now we are free to make it nice and know we won't break anything accidentally.”
WebSockets › Refactor查看原文 →
另外 3 个论点
给套接字包装器一个输出接口,就足以把实时的盲注提醒推给浏览器。
承接每请求目的地的问题,作者指出,在存在 Write 方法之前,编译器拒绝把这个包装器当作输出目标;加上它就是全部的改动。缩短盲注上涨的间隔并打开页面,显示金额实时攀升——尽管作者明确说这是手工目测,不是自动化测试。
“我们设法在 PlayerServer 里复用了 Game,它把所有细节都处理好了。”
“We've managed to re-use Game with PlayerServer and it has taken care of all the details.”
WebSockets › Write enough code to make it pass查看原文 →
与其监视产生消息的函数调用,不如断言经由连接传回来的真实消息。
只目测过浏览器之后,作者主张去测试服务器实际发送的东西,因为监视内部调用的测试,一旦实现被重新洗牌就会坏。替身游戏仍在使用,但现在给了一个罐装的提醒,它写到所提供的目的地,所以测试等待那段确切的文本经由套接字到达。
“同样,我们应该也能测试我们的服务器经由 WebSocket 连接发回的消息。”
“Equally we should be able to test the messages our server sends back over the websocket connection.”
WebSockets › Let's not write a test!查看原文 →
异步测试既不该挂起,也不该按猜测去睡眠;对必须到达的东西用超时,对最终会到达的东西用重试循环。
从套接字读取会无限期阻塞,等一条永远不会被发送的消息,所以测试冻结,直到一个截止时间辅助函数让读取与计时器赛跑,并报告失败。对只是迟迟才变真的断言,一个重试辅助函数重新检查所记录的状态直到截止时间,这使作者得以从共享的断言辅助函数里删掉写死的停顿。
“我们永远不该有挂起的测试,所以让我们引入一种办法来处理我们想让其超时的代码。”
“We should never have tests that hang so let's introduce a way of handling code that we want to timeout.”
WebSockets › Write the minimal amount of code for the test to run and check the failing test output查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
每个请求带来自己的输出目标
浏览器请求产生一个全新的响应写入器,被升级成自己的套接字,所以在服务器的依赖被构建时,不存在单一的目的地。作者于是把提醒的目的地从提醒器的构造中挪出来、放进调用本身,使提醒触发时哪个连接是活的,哪个就收到提醒。
机制 2
一个微小的 Write 方法在套接字与游戏之间架桥
游戏逻辑只需要一个发送字节的地方,所以把套接字包进一个带有 Write 方法的小类型,就使连接成为一个普通的输出目的地。一旦编译器不再抱怨缺失的方法,现有的游戏代码就把盲注提醒流向浏览器,自己无需任何改动。
机制 3
为什么阻塞读取需要截止时间
从套接字读取会一直等一条可能永远不来的消息,这会冻结整个测试运行而不是让它失败。作者在单独的 goroutine 里运行断言,并与一个计时器通道赛跑,于是缺失的消息变成一个失败;对只是迟到的状态,则用循环重新检查直到截止时间,而不是停顿一个猜出来的时长。
常见误读
既然替身协作者精确记录了做过哪些调用,检查这些被记录的调用就是功能能工作的最强证明。
被记录的调用只确认了一段代码以某种特定方式对另一段代码说了话,这就是作者拒绝检查模板被要求渲染、而改为断言真实的提醒文本经由连接传回来的原因。钉在内部调用上的测试,一旦实现被重新排布就会失败,即使可见的行为没变。
“同样,我们应该也能测试我们的服务器经由 WebSocket 连接发回的消息。”
“Equally we should be able to test the messages our server sends back over the websocket connection.”
WebSockets › Let's not write a test!查看原文 →
既然作者交付了一个忽略所有错误的处理器和一个只停顿十毫秒的测试,那种风格是可以留着不动的。
两者都是临时的脚手架:作者在写下那个停顿的那一刻就把它标为不良做法,这团乱之所以可以容忍,唯一的原因是通过的测试使随后的清理安全。到最后,捷径都消失了——错误处理被拉进共享代码,停顿被截止时间和重试循环取代。
“我们有了丑陋、可怕、却能工作并由测试支撑的软件,所以现在我们可以自由地把它变漂亮,并知道不会意外破坏任何东西。”
“We have nasty, horrible, working software backed by a test, so now we are free to make it nice and know we won't break anything accidentally.”
WebSockets › Refactor查看原文 →
构建一个应用的核心概念
- WebSocket
- 一种协议,保持一个 TCP 连接打开,使服务器和浏览器可以随时互发消息。
- 适用处: 只内建于现代浏览器;较旧的浏览器在此不受支持。
- html/template
- 标准库的包,解析一个 HTML 文件,并把渲染结果写入一个 io.Writer。
- 适用处: 每个请求都解析会无谓地访问文件系统,所以应在构造时解析一次。
- io.Writer
- 通用的输出目的地,使同一个提醒器既可以写到标准输出,也可以写到 WebSocket 连接。 一个只有 Write 的小接口,使任何能接受字节的东西都可以充当输出。
- 适用处: 只解决输出去哪里,不涉及何时或如何被调度。 只涵盖写入字节;对分帧、上游的错误或读取只字未提。
- 迭代式应用开发
- 先做最小的有用工作,再让它长大,而不是一次构建一切。
- 适用处: 只因为有测试守着重构,才容忍暂时丑陋的代码。
- 间谍
- 一个记录对它所做调用的替身协作者,使测试可以对其断言。 一个记录自己如何被调用的替身,这里还会送出一条罐装消息。
- 适用处: 只确认交互,不确认真实的盲注调度行为。 对实现细节有用,但当你重构它所观察的东西时就会变得脆弱。
- time.After
- 一个在一段时长后触发的通道,用在 select 里让超时与断言赛跑。
- 适用处: 给出的是截止时间,不是取消;运行 assert 的 goroutine 会继续跑下去。
另外 1 个概念
- 依赖注入
- 从外部把 Game 交给服务器,而不是让它自己构建一个。
- 适用处: 有助于复用和测试;本身并不能修复测试里的异步时序。
构建一个应用划线与批注AI 陪读
“所以在《Learn Go with Tests》这本书里,我们第一次不打算写测试。”
“So in the book "Learn Go with Tests" for the first time, we're not going to write a test.”
查看原文 →本章最诚实的一句。一本 TDD 的书靠点出 TDD 在哪里不再划算来挣得可信度。前面那份三选项的成本分析,使它成为一个判断而不是懒惰。
“眼下就这样吧,但要承认在测试里放入武断的睡眠是非常糟糕的做法。”
“Let's go with that for now but acknowledge that putting in arbitrary sleeps into tests is very bad practice.”
查看原文 →坦白了并留着。这个睡眠是一个竞争,不是一个延迟——10ms 管用,直到 CI 负载很高。诚实,但这笔债在这一段里并未偿还。
“我们有了丑陋、可怕、但能工作的、有测试支撑的软件,所以现在我们可以自由地把它变漂亮”
“We have nasty, horrible, working software backed by a test, so now we are free to make it nice”
查看原文 →红-绿-重构的全部论证浓缩在一句话里:测试买来的,是先粗心的许可。
另外 11 条批注
“对每个请求,我们都会得到一个新的 http.ResponseWriter,然后把它升级成 *websocket.Conn。所以我们在构造依赖时,无从知道我们的提醒需要送到哪里。”
“For every request we get a new http.ResponseWriter which we then upgrade to *websocket.Conn. So we can't know when constructing our dependencies where our alerts need to go.”
查看原文 →这是本章真正的设计压力。标准输出是进程生命周期的单例;套接字是每个请求一个。修复——把目的地从构造时挪到调用时——才是真正的教训,而且比 WebSocket 大。
“命令行和服务器的需求是相同的!只是投递机制不同。”
“The requirements of CLI and Server are the same! It's just the delivery mechanism is different.”
查看原文 →上一章 Game 抽象的回报,在公开场合被兑现。不过注意它并非完全免费——接口不得不改变形状,才能熬过第二个调用者。
“(是的,我们现在忽略了很多错误!)”
“(Yes, we're ignoring a lot of errors right now!)”
查看原文 →三行里有三个被丢弃的错误。括号里的话干了很多活——它为读者买来耐心,而不假装代码没问题。
“p.game.Start(numberOfPlayers, io.Discard) //todo: Don't discard the blinds messages!”
查看原文 →注释作为叙事里的书签。漂亮的教学动作:接缝已经在那里,只缺接线——下一步会很小。
“我选择创建一个符号链接 (ln -s ../../game.html game.html)”
“I chose to create a symlink (ln -s ../../game.html game.html)”
查看原文 →指向模板文件的相对路径是一个伪装成工作目录问题的潜伏缺陷——服务器只能从一个文件夹运行。符号链接是把它藏起来而不是解决它;嵌入才能解决。
“咱们不写测试!”
“Let's not write a test!”
查看原文 →书里最诚实的一句。TDD 的教条恰恰在该放松的地方放松了:探索不是验证。那条提醒——先提交——使它安全而不是草率。
“显而易见该做的事,似乎就是让 playerServerWS 实现 io.Writer”
“It seems the obvious thing to do, would be to make it so playerServerWS does implement io.Writer”
查看原文 →这是整个 Go 设计论证浓缩在一个动作里:接口从未被声明,而是被发现的。三行,WebSocket 就成了游戏本来就知道怎么与之交谈的一根管道。
“这看起来太容易了!试着运行应用,看看它是否能工作。”
“This seems too easy! Try and run the application and see if it works.”
查看原文 →作者让自己的不信任留在页面上,而不是把它抹平。对容易的怀疑是一种真正的工程本能,而他靠运行它而不是争辩来化解。
“经常是间谍测试开始失败,因为它们通常检查的是你正试图改变的实现细节”
“it's often spy tests that start failing because they are usually checking implementation details that you're trying to change”
查看原文 →这里最尖锐的主张:一个在重构时就坏的测试不是在保护你,而是在向你征税。不过随后的 GameSpy 仍然是个间谍——他缩窄了它断言的范围,而没有逃脱 mock。
“你应该会发现测试永远挂起。这是因为 ws.ReadMessage() 会一直阻塞,直到收到一条消息”
“You should find the test hangs forever. This is because ws.ReadMessage() will block until it gets a message”
查看原文 →挂起比失败更糟——它什么也告诉不了你,还阻塞一切。好在修复办法是一个通用工具 within,而不是调用处的补丁。
“一路走来我们犯过错误,但有了 TDD 的流程,我们从未离可工作的软件很远。”
“Through the journey we have made mistakes but with the TDD flow we have never been very far away from working software.”
查看原文 →这套实践真正的承诺,被谦逊地陈述:不是更少的错误,只是回头的距离更短。那是关于恢复时间的主张,不是关于正确性的。
读后感AI 陪读
这一章是扑克三部曲的凯旋巡游,它之所以挣得这一圈,是因为诚实地交代了这一圈的代价。弧线说起来简单:拿已经能工作的命令行游戏,提供一个 HTML 页面,打开一个 WebSocket,让浏览器宣布赢家,并把盲注提醒沿线路推回去。这条弧线也正是要点——游戏本身几乎没变。变的是投递。那句“命令行和服务器的需求是相同的!只是投递机制不同”,是三章的论点收在一句话里,而本章真正的工作是展示这类回报从来不是完全免费的:Game 接口仍然必须改变形状,才能熬过它的第二个调用者。
这次重塑才是本章真正的教训,它比 WebSocket 大。标准输出是进程生命周期的单例;套接字是每个请求一个,诞生于一个在你接线依赖时还不存在的 http.ResponseWriter。所以目的地不能活在构造时——它必须挪到调用时。BlindAlerter 和 Game 都长出一个 io.Writer 参数,修复就是这些。然后是让它感觉不可避免而不是聪明的那一招:把连接包进 playerServerWS 并给它一个 Write 方法,三行,套接字就成了游戏本来就知道怎么与之交谈的东西。这个接口从来不是为这个目的而声明的;它被发现原本就合身。那是 Go 设计论证的缩影,而本章是演示它,而不是宣讲它。
继续阅读
我最敬重的是作者走下他自己教义的地方。“咱们不写测试!”——两次,在一本书名叫《Learn Go with Tests》的书里——是其中最可信的文字,因为它出现在一次明确的三方成本分析之后,并附带一道安全护栏:先提交,再探索。那是判断,不是懒惰。同样的诚实贯穿括号里的话:“是的,我们现在忽略了很多错误!”,旁边带着 //todo 的 io.Discard,坦白的武断的睡眠。这些为读者买来耐心,而不假装代码没问题。但请注意哪些债被偿还、哪些没有。io.Discard 这个书签在一页之内就兑现了。睡眠最终被 within 和 retryUntil 取代——一次真正的偿还,而且是一个通用工具而不是调用处的补丁,由本章最好的教学时刻促成:一个在 ws.ReadMessage 上挂起的测试,因为挂起什么也告诉不了你,还阻塞一切,严格比失败更糟。从未被偿还的债,是指向 game.html 的相对路径,被一个符号链接糊了过去。那是一个伪装成工作目录问题的潜伏缺陷;embed.FS 可以终结它,而交付这段代码的读者,应当在从那个文件夹以外的任何地方部署之前修好它。
有两条更尖锐的线索值得点名。第一,“经常是间谍测试开始失败,因为它们通常检查的是实现细节”这句话,是这里关于测试最有用的东西——一个每次重构就坏的测试不是在保护你,而是在向你征税。出于诚实要指出,随后的 GameSpy 仍然是个间谍;他缩窄了它断言的范围,而没有逃脱 mock。第二,“这看起来太容易了!试着运行应用”——对容易的怀疑是一种真正的本能,而他靠运行它而不是与它争辩来化解。同样,“我们有了丑陋、可怕、但能工作的、有测试支撑的软件,所以现在我们可以自由地把它变漂亮”平白地说出了红-绿-重构实际买到的是什么:先粗心的许可,之后再收回。
所以该带走什么。别把这当作 WebSocket 教程;套接字代码很薄,反正你也会去查。读它是为了依赖生命周期的问题和 io.Writer 这道接缝,每当一个每请求的目的地遇上一个进程生命周期的设计,它们都会再次出现。把两个不写测试的时刻读作你必须以同样方式挣来的许可——有明言的成本比较,背后有一次干净的提交。并且把结尾那句话按它真实的分量来拿:“有了 TDD 的流程,我们从未离可工作的软件很远”是关于恢复时间的主张,不是关于正确性的。那是一个谦逊的承诺,而谦逊的承诺才是值得相信的。
在 FeedLitra 里读构建一个应用,每段旁边都有这些卡片。
开始读书问答:OS Exec
还有哪些标准库接口能成为好的接缝?
问答是怎样展开的AI 辅助
开篇:以读者的一个问题作为钩子。 本章不是以作者虚构的问题开篇;它始于发在 Reddit 上的一个问题。一位读者有一个函数 GetData(),通过 os/exec 调出外部命令来产生 XML,并希望测试从准备好的 testdata 文件读取,而不是真的运行那条命令。读者甚至提出一个解法——传一个模式参数,让函数知道自己处于“测试”模式。这个提议才是本章真正的主题,因为作者立刻拒绝了它,并提出两条指导性的主张取而代之:测试困难通常是关注点分离放错了地方的症状,而修复是依赖注入,不是写进生产代码的测试开关。由于无法看到读者实际的代码,作者重构出一个看似合理的版本——一个 Payload 结构体,exec.Command(“cat”, “msg.xml”),一个 StdoutPipe,一个 XML 解码器,以及对解码出的消息应用 strings.ToUpper——连同它所读取的 msg.xml 文件,和一个断言大写的“Happy New Year!”问候的首个测试。
展开:定位接缝。 中段做着分析的活。作者走读借来的标准库模式——把标准输出捕获为 io.ReadCloser,Start 命令,中间解码,然后 Wait 读取结束——并标出 io.ReadCloser 是将要紧的那个细节。然后是诊断:这段代码里装着两个关注点,获取原始 XML,与解码它并应用业务逻辑,而它们被焊在了一起。拆开它们所需的抽象已经存在于代码里;io.Reader 的边界就是“接缝”所在,所以不必发明新的接口。在动刀之前,作者还指出,现有的测试将被保留,作为跨越两半的集成测试——一个小小的方法论要点:重构时不丢失覆盖。
转折与收束:重构,以及它买到什么。 转折是重写后的代码:GetData 现在接收一个 io.Reader,只做解码和转大写,而一个独立的 getXMLFromCommand 拥有 exec 的那套机制,并交回一个读取了它所读字节的 reader。旧的测试保留了下来,改名为 TestGetDataIntegration,把两者接在一起。回报随后被展示,而不只是被断言:一个朴素的单元测试,用装着内联 XML 的 strings.NewReader(那个俏皮的“Cats are the best animal”载荷)喂给 GetData,并检查转成大写的结果——没有文件,没有子进程,没有模式开关。本章以把这个胜利一般化收尾:因为 GetData 不再关心它的字节从哪里来,它就可以与任何 io.Reader 一起复用——比如经由 HTTP 获取的 XML——教训落成一句话:分离关注点,依靠 Go 既有的抽象,测试真正要紧的逻辑就变得容易。
关键论点,以及支撑它们的原文
写测试时的麻烦通常是职责纠缠的信号,而不是往代码里构建一个测试开关的理由。
本章以一位读者的问题开篇,他提议加一个模式参数,让函数读一个夹具文件而不是运行外部命令。作者拒绝了这个开关,并重新框定了请求:别扭来自一个函数做两份工作,补救是注入依赖,使生产路径和测试路径是同一条代码路径。
“当某件事难以测试时,往往是因为关注点分离没有做对”
“When something is difficult to test, it's often due to the separation of concerns not being quite right”
OS Exec查看原文 →
原来的函数有两份可分离的工作:获得原始 XML,以及把它变成结果。
读着勾勒出的代码,作者指出获取那一半基本上是从标准库文档里抄来的,而业务逻辑是解码加上把消息转大写。既然逻辑是值得测试的部分,又与命令调用焊在一起,两者就必须分开。
“第二部分是我们的业务逻辑所在,看代码就能看出我们逻辑里的“接缝”从哪里开始;就是我们得到 io.ReadCloser 的地方。”
“The second part is where we have our business logic and by looking at the code we can see where the "seam" in our logic starts; it's where we get our io.ReadCloser.”
OS Exec › Testable code查看原文 →
下刀的恰当位置,是输出变成 io.Reader 的地方。
承接这两份工作的观察,作者指出管道交回的是一个 reader,所以那个类型已经是代码里的关节。重构把命令、Start/Wait 这一对、以及字节的读取保留在一个独立的获取辅助函数里,让解码并转大写的函数只接收一个 reader。
“第二部分是我们的业务逻辑所在,看代码就能看出我们逻辑里的“接缝”从哪里开始;就是我们得到 io.ReadCloser 的地方。”
“The second part is where we have our business logic and by looking at the code we can see where the "seam" in our logic starts; it's where we get our io.ReadCloser.”
OS Exec › Testable code查看原文 →
另外 2 个论点
接受 io.Reader 的函数,既易于单元测试,也可以与其他数据源复用。
作者通过 strings.NewReader 喂入一段字面 XML 载荷并断言转成大写的消息来演示测试,没有启动任何进程。同样的性质在生产中也适用:任何产生 reader 的东西都行,所以 XML 之后可以来自 HTTP 响应而不是 shell 命令,而不必触动逻辑。
“比如,我们可以改成从 URL 而不是命令行去获取 XML。”
“For example we could start fetching the XML from a URL instead of the command line.”
OS Exec › Testable code查看原文 →
一旦代码被拆开,最初的端到端测试值得保留。
解耦之后,作者保留了把真实命令与逻辑接在一起的测试,把它重新标为集成测试,其任务是确认两半仍能契合。它与快速的单元测试并存,而不是取代它,因为单元测试刻意从不演练命令那条路。
“第二部分是我们的业务逻辑所在,看代码就能看出我们逻辑里的“接缝”从哪里开始;就是我们得到 io.ReadCloser 的地方。”
“The second part is where we have our business logic and by looking at the code we can see where the "seam" in our logic starts; it's where we get our io.ReadCloser.”
OS Exec › Testable code查看原文 →
它是怎样运作的
本章论证所依靠的 2 个机制。
机制 1
难以测试的代码指向设计缺陷
当一个函数把获取数据与处理数据混在一起时,测试不触发获取就够不到逻辑。作者把这种摩擦当作关于设计的信息,而不是测试问题,所以修复办法是把依赖作为参数传入,让调用者决定数据从哪里来。
机制 2
在既有的接口处切分代码
命令的输出本来就以 io.ReadCloser 的形式到来,所以在任何重构之前,代码里就存在一道接口边界。把函数改为接受 io.Reader,就把这道边界变成切分点:命令那条路仍然满足它,测试提供的任何内存 reader 也满足。
常见误读
在测试里使用夹具数据的干净办法,是给函数一个模式或测试标志,让它读文件而不是调外部命令。
把数据源作为参数交给函数。调用者随后提供一个文件、一个字符串、或真实命令的输出,而被测函数在生产和测试中运行的是完全相同的代码——不存在只有测试才走的分支。
“当某件事难以测试时,往往是因为关注点分离没有做对”
“When something is difficult to test, it's often due to the separation of concerns not being quite right”
OS Exec查看原文 →
一旦逻辑可以用内存 reader 测试,真正运行外部命令的那个测试就多余了。
作者把那个测试作为集成检查留着。基于 reader 的测试对命令是否运行、其输出是否被正确读取、两个部分是否仍接在一起,什么都没说——只有端到端测试涵盖接缝本身。
“第二部分是我们的业务逻辑所在,看代码就能看出我们逻辑里的“接缝”从哪里开始;就是我们得到 io.ReadCloser 的地方。”
“The second part is where we have our business logic and by looking at the code we can see where the "seam" in our logic starts; it's where we get our io.ReadCloser.”
OS Exec › Testable code查看原文 →
问答的核心概念
- 依赖注入
- 把依赖作为参数传入,由调用者决定数据从哪里来。
- 适用处: 当依赖可以表达为像 io.Reader 这样现成的小接口时有效。
- 集成测试
- 一个保留真实命令接线的测试,用来检查分开的两部分仍能一起工作。
- 适用处: 与逻辑的快速单元测试并存,而不是取代它。
- 可测试的代码
- 解耦、只做一件事的代码,使你可以直接喂给它输入。
- 适用处: 按关注点来判断,而不是按文件。
另外 0 个概念
问答划线与批注AI 陪读
“当某件事难以测试时,往往是因为关注点分离没有做对”
“When something is difficult to test, it's often due to the separation of concerns not being quite right”
查看原文 →本章最好的一句,它在回答之前先重新框定了问题。测试的痛苦是设计的坏味道,而不是工具的缺口。
“不要往你的代码里添加“测试模式”
“Don't add "test modes" into your code”
查看原文 →值得说明原因:测试模式意味着你交付的代码永远不是你测试过的代码。你演练的分支,是没人用的分支。
“这 3 个都可能返回错误,但为了简洁我忽略了”
“these 3 can return errors but I'm ignoring for brevity”
查看原文 →方便的简洁——而重构后的版本悄悄地继续忽略它们。一章讲好的接缝,却没碰错误这道接缝。
另外 3 条批注
“它是我们得到 io.ReadCloser 的地方”
“it's where we get our io.ReadCloser”
查看原文 →接缝早已在那里;作者没有发明一个抽象,他注意到的是标准库交给他的那个。这才是真正的教训。
“我们的 TestGetData 可以充当我们这两个关注点之间的集成测试”
“Our TestGetData can act as our integration test between our two concerns”
查看原文 →漂亮的动作——在把代码拆开的同时,保留那个丑陋的端到端测试作为安全网。旧测试仍在盯着,同时重构。
“我们可以开始从 URL 而不是命令行获取 XML”
“we could start fetching the XML from a URL instead of the command line”
查看原文 →可测试性被当作良好接口的副作用来推销,而不是目标。复用的论证才是更强的那个。
读后感AI 陪读
一章微小的内容,干净地只做一件事:它拒绝回答被问到的问题。读者想要一个测试开关;答案是那个开关是症状。它之所以有说服力,是因为修复什么也没发明——io.Reader 早已坐在原来的代码里,重构大半只是把线画在 Go 本来已经画好的地方。更深的教训与其说是“使用依赖注入”,不如说是“学会看出你的代码在哪里已经收窄到单一类型,并在那里下刀。”它单薄之处在于:在“改进”的版本里错误依然被忽略,这在一本关于用心的书里很奇怪;而 getXMLFromCommand 一声不吭地以流式换成了整个内存缓冲区。同样未被审视的是,getXMLFromCommand 本身仍然无法测试——不可测试性并没有消失,而是被推进了一个小而无聊、明显抄自文档的函数里,在那里它造成的危害更小。这其实正是这项技术的诚实形状,把这点说出来会让本章更强,而不是更弱。
在 FeedLitra 里读问答,每段旁边都有这些卡片。
开始读书问答:错误类型
什么时候哨兵错误值就够了,而不需要一个完整的错误类型?
问答是怎样展开的AI 辅助
开篇:一个问题,然后是一个具体的标本。 本章不是以一节课开篇,而是以从一次 Slack 对话里摘出来的一个问题——如果一个错误是用 fmt.Errorf 和插值构建的,你能在不比较字符串的情况下测试它的相等吗?为了让这个问题具体可感,作者发明了 DumbGetter,一个小小的函数,获取一个 URL,只在 200 响应时才返回其正文。它有两条不同的失败路径:HTTP 调用本身可能失败,或者响应可能带着非 200 的状态。这种双重失败的形状正是这个例子的全部要点,因为它布置出这样的情形:调用者合理地可能想知道哪一件事出了错。本章随后写下提问者提议的那个朴素测试:启动一个总是回答“I'm a teapot”的 httptest 服务器,针对它的 URL 调用 DumbGetter,并把 err.Error() 与测试里重新构造的 fmt.Sprintf 字符串比较。
展开:倾听测试的不适。 作者没有宣布这个测试是错的,而是祭出本书反复出现的纪律“倾听你的测试”,并逐条列出为什么这个测试感觉糟糕——测试重复了生产代码的格式化逻辑,读写都乏味,而且消息的确切措辞多半不是任何人真正在意的。关键的一步,是把测试的人体工学读作 API 人体工学的替身:如果测试只能通过检查错误的字符串来盘问它,那么库的每一个真实消费者也只能如此,这既脆弱又令人不快。这把问题从“我怎么测试这个”重新框定为本书不断回到的 TDD 之问:我想要怎样使用这段代码?
转折:用数据代替字符串。 答案是一个结构体 BadStatusError,携带一个 URL 和一个 Status——真正的字段而不是格式化的句子。测试先被改写:它把返回的错误类型断言为 BadStatusError,如果断言不成立就用 %T 快速失败,然后才把取出的值与期望的结构体比较。一个一行的 Error() string 方法满足 error 接口,并保住了人类可读的消息,现在它由类型拥有,而不是散落在函数里。测试运行并以信息丰富的方式失败——它报告得到的是 *errors.errorString——修复是 DumbGetter 里的一次替换,在原来 fmt.Errorf 的地方返回结构体的值。作者清点收益:函数不再操心消息格式化,测试如今兼作调用者如何根据错误种类分支的文档,而错误仍是一个普通的 error,依然可以被记录或往上层传递。
收束:规则,然后是一条现代化的脚注。 总结把这一插曲泛化成建议——当一个函数可能以多种方式失败时,不要靠比较消息来测试;定义错误类型,使你的处理代码和用户的处理代码都能操作结构化的值。附录随后为 Go 1.13 及以后更新这项技术:同一个测试用 errors.As 展示,它声明一个目标变量,返回一个表示成功的布尔值,并填充目标——好处是它也能看穿被包装的错误,而光秃秃的类型断言做不到。
关键论点,以及支撑它们的原文
通过比较错误消息字符串来测试失败,是一种脆弱的做法,而不只是难看。
作者对非 200 响应的第一个测试,格式化出与函数所格式化的完全相同的句子,用相同的 URL 和状态码占位符,并把它与返回的消息比较。所以测试复述的是实现而不是行为,写起来读起来都乏味,还钉死了一个作者认为没人真正在意的细节。
“我们在构造与生产代码相同的字符串来测试它”
“We're constructing the same string as production code does to test it”
Error types › Problems with this way of testing查看原文 →
修复来自于问你想怎样使用这段代码:让函数交回一个携带真实数据的有类型错误。
作者没有用消息,而是定义了一个装着 URL 和状态码的小结构体,并给它一个 Error 方法。改写后的测试断言返回的错误是那个类型,断言失败时报告它得到的具体类型,然后才把结构体与一个由测试服务器的 URL 和 teapot 状态构建的期望值比较——先检查失败的种类,后检查细节。
“我们的 DumbGetter 函数变简单了,它不再关心错误字符串的细枝末节,只是创建一个 BadStatusError。”
“Our DumbGetter function has become simpler, it's no longer concerned with the intricacies of an error string, it just creates a BadStatusError.”
Error types › What does the test do?查看原文 →
返回自定义错误类型,使产生它的函数更简单,同时记录了调用者可以如何处理失败。
获取器的状态分支塌缩成一行,返回填好了 URL 和状态的结构体,所以函数不再带有任何消息格式化的逻辑。作者指出测试现在兼作文档:任何想要比记录日志更多的人,只需断言类型并读取字段,而且因为这个值仍满足 error 接口,它可以像任何其他错误一样被记录或向上传递。
“我们的 DumbGetter 函数变简单了,它不再关心错误字符串的细枝末节,只是创建一个 BadStatusError。”
“Our DumbGetter function has become simpler, it's no longer concerned with the intricacies of an error string, it just creates a BadStatusError.”
Error types › What does the test do?查看原文 →
另外 1 个论点
自 Go 1.13 起,标准库提供了一种更干净的办法,把自定义类型从错误值里取出来。
作者添加了同一测试的一个收尾变体,它声明一个自定义错误类型的变量并传给 errors.As,后者以布尔值报告成功并填充该变量,所以测试在不匹配时可以带着实际的类型失败,否则就像之前一样比较字段值。
“在这种情况下,我们用 errors.As 尝试把我们的错误提取成自定义类型。”
“In this case we are using errors.As to try and extract our error into our custom type.”
Error types › Addendum查看原文 →
它是怎样运作的
本章论证所依靠的 2 个机制。
机制 1
别扭的测试暴露别扭的 API
为了检查一次失败,测试不得不重建生产代码所构建的那个格式字符串,所以断言重复了实现。这种摩擦与调用者面对的摩擦相同:对失败唯一的把手是它的消息文本,所以测试里的痛苦,证明 API 没有给调用者更好的东西可用。
机制 2
作为结构体的错误把种类挪进类型
给失败一个自己的、带具名字段和 Error 方法的结构体,使它仍是一个普通的错误,同时让它的身份和数据对类型系统可见。调用者断言类型以得知发生了哪种失败,然后直接读取字段,所以双方都不必解析或重建消息。
常见误读
换成自定义错误类型,就把错误变成了你再也不能简单地记录或向上返回的特殊对象。
这个结构体实现了 error 接口,所以它仍是一个普通的错误:不在乎细节的调用者可以照常传播或记录它,只有想根据失败种类分支的人,才会费心去断言类型并检查它的字段。
“我们的 DumbGetter 函数变简单了,它不再关心错误字符串的细枝末节,只是创建一个 BadStatusError。”
“Our DumbGetter function has become simpler, it's no longer concerned with the intricacies of an error string, it just creates a BadStatusError.”
Error types › What does the test do?查看原文 →
errors.As 只是同一个类型断言的新语法,所以用哪个在实践中没有区别。
作者把 errors.As 呈现为标准库自己用来把错误提取到自定义类型里的机制——它以布尔值表示成功,并替你填好有类型的变量——并把它作为做这项工作的最新办法引入,而不是对手写断言的装饰性重写。
“在这种情况下,我们用 errors.As 尝试把我们的错误提取成自定义类型。”
“In this case we are using errors.As to try and extract our error into our custom type.”
Error types › Addendum查看原文 →
问答的核心概念
- 错误类型
- 携带错误数据并实现 error 接口的结构体,使调用者可以做类型断言,而不是读字符串。
- 适用处: 当一个函数因几种不同原因失败、调用者必须区分它们时有用。
- 对错误的类型断言
- 在运行时检查一个错误值是否是某个特定的具体类型,然后读取它的字段。
- 适用处: 朴素的断言会错过被包装的错误;errors.As 能处理整条链。
- 倾听你的测试
- 别扭的测试是一个信号:你代码的使用者将面对同样的别扭。
- 适用处: 是设计反馈的一种启发,而不是代码有错的证明。
另外 0 个概念
问答划线与批注AI 陪读
“有没有办法在不比较字符串值的情况下测试相等?”
“is there a way to test equality without comparing the string value?”
查看原文 →整章从一个 Slack 问题里长出来。值得注意好的 API 设计经常始于某人的恼火,而不是一条原则。
“我们在构造与生产代码相同的字符串来测试它”
“We're constructing the same string as production code does to test it”
查看原文 →这才是真正的控诉。一个重复了实现的格式字符串的测试,只证明你会复制粘贴;它对行为什么也没验证。
“他们能做的最好的,就是看错误字符串”
“The best they can do is look at the error string”
查看原文 →我喜欢的支点:测试的痛苦被当作消费者痛苦的先行指标,而不是要绕过的测试不便。
另外 4 条批注
“与其是一个魔法字符串,我们有了真正的数据可以处理。”
“Rather than a magical string, we have actual data to work with.”
查看原文 →其实这是解析与结构化的对立:字符串版本迫使用户重新推导函数本来就有的数据。为什么要扔掉 URL 和状态,只为把它们重新编码进散文?
“它仍然“只是”一个错误,所以如果他们愿意,可以把它往上传”
“It is still "just" an error, so if they choose to they can pass it up”
查看原文 →接口悄悄的回报——你添加了结构,却没拿走任何东西。下游无需知道任何事。
“got, isStatusErr := err.(BadStatusError)”
查看原文 →注意附录悄悄承认了什么:光秃秃的类型断言,一旦有人用 %w 包装这个错误就会坏掉。errors.As 不是风格上的更新,而是正确性的修复。
“if got != want”
查看原文 →用 == 比较错误之所以行得通,只因为 BadStatusError 是一个可比较的结构体。加一个切片或映射字段,这一行就会在运行时 panic——一个本章从未点名的隐藏约束。
读后感AI 陪读
一章简短的内容,把一个动作做得很好:它把一个别扭的测试当作 API 别扭的证据,然后修 API 而不是修测试。论证最强的地方恰恰是与错误最无关的地方——写一个测试是你第一次有机会成为自己代码的用户。它隐而不言的,与它所说的一样有意思。最终断言里的结构体相等只对可比较的类型有效;正文里的类型断言一旦错误被包装就悄悄地是错的,附录给它打了补丁,却没有承认先前的版本是脆弱的。还有一个本章没有提出的范围问题:当调用者必须根据错误分支时,自定义错误类型是对的,但当唯一的消费者是一行日志时,它就是小题大做。“不要比较错误字符串”是好建议;“给每个错误造一个类型”则不是,而本章从未真正画出那条线。
在 FeedLitra 里读问答,每段旁边都有这些卡片。
开始读书问答:感知 context 的读取器
为什么 readerCtx 的 Read 方法用值接收者?
问答是怎样展开的AI 辅助
开篇:两个小抽象,分别引入。 本章把自己框定为对一个借自 Mat Ryer 和 David Hernandez 博客文章的想法的走读,然后放慢脚步,确保标题的两半都讲得通。先是对 io.Reader 的复习——一个关于“从某物读取数据”的单方法接口,其价值主要在于遵循它能买到跨整个标准库的复用。然后是引出动机的问题:io.Reader 对一次读取要花多久不作任何保证,可能是纳秒,也可能是数小时,所以把它与 context 的取消(早先一章已涉及)配对,让你能中止昂贵的工作。目标被陈述为一个具体的场景:一个持有“ABCDEF”的 reader,在中途被取消,只产出“ABC”,再没有别的。
展开:找到接缝,然后写一个证明尚无东西坏掉的测试。 作者指出一个测试上的难处——通常你把 reader 交给像 json.NewDecoder 这样的东西,自己从不碰 Read——并通过直接针对接口工作来解决:传一个装不下全部内容的缓冲区,取消,再读一次,期望零字节和一个错误。在任何生产代码之前,一个刻意琐碎的“顺利路径”测试把“123456”读进一个三字节的缓冲区两次,并断言“123”然后“456”,纯粹为了感受机制。TDD 循环本身随后以想象中的 API NewCancellableReader 开始,并按本书特有的微步前进:未定义的函数 → 返回 nil 的桩 → 空指针 panic → 最偷懒的通过实现,返回传进来的那个 reader。作者预料到读者在这里的不耐烦,以这个测试是对普通 reader 行为的守卫、在本章其余部分都会保持有用为由,为这份吹毛求疵辩护。
转折:第二个测试迫出真正的设计。 取消测试是通过复制第一个测试、插入一个 context.WithCancel、在两次读取之间调用 cancel()、并断言随后的读取出错且返回零字节来写成的。每一条编译器的抱怨都推动下一次编辑——签名长出一个 ctx 参数,一个 readerCtx 结构体出现来同时持有 context 和委托 reader,编译器指出缺失的 Read 方法,一个会 panic 的桩满足它,委托给被包装的 reader 恢复了顺利路径,最后在委托调用之前加一道守卫(if err := r.ctx.Err(); err != nil { return 0, err }),使两个测试都通过。返回 context 自己的错误而不是一个通用的错误,被标为一个刻意的选择:调用者于是可以检视取消为何发生。
收束:可泛化的教训。 总结从这次练习里提取三点——小接口易于组合;用一样东西去增强另一样东西,是委托模式的活(被引为通过组合获得类似继承的复用);以及作为一种工作方法:包装你的委托对象,并先测试它的行为恰如委托对象所为,这样在你于其上叠加新行为时就有了一张安全网。
关键论点,以及支撑它们的原文
值得让 reader 可取消,因为你永远不知道一次读取要花多久。
作者指出 reader 抽象对速度不作任何承诺:一次调用可能一纳秒就完成,另一次可能跑上数小时。既然工作可能昂贵且没有尽头,应用就需要一种从中抽身的办法,而这恰恰是取消信号所提供的。
“当你使用 io.Reader 时,你对速度没有任何保证,它可能花 1 纳秒,也可能花数百小时。”
“When you're using an io.Reader you have no guarantees over speed, it could take 1 nanosecond or hundreds of hours.”
Context-aware readers › Context aware?查看原文 →
为你希望存在的 API 写测试,让编译器的抱怨驱动每一个下一步。
作者调用一个尚不存在的构造函数,一次处理一个错误:未定义的函数,然后是加上 context 之后参数个数错误,然后是一个因缺少 Read 方法而无法满足 reader 接口的类型。每条信息都精确点出下一处编辑,所以设计的浮现无需猜测。
“开始这类工作的一个简便办法,是包装你的委托对象,并写一个测试,断言它的行为就如委托对象通常那样,然后才开始组合其他部分来改变行为。”
“An easy way to start this kind of work is to wrap your delegate and write a test that asserts it behaves how the delegate normally does before you start composing other parts to change behaviour.”
Context-aware readers › Wrapping up查看原文 →
增强某样东西时,先包装委托对象,并证明它仍然正常工作。
在碰取消之前,作者先交付一个返回原始 reader 的包装器,并让普通的分块读取测试保持绿色。他承认这一步看起来傻而吹毛求疵,但论证它买来的信心正是让后来的改动安全的东西。
“开始这类工作的一个简便办法,是包装你的委托对象,并写一个测试,断言它的行为就如委托对象通常那样,然后才开始组合其他部分来改变行为。”
“An easy way to start this kind of work is to wrap your delegate and write a test that asserts it behaves how the delegate normally does before you start composing other parts to change behaviour.”
Context-aware readers › Wrapping up查看原文 →
另外 2 个论点
取消是通过在 Read 的开头查询 context 来实现的,而返回它的错误让原因保持完整。
直接建立在直通包装器之上,作者添加一个携带 context 和委托对象的结构体;它的 Read 在 context 已被设置时返回零和 context 的错误,否则就委托。普通读取的测试和取消时停止的测试随后都通过,而且因为 context 自己的错误被传播,调用者可以检视发生的是哪种取消。
“这使代码的调用者能检查发生取消的各种原因,原帖对此有更多介绍。”
“This allows callers of the code to inspect the various reasons cancellation has occurred and this is covered more in the original post.”
Context-aware readers › Write enough code to make it pass查看原文 →
小接口之所以有价值,恰恰因为它们能组合。
整个功能来自通过委托把两个朴素的抽象——一个取消信号和一个单方法的 reader——配对,作者把这描述为通过组合获得继承的复用。因为包装器仍满足 reader 接口,它可以放进任何已经消费 reader 的东西,比如 JSON 解码,或把一切读进内存。
“这使代码的调用者能检查发生取消的各种原因,原帖对此有更多介绍。”
“This allows callers of the code to inspect the various reasons cancellation has occurred and this is covered more in the original post.”
Context-aware readers › Write enough code to make it pass查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
委托之前先检查 context
包装类型同时持有一个 context 和底层的 reader。每次 Read 先问 context 是否已完成;如果是,就返回零字节加上 context 自己的错误,否则就原封不动地转发调用。所以取消在下一次读取开始时生效,而调用者得知原因。
机制 2
先直通,作为安全网
包装器的第一个版本只是交回它所得到的 reader,所以普通读取的测试仍然通过。这把正常的 reader 行为锁定为基线,于是之后在添加取消时发生的任何破坏,都会立刻表现为一个失败的测试,而不是悄悄的回归。
机制 3
小缓冲区暴露接缝
Read 填充调用者提供的字节切片,所以一个三字节的缓冲区面对六个字节的数据,就迫使进行两次独立的读取。两次调用之间的那道缝隙,正是测试发出取消信号的地方,使得无需任何特殊机制就能观察到中途取消。
常见误读
把 context 交给 reader,意味着一次已经在进行的读取会在你取消的那一刻被打断。
context 只携带一个信号;是包装器对它采取行动,而它的做法是在每次 Read 调用的入口处检查。所以一次进行中的读取会运行到完成,而取消在随后那次调用时表现为零字节加一个错误。
“这使代码的调用者能检查发生取消的各种原因,原帖对此有更多介绍。”
“This allows callers of the code to inspect the various reasons cancellation has occurred and this is covered more in the original post.”
Context-aware readers › Write enough code to make it pass查看原文 →
测试一个只是返回你传入之物的包装器什么也证明不了,可以跳过。
那个测试钉住了你绝不能弄坏的行为。作者刻意保留它,指出它验证普通读取仍然工作,并提供了在其上叠加取消逻辑时所需的信心。
“开始这类工作的一个简便办法,是包装你的委托对象,并写一个测试,断言它的行为就如委托对象通常那样,然后才开始组合其他部分来改变行为。”
“An easy way to start this kind of work is to wrap your delegate and write a test that asserts it behaves how the delegate normally does before you start composing other parts to change behaviour.”
Context-aware readers › Wrapping up查看原文 →
问答的核心概念
- io.Reader
- 一个单方法接口,把字节拉进你提供的缓冲区。
- 适用处: 对一次读取要花多久不作任何保证。
- Context
- 一个随身携带的值,向进行中的工作发出取消信号。
- 适用处: 只发信号;被包装的代码必须真的去检查它。
- 装饰器模式
- 包装一个对象并把调用转发给它,使你可以围绕它们添加行为。
- 适用处: 要求包装器实现与委托对象相同的接口。
另外 0 个概念
问答划线与批注AI 陪读
“提供一个固定大小的字节数组,装不下全部内容”
“Supply a fixed-size byte array that doesn't fit all the contents”
查看原文 →整章系于这一招。分小块读取,才让取消变得可观察——否则就没有可供取消的缝隙。
“让我们先写一个没有取消的“顺利路径”测试”
“let's just write a "happy path" test where there is no cancellation”
查看原文 →这是对标准库的测试,不是对他们自己代码的测试。教学上很诚实:他在针对自己的心智模型编码之前先检查它。
“return nil”
查看原文 →返回 nil 以得到段错误,是一个刻意廉价的步骤。panic 就是信号——先编译,再响亮地失败,然后修复。
另外 4 条批注
“我知道,我知道,这看起来很傻、很吹毛求疵”
“I know, I know, this seems silly and pedantic”
查看原文 →先发制人的道歉承认读者已经在翻白眼。辩护——为委托对象正常行为准备的回归网——才是本章真正的教训。
“func (r *readerCtx) Read(p []byte) (n int, err error”
查看原文 →在章节中途悄悄从指针接收者换成了值接收者,而构造函数仍返回 &readerCtx。它能编译,所以没人注意——但这是一本讲究慢走的书里一处未被评说的不一致。
“if err := r.ctx.Err(); err != nil”
查看原文 →值得点明这个局限:它只在两次读取之间检查。一次已经阻塞数小时的 Read 不会被打断。本章推销取消,交付的却是轮询。
“委托模式是一种面向对象的设计模式”
“the delegation pattern is an object-oriented design pattern”
查看原文 →一个被引用的面向对象定义,空降进了一章 Go 里。Go 抵达委托,不是出于设计上的选择,而是因为它没有继承可选。
读后感AI 陪读
一章简短的内容,真正的主题不是 context 或 reader,而是 TDD 的节奏:每一步都小得近乎侮辱人——返回 nil,得到一个段错误,原样返回输入,加一个会 panic 的方法——而作者知道这一点,并事先为这份吹毛求疵道歉。他主张的回报是真实的:包装一个委托对象,并先断言它的行为仍与委托对象完全一致,让你在改变任何行为之前先有了一张回归网。技术内容按设计就很薄,基本上是从 Ryer 和 Hernandez 那里借来的十五行,而本章对此直言不讳。有两件事未被审视。第一,接收者一声不吭地从 *readerCtx 变成了 readerCtx,在一篇其余部分都在宣讲让编译器逐行引路的文字里。第二,也更实质:在 Read 的开头检查 ctx.Err(),只在两次读取之间取消——一次阻塞数小时的 Read,恰恰是用来激发整个练习的那个场景,丝毫没被触及。本章承诺取消慢速的 reader,交付的却是协作式的轮询。这个缺口大概是敏锐的读者能带走的最有用的东西,也是总结唯一没有提到的东西。
在 FeedLitra 里读问答,每段旁边都有这些卡片。
开始读书问答:重访 HTTP 处理器
你会如何给不同的错误类型不同的状态码,而不是一律 500?
问答是怎样展开的AI 辅助
本章开篇把自己框定为续篇,而不是重复:本书已经讲过测试一个 HTTP 处理器,所以这一期关乎的是设计。作者预先陈述一个论点——测试处理器的困难很少是测试问题,而是设计问题——并把它压缩成他说自己在全书反复念叨的那句话:如果测试让你痛苦,就把这份痛苦当作关于代码的信号。单一职责和关注点分离被点名为解药,接口和依赖注入则是机制。
从这里起,它转向证据。一位读者的问题——如何测试一个依赖 MongoDB 的处理器——提供了一个真实的处理器,而作者只是列举这一个函数做了什么:写响应和状态码,解码正文,打开数据库连接,查询并应用业务规则,对密码做哈希,插入一条记录。结论只有一行:太多了。本章随后从具体的代码退后一步,问处理器是干什么的,并给出一个作者声称在他经手的几乎每个项目里都成立的三部分回答:解析并校验请求,把数据交给某个服务,把服务的结果翻译成响应。他列出回报——处理器的测试变窄,业务逻辑无需 HTTP 即可测试,逻辑可在别处复用,处理器被隔离在一个稳定接口之后而免受变化的影响。
转折是对神秘感的刻意放气。http.HandlerFunc 被完整引出——一个建立在两参数函数之上的类型——并邀请读者留意这里没有框架魔法,只有一个函数,而我们早就知道怎么测试函数。一个最小的“茶壶”处理器及其测试演示了整个机制:调用函数,把记录器作为写入器传进去,对记录下来的东西做断言。作者随后回应了 TDD 示例过于简单这一标准反对,把它倒过来问——如果真实的代码读起来也这么简单,不是很好吗?——并据此重新设计注册处理器:一个带 Register 方法的 UserService 接口,一个持有它的 UserServer 结构体,和一个解码、委托、并把结果映射成 400、500 或 201 的处理器。他对捷径很坦率,标出一律 500 是糟糕的 API,并为区分错误类型留了一个 TODO。
测试随后充当该主张的证明。一个手写的 MockUserService,带可注入的函数和已注册用户的记录,支撑三个小子测试:一次有效的注册(状态、正文,以及用户确实被传了过去),无效的 JSON,以及一个失败的服务。预料到作弊的指控——数据库去哪了?——作者说省略正是要点:把处理器从持久化中解放出来,也把持久化从 HTTP 中解放出来,于是两者可以各按自己的条件被测试。一个打了桩的 MongoUserService 和一个四行的 main 显示两半只在程序的边缘才拍合在一起。
收尾的论证关乎未来而不是现在。以后加一封确认邮件,旧的处理器及其测试都得改——这正是代码悄悄变得无法维护的方式,每个新关注点都堆积到那个被设计来装下一切的函数上。在接口后面,处理器根本不必动。总结把开头的论点重述为对一种文化习惯的诊断:人们把处理器当作特殊的东西,并中止了自己通常的工程判断,而让它们难以测试的是这个,而不是 Go 的 HTTP 包。像写普通函数那样、带着清晰的职责来写它们,测试就会自己料理好。
关键论点,以及支撑它们的原文
代码难以测试时,问题通常出在它的设计,而不是测试本身。
作者以一位读者关于测试依赖 MongoDB 的处理器的问题开篇,然后列出这一个函数所做的一切:写头部和状态码,把正文解码成用户,拨号数据库,查询并应用业务规则,对密码做哈希,插入一条记录。结论是这对一个函数来说就是太多了——测试的痛苦是这种堆积的症状,并为随后的重新设计铺好了路。
“如果你的测试给你带来痛苦,就倾听这个信号,并思考你代码的设计。”
“If your tests are causing you pain, listen to that signal and think about the design of your code.”
Revisiting HTTP Handlers查看原文 →
处理器应当做三件事:解析并校验进来的请求,把数据交给服务,并根据返回的东西选择响应。
一个最小的茶壶处理器展示了响应这一半:测试只是用假请求和记录器调用函数,并对记录下来的状态码断言。重写后的注册处理器展示了完整的形状——解码正文,调用服务的 Register 方法,然后对坏的载荷回 400,服务出错回 500,成功则回 201 并带上新的 ID。作者把这当作一个强有力的默认,而不是法则,说它大约在一百个情形里合身九十九个。
“调用某个 ServiceThing,用我从第1步得到的数据去做 ImportantBusinessLogic。”
“Call some ServiceThing to do ImportantBusinessLogic with the data I got from step 1.”
Revisiting HTTP Handlers › What is a HTTP Handler and what should it do ?查看原文 →
一旦依赖位于接口后面,处理器的测试就变得又小又快。
一个假的用户服务记录传给它的用户,并返回测试想要的任何东西,所以三个简短的子测试就涵盖了处理器的全部工作:有效的载荷得到 201 加 ID 和一条被记录的用户,乱码文本得到 400 且完全不需要服务,而失败的服务得到 500。不需要数据库、网络或夹具,因为处理器不再知道注册是如何发生的。
“通过用接口分离关注点,我们根本不必编辑处理器,因为它不关心围绕注册的业务逻辑。”
“By separating concerns using an interface we don't have to edit the handler at all because it's not concerned with the business logic around registration.”
Revisiting HTTP Handlers › A more robust and extensible design with little effort查看原文 →
另外 3 个论点
把数据库工作挪出处理器,也让持久化层更容易打交道。
作者把以 Mongo 为底的服务写成一个独立的类型,带自己的构造函数和 Register 方法,刻意不实现,并指出它可以被单独测试。main 随后把两半拍合在一起:构建 Mongo 服务,传给服务器,并提供处理器方法。因为存储代码不带任何 HTTP 细节,它的测试不必模拟请求或响应。
“通过用接口分离关注点,我们根本不必编辑处理器,因为它不关心围绕注册的业务逻辑。”
“By separating concerns using an interface we don't have to edit the handler at all because it's not concerned with the business logic around registration.”
Revisiting HTTP Handlers › A more robust and extensible design with little effort查看原文 →
这种分离让系统将来更便宜地改动,而不只是今天更容易测试。
作者设想一个很可能的下一个需求——给新用户发一封确认邮件。在原来的设计下,这个改动会落进处理器,迫使它的测试被重写,这正是处理器逐渐吸收一切、然后腐烂的方式。注册放在接口后面,新的行为进入服务,处理器完全不需要编辑,只要接口的形状保持稳定。
“通过用接口分离关注点,我们根本不必编辑处理器,因为它不关心围绕注册的业务逻辑。”
“By separating concerns using an interface we don't have to edit the handler at all because it's not concerned with the business logic around registration.”
Revisiting HTTP Handlers › A more robust and extensible design with little effort查看原文 →
抱怨教程示例不切实际地简单,是没抓住这个练习的要点。
作者的回答是把这个反对翻转过来:如果生产代码读起来、测试起来也像玩具示例那样轻松,那会很好,而这个结果是可以达到的——虽然并不轻松——靠的是实践同样的工程原则。重新设计后的注册代码,被拿来作为一个普通的真实端点最终可以变得那样简单的演示。
“如果你的测试给你带来痛苦,就倾听这个信号,并思考你代码的设计。”
“If your tests are causing you pain, listen to that signal and think about the design of your code.”
Revisiting HTTP Handlers查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
处理器只是函数
Go 的处理器类型只是一个接收响应写入器和请求的函数,不涉及任何框架钩子或注解。正因如此,测试可以直接调用它,传入一个假请求加一个内存记录器,然后检查记录下来的状态和正文——测试它与测试任何其他函数没有两样。
机制 2
注入一个窄接口
处理器内部不再打开数据库,服务器类型持有一个以接口表达的服务,并通过构造函数接收它。处理器于是只调用一个方法,所以测试可以提供一个返回罐装结果的记录替身,而真实的数据库实现可以在启动时换进去,而不触动处理器的代码。
机制 3
关注点双向分离
把注册逻辑推到服务后面,处理器只剩解析和响应,而业务和持久化代码则不必再与响应写入器和状态码打交道。双方于是可以各按自己的条件被演练:处理器用快速的内存测试,存储层对着真实的数据库。
常见误读
HTTP 处理器是一种特殊的代码,需要专门的测试框架或繁复的脚手架。
它们是普通函数,而标准库已经提供了测试所需的一切:一个辅助函数构造假请求,另一个记录处理器写出的任何东西,测试只需调用函数并检查记录。作者论证,人们之所以惹上麻烦,恰恰因为他们把处理器当作特殊的东西,在写它们时抛开了正常的设计习惯。
“调用某个 ServiceThing,用我从第1步得到的数据去做 ImportantBusinessLogic。”
“Call some ServiceThing to do ImportantBusinessLogic with the data I got from step 1.”
Revisiting HTTP Handlers › What is a HTTP Handler and what should it do ?查看原文 →
重新设计后的处理器的错误处理就是该照抄的模式——坏的 JSON 得 400,其余一律 500。
作者把一律 500 标为出于简洁而选的替身,并直说对每个错误都返回它会造出糟糕的 API;他还考虑了原代码对一切出错都回 400 的习惯。想传达的教训是关注点的分离,更有区分度的错误处理——例如区分错误类型——留作之后的改进。
“调用某个 ServiceThing,用我从第1步得到的数据去做 ImportantBusinessLogic。”
“Call some ServiceThing to do ImportantBusinessLogic with the data I got from step 1.”
Revisiting HTTP Handlers › What is a HTTP Handler and what should it do ?查看原文 →
问答的核心概念
- 处理器
- 在 Go 里,一个接收 ResponseWriter 和 Request 的普通函数——没有框架魔法,所以可以像任何函数一样测试。
- 适用处: 它只应解析/校验输入,调用服务,并写出响应。
- 依赖注入
- 把处理器所需的服务传入它的构造函数,而不是在处理器内部构建数据库连接。
- 适用处: 当依赖可以表达为一个窄接口时有效。
- 接口
- 一组具名方法,处理器调用它们而不知道背后的具体实现。
- 适用处: 只有接口的形状保持稳定,解耦才成立。
- 模拟
- 一个替身服务,这里是 MockUserService,记录调用并返回罐装结果,使处理器测试保持快速。
- 适用处: 只检查处理器自己的职责,不检查真实的持久化。
- httptest
- 标准库的辅助函数:NewRequest 构建一个假请求,NewRecorder 窥探处理器写出了什么。
- 适用处: 在内存中记录响应;不涉及真实的网络。
另外 0 个概念
问答划线与批注AI 陪读
“如果你的测试给你带来痛苦,就倾听这个信号,并思考你代码的设计。”
“If your tests are causing you pain, listen to that signal and think about the design of your code.”
查看原文 →整章是这一句话的展开。把测试的痛苦当作诊断而不是苦差——这种重新框定才是真正的教训;Mongo 的重构只是演示。
“ctx, _ := context.WithTimeout(context.Background(), 10*time.Second)”
查看原文 →注意被丢弃的 cancel 函数——示例里静静躺着一个 context 泄漏。作者从未提到它,因为他的视角是结构性的,而不是逐行的。换一位评审者会先标出这个。
“界线可能模糊,取决于你想得多抽象,而且有时你的第一个猜测可能并不对。”
“The lines can blur depending on how abstractly you're thinking and sometimes your first guess might not be right.”
查看原文 →在讲原则的一章里难得的诚实。他承认单一职责原则没有客观的单位——随即断言他知道处理器的那个单位。这份自信是经验性的,不是推导出来的。
另外 5 条批注
“它只是一个函数,而我们知道怎么测试函数。”
“It's just a function, and we know how to test functions.”
查看原文 →祛魅的一击。没有 DI 容器,没有测试客户端,没有框架外壳——Go 的处理器签名刻意很无聊,而这份无聊就是特性。
“我必须强调,对所有错误都返回 500 会造就一个糟糕的 API!”
“I must stress that returning 500 for all errors makes for a terrible API!”
查看原文 →他点名了自己的捷径,而不是藏起来,这使示例保持诚实。但错误分类恰恰是处理器/服务边界在实践中变得混乱的地方——难的部分被推迟成了一个 todo。
“server := NewUserServer(nil)”
查看原文 →把 nil 作为服务传进去,是测试在悄悄证明一个主张:坏的 JSON 永远到不了依赖。优雅,而且若设计退化,它会响亮地 panic。
“那数据库代码呢?你在作弊!”
“What about the database code? You're cheating!”
查看原文 →他在读者之前说出了这个反对——然后靠挪动问题而不是解决问题来回答。公平,但真正的复杂性(连接池、事务、重复键语义)现在住在一个尚未编写的 MongoUserService 里。
“我们会想给用户发一封注册确认邮件”
“we'd want to email the user a confirmation of registration”
查看原文 →可扩展性的论证是双刃的:处理器保持不动,但 UserService.Register 现在悄悄做两件事。上帝对象的压力是迁移到下一层,而不是消解。
读后感AI 陪读
一章简短、带论战意味的内容,它真正的主题根本不是 HTTP——而是这样一个论断:不可测试的代码就是设计糟糕的代码,处理器只是方便的犯罪现场。它的长处是对原函数外科手术式的解读:六项职责被平铺直叙地列出,比任何关于单一职责原则的论证都更有修辞力量。它的结构动作是接口加 DI,回报是真实的——结尾的那三个测试简单得近乎令人难堪,而 NewUserServer(nil) 是一小件手艺。但这一章在一定程度上靠挑选战场取胜。一切真正困难的东西——错误的分类、连接的生命周期、当注册长出第二第三项义务时会怎样——都被推到 UserService 的边界后面并标上 todo。作者知道这一点;“你在作弊!”是他自己的声音。所以诚实的读法是,复杂性被转移到能被隔离测试的地方,而不是被减少。这仍然是巨大的胜利,而本章最后坚持处理器“只是函数”,是对付把 Web 入口当作免于普通工程判断的特殊魔法类别的正确解药。同样值得注意的是,示例自己的 context 泄漏无人评说——提醒我们,设计的视角无论多锐利,也是一种过滤。
在 FeedLitra 里读问答,每段旁边都有这些卡片。
开始读书关于本书:TDD 反模式
一旦规则从处理器里挪出去,你会如何测试 UserService 本身?
关于本书是怎样展开的AI 辅助
开篇:把 TDD 的困难框定为设计问题。 本章从一个简短的前提开始,而不是定义——从业者应当定期审计自己的习惯——并立刻做出一个支配其后一切的区分:TDD 本身在概念上很简单,人们归咎于它的痛苦,其实是设计的痛苦。从这里起,作者转到基线情形,也就是根本不做 TDD,承认没有它也可以写出好软件,同时论证红-绿-重构循环提供了一种正式的分解问题的办法,是临时起意的工作所缺乏的。接下来几项贴近机制和对循环的误读:吹毛求疵地相信重构需要先有它自己的新测试(真正的规则是绿灯时你可以改变除行为以外的任何东西),然后是一簇症状,全都可追溯到跳过或匆忙对待红灯这一步——永远无法失败的测试,失败信息除了“false 不是 true”什么也不说的断言,以及在测试只关心单个字段时却比较整个复杂对象的断言。每一个都以同样的方式诊断:你没有足够专心地写一个测试并看着它失败。
展开:从机制到中心论点,“倾听你的测试”。 文章随后从技术拓宽到解读。在警告不要把许多断言堆进一个场景之后(以子测试作为 Go 特有的补救,并承认缓慢的验收测试迫使人做取舍),它转向 Dave Farley 的一句引文——TDD 给出关于设计的最快反馈——并陈述本章其余部分所阐发的论点:测试的困难就是使用的困难,所以把测试当作你代码的第一个用户。随后的一切都成了这一原则的应用。繁重的搭建和成群的测试替身,被读作关于依赖数量和耦合的报告,而不只是不便。大接口被视为泄漏的抽象,而关于大接口产生弱抽象的 Go 谚语,支持了反对接口污染的论证:导出一个带方法的结构体,让客户端声明他们真正需要的窄接口,把导出的接口留给小而连贯的集合,或确实解耦的实现。一段关于测试替身分类的简短旁白,主张桩胜过 mock,并劝阻对实现细节的监视。
转折:一个论证设计而不是测试的实例。 本章的合页是注册处理器的例子,抽象的建议在代码里兑现。一个接收 UserStore 和 Emailer 的处理器,在两个依赖时看起来站得住,于是作者通过列出职责来重读它,并标出哪些属于 HTTP、哪些不属于——解析请求和返回状态码算,而存在性检查、存储和邮件撰写被标为可疑。随后通过假设的需求(翻译、短信)以及一组反问来施压:一条关于发邮件的业务规则该住在处理器里吗?验证它该需要构造请求和读取响应吗?解决办法是单一的 UserService 依赖,这使处理器的测试变得微不足道,并把注册规则与传输隔离开。这是本章的演示:写测试时感到的不适,是关于结构的信息。
收束:封装、表格,以及一个回到设计的总结。 两个较短的反模式以同样的语域随后出现。仅仅为了测试而导出一个函数,被视为设计上的倒退,会误导维护者,并以人们调试只有测试才调用的代码而告终;带 Go 味的对策,是从消费者的立场测试,使用外部的 _test 包作为防止与内部耦合的编译期屏障。复杂的表驱动测试得到一个刻意荒唐的结构体——坐标与闰年和满月并列——来展示把无关的场景硬塞进一张表会怎样,建议是开一个新测试,而不是再追加一个布尔值,格言是简单不等于容易。总结随后闭合开头几行打开的回路:几乎所有单元测试的问题,都可归结为没有遵循流程或设计糟糕,TDD 的目的是对该设计的反馈,而反复的指示——倾听你的测试,因为它们在把你的设计反射回给你——被重述为本章最后的要点,并承诺对测试质量保持诚实会让你成为更好的开发者。
关键论点,以及支撑它们的原文
TDD 本身很容易描述;它让人觉得难的,是它所暴露的设计工作。
作者把循环的结构指为回报:红灯迫使你陈述想要什么,绿灯让它能工作,重构是你把它做对的地方。没有这种正式拆解的团队最终是临时起意地工作,他论证这使工程比本来必要的更难,而不是更容易。
“别误以为是 TDD 难,难的是设计!”
“Don't mistake this for TDD being hard, it's design that's hard!”
TDD Anti-patterns查看原文 →
严格遵循红灯这一步并真的去读失败信息,能防止大多数糟糕的单元测试。
提供两个症状作为跳过它的代价的证据:一个只说 false 不同于 true 的失败,这对调试者而言没有说出任何关于什么坏了的信息;以及在测试只关心一个字段时断言整个复杂对象,这把测试焊在它从不需要的数据上。如果你坚持先看到测试以有信息量的方式失败,两者都会消失。
“写一个测试,看它失败(并且别为错误信息感到羞耻)”
“Write a test, see it fail (and don't be ashamed of the error message)”
TDD Anti-patterns › Useless assertions查看原文 →
你的测试是你代码的第一个消费者,所以测试的困难就是使用困难的证据。
作者唤起这样的经历:打开一个测试,在任何有趣的事发生之前有二十行、五十行或两百行的搭建,并把它读作一个朴素的等式:复杂的测试意味着复杂的代码。补救是问为什么代码必须那么复杂,而不是容忍那份搭建。
“简单地说,如果测试你的代码很难,那么使用你的代码也很难。把你的测试当作你代码的第一个用户,你就会看出你的代码用起来是否愉快。”
“Simply put, if testing your code is difficult, then using your code is difficult too. Treat your tests as the first user of your code and then you'll see if your code is pleasant to work with or not.”
TDD Anti-patterns › Not listening to your tests查看原文 →
另外 3 个论点
把分散的依赖拉到一个有意义的服务后面,使业务规则远离传输代码,并使两者都更容易测试。
承接前一点,他走读一个注册的 HTTP 处理器,它只有两个依赖时看着没问题,然后列出它真正的职责,发现它拥有关于重复检查、存储和确认邮件的规则。加上邮件翻译或短信,就意味着要编辑一个 Web 处理器,而验证这条规则需要制造 HTTP 请求;把两个依赖都换成单个 Register 调用,缩小了处理器的测试,并隔离了规则。这被框定为重新评估一个设计,而不是“两个依赖永远太多”的规则。
“关于注册的规则的改动与 HTTP 隔离开了,所以它们也更容易测试 ✅”
“Changes to the rules around registration are isolated away from HTTP, so they are also simpler to test”
TDD Anti-patterns › Consolidate dependencies查看原文 →
一个测试里塞进很多断言,通常是设计问题,而不是阅读问题。
他观察到,额外的断言恰恰在搭建很糟糕、没人愿意为第二个测试重现它时堆积起来,所以修复办法是修好让新断言难写的那个东西;每个测试一条断言,需要更多时用子测试,使行为与实现细节分开。所述的例外是搭建或运行时间昂贵的测试,比如由浏览器驱动的验收测试,在那里更慢的调试也许是更好的取舍。
“简单地说,如果测试你的代码很难,那么使用你的代码也很难。把你的测试当作你代码的第一个用户,你就会看出你的代码用起来是否愉快。”
“Simply put, if testing your code is difficult, then using your code is difficult too. Treat your tests as the first user of your code and then you'll see if your code is pleasant to work with or not.”
TDD Anti-patterns › Not listening to your tests查看原文 →
默认按局外人使用你的包的方式来测试它,而不是为了测试拓宽它的表面。
仅仅为了从测试里够到一个函数而导出它,被描述为让设计更糟并误导维护者,并有一个具体的结局:有人调试时最终发现被测函数除了测试之外哪里也没被调用。把测试放在单独的测试包里,把这份克制变成编译期的限制,因为只有导出的成员才够得到。
“简单地说,如果测试你的代码很难,那么使用你的代码也很难。把你的测试当作你代码的第一个用户,你就会看出你的代码用起来是否愉快。”
“Simply put, if testing your code is difficult, then using your code is difficult too. Treat your tests as the first user of your code and then you'll see if your code is pleasant to work with or not.”
TDD Anti-patterns › Not listening to your tests查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么失败这一步要紧
看着一个全新的测试失败,证明它至少能失败,并迫使你去读它产生的信息。跳过那一步,你就得到在代码之后写的、不再守护任何东西的测试,以及只报告一个布尔值与另一个布尔值不同的失败。
机制 2
测试替身作为设计的量规
测试所需的每一个替身都代表一个真实的依赖,每一次脚本化的交互都代表代码做出的一次调用。所以一个需要很多替身和很长搭建的测试,是在度量生产代码有多纠缠,这就是为什么痛苦的测试预示着痛苦的使用。
机制 3
大接口把成本推给使用者
导出一个宽接口,任何针对它测试的人都必须实现每一个方法,包括他们从不调用的,常常用 panic 给它们打桩。改为导出一个普通结构体,则让每个调用者声明它真正需要的窄接口,这就是标准库把接口保持得极小的原因。
常见误读
重构时,在你为它写好测试之前,你不得引入新的抽象,比如提取一个结构体。
测试是绿的时候,你可以随意重构代码;唯一的限制是你不能添加或改变行为,而新行为才需要先有一个新的失败测试。自由地重新安排,正是这些测试存在的全部原因。
“当测试是绿色的时候,你可以对代码做任何你喜欢的事,唯一不被允许的,是添加或改变行为。”
“You can do whatever you like to the code when the tests are green, the only thing you're not allowed to do is add or change behaviour.”
TDD Anti-patterns › Misunderstanding the constraints of the refactoring step查看原文 →
既然表驱动测试是惯用法,最整齐的做法就是不断扩大一张表,每当出现新场景就加一个字段或布尔值。
表格只有在搭建相同、输入变化时才配得上它的位置;一旦无关的条件被当作额外的标志拴上去,表格就变得难读难调试。写一个单独的测试是更好的做法,因为加一个字段很容易,而让套件保持简单是另一回事。
“不要害怕跳出你的表,写新的测试,而不是往表结构体里添加新字段和布尔值。”
“Don't be afraid to break out of your table and write new tests rather than adding new fields and booleans to the table struct.”
TDD Anti-patterns › Complicated table tests查看原文 →
关于本书的核心概念
- 重构
- 有绿色的测试时,你可以自由地重构代码;唯一的禁令是添加或改变行为。
- 适用处: 只在测试通过时适用;新行为仍需要先写一个新的失败测试。
- 接口污染
- 导出一个庞大的接口,迫使客户端去给他们从不使用的方法打桩;暴露一个结构体,让他们声明自己需要的小接口。
- 适用处: 只有接口既小又连贯,或实现确实必须解耦时,才导出接口。
- 常青测试
- 无法以它声称要防范的方式失败的测试,通常是在代码之后写的,或是为了追逐覆盖率。
- 适用处: 如果你总是先看着新测试失败,这几乎不可能发生。
- 测试替身
- 依赖的替身;很多替身或繁重的 mock 搭建,暗示设计里的依赖和交互太多。
- 适用处: mock 和间谍偶尔有用,但会把测试与实现细节耦合;优先用桩和行为。
- 表驱动测试
- 一份共享的搭建,配以变化的输入;当无关的场景被作为额外的布尔字段硬塞进来时,它们就变差。
- 适用处: 只有搭建相同、仅输入变化时才好;否则写单独的测试。
- 封装
- 保持一个小的导出表面;从单独的测试包测试,防止测试与内部耦合。
- 适用处: 仅为测试而把东西公开,会让设计更糟,并可能留下只被测试调用的函数。
另外 0 个概念
关于本书划线与批注AI 陪读
“别误以为是 TDD 难,难的是设计!”
“Don't mistake this for TDD being hard, it's design that's hard!”
查看原文 →方便的框定:TDD 的任何失败都被重新分派给开发者的设计技能,这使该方法不可证伪。大体上仍然是真的,但请注意这一招。
“当测试是绿的时候,你可以对代码做任何你喜欢的事,唯一不被允许的是添加或改变行为”
“You can do whatever you like to the code when the tests are green, the only thing you're not allowed to do is add or change behaviour”
查看原文 →本章最干净的一句。整条重构规则塌缩成一行,而轶事里那位吹毛求疵者靠定义而不是论证被驳倒。
“没有任何场景能让这个测试失败”
“there are no scenarios where this test can fail”
查看原文 →常青测试比没有测试更糟——它们花维护成本,还贩卖虚假的信心。覆盖率指标积极地奖励制造它们。
另外 9 条批注
“false 不等于 true”
“false was not equal to true”
查看原文 →值得注意,真正的诊断不在断言库,而在于从没人看过这个测试失败。这条信息是被跳过的红灯步骤的化石。
“把你的测试当作你代码的第一个用户”
“Treat your tests as the first user of your code”
查看原文 →这里最强的想法。但它有作者没有承认的局限:测试是一个异常具有侵入性的用户,有时测试的痛苦反映的是测试工具的薄弱,而不是设计的腐烂。
“当你的测试里有很多测试替身时,这意味着你所测试的代码有很多依赖”
“When you have lots of test doubles in your tests, that means the code you're testing has lots of dependencies”
查看原文 →没错,但 mock 的数量也反映你如何测试。过度 mock 往往是伪装成设计裁决的测试风格选择。
“接口越大,抽象越弱”
“the bigger the interface, the weaker the abstraction”
查看原文 →这是 Go 特有的,但更深的一点是归属权:在 Go 里由消费者声明接口,所以作者的工作是不要替他们预先决定。
“我们应该发一封邮件”这条重要的规则,居然住在一个 HTTP 处理器里,你觉得对吗?”
“Does it feel right that the important rule of "we should send an email" resides within a HTTP handler?”
查看原文 →重构为 UserService 并没有消除复杂性,而是把它挪了位置——现在 UserService 拥有全部五项职责。收益是真实的,但那是可测试性和隔离,而不是更少的工作。
“被测试的函数只被测试调用”
“the function being tested is only called from tests”
查看原文 →阴森地好笑,而且极其眼熟。这类封装泄漏是永久的;迫使它出现的测试通常早被遗忘。
“AtWarWithEurasia bool”
查看原文 →奥威尔的笑话之所以说中,因为每一个臃肿的表驱动测试最终都会长出这样一个字段——一个布尔值,偷运进一整个第二场景。
“简单不等于容易”
“Simple is not easy”
查看原文 →借自 Rich Hickey,在这里未注明出处却承担着大量承重的活。仍是对付“再加一个字段”本能的最佳一句话辩护。
“倾听你的测试,它们在把你的设计反射回给你”
“listen to your tests, they are reflecting your design back at you”
查看原文 →一章里大概重复了五次。重复表明作者知道这是唯一可迁移的教训——反模式清单不过是它的证据。
读后感AI 陪读
一份从业者的清单,不是理论,而且对此很坦率。真正的脊梁不是反模式的目录,而是反复念到入脑的一个主张:测试的痛苦是设计的反馈,所以别再麻痹它。几乎每一项都是它的推论——无用的断言和常青测试来自跳过红灯;mock 的蔓延、泄漏的接口和注册处理器的例子来自依赖的纠缠;被破坏的封装和庞大的表驱动测试来自把测试当作一个要敷衍的事后想法,而不是一个要服务的设计客户。本章的软肋正在它最自信的地方:把一切失败都归因于“开发者没有遵循流程”或“设计糟糕”,它让 TDD 本身免于批评,从不承认一个测试可能因为测试工具糟糕、或因为所跨越的边界确实昂贵(数据库、浏览器、时间)而痛苦。处理器的重构是最清楚的例子——复杂性被转移到 UserService 后面,这买来隔离和廉价的 HTTP 测试,但本章把它呈现得仿佛复杂性被消除了。最好的句子:重构的许可规则(想怎么做都行,只是不能改变任何可观察的东西),把测试当作第一个用户的重新框定,以及“简单不等于容易”。对 Go 而言最有用的具体收获:从外部的 _test 包做测试,使对内部的耦合变成编译错误;并且暴露结构体而不是接口,让消费者自己声明他们真正需要的窄抽象。
在 FeedLitra 里读关于本书,每段旁边都有这些卡片。
开始读书论证在哪里吃力
不是反驳,而是细心的读者会想追问的地方。
- 桩和间谍被谴责为未经验证的假设的载体,而应用各章却恰恰用一个桩存储和一个间谍来搭建 HTTP 服务器,以打破 GET/POST 的死锁。
“如果替身与真实依赖的行为之间有差异,或者随时间产生了差异(比如真实依赖变了,这是可以预料的),你可能有通过的测试,却有失败的软件。”
“If there is a discrepancy between the behaviour of the double and the real dependency, or if one happens over time (e.g. the real dependency changes, which has to be expected), you may have passing tests but failing software.”
Working without mocks, stubs and spies › The problem with stubs and mocks查看原文 → - 重构被定义为从不触及测试的变更,而本书所教的、由发现驱动的 TDD,却一再重塑设计的接缝,因而也重塑它的测试。
“如果你在“重构”某些代码时不得不同时修改测试,那你做的是别的事情。”
“You're doing something else if you are "refactoring" some code and having to change tests at the same time.”
Refactoring step, starting checklist › Refactoring vs other activities查看原文 → - 为了逃离红色而硬编码或用烂泥糊弄被认可,同时又警告粗糙的实现会变得不可维护,却没有给出烂泥何时必须去掉的规则。
“我知道只是硬编码结果感觉怪怪的,但用 TDD,我们想尽可能长时间地待在“红”之外。”
“I know it feels weird just to hard-code the result but with TDD we want to stay out of "red" for as long as possible.”
Roman Numerals › Refactor查看原文 → - 测试既是安全变更的使能者,又是要最小化的成本,验收测试既是北极星,又昂贵且与设计耦合,所以各自合适的数量没有解决。
“出于这个原因,只依赖验收测试是愚蠢的。它们不具备单元测试的许多品质,而拥有大量验收测试的系统,往往会在维护成本和糟糕的交付周期方面受苦。”
“For this reason, it is foolish to only rely on acceptance tests. They do not have many of the qualities unit tests have, and a system with a large number of acceptance tests will tend to suffer in terms of maintenance costs and poor lead time.”
Introduction to acceptance testing › Potential drawbacks vs unit tests查看原文 →
另外 4 处张力,以及 8 条证据上的局限
- 力主只测试公共 API,然而编译器错误和实现层面的选择——如指针接收者或 strings.Builder——一再驱动下一个测试,模糊了行为边界在哪里。
“宁可测试行为,而不是实现细节”
“Favour testing behaviour rather than implementation detail”
Why unit tests and how to make them work for you › Favour testing behaviour rather than implementation detail查看原文 → - 反射和 interface{} 被详细讲授,随后又被建议不要用,而通道、互斥锁和原子操作各自被提供为共享状态的解法,除了单个值对多字段之外,没有任何决策规则。
“作为一个接受 interface{} 的函数的使用者,你失去了类型安全。”
“As a user of a function that takes interface{} you lose type safety.”
Reflection › So why not use interface{} for everything and have really flexible functions?查看原文 → - 有纪律的最小化 TDD,让一个看似合理却错误的取消实现一路通过,直到增加了一条额外的断言,表明这个循环本身并不保证有一套完整的测试。
“靠着自律,它凸显了我们测试中的一个缺陷,这是件好事!”
“By being disciplined it highlighted a flaw in our tests and this is a good thing!”
Context › Write enough code to make it pass查看原文 → - 把规格说明与驱动分开、提取共享抽象会增加间接层,这与重构建议里“清晰来自去掉结构而不是添加结构”的说法相处得不安。
“把规格说明如何被执行解耦,使我们能在不同场景中复用它。”
“Decoupling how the specification is executed allows us to reuse it in different scenarios.”
Learn Go with Tests - Scaling Acceptance Tests (and light intro to gRPC) › Testing on steroids查看原文 →
证据上的局限
- 每一课都是在刻意琐碎的练习上演示的——Hello、Sum、Wallet、Dictionary、一个钟面、一个小小的扑克应用——所以关于大型系统或团队约束的说法,依靠论证而不是演示。
“选择不写测试的人,通常只能依赖手工测试。对任何不止是小项目的东西,这都会是巨大的时间黑洞,而且从长远看无法扩展。”
“Those who choose not to write tests will typically be reliant on manual testing. For anything other than a small project this will be a tremendous time-sink and does not scale in the long run.”
Why unit tests and how to make them work for you › When refactoring code you must not be changing behaviour查看原文 → - 性能数字来自作者的一台机器、一个20毫秒的睡眠桩而不是真实的 HTTP 调用,以及作者自己也承认通常可以忽略的一个模板基准测试。
“Go 中的字符串是不可变的,这意味着每次拼接,比如在我们的 Repeat 函数里,都涉及复制内存以容纳新字符串。”
“Strings in Go are immutable, meaning every concatenation, such as in our Repeat function, involves copying memory to accommodate the new string.”
Iteration › Benchmarking查看原文 → - 关于测试替身和糟糕设计导致糟糕测试的论证,依靠的是轶事——一个六个 API 的故事,一个数据库连接池的例证,读者的片段明确说明不是调查。
“如果替身与真实依赖的行为之间有差异,或者随时间产生了差异(比如真实依赖变了,这是可以预料的),你可能有通过的测试,却有失败的软件。”
“If there is a discrepancy between the behaviour of the double and the real dependency, or if one happens over time (e.g. the real dependency changes, which has to be expected), you may have passing tests but failing software.”
Working without mocks, stubs and spies › The problem with stubs and mocks查看原文 → - 关键的并发证据是非确定性的:并发映射写入的失败是间歇性的,而 synctest 的发现依赖于特定的运行时调度,可能无法推广。
“Go 的映射对并发写入不安全,所以运行时会抛出致命错误,以防内存损坏。”
“Go maps are not safe for concurrent writes, so the runtime throws a fatal error to prevent memory corruption.”
Concurrency › ... and we're back.查看原文 → - 数个所教的解法被承认只是粗糙的:被承认脆弱的削前缀式路由,WebSocket 测试里的一个计时 sleep,以及在不同目录运行二进制文件会悄悄创建两个数据库文件。
“这个测试迫使我们真正去看请求的 URL 并作出决定。”
“This test has forced us to actually look at the request's URL and make a decision.”
HTTP Server › Write enough code to make it pass查看原文 → - 保证是局部或碰巧的:可测试示例只有带 Output 注释时才会执行,精确的浮点比较只有因为所选的值是整数才安全,而未检查的错误只能靠外部的 errcheck 检查器发现。
“代码库之外的代码示例,比如 readme 文件里的,往往会随着时间过时、与实际代码不符,因为它们没有被检查。”
“Often code examples that can be found outside the codebase, such as a readme file, become out of date and incorrect compared to the actual code because they don't get checked.”
Integers › Testable Examples查看原文 → - 所有指导都绑定在 Go 特定的表面和工具链版本上——modules、io.Reader、context、errors.As、httptest、编辑器快捷键清单——留下向其他语言迁移未经检验。
“使用模块相当直接。选择 GOPATH 之外的任何目录作为项目根,用 go mod init 命令创建一个新模块。”
“Using Modules is pretty straightforward. Select any directory outside GOPATH as the root of your project, and create a new module with the go mod init command.”
Install Go, set up environment for productivity › Go Modules查看原文 → - 值得注意的结论是判断而非已演示的结果:网站不需要 SPA,HTML 不值得测试,测试文件夹是错误的取舍,以及 context 放第一位是因为 Google 这么做。
“许多网站并不需要是单页应用(SPA)。HTML 和 CSS 是交付内容的绝佳方式,而你可以用 Go 做一个交付 HTML 的网站。”
“Many websites do not need to be an SPA. HTML and CSS are fantastic ways of delivering content and you can use Go to make a website to deliver HTML.”
HTML Templates查看原文 →
全书概念导图
5 组概念:通俗的叫法、一句话解释,以及原书自己的用语。
1 · 先失败,廉价通过,再收拾整齐
- 写一个失败的测试,粗糙地让它通过,再改进它
- 写一个失败的测试,做最少的工作让它通过——甚至硬编码或用“烂泥”(slime)糊弄一个返回值——然后重构;从钢线或验收测试由外向内生长,让红色状态保持尽可能短。原书用语: TDD cycle
- 在测试持续通过的同时,重塑代码的内部
- 为清晰而重塑代码,行为不变,所以测试理想情况下不需要编辑;更大的、部分带主观性的结构变更是“大设计”,其本身必须以小而有测试支撑的步骤来推动。原书用语: Refactoring
- 一个测试遍历一份具名用例的列表
- 一个测试体,用子测试遍历一份具名的输入/期望用例切片,所以新场景几乎不花样板代码——但当不相关的用例被硬塞进来作为额外标志时就会变差。原书用语: Table driven tests
- 检验一条规则对许多输入都成立,而不是几个挑出来的
- 陈述一条领域真理——罗马数字的减数规则,单位圆——的测试,并让一个库向它扔大量生成的输入,由你自己的生成器把这些输入保持在领域之内。原书用语: Property-based tests
2 · 从外部测试,把依赖传进来
- 如果一个测试写起来很痛苦,设计就有问题
- 写测试的困难被读作关注点分离不佳或对调用者不顺手的证据,所以修复应属于生产设计,而不是仅供测试的模式。原书用语: Testability as a design signal
- 只命名调用者需要的方法,使各部分可以替换
- 一个具名的、只含调用者需要的方法的集合,被隐式满足,所以依赖可以被替换;保持它小,只有当真有别的东西要传进来时才发明一个。原书用语: Interface as a seam
- 把协作者从外面递进来,而不是自己构造它们
- 把协作者从外面传入,而不是在内部构造或去取全局变量,所以测试提供假实现,而生产提供真实的东西。原书用语: Dependency injection
- 接受标准的流类型,使任何来源都能接上
- 标准的单方法 IO 抽象;因为文件、os.Stdout、http.ResponseWriter、WebSocket 连接和 strings.NewReader 都满足它们,一套代码就能服务测试和每一种前端。原书用语: io.Writer and io.Reader
3 · 假实现与整个系统的测试
- 照剧本行事的替身,可以对真实的东西撒谎
- 对真实依赖的任何可控替身,从填满参数位置的哑对象,到记录调用、时长和次序的间谍,使原本不可见的行为得以被断言。原书用语: Test double (mock, stub, spy)
- 一个简单可用的替身,靠一个共享的测试保持诚实
- 依赖的一个真实的、有状态的、通常在内存里的实现,之所以被信任,是因为针对接口写的一套可复用测试,同时对它和真实的东西运行。原书用语: Fake verified by a contract
- 把整个东西启动起来,像真实用户那样驱动它
- 一种黑盒测试,像用户或业务那样从外部启动并驱动整个系统,只有当用户可见的功能端到端地工作时才通过。原书用语: Acceptance test
- 把规则与你戳系统的方式分开
- 把本质的领域复杂度拆成一份不含实现的规格说明,与偶然复杂度——网络、传输方式、API——分开,由一个把它适配到特定系统的驱动去处理。原书用语: Specification and driver
4 · 把正确性放进类型里
- 把失败作为可以检查和匹配的值返回
- 通过返回值来发出失败信号——用 errors.Is 比较的哨兵错误,用 errors.As 检查的携带数据的结构体,用 %w 包装以添加上下文而不丢掉原因。原书用语: Error as an inspectable value
- 塑造类型,使坏值根本造不出来
- 结构体,建立在现有类型之上、带着自己方法的具名类型,以及泛型类型参数,使领域之外的值无法被表达——与 interface{} 只在运行时检查正相反。原书用语: Types that exclude invalid values
- 依靠 Go 自带的包,而不是你自己的
- 所提供的各个包——ServeMux、httptest、JSON、带 go:embed 的 html/template、time、os/exec——被呈现为对应用及其测试都足够,无需框架。原书用语: Go standard library
5 · 并发和速度需要证据
- 在改动前后给代码计时,以证明它变快了
- 一个函数,其循环由框架计时,用来测量一次改动前后,使性能说法成为测量,而不是断言。原书用语: Benchmark
- 把工作分开运行,并通过通道传递结果
- 用
go启动的独立过程,通常是匿名函数,通过发送和接收值来通信;select 等待多个通道,并运行最先就绪的那个。原书用语: Goroutines and channels - 两个线程碰同一个值,就需要一把锁
- 对共享内存的并发冲突访问,用 -race 检测器发现,并通过串行化修复:多步不变式用互斥锁,单个值用原子操作,等待完成用 WaitGroup。原书用语: Data race and synchronisation
- 把取消信号向下传递,使工作可以被停止
- 一个沿调用链向下穿的值,向从它派生的一切发出取消信号并携带原因,time.After 则给出可测试的超时。原书用语: Context
值得停下来的段落AI 陪读
几处值得停下来的原文,每处附一句它为什么对全书要紧。
“把你的“领域”代码和外部世界(副作用)分开是好事”
“It is good to separate your "domain" code from the outside world \(side-effects”
查看原文 →放到全书的尺度来读,这句旁白就是此后每一章重新推导的架构命题:领域与副作用之间的接缝之所以存在,是因为可测试性,而不是因为优雅。
“如果你的测试给你带来痛苦,就倾听这个信号,并思考你代码的设计。”
“If your tests are causing you pain, listen to that signal and think about the design of your code.”
查看原文 →整本书为之论证的那一句话——测试的痛苦是设计信号——它回溯地把 DI、模拟、context 和 HTTP 各章解释成同一个想法。
“如果你在“重构”某些代码时不得不同时修改测试,那你做的是别的事情。”
“You're doing something else if you are "refactoring" some code and having to change tests at the same time.”
查看原文 →给书里用得最多的词一个可证伪的定义,把“重构”从一种心情变成测试套件能裁决的主张。
另外 9 条批注
“干脆把它弄坏!(不过先确保你已把改动提交到源码管理)”
“just break it! (make sure you have committed your changes to source control first though)”
查看原文 →整个方法的认知底线:在你亲眼看到测试因正确的原因失败之前,绿色毫无意义——这一步把 TDD 与事后补测试的作秀区分开来。
“靠着自律,它凸显了我们测试中的一个缺陷,这是件好事!”
“By being disciplined it highlighted a flaw in our tests and this is a good thing!”
查看原文 →点出了本书悄悄的逆转:测试是规格说明,而不是验证,这就是为什么这么多章节发现的是缺失的约束,而不是缺陷。
“人们对闪亮的新语言特性常犯的一个错误,是在没有具体用例时就开始使用它们。”
“A mistake people often make with shiny new language features is they start by using them without having a concrete use-case.”
查看原文 →全书最强的纪律主张——抽象只有从具体案例中提取才安全,这同样控诉了过早的泛型、过早的接口和过早的包。
“尽快让测试通过,过程中犯什么罪都行。”
“Make the test work quickly, committing whatever sins necessary in process.”
查看原文 →许可了应用部分所依靠的 TDD 的“犯罪”那一半,而这恰是各团队引用、却跳过不可商量的重构的那一半。
“我们需要做的是能够注入(这只是传入的一个花哨词)打印这个依赖。”
“What we need to do is to be able to inject \(which is just a fancy word for pass in\) the dependency of printing.”
查看原文 →把本书最令人畏惧的企业概念压成函数参数,而这种压平,正是让后面每一个可测试的设计在事后看来都显而易见的原因。
“所以在《Learn Go with Tests》这本书里,我们第一次不打算写测试。”
“So in the book "Learn Go with Tests" for the first time, we're not going to write a test.”
查看原文 →一本 TDD 的书标出 TDD 在哪里不再划算,是为其余四十章换来可信度的代价——这个方法被呈现为判断,而不是教条。
“乐观是编程的职业病。反馈是治疗。”
“Optimism is an occupational hazard of programming. Feedback is the treatment.”
查看原文 →对本书的循环究竟为何存在的最干净陈述:TDD 是对程序员性格缺陷的治疗,而不是栓在代码上的质量流程。
“这些假设通常除了手工检查外没有被验证,所以它们威胁你测试套件的用处”
“These assumptions are usually not validated beyond manual checking, so they threaten your test suite's usefulness”
查看原文 →事后重新框定了那几章模拟——危害从来不是测试替身,而是跨整套测试重复的未经验证的假设。
“别误以为是 TDD 难,难的是设计!”
“Don't mistake this for TDD being hard, it's design that's hard!”
查看原文 →值得留作本书自我封闭的一招:把每一次 TDD 的失败都重新归于设计技能,使这个方法即使在大体正确的地方也难以证伪。
读后感AI 陪读
假装在教 Go,实则对单一设计主张做压力测试
通读全书,这并不真是一本 Go 教程,到一半时几乎已不再假装是了。它真正的主题是一个主张,在“为什么要写单元测试,以及怎样让它们为你所用”一章提出,随后经受四十章的压力测试:软件必须变化,变化需要重构,重构需要测试,因此你测试的质量就是你设计的质量。其余一切——语法、goroutine、模板、泛型、WebSocket——都是借口,用来给这一主张施压,看它是否成立。主线分三个乐章。第一,入门各章(5–24),其中的琐碎问题是刻意选的,好让循环本身成为课程:Hello World 的存在,是为了让你观看失败,甚至破坏 t.Helper() 以看行号如何变差;整数让 return 4 作为正当的反对意见存在,而不是挥手打发;迭代把基准测试夹带进来,仿佛测量是寻常之事;指针和映射递给你的是一个矛盾(两个地址,一次对 nil 写入的 panic),而不是一条要背的规则。第二,设计各章(12–18、25–28、41),其中本书不再教 Go,而开始论证:依赖注入和模拟把测试的痛苦重新框定为设计反馈;Context 上演了一场两幕的背叛,一个通过测试、忠实遵循 TDD 的服务器,因为不合惯用法而被扔掉;“不用 mock、stub 和 spy 工作”一章控诉整个桩与间谍的传统散布未经验证的假设,并以有状态的假实现回应,其纪律来自对假实现和真实依赖都运行的契约;“扩展验收测试(以及 gRPC 简介)”一章把规格说明与传输方式分开得如此令人信服,以至于一个全新的协议落地而不触动一个测试。
第三,应用各章(29–40),其中方法被迫在一个真实的代码库里存活——一个被发现而不是被规划出来的记分牌服务器,作为一连串让步而不是设计而到来的持久化,一个真正主题是包边界的命令行,以及一个扑克游戏,其依赖膨胀被读作一个 Game 领域类型想要存在的信号。最尖锐的判断,是那些违背读者直觉的。不可测试的代码不是需要一个测试标志的代码;它是设计糟糕的代码(37、40)。一套通过的测试不是良好设计的证据(18、35,其中两次习惯性的 -race 运行,推翻了看似已完成的测试)。抽象属于重构步骤,从你已经写好的代码中发现,而不是预先设计(21、24)——并且当它挣不回自己的开销时,应毫不羞愧地撤销(16)。反射被透彻地讲授,然后作为警告被撤回,作者承认实现仍会在指针环上崩溃。基于性质的测试被表明能浮出示例遗漏的真理,随即又被纠正:在断言里加守卫会掩盖不匹配,你必须收窄生成(19)。模板应保持无逻辑,因为其中的逻辑只能靠比较渲染出的 HTML 来测试(22)。重构由一项机械的检验来定义——你的测试是否必须改变?——随后本书遵循自己的规则,先重构生产代码,再重构测试,绝不两者同时(28、34)。诚实就是教学法:验收测试得到一份坦率的缺点清单(25),作者承认他并不相信自己那个通过的库,“数学在今天依然有用!”的推销从未真正兑现,而“构建一个应用”一章虚构的产品负责人被承认只是一种方便。我想对读者说的是:不要略读那些小章节,也不要为了 Go 来读这本书。
继续阅读
玩具问题是循环变成肌肉记忆的地方,而后面的各章若只当教程来读就读不通——“不用 mock、stub 和 spy 工作”一章只有在你感受过模拟那一章的不安之后才会落地,“扩展验收测试(以及 gRPC 简介)”一章只有在你看过测试与实现耦合并腐烂之后才会落地。敲下代码,让编译器说话,让步子比体面所允许的更小,把每一个别扭的测试都当作关于你设计的信息,而不是要完成的杂务。本书最后的礼物,在“TDD 反模式”一章送出,是允许你不再麻痹这种痛苦:测试不是覆盖率的义务,它们是你所能得到的、关于你真正在构建的东西的最廉价的诚实反馈。
网络书评怎么说
摘录中的评论者称赞这本免费开源的书,通过测试交互式地教授 Go:快速的反馈,运行的示例而不是抽象的话语,覆盖从类型和指针到 context 和并发,外加 Web 和命令行应用。几位评论者把它框定为一本用 Go 写的 TDD 与软件设计书,推荐给新手。有一条抱怨:后面几章的代码片段摆放位置不清楚。
综合已发表书评的看法;不展示评分,完整书评请点来源链接。
来源与授权
在 FeedLitra 里一边读《通过测试学习 Go》,一边看这些卡片。
开始读书