news 2026/9/20 3:36:30

在 learn-go-with-tests 中用 TDD 实现 Context-aware Reader:让 `io.Reader` 支持 `context` 取消

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
在 learn-go-with-tests 中用 TDD 实现 Context-aware Reader:让 `io.Reader` 支持 `context` 取消

在 learn-go-with-tests 中用 TDD 实现 Context-aware Reader:让io.Reader支持context取消

【免费下载链接】learn-go-with-testsLearn Go with test-driven development项目地址: https://gitcode.com/gh_mirrors/le/learn-go-with-tests

io.Reader是 Go 标准库中最常用的小接口之一,但它本身不提供任何取消机制——一旦开始读取,调用方就无法优雅地中断。本篇文章以 learn-go-with-tests 仓库中 q-and-a/context-aware-reader 一章为骨架,演示如何通过 TDD 将context.Contextio.Reader两个简单抽象组合起来,写一个"感知上下文"的读取器:一旦上下文被取消,后续读取立即返回错误与 0 字节。读完本文,你将掌握用小接口组合解决问题的思路、委托模式的落地手法,以及"先测委托行为、再叠加新能力"的测试驱动节奏。

io.Reader说起:标准库最常用的抽象之一

如果你读过本书前面的章节(如 JSON 编码、文件读取),一定已经反复接触过io.Reader。它是从"某个东西"读取数据的简单抽象:

type Reader interface { Read(p []byte) (n int, err error) }

Read方法把读取到的内容填进调用方提供的[]byte,返回实际读取的字节数n与可能的错误err。利用io.Reader可以复用海量的标准库代码——json.NewDecoderio.ReadAllio.Copy等都接受io.Reader。它和它的孪生兄弟io.Writer一起,构成了 Go 生态中最常见的抽象之一。

也正因为接口极小、语义明确,io.Reader非常适合被"包装"(wrap)来增强行为——这正是本篇文章要做的:给它加上"可取消"的能力。

为什么需要 Context-aware:读取速度不可控,取消很重要

在本书的 context.md 一章中,我们讨论过如何用context提供取消能力:软件经常会启动长时间运行、资源密集的进程(通常在 goroutine 中),如果触发该进程的操作被取消或失败,就需要在整条调用链上以一致的方式停止这些进程;否则一个本该流畅的 Go 程序可能逐渐出现难以排查的性能问题。书中用一个 Web 服务器场景演示了:请求被用户取消后,select监听ctx.Done()通道并调用store.Cancel()通知下游停止工作。

这个思想放到io.Reader上同样适用。使用io.Reader时,你对它的读取速度没有任何保证——它可能耗时 1 纳秒,也可能耗时数百小时。当你执行一些计算密集、或从慢速来源(网络、外部存储)读取数据的任务时,你会希望能主动取消这类任务,这正是本篇文章要解决的问题。

Mat Ryer 与 David Hernandez 在 The Pace Dev Blog 上提出了一个思路:把两个简单的抽象——context.Contextio.Reader——组合起来。我们通过 TDD 来复现这个方案,实现一个可以被取消的io.Reader包装器。

需求定义:可取消的读取器应该怎样表现

直接测试"包装后的读取器"有一个有意思的挑战:平时我们用io.Reader时,往往把它交给别的函数(如json.NewDecoderio.ReadAll),并不关心内部细节。而这里我们想验证的行为是:

给定一个内容为 "ABCDEF" 的io.Reader,当我在读取到一半时发送取消信号,之后再尝试继续读取将拿不到任何数据——所以最终只得到 "ABC"。

再回看接口:

type Reader interface { Read(p []byte) (n int, err error) }

ReaderRead方法把内容读到我们提供的[]byte里。因此,与其一次性读取全部内容,我们可以:

  • 提供一个装不下全部内容的定长字节数组;
  • 发送取消信号;
  • 再次调用Read,此时应当返回一个错误,且读取字节数为 0。

这个流程既验证了"普通读取行为不被破坏",又验证了"取消后立即停止"。

TDD 第一步:先验证普通 reader 行为(happy path)

为了先熟悉问题、在没有任何生产代码的情况下打好测试基础,先写一个"无取消"的 happy path 测试,看看普通 reader 是如何工作的:

func TestContextAwareReader(t *testing.T) { t.Run("lets just see how a normal reader works", func(t *testing.T) { rdr := strings.NewReader("123456") got := make([]byte, 3) _, err := rdr.Read(got) if err != nil { t.Fatal(err) } assertBufferHas(t, got, "123") _, err = rdr.Read(got) if err != nil { t.Fatal(err) } assertBufferHas(t, got, "456") }) } func assertBufferHas(t testing.TB, buf []byte, want string) { t.Helper() got := string(buf) if got != want { t.Errorf("got %q, want %q", got, want) } }

这里的要点是:

  • 从一个字符串构造io.Readerstrings.NewReader);
  • 准备一个比 reader 内容更小的字节数组(3 字节);
  • 调用一次Read,检查内容;再调用一次,再检查。

从这段"分块读取"的演示中,我们就能想象:如果在第二次Read之前发送某种取消信号,行为会发生怎样的改变。这就是后续所有测试的原型。

从理想 API 出发:编写新功能的第一个测试

TDD 的最佳起点是先想象你想要的 API,然后为它写测试,让编译器和失败的测试输出引导我们走向解决方案。我们想要的能力是:把io.Readercontext.Context组合起来。

t.Run("behaves like a normal reader", func(t *testing.T) { rdr := NewCancellableReader(strings.NewReader("123456")) got := make([]byte, 3) _, err := rdr.Read(got) if err != nil { t.Fatal(err) } assertBufferHas(t, got, "123") _, err = rdr.Read(got) if err != nil { t.Fatal(err) } assertBufferHas(t, got, "456") })

尝试运行测试,编译器给出第一个提示:

./cancel_readers_test.go:12:10: undefined: NewCancellableReader

最小实现:从nil到直接委托

我们定义一个返回io.Reader的函数,先用最蠢的实现让测试能跑起来:

func NewCancellableReader(rdr io.Reader) io.Reader { return nil }

运行测试,得到预期的 panic:

=== RUN TestCancelReaders === RUN TestCancelReaders/behaves_like_a_normal_reader panic: runtime error: invalid memory address or nil pointer dereference [recovered] panic: runtime error: invalid memory address or nil pointer dereference [signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x10f8fb5]

接下来写"刚好能让测试通过"的代码——直接把传入的 reader 原样返回:

func NewCancellableReader(rdr io.Reader) io.Reader { return rdr }

测试通过了。这看起来既愚蠢又教条,但它的价值在于:在动手做那些"花哨功能"之前,先有某种验证证明我们没有破坏io.Reader的正常行为。这个测试会在后续每一步演进中持续给我们信心。

加入取消能力:第二个测试

接下来测试"取消"行为。几乎可以复制第一个测试,但有几点不同:

  • 创建一个带取消能力的context.Context,以便在第一次读取后调用cancel
  • 为了让我们的代码工作,需要把ctx传给NewCancellableReader
  • 取消后,断言没有读到任何字节
t.Run("stops reading when cancelled", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) rdr := NewCancellableReader(ctx, strings.NewReader("123456")) got := make([]byte, 3) _, err := rdr.Read(got) if err != nil { t.Fatal(err) } assertBufferHas(t, got, "123") cancel() n, err := rdr.Read(got) if err == nil { t.Error("expected an error after cancellation but didn't get one") } if n > 0 { t.Errorf("expected 0 bytes to be read after cancellation but %d were read", n) } })

运行测试,编译器再次给出指引——参数不匹配:

./cancel_readers_test.go:33:30: too many arguments in call to NewCancellableReader have (context.Context, *strings.Reader) want (io.Reader)

更新函数签名以接收context(同时记得把第一个测试也改成传入context.Background()):

func NewCancellableReader(ctx context.Context, rdr io.Reader) io.Reader { return rdr }

现在能看到非常清晰的失败输出:

=== RUN TestCancelReaders === RUN TestCancelReaders/stops_reading_when_cancelled --- FAIL: TestCancelReaders (0.00s) --- FAIL: TestCancelReaders/stops_reading_when_cancelled (0.00s) cancel_readers_test.go:48: expected an error but didn't get one cancel_readers_test.go:52: expected 0 bytes to be read after cancellation but 3 were read

实现委托模式:readerCtxctx.Err()检查

到了这一步,可以参考 Mat 和 David 原始文章的做法,但仍要慢慢来、迭代式前进。我们知道自己需要一个类型来同时封装"要读取的io.Reader"和"context.Context",于是创建它并从函数中返回,替代原来的原始io.Reader

func NewCancellableReader(ctx context.Context, rdr io.Reader) io.Reader { return &readerCtx{ ctx: ctx, delegate: rdr, } } type readerCtx struct { ctx context.Context delegate io.Reader }

就像本书反复强调的那样:走慢一点,让编译器帮你。运行测试,编译错误出现:

./cancel_readers_test.go:60:3: cannot use &readerCtx literal (type *readerCtx) as type io.Reader in return argument: *readerCtx does not implement io.Reader (missing Read method)

抽象方向是对的,但它还没有实现我们需要的接口(io.Reader),所以要补上Read方法:

func (r *readerCtx) Read(p []byte) (n int, err error) { panic("implement me") }

此时测试可以编译但会 panic——这仍然是进展。接着让第一个测试通过:只需把调用委托给底层io.Reader

func (r readerCtx) Read(p []byte) (n int, err error) { return r.delegate.Read(p) }

happy path 测试重新通过,我们也完成了漂亮的抽象封装。要让第二个测试通过,只需在每次Read时检查context.Context是否已被取消:

func (r readerCtx) Read(p []byte) (n int, err error) { if err := r.ctx.Err(); err != nil { return 0, err } return r.delegate.Read(p) }

所有测试都应通过。注意这里返回的是来自context.Context的错误(如context.Canceledcontext.DeadlineExceeded)。这样做的好处是:调用方可以通过检查返回的错误来区分取消发生的各种原因(手动取消、超时等),而不是面对一个笼统的"读取失败"。这一语义在原文章中也有更深入的讨论。

当前仓库中的最终实现与测试佐证

上述演进过程的最终成果,就是仓库 q-and-a/context-aware-reader 目录中的完整代码。生产实现位于 context_aware_reader.go:

package cancelreader import ( "context" "io" ) // NewCancellableReader will stop reading to rdr if ctx is cancelled. func NewCancellableReader(ctx context.Context, rdr io.Reader) io.Reader { return &readerCtx{ ctx: ctx, delegate: rdr, } } type readerCtx struct { ctx context.Context delegate io.Reader } func (r *readerCtx) Read(p []byte) (n int, err error) { if err := r.ctx.Err(); err != nil { return 0, err } return r.delegate.Read(p) }

从源码结构可以确认几个关键细节:

  • NewCancellableReader只接受两个参数:ctxrdr,返回io.Reader——调用方可以无感地把这个包装器继续传给io.ReadAlljson.NewDecoder等任何接受io.Reader的标准库函数;
  • readerCtx结构体只包含两个字段:ctxdelegate,职责单一,就是一个"带上下文的委托";
  • Read方法(context_aware_reader.go#L21-L25)在每次读取前都先调用r.ctx.Err()做检查:已取消则立即返回(0, err),未取消则原样委托给底层 reader。

值得注意的一个实现差异:仓库最终源码中Read使用的是指针接收者func (r *readerCtx) Read,而文档正文演进过程中展示的是值接收者func (r readerCtx) Read。两者对于这个结构体而言都能工作,但指针接收者更符合 Go 的惯例(避免结构体被拷贝、便于统一方法集),这也是从文档到最终落地代码之间值得观察的细微差异。

对应的测试位于 context_aware_reader_test.go,结构与本文的 TDD 演进完全对应:

  • TestContextAwareReader/behaves_like_a_normal_reader(第 10-28 行):用context.Background()(一个永远不会取消的上下文)验证普通分块读取行为不受影响;
  • TestContextAwareReader/stops_reading_when_cancelled(第 30-53 行):用context.WithCancel构造可取消上下文,第一次Read读到 "123",cancel()之后再次Read断言"必须返回错误且字节数为 0"。

这个目录是一个独立的 Go 包(包名cancelreader),仓库根目录的 go.mod 声明了模块github.com/quii/learn-go-with-tests。你可以进入该目录直接运行go test ./...go test -v ./...观察两个子测试的执行结果——无需联网,也无需额外依赖。

小结与启发

  • 小接口是好的,且易于组合context.Contextio.Reader各自极小、职责清晰,组合出"可取消的读取器"几乎不需要额外框架,只用一个小小的结构体就够了。
  • 当你想要给某个东西(如io.Reader)增强能力时,通常应该使用委托模式:包装器持有原对象的引用,把调用转发给它,并在转发前后插入自己的逻辑。用 Go 的说法就是"通过对象组合达到与继承相同的代码复用效果"。
  • 一个降低风险的工作方式:在叠加新行为之前,先写一个断言"包装器表现得和底层委托一模一样"的测试并让它通过。这样一来,随着你逐步加入组合逻辑,可以持续确保基础行为没有被破坏——这正是本篇文章整个 TDD 过程的主线,也是 learn-go-with-tests 全书反复强调的纪律。

【免费下载链接】learn-go-with-testsLearn Go with test-driven development项目地址: https://gitcode.com/gh_mirrors/le/learn-go-with-tests

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/20 3:36:05

财务智能体“财小问”案例拆解:架构、场景与数据安全

看到“中国土木构建‘财小问’智能体”这个案例,我第一反应不是“又一个财务ChatGPT”,而是想看看它到底有没有把财务人员的活真正接过去。做了几年企业级AI应用,我见过太多Demo惊艳、上线沉默的项目。财务领域尤其明显,因为财务对…

作者头像 李华
网站建设 2026/9/20 3:35:07

VS2022离线安装实战:从创建布局到内网批量部署

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/20 3:33:58

Mac 录屏怎么录到系统声音:QuickRecorder 免驱动内录,3 步出片

Mac 录屏怎么录到系统声音:QuickRecorder 免驱动内录,3 步出片 【免费下载链接】QuickRecorder A lightweight screen recorder based on ScreenCapture Kit for macOS / 基于 ScreenCapture Kit 的轻量化多功能 macOS 录屏工具 项目地址: https://git…

作者头像 李华
网站建设 2026/9/20 3:33:50

DirectX修复工具详解:从0xc000007b到游戏闪退的完整排查指南

用了这么多年Windows,我早就总结出一条规律:你永远不知道游戏会在哪一秒突然给你脸色看。前一秒还好好的,后一秒双击图标,先弹出一个"应用程序无法正常启动0xc000007b",再要不就是游戏loading到一半直接闪退…

作者头像 李华
网站建设 2026/9/20 3:30:12

AI论文降重工具深度实测:PaperZZ如何高效降低重复率

写论文遇到高重复率,我相信绝大多数学术党都有过这种体验:初稿写完,满怀信心丢进查重系统,结果屏幕上蹦出一个触目惊心的红字百分比,那一刻心态直接崩掉。尤其是社科类、经管类的论文,引用的经典理论、政策…

作者头像 李华
网站建设 2026/9/20 3:24:39

Windows下Claude Code安装配置全攻略:从环境搭建到故障排查

1. 为什么要在Windows上折腾Claude Code如果你是一名长期在Windows环境下写代码的开发者,最近大概率被Claude Code刷过屏。简单说,它是Anthropic推出的一个跑在终端里的AI编程助手,能直接读写你本地的项目文件、执行命令、跑测试、改bug&…

作者头像 李华