在 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.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.Reader(strings.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 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实现委托模式:readerCtx与ctx.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.Canceled或context.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只接受两个参数: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读到 "123",cancel()之后再次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),仅供参考