Context-aware Reader
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
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.
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 doesn't 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.
Make an
io.Reader
from a string with some dataA 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
Try to run the test
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
If you try and run it
As expected
Write enough code to make it pass
For now, we'll just return the io.Reader
we pass in
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.
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 readFor our code to work we'll need to pass
ctx
to our functionWe then assert that post-
cancel
nothing was read
Try to run the test
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
(You'll need to update the first test to pass in context.Background
too)
You should now see a very clear failing test output
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
As I have stressed many times in this book, go slowly and let the compiler help you
The abstraction feels right, but it doesn't implement the interface we need (io.Reader
) so let's add the method.
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
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.
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
Last updated