Context-aware readers
You can find all the code here
This chapter demonstrates how to test-drive a context aware io.Reader
as written by Mat Ryer and David Hernandez in The Pace Dev Blog.
Context aware reader?
First of all, a quick primer on io.Reader
.
If you’ve read other chapters in this book you will have ran into io.Reader
when we’ve opened files, encoded JSON and various other common tasks. It’s a simple abstraction over reading data from something
type Reader interface {
Read(p []byte) (n int, err error)
}
By using io.Reader
you can gain a lot of re-use from the standard library, it’s a very commonly used abstraction (along with its counterpart io.Writer
)
Context aware?
In a previous chapter we discussed how we can use context
to provide cancellation. This is especially useful if you’re performing tasks which may be computationally expensive and you want to be able to stop them.
When you’re using an io.Reader
you have no guarantees over speed, it could take 1 nanosecond or hundreds of hours. You might find it useful to be able to cancel these kind of tasks in your own application and that’s what Mat and David wrote about.
They combined two simple abstractions (context.Context
and io.Reader
) to solve this problem.
Let’s try and TDD some functionality so that we can wrap an io.Reader
so it can be cancelled.
Testing this poses an interesting challenge. Normally when using an io.Reader
you’re usually supplying it to some other function and you don’t really concern yourself with the details; such as json.NewDecoder
or io.ReadAll
.
What we want to demonstrate is something like
Given an
io.Reader
with “ABCDEF”, when I send a cancel signal half-way through I when I try to continue to read I get nothing else so all I get is “ABC”
Let’s look at the interface again.
type Reader interface {
Read(p []byte) (n int, err error)
}
The Reader
’s Read
method will read the contents it has into a []byte
that we supply.
So rather than reading everything, we could:
- Supply a fixed-size byte array that doesnt fit all the contents
- Send a cancel signal
- Try and read again and this should return an error with 0 bytes read
For now, let’s just write a “happy path” test where there is no cancellation, just so we can get familiar with the problem without having to write any production code yet.
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)
}
}
- Make an
io.Reader
from a string with some data - A byte array to read into which is smaller than the contents of the reader
- Call read, check the contents, repeat.
From this we can imagine sending some kind of cancel signal before the second read to change behaviour.
Now we’ve seen how it works we’ll TDD the rest of the functionality.
Write the test first
We want to be able to compose an io.Reader
with a context.Context
.
With TDD it’s best to start with imagining your desired API and write a test for it.
From there let the compiler and failing test output can guide us to a solution
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")
})
Try to run the test
./cancel_readers_test.go:12:10: undefined: NewCancellableReader
Write the minimal amount of code for the test to run and check the failing test output
We’ll need to define this function and it should return an io.Reader
func NewCancellableReader(rdr io.Reader) io.Reader {
return nil
}
If you try and run it
=== 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]
As expected
Write enough code to make it pass
For now, we’ll just return the io.Reader
we pass in
func NewCancellableReader(rdr io.Reader) io.Reader {
return rdr
}
The test should now pass.
I know, I know, this seems silly and pedantic but before charging in to the fancy work it is important that we have some verification that we haven’t broken the “normal” behaviour of an io.Reader
and this test will give us confidence as we move forward.
Write the test first
Next we need to try and cancel.
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)
}
})
We can more or less copy the first test but now we’re:
- Creating a
context.Context
with cancellation so we cancancel
after the first read - For our code to work we’ll need to pass
ctx
to our function - We then assert that post-
cancel
nothing was read
Try to run the test
./cancel_readers_test.go:33:30: too many arguments in call to NewCancellableReader
have (context.Context, *strings.Reader)
want (io.Reader)
Write the minimal amount of code for the test to run and check the failing test output
The compiler is telling us what to do; update our signature to accept a context
func NewCancellableReader(ctx context.Context, rdr io.Reader) io.Reader {
return rdr
}
(You’ll need to update the first test to pass in context.Background
too)
You should now see a very clear failing test output
=== 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
Write enough code to make it pass
At this point, it’s copy and paste from the original post by Mat and David but we’ll still take it slowly and iteratively.
We know we need to have a type that encapsulates the io.Reader
that we read from and the context.Context
so let’s create that and try and return it from our function instead of the original 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
}
As I have stressed many times in this book, go slowly and let the compiler help you
./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)
The abstraction feels right, but it doesn’t implement the interface we need (io.Reader
) so let’s add the method.
func (r *readerCtx) Read(p []byte) (n int, err error) {
panic("implement me")
}
Run the tests and they should compile but panic. This is still progress.
Let’s make the first test pass by just delegating the call to our underlying io.Reader
func (r readerCtx) Read(p []byte) (n int, err error) {
return r.delegate.Read(p)
}
At this point we have our happy path test passing again and it feels like we have our stuff abstracted nicely
To make our second test pass we need to check the context.Context
to see if it has been cancelled.
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)
}
All tests should now pass. You’ll notice how we return the error from the 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.
Wrapping up
- Small interfaces are good and are easily composed
- When you’re trying to augment one thing (e.g
io.Reader
) with another you usually want to reach for the delegation pattern
In software engineering, the delegation pattern is an object-oriented design pattern that allows object composition to achieve the same code reuse as inheritance.
- 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. This will help you to keep things working correctly as you code toward your goal