IO and sorting
You can find all the code for this chapter here
In the previous chapter we continued iterating on our application by adding a new endpoint /league
. Along the way we learned about how to deal with JSON, embedding types and routing.
Our product owner is somewhat perturbed by the software losing the scores when the server was restarted. This is because our implementation of our store is in-memory. She is also not pleased that we didn't interpret the /league
endpoint should return the players ordered by the number of wins!
The code so far
You can find the corresponding tests in the link at the top of the chapter.
Store the data
There are dozens of databases we could use for this but we're going to go for a very simple approach. We're going to store the data for this application in a file as JSON.
This keeps the data very portable and is relatively simple to implement.
It won't scale especially well but given this is a prototype it'll be fine for now. If our circumstances change and it's no longer appropriate it'll be simple to swap it out for something different because of the PlayerStore
abstraction we have used.
We will keep the InMemoryPlayerStore
for now so that the integration tests keep passing as we develop our new store. Once we are confident our new implementation is sufficient to make the integration test pass we will swap it in and then delete InMemoryPlayerStore
.
Write the test first
By now you should be familiar with the interfaces around the standard library for reading data (io.Reader
), writing data (io.Writer
) and how we can use the standard library to test these functions without having to use real files.
For this work to be complete we'll need to implement PlayerStore
so we'll write tests for our store calling the methods we need to implement. We'll start with GetLeague
.
We're using strings.NewReader
which will return us a Reader
, which is what our FileSystemPlayerStore
will use to read data. In main
we will open a file, which is also a Reader
.
Try to run the test
Write the minimal amount of code for the test to run and check the failing test output
Let's define FileSystemPlayerStore
in a new file
Try again
It's complaining because we're passing in a Reader
but not expecting one and it doesn't have GetLeague
defined yet.
One more try...
Write enough code to make it pass
We've read JSON from a reader before
The test should pass.
Refactor
We have done this before! Our test code for the server had to decode the JSON from the response.
Let's try DRYing this up into a function.
Create a new file called league.go
and put this inside.
Call this in our implementation and in our test helper getLeagueFromResponse
in server_test.go
We haven't got a strategy yet for dealing with parsing errors but let's press on.
Seeking problems
There is a flaw in our implementation. First of all, let's remind ourselves how io.Reader
is defined.
With our file, you can imagine it reading through byte by byte until the end. What happens if you try to Read
a second time?
Add the following to the end of our current test.
We want this to pass, but if you run the test it doesn't.
The problem is our Reader
has reached the end so there is nothing more to read. We need a way to tell it to go back to the start.
ReadSeeker is another interface in the standard library that can help.
Remember embedding? This is an interface comprised of Reader
and Seeker
This sounds good, can we change FileSystemPlayerStore
to take this interface instead?
Try running the test, it now passes! Happily for us strings.NewReader
that we used in our test also implements ReadSeeker
so we didn't have to make any other changes.
Next we'll implement GetPlayerScore
.
Write the test first
Try to run the test
Write the minimal amount of code for the test to run and check the failing test output
We need to add the method to our new type to get the test to compile.
Now it compiles and the test fails
Write enough code to make it pass
We can iterate over the league to find the player and return their score
Refactor
You will have seen dozens of test helper refactorings so I'll leave this to you to make it work
Finally, we need to start recording scores with RecordWin
.
Write the test first
Our approach is fairly short-sighted for writes. We can't (easily) just update one "row" of JSON in a file. We'll need to store the whole new representation of our database on every write.
How do we write? We'd normally use a Writer
but we already have our ReadSeeker
. Potentially we could have two dependencies but the standard library already has an interface for us ReadWriteSeeker
which lets us do all the things we'll need to do with a file.
Let's update our type
See if it compiles
It's not too surprising that strings.Reader
does not implement ReadWriteSeeker
so what do we do?
We have two choices
Create a temporary file for each test.
*os.File
implementsReadWriteSeeker
. The pro of this is it becomes more of an integration test, we're really reading and writing from the file system so it will give us a very high level of confidence. The cons are we prefer unit tests because they are faster and generally simpler. We will also need to do more work around creating temporary files and then making sure they're removed after the test.We could use a third party library. Mattetti has written a library filebuffer which implements the interface we need and doesn't touch the file system.
I don't think there's an especially wrong answer here, but by choosing to use a third party library I would have to explain dependency management! So we will use files instead.
Before adding our test we need to make our other tests compile by replacing the strings.Reader
with an os.File
.
Let's create some helper functions which will create a temporary file with some data inside it, and abstract our score tests
CreateTemp creates a temporary file for us to use. The "db"
value we've passed in is a prefix put on a random file name it will create. This is to ensure it won't clash with other files by accident.
You'll notice we're not only returning our ReadWriteSeeker
(the file) but also a function. We need to make sure that the file is removed once the test is finished. We don't want to leak details of the files into the test as it's prone to error and uninteresting for the reader. By returning a removeFile
function, we can take care of the details in our helper and all the caller has to do is run defer cleanDatabase()
.
Run the tests and they should be passing! There were a fair amount of changes but now it feels like we have our interface definition complete and it should be very easy to add new tests from now.
Let's get the first iteration of recording a win for an existing player
Try to run the test
./file_system_store_test.go:67:8: store.RecordWin undefined (type FileSystemPlayerStore has no field or method RecordWin)
Write the minimal amount of code for the test to run and check the failing test output
Add the new method
Our implementation is empty so the old score is getting returned.
Write enough code to make it pass
You may be asking yourself why I am doing league[i].Wins++
rather than player.Wins++
.
When you range
over a slice you are returned the current index of the loop (in our case i
) and a copy of the element at that index. Changing the Wins
value of a copy won't have any effect on the league
slice that we iterate on. For that reason, we need to get the reference to the actual value by doing league[i]
and then changing that value instead.
If you run the tests, they should now be passing.
Refactor
In GetPlayerScore
and RecordWin
, we are iterating over []Player
to find a player by name.
We could refactor this common code in the internals of FileSystemStore
but to me, it feels like this is maybe useful code we can lift into a new type. Working with a "League" so far has always been with []Player
but we can create a new type called League
. This will be easier for other developers to understand and then we can attach useful methods onto that type for us to use.
Inside league.go
add the following
Now if anyone has a League
they can easily find a given player.
Change our PlayerStore
interface to return League
rather than []Player
. Try to re-run the tests, you'll get a compilation problem because we've changed the interface but it's very easy to fix; just change the return type from []Player
to League
.
This lets us simplify our methods in file_system_store
.
This is looking much better and we can see how we might be able to find other useful functionality around League
that can be refactored.
We now need to handle the scenario of recording wins of new players.
Write the test first
Try to run the test
Write enough code to make it pass
We just need to handle the scenario where Find
returns nil
because it couldn't find the player.
The happy path is looking ok so we can now try using our new Store
in the integration test. This will give us more confidence that the software works and then we can delete the redundant InMemoryPlayerStore
.
In TestRecordingWinsAndRetrievingThem
replace the old store.
If you run the test it should pass and now we can delete InMemoryPlayerStore
. main.go
will now have compilation problems which will motivate us to now use our new store in the "real" code.
We create a file for our database.
The 2nd argument to
os.OpenFile
lets you define the permissions for opening the file, in our caseO_RDWR
means we want to read and write andos.O_CREATE
means create the file if it doesn't exist.The 3rd argument means sets permissions for the file, in our case, all users can read and write the file. (See superuser.com for a more detailed explanation).
Running the program now persists the data in a file in between restarts, hooray!
More refactoring and performance concerns
Every time someone calls GetLeague()
or GetPlayerScore()
we are reading the entire file and parsing it into JSON. We should not have to do that because FileSystemStore
is entirely responsible for the state of the league; it should only need to read the file when the program starts up and only need to update the file when data changes.
We can create a constructor which can do some of this initialisation for us and store the league as a value in our FileSystemStore
to be used on the reads instead.
This way we only have to read from disk once. We can now replace all of our previous calls to getting the league from disk and just use f.league
instead.
If you try to run the tests it will now complain about initialising FileSystemPlayerStore
so just fix them by calling our new constructor.
Another problem
There is some more naivety in the way we are dealing with files which could create a very nasty bug down the line.
When we RecordWin
, we Seek
back to the start of the file and then write the new data—but what if the new data was smaller than what was there before?
In our current case, this is impossible. We never edit or delete scores so the data can only get bigger. However, it would be irresponsible for us to leave the code like this; it's not unthinkable that a delete scenario could come up.
How will we test for this though? What we need to do is first refactor our code so we separate out the concern of the kind of data we write, from the writing. We can then test that separately to check it works how we hope.
We'll create a new type to encapsulate our "when we write we go from the beginning" functionality. I'm going to call it Tape
. Create a new file with the following:
Notice that we're only implementing Write
now, as it encapsulates the Seek
part. This means our FileSystemStore
can just have a reference to a Writer
instead.
Update the constructor to use Tape
Finally, we can get the amazing payoff we wanted by removing the Seek
call from RecordWin
. Yes, it doesn't feel much, but at least it means if we do any other kind of writes we can rely on our Write
to behave how we need it to. Plus it will now let us test the potentially problematic code separately and fix it.
Let's write the test where we want to update the entire contents of a file with something that is smaller than the original contents.
Write the test first
Our test will create a file with some content, try to write to it using the tape
, and read it all again to see what's in the file. In tape_test.go
:
Try to run the test
As we thought! It writes the data we want, but leaves the rest of the original data remaining.
Write enough code to make it pass
os.File
has a truncate function that will let us effectively empty the file. We should be able to just call this to get what we want.
Change tape
to the following:
The compiler will fail in a number of places where we are expecting an io.ReadWriteSeeker
but we are sending in *os.File
. You should be able to fix these problems yourself by now but if you get stuck just check the source code.
Once you get it refactoring our TestTape_Write
test should be passing!
One other small refactor
In RecordWin
we have the line json.NewEncoder(f.database).Encode(f.league)
.
We don't need to create a new encoder every time we write, we can initialise one in our constructor and use that instead.
Store a reference to an Encoder
in our type and initialise it in the constructor:
Use it in RecordWin
.
Didn't we just break some rules there? Testing private things? No interfaces?
On testing private types
It's true that in general you should favour not testing private things as that can sometimes lead to your tests being too tightly coupled to the implementation, which can hinder refactoring in future.
However, we must not forget that tests should give us confidence.
We were not confident that our implementation would work if we added any kind of edit or delete functionality. We did not want to leave the code like that, especially if this was being worked on by more than one person who may not be aware of the shortcomings of our initial approach.
Finally, it's just one test! If we decide to change the way it works it won't be a disaster to just delete the test but we have at the very least captured the requirement for future maintainers.
Interfaces
We started off the code by using io.Reader
as that was the easiest path for us to unit test our new PlayerStore
. As we developed the code we moved on to io.ReadWriter
and then io.ReadWriteSeeker
. We then found out there was nothing in the standard library that actually implemented that apart from *os.File
. We could've taken the decision to write our own or use an open source one but it felt pragmatic just to make temporary files for the tests.
Finally, we needed Truncate
which is also on *os.File
. It would've been an option to create our own interface capturing these requirements.
But what is this really giving us? Bear in mind we are not mocking and it is unrealistic for a file system store to take any type other than an *os.File
so we don't need the polymorphism that interfaces give us.
Don't be afraid to chop and change types and experiment like we have here. The great thing about using a statically typed language is the compiler will help you with every change.
Error handling
Before we start working on sorting we should make sure we're happy with our current code and remove any technical debt we may have. It's an important principle to get to working software as quickly as possible (stay out of the red state) but that doesn't mean we should ignore error cases!
If we go back to FileSystemStore.go
we have league, _ := NewLeague(f.database)
in our constructor.
NewLeague
can return an error if it is unable to parse the league from the io.Reader
that we provide.
It was pragmatic to ignore that at the time as we already had failing tests. If we had tried to tackle it at the same time, we would have been juggling two things at once.
Let's make it so our constructor is capable of returning an error.
Remember it is very important to give helpful error messages (just like your tests). People on the internet jokingly say that most Go code is:
That is 100% not idiomatic. Adding contextual information (i.e what you were doing to cause the error) to your error messages makes operating your software far easier.
If you try to compile you'll get some errors.
In main we'll want to exit the program, printing the error.
In the tests we should assert there is no error. We can make a helper to help with this.
Work through the other compilation problems using this helper. Finally, you should have a failing test:
We cannot parse the league because the file is empty. We weren't getting errors before because we always just ignored them.
Let's fix our big integration test by putting some valid JSON in it:
Now that all the tests are passing, we need to handle the scenario where the file is empty.
Write the test first
Try to run the test
Write enough code to make it pass
Change our constructor to the following
file.Stat
returns stats on our file, which lets us check the size of the file. If it's empty, we Write
an empty JSON array and Seek
back to the start, ready for the rest of the code.
Refactor
Our constructor is a bit messy now, so let's extract the initialise code into a function:
Sorting
Our product owner wants /league
to return the players sorted by their scores, from highest to lowest.
The main decision to make here is where in the software should this happen. If we were using a "real" database we would use things like ORDER BY
so the sorting is super fast. For that reason, it feels like implementations of PlayerStore
should be responsible.
Write the test first
We can update the assertion on our first test in TestFileSystemStore
:
The order of the JSON coming in is in the wrong order and our want
will check that it is returned to the caller in the correct order.
Try to run the test
Write enough code to make it pass
Slice sorts the provided slice given the provided less function.
Easy!
Wrapping up
What we've covered
The
Seeker
interface and its relation toReader
andWriter
.Working with files.
Creating an easy to use helper for testing with files that hides all the messy stuff.
sort.Slice
for sorting slices.Using the compiler to help us safely make structural changes to the application.
Breaking rules
Most rules in software engineering aren't really rules, just best practices that work 80% of the time.
We discovered a scenario where one of our previous "rules" of not testing internal functions was not helpful for us so we broke the rule.
It's important when breaking rules to understand the trade-off you are making. In our case, we were ok with it because it was just one test and would've been very difficult to exercise the scenario otherwise.
In order to be able to break the rules you must understand them first. An analogy is with learning guitar. It doesn't matter how creative you think you are, you must understand and practice the fundamentals.
Where our software is at
We have an HTTP API where you can create players and increment their score.
We can return a league of everyone's scores as JSON.
The data is persisted as a JSON file.
Last updated