尧图精选

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

🕒 发布时间:2026/9/20 3:36:37 📁 来源:尧图网络
在 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-testsio.Reader是 Go 标准库中最常用的小接口之一但它本身不提供任何取消机制——一旦开始读取调用方就无法优雅地中断。本篇文章以 learn-go-with-tests 仓库中 q-and-a/context-aware-reader 一章为骨架演示如何通过 TDD 将context.Context与io.Reader两个简单抽象组合起来写一个感知上下文的读取器一旦上下文被取消后续读取立即返回错误与 0 字节。读完本文你将掌握用小接口组合解决问题的思路、委托模式的落地手法以及先测委托行为、再叠加新能力的测试驱动节奏。从io.Reader说起标准库最常用的抽象之一如果你读过本书前面的章节如 JSON 编码、文件读取一定已经反复接触过io.Reader。它是从某个东西读取数据的简单抽象type Reader interface { Read(p []byte) (n int, err error) }Read方法把读取到的内容填进调用方提供的[]byte返回实际读取的字节数n与可能的错误err。利用io.Reader可以复用海量的标准库代码——json.NewDecoder、io.ReadAll、io.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.Context与io.Reader——组合起来。我们通过 TDD 来复现这个方案实现一个可以被取消的io.Reader包装器。需求定义可取消的读取器应该怎样表现直接测试包装后的读取器有一个有意思的挑战平时我们用io.Reader时往往把它交给别的函数如json.NewDecoder、io.ReadAll并不关心内部细节。而这里我们想验证的行为是给定一个内容为 ABCDEF 的io.Reader当我在读取到一半时发送取消信号之后再尝试继续读取将拿不到任何数据——所以最终只得到 ABC。再回看接口type Reader interface { Read(p []byte) (n int, err error) }Reader的Read方法把内容读到我们提供的[]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.Reader与context.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 code0x1 addr0x0 pc0x10f8fb5]接下来写刚好能让测试通过的代码——直接把传入的 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 didnt 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 didnt get one cancel_readers_test.go:52: expected 0 bytes to be read after cancellation but 3 were read实现委托模式readerCtx与ctx.Err()检查到了这一步可以参考 Mat 和 David 原始文章的做法但仍要慢慢来、迭代式前进。我们知道自己需要一个类型来同时封装要读取的io.Reader和context.Context于是创建它并从函数中返回替代原来的原始io.Readerfunc 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.Readerfunc (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.Canceled或context.DeadlineExceeded。这样做的好处是调用方可以通过检查返回的错误来区分取消发生的各种原因手动取消、超时等而不是面对一个笼统的读取失败。这一语义在原文章中也有更深入的讨论。当前仓库中的最终实现与测试佐证上述演进过程的最终成果就是仓库 q-and-a/context-aware-reader 目录中的完整代码。生产实现位于 context_aware_reader.gopackage 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只接受两个参数ctx与rdr返回io.Reader——调用方可以无感地把这个包装器继续传给io.ReadAll、json.NewDecoder等任何接受io.Reader的标准库函数readerCtx结构体只包含两个字段ctx与delegate职责单一就是一个带上下文的委托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读到 123cancel()之后再次Read断言必须返回错误且字节数为 0。这个目录是一个独立的 Go 包包名cancelreader仓库根目录的 go.mod 声明了模块github.com/quii/learn-go-with-tests。你可以进入该目录直接运行go test ./...或go test -v ./...观察两个子测试的执行结果——无需联网也无需额外依赖。小结与启发小接口是好的且易于组合。context.Context与io.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),仅供参考
上一篇/下一篇内容由系统自动关联 返回资讯列表 →