Getting into Go: redis-go
I've lately been interested in trying out go ( also called golang as go is ironically quite the ungooglable term ) and decided to complete few of the codecrafters challenges with it. This series of posts will serve as a journal to document this journey of learning go and implementing toy versions of commonly used software.
Getting started
Use a version manager to handle different go versions and their installations. goenv or asdf will do the job. I personally use asdf since I already use it to manage other runtime versions.
The Codecrafters challenges have instructions on how to get set up, but this post can be followed independently as I'll provide the missing instructions on how to run and test the program we are going to write.
Let's get started with the "Building your own Redis" challenge.
Building your own Redis
In this challenge we are going to build a toy implementation of redis. Redis is an in-memory data store that can serve as database, cache server, streaming engine or message broker. We are only going to implement a subset of the redis features in this example, and will focus on the key-value store that can be used as a database or caching server.
Let's create a directory called redis-go
, inside of which we will create a directory called app
and two files, a README.md
and a spawn_redis_server.sh
. Inside the app
directory create a file called server.go
. You should have the following structure:
1redis-go
2├── app
3│ └── server.go
4├── README.md
5├── spawn_redis_server.sh
6
and the following code inside spawn_redis_server.sh
:
1# spawn_redis_server.sh
2
3set -e
4tmpFile=$(mktemp)
5go build -o "$tmpFile" app/*.go
6exec "$tmpFile"
7
Don't forget to make the script executable using chmod +x spawn_redis_server.sh
. We will use it in the next step to test our program.
Let's get started by creating a TCP server that listens on the port 6379, redis default port. You can follow along with the code available at the b1b9cbc commit of the nfabredev/redis-go repository on GitHub, which I will reproduce in this article too.
Bind to a port
Per the go specification:
A complete program is created by linking a single, unimported package called the main package [...]. The main package must have package name
main
and declare a functionmain
that takes no arguments and returns no value.
We declare the package name, import statements for the standard library packages we are going to use, and the main function. We define a startServer() function to wrap our code.
We use the net
package that provides interfaces for network I/O, and it's Listen
function, to listen for connections on the local system on port 6379.
The Listen
function returns a Listener
interface that exposes an Accept
function.
Both net.Listen
and the listener.Accept
function return two values. By convention, results are the first values returned from a function and errors are the last return value. This leads to this idiomatic pattern of always checking the potential error returned by a function call, and allowing to print some more context before interrupting the program execution - or resuming it if the error is recoverable.
We use the Println
function from the fmt
package to write to standard output, as a way of logging our errors.
In our case failing to bind to the port or not being able to accept connections are deemed fatal errors, and result in a program exit with a code 1, using os.Exit
. Exiting with a non-zero code is an error.
See: https://go.dev/blog/error-handling-and-go
1// server.go
2
3package main
4
5import (
6 "fmt"
7 "net"
8 "os"
9)
10
11func main() {
12 startServer()
13
14 os.Exit(0)
15}
16
17func startServer() {
18 fmt.Println("Starting to listen")
19
20 listener, err := net.Listen("tcp", "0.0.0.0:6379")
21 if err != nil {
22 fmt.Println("Failed to bind to port 6379")
23 os.Exit(1)
24 }
25 fmt.Println("Listening")
26
27 conn, err := listener.Accept()
28 defer conn.Close()
29 if err != nil {
30 fmt.Println("Error accepting connection: ", err.Error())
31 os.Exit(1)
32 }
33 fmt.Println("Connection accepted")
34
35 return
36}
37
To test that our program listens to the port we can use netcat to connect to the address and port our server is available on.
Let's start our program and run netcat against it to check if it works. In the terminal run:
1./spawn_redis_server.sh
2
and in another run:
1nc -z 0.0.0.0 6379
2
It should print the following statement:
1Connection to 0.0.0.0 port 6379 [tcp/*] succeeded!
2
You can see that after accepting the first connection our program exits. This is because we are not yet supporting several connections. We will fix that in the next step.
Before we move on let's write a test to make sure our program always works the way we intended, and save us some manual testing. This will become particularly useful when adding more and more features.
Go provides first class support for testing, and it builds on some conventions:
- test files end in ``*_test.go`
- they import the
testing
package - the test function name should start with a capitalized
Test
and be followed by the capitalized name of the function being tested.
This test has the particularity that it's testing a function that has a side effect, starting a server - and not a pure function that takes data in and returns it under another form. Usually these would be handled by mocking functions so we wouldn't have to be bound by the side effect constraints such as sharing a global state with other processes ( ie.: are ports on the testing maching free ) or latency ( we need to take into account the time it takes to start listening on the host system before asserting that the test fails or succeeds.) I might revisit this test later to use mocks.
We import our package, named main
, to make the startServer function available for testing. We execute the function in a goroutine so it runs concurenlty to the testing code. This is so we can have our server running while executing the code that will call it.
We use the for
statement to start a loop, make a request to our server using net.Dial
, and check if we need to interrupt the loop if we have errored and retried for over 10 times. If we couldn't connect we interrupt the execution of the loop for another retry to give time to the server to start up. If there are no error - i.e. the connection succeeded - we break out of the loop.
Finally as the test function returns without an error, the test passes.
We can run the test using the go test
command in our app folder.
1package main
2
3import (
4 "net"
5 "testing"
6 "time"
7 "fmt"
8)
9
10func TestStartServer(t *testing.T) {
11 go startServer()
12
13 retries := 0
14
15 for {
16 _, err := net.Dial("tcp", "0.0.0.0:6379")
17
18 if err != nil && retries > 10 {
19 t.Fatal("All retries failed.")
20 }
21
22 if err != nil {
23 fmt.Println("Failed to connect to port 6379, retrying in 1s")
24
25 retries += 1
26 time.Sleep(1 * time.Second)
27 } else {
28 break
29 }
30 }
31
32 return
33}
34
The code is also available at the b1b9cbc commit of the nfabredev/redis-go repository on GitHub.
Respond to PING
In this part we will go through the second stage of the codecrafters challenge redis-go, in which we will respond to the redis PING command.
Returns PONG if no argument is provided, otherwise return a copy of the argument as a bulk. This command is useful for:
- Testing whether a connection is still alive.
- Verifying the server's ability to serve data - an error is returned when this isn't the case (e.g., during load from persistence or accessing a stale replica).
- Measuring latency.
You can follow along with the code available at the 4730f3b commit of the nfabredev/redis-go repository on GitHub, which I will reproduce in this part too.
We modify the startServer command to return the connection struct, so we can pass it to a handleConnection function.
In this new handleConnection function create a buffer with the make function, which will hold the data we are reading from the connection.
We then use three function called parseRequest, handleRequest, and handleResponse. Let's go through each of them.
parseRequest takes the buffer in, and returns an array of strings. It's there to help us manipulate the data sent by the client. According to the redis specification we know a few things about the supported requests.
- Commands items are separated by CRLF - which is
\r\n
- RESP Arrays start with a * character as the first byte
- Following elements are of the RESP type ( i.e.: RESP Bulk Strings, RESP Bulk Integers and so on)
The parseRequest checks that the request is a RESP Array, and that its first element is a RESP Bulk String. We could do additional checking such as checking the number of elements in the array or the length of each elements, which are specified in the following manner:
1"*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n"
2 ^^ ^
3 |_ RESP Array start
4 |_ 2 elements in the RESP Array
5 |_ RESP Integer checking the length of the next element ( "hello")
6
We do a little bit of error handling using the error type and Go idiomatic error checking. See Error handling in Go for more reading on that subject. The way to return errors to the client is using RESP Errors
The parsedRequest is passed to the handleRequest function that will provide the appropriate response. For now we only handle commands being "ping" to which we response "PONG". We use a case-insensitive string equality check from the strings package called EqualFold.
Once we have a response back, we pass it to the handleResponse, which writes to the conn struct, sending our response back to the client.
1 package main
2
3 import (
4+ "errors"
5 "fmt"
6+ "io"
7+ "log"
8 "net"
9 "os"
10+ "strings"
11 )
12
13 func main() {
14- startServer()
15+ conn := startServer()
16+ defer conn.Close()
17+ handleConnection(conn)
18
19 os.Exit(0)
20 }
21
22-func startServer() {
23+func startServer() net.Conn {
24 fmt.Println("Starting to listen")
25
26 listener, err := net.Listen("tcp", "0.0.0.0:6379")
27
28 conn, err := listener.Accept()
29- defer conn.Close()
30 if err != nil {
31 fmt.Println("Error accepting connection: ", err.Error())
32 os.Exit(1)
33 }
34 fmt.Println("Connection accepted")
35
36- return
37+ return conn
38+}
39+
40+func handleConnection(conn net.Conn) {
41+ buf := make([]byte, 1024)
42+
43+ _, err := conn.Read(buf)
44+
45+ if err != nil {
46+ if err == io.EOF {
47+ // connection closed by client
48+ return
49+ } else {
50+ log.Fatal("Error reading from client: ", err.Error())
51+ }
52+ }
53+
54+ request, err := parseRequest(buf)
55+ if err != nil {
56+ handleResponse(conn, err.Error())
57+ }
58+ response, err := handleRequest(request)
59+ if err != nil {
60+ handleResponse(conn, err.Error())
61+ }
62+ handleResponse(conn, response)
63+}
64+
65+func parseRequest(buf []byte) ([]string, error) {
66+ request := strings.Split(string(buf), "\r\n")
67+
68+ fmt.Println("request:")
69+ fmt.Println(request)
70+
71+ if strings.HasPrefix(request[0], "*") == false {
72+ error := errors.New("-ERR The request should be a RESP Array \n" +
73+ strings.Join(request, " \n") +
74+ "See: https://redis.io/docs/reference/protocol-spec/#resp-arrays")
75+ return nil, error
76+ }
77+ // pop first item after above check
78+ _, request = request[0], request[1:]
79+
80+ if strings.HasPrefix(request[0], "$") == false {
81+ error := errors.New("-ERR Request is not bulk string \n" +
82+ strings.Join(request, " \n") +
83+ "See: https://redis.io/docs/reference/protocol-spec/#resp-bulk-strings")
84+ return nil, error
85+ }
86+
87+ return request, nil
88+}
89+
90+func handleRequest(request []string) (string, error) {
91+ command := request[1]
92+
93+ switch {
94+ case strings.EqualFold(command, "ping") == true:
95+ response := encodeSimpleString("PONG")
96+ return response, nil
97+ default:
98+ return "", errors.New("-ERR unknown command '" + request[1] + "'\r\n")
99+ }
100+}
101+
102+func encodeSimpleString(string string) string {
103+ return "+" + string + "\r\n"
104+}
105+
106+func handleResponse(conn net.Conn, response string) {
107+ conn.Write([]byte(response))
108 }
109
Respond to multiple PINGs
In this part we will go through the third stage of the codecrafters challenge redis-go, in which we will respond to multiple PINGs.
You can follow along with the code available at the 5446408 commit of the nfabredev/redis-go repository on GitHub, which I will reproduce in this part too.
We need to respond to multiple PING commands sent by the same connection. To handle that we will create a loop around the connection handling, that stops when the connection is closed by the client. Until it's closed it will read its input and sends the response back.
1func handleConnection(conn net.Conn) {
2+ for {
3 buf := make([]byte, 1024)
4
5 _, err := conn.Read(buf)
6
7 if err != nil {
8 if err == io.EOF {
9 // connection closed by client
10- return
11+ break
12 } else {
13 log.Fatal("Error reading from client: ", err.Error())
14 }
15 }
16
17 request, err := parseRequest(buf)
18 if err != nil {
19 handleResponse(conn, err.Error())
20 }
21 response, err := handleRequest(request)
22 if err != nil {
23 handleResponse(conn, err.Error())
24 }
25 handleResponse(conn, response)
26+ }
27}
28
We wrap the whole handleConnection function in a for loop, which is an infinite loop. We only break out of that loop when the error we receive from reading the connection is EOF (end of file), which means that the client has closed the connection. In other case we continue processing the client's requests.
Concurrent clients
In this part we will go through the fourth stage of the codecrafters challenge redis-go, in which we will handle concurrent clients.
You can follow along with the code available at the 7c7cc67 commit of the nfabredev/redis-go repository on GitHub, which I will reproduce in this part too.
On table stake feature of modern webservers is the ability to handle concurrent clients, in other words serving 2 or more clients at the same time. We can add that feature to our program with minimal changes.
We start by wrapping the startServer and handleConnection functions in a loop. We add a defer statement to ensure we close the listener when the startServer function execution is over. Finally we wrap the handleConnection code into a goroutine, which executes it in a lightweight thread of execution, and lets the program execution continue back to the next loop iteration where we listen for a new connection. This makes sure that a second client can connect while the first client request is being processed.
1 func main() {
2+ for {
3 conn := startServer()
4 defer conn.Close()
5 handleConnection(conn)
6+ }
7}
8
9@@ -30,2 +30,3 @@ func startServer() net.Conn {
10 }
11+ defer listener.Close()
12 fmt.Println("Listening")
13@@ -43,26 +44,28 @@ func startServer() net.Conn {
14
15 func handleConnection(conn net.Conn) {
16+ go func(conn net.Conn) {
17 for {
18 buf := make([]byte, 1024)
19
20 _, err := conn.Read(buf)
21
22 if err != nil {
23 if err == io.EOF {
24 // connection closed by client
25 break
26 } else {
27 log.Fatal("Error reading from client: ", err.Error())
28 }
29 }
30
31 request, err := parseRequest(buf)
32 if err != nil {
33 handleResponse(conn, err.Error())
34 }
35 response, err := handleRequest(request)
36 if err != nil {
37 handleResponse(conn, err.Error())
38 }
39 handleResponse(conn, response)
40 }
41+ }(conn)
42 }
43
Implement the ECHO command
In this part we will go through the fifth stage of the codecrafters challenge redis-go, in which we will implement the ECHO command.
You can follow along with the code available at the fe41a75 commit of the nfabredev/redis-go repository on GitHub, which I will reproduce in this part too.
The ECHO command sends back the arguments passed to it as a RESP Bulk String.
We're going to modify the handleRequest
function, by checking if the command sent by the client is "ECHO". If that's the case we know that its fourth element ( at index 3 in the request array ) is the message we need to echo back. We pass it to an encodeBulkString
function that formats it as a RESP Bulk String.
1import (
2 "os"
3+ "strconv"
4 "strings"
5@@ -104,2 +105,5 @@ func handleRequest(request []string) (string, error) {
6 return response, nil
7+ case strings.EqualFold(command, "echo") == true:
8+ response := encodeBulkString(request[3])
9+ return response, nil
10 default:
11@@ -113,2 +117,7 @@ func encodeSimpleString(string string) string {
12
13+func encodeBulkString(string string) string {
14+ stringLength := strconv.Itoa(len(string))
15+ return "$" + stringLength + "\r\n" + string + "\r\n"
16+}
17+
18
Implement the SET & GET commands
In this article we will go through the sixth stage of the codecrafters challenge redis-go, in which we will implement the SET & GET commands.
You can follow along with the code available at the ea7f0e8 commit of the nfabredev/redis-go repository on GitHub, which I will reproduce in this article too.
To start things off and learn something new we are going to create a local package called 'storage' that will be our toy implementation of a key value store.
We will need to create go.mod
files, that hold the name of our packages and their dependencies. We will run the go mod init redis-go
in the root of the directory and go mod init storage
in a new directory called storage that we will place in the app
folder.
You should have the following structure:
1├── README.md
2├── app
3│ ├── server.go
4│ ├── server_test.go
5+ │ └── storage
6+ │ ├── go.mod
7+ │ └── storage.go
8+ ├── go.mod
9└── spawn_redis_server.sh
10
The app/storage/go.mod file will only hold our package name and the go version we are using:
1module storage
2
3go 1.16
4
while our root level go.mod file will additionally hold our dependency to the storage module:
1module redis_go
2
3go 1.16
4
5require storage v0.0.0-0
6
7replace storage => ./app/storage
8
We require our app to bundle the module storage alongside its main code, and replace the reference to our package name to the file where it's located.
Regular module imports can reference urls directly - but in our case we aren't going to go the length to publish anything, it's all local work.
Our storage implementation is the following:
1package storage
2
3type Storage map[string]string
4
5var storage Storage
6
7func init() {
8 storage = make(Storage)
9}
10
11func Set(key string, value string) string {
12 storage[key] = value
13 return 'ok'
14}
15
16func Get(key string) (string, error) {
17 value, ok := storage[key]
18 if !ok {
19 return "", errors.New("-ERR get called on unset key: " + key)
20 }
21 return value, nil
22}
23
We define the name of our package, and the type of Storage
which is a map with strings keys and values. The initialization function - which executes after the package is imported - uses the global private variable storage as our key value store. We then have two public functions that set the key to the value, or retrieve the value from the key. Accessing a key that hasn't been set in the Get
funtion results in an erorr.
In our server.go file the implementation of get and set commands looks like the following code:
1import (
2 "os"
3+ "storage"
4 "strings"
5 ...
6
We start by importing our new package storage
, and then define new cases to handle the set and get commands. We're again going to modify the handleRequest
function, by checking if the command sent by the client is "SET" or "GET".
The set command is the following:
1@@ -103,2 +103,45 @@ func handleRequest(request []string) (string, error) {
2+ case strings.EqualFold(command, "set"):
3+ REQUEST_LENGTH_WITH_KEY := 5
4+ REQUEST_LENGTH_WITH_KEY_VALUE := 7
5+
6+ var key string
7+ var value string
8+
9+ if len(request) == REQUEST_LENGTH_WITH_KEY {
10+ key = request[3]
11+ value = ""
12+ } else if len(request) == REQUEST_LENGTH_WITH_KEY_VALUE {
13+ key = request[3]
14+ value = request[5]
15+ } else {
16+ return "", errors.New(encodeError("SET command called without key value pair to set"))
17+ }
18+ storage.Set(key, value)
19+ response := encodeSimpleString("OK")
20+ return response, nil
21
We define two variables, one to handle the case the request only has a key to be set, and one where both key and value are set. An example of each request could look like that, first with only a key:
1*2\r\n$3\r\nset\r\n$5\r\nhello
2 ^ ^ ^
3 |_ 2 elements in the request
4 |_ set command
5 |_ set the key "hello"
6
Which will have 5 elements in the request array once it's parsed.
And then with both key and its corresponding value, which will have 7 elements in the request array once it's parsed:
1*3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld
2 ^ ^ ^ ^
3 |_ 3 elements in the request |_ ..to the value "world"
4 |_ set command
5 |_ set the key "hello"..
6
We then use an if..else
construct to handle the case where only the key is set - which result in the corresponding value being an empty string, otherwise when both keys and values are set we pass them both to our Storage.Set
function. We only return the string "OK" once the key value pair have been stored.
The GET function looks like that:
1@@ -103,2 +103,45 @@ func handleRequest(request []string) (string, error) {
2 return response, nil
3+ case strings.EqualFold(command, "get"):
4+ REQUEST_LENGTH_WITH_KEY := 5
5+
6+ if len(request) != REQUEST_LENGTH_WITH_KEY {
7+ return "", errors.New(encodeError("GET command called without a key to retrieve"))
8+ }
9+ storedKey, err := storage.Get(request[3])
10+ if err != nil {
11+ return "", errors.New(encodeError(err.Error()))
12+ }
13+ response := encodeBulkString(storedKey)
14+ return response, nil
15 default:
16@@ -116,2 +159,7 @@ func encodeError(string string) string {
17
18+func encodeBulkString(string string) string {
19+ stringLength := strconv.Itoa(len(string))
20+ return "$" + stringLength + "\r\n" + string + "\r\n"
21+}
22
We check the length of the request, and retrieve the key. After handling a possible error - if the key wan't set in our Storage - we pass the value to an encodeBulkString
function that formats it as a RESP Bulk String, and finally return it back to the caller.
Expiry
In this part we will go through the seventh stage of the codecrafters challenge redis-go, in which we will implement the expiry feature of the SET command.
You can follow along with the code available at the db74152 commit of the nfabredev/redis-go repository on GitHub, which I will reproduce in this part too.
Expiry in the SET command can be implemented in a few different ways. We are going to implement it using the PX
argument, which sets the specified expire time, in milliseconds.
We will start by allowing our storage
package to hold one more value - the expiry time.
The first change defines a Value
struct, that represents the held value and its optional expiry timestamp, which are now the returned value from the Storage
map instead of arbitrary strings.
1@@ -4,3 +4,8 @@ import "errors"
2
3-type Storage map[string]string
4+type Storage map[string]Value
5+type Value struct {
6+ Value string
7+ Exp int64
8+}
9+
10
We then have to change the type of the value we get as a parameter to the Set
function:
1@@ -12,3 +17,3 @@ func init() {
2
3-func Set(key string, value string) Storage {
4+func Set(key string, value Value) Storage {
5 storage[key] = value
6@@ -17,6 +22,6 @@ func Set(key string, value string) Storage {
7
And finally as a return type to the Get
function:
1-func Get(key string) (string, error) {
2+func Get(key string) (Value, error) {
3 value, ok := storage[key]
4 if !ok {
5- return "", errors.New("-ERR get called on unset key: " + key)
6+ return Value{}, errors.New("-ERR get called on unset key: " + key)
7 }
8 return value, nil
9}
10
We will then start by importing the time
package in our server.go
file - that we will use to compare timestamps and decide wether the stored key value pair has reached its expiry time or not.
1@@ -12,2 +12,3 @@ import (
2 "strings"
3+ "time"
4 )
5
The implementation takes place as usual in the handleRequest
function, here for the "SET" command:
1@@ -117,5 +118,7 @@ func handleRequest(request []string) (string, error) {
2 case strings.EqualFold(command, "set"):
3 REQUEST_LENGTH_WITH_KEY := 5
4 REQUEST_LENGTH_WITH_KEY_VALUE := 7
5+ REQUEST_LENGTH_WITH_KEY_VALUE_EXPIRY := 11
6
7 var key string
8- var value string
9+ var value storage.Value
10+ var exp int64 = 0
11
12@@ -123,6 +126,13 @@ func handleRequest(request []string) (string, error) {
13 key = request[3]
14- value = ""
15 } else if len(request) == REQUEST_LENGTH_WITH_KEY_VALUE {
16 key = request[3]
17- value = request[5]
18+ value = storage.Value{Value: request[5], Exp: exp}
19+ } else if len(request) == REQUEST_LENGTH_WITH_KEY_VALUE_EXPIRY {
20+ exp, err := strconv.ParseInt(request[9], 10, 64)
21+ if err != nil {
22+ return "", err
23+ }
24+ key = request[3]
25+ exp = setExpiry(exp)
26+ value = storage.Value{Value: request[5], Exp: exp}
27 } else {
28
We add a variable and a condition that match the length of the request with the expiry key. We then parse the value to an integer to get the expiry, pass it to the setExpiry function and finally set the Value struct with both the Value and the Exp property set. To calculate the expiry value we use the setExpiry function like that:
1@@ -167 +187,10 @@ func handleResponse(conn net.Conn, response string) {
2 }
3+
4+func setExpiry(exp int64) int64 {
5+ return (time.Now().UnixNano() / int64(time.Millisecond)) + exp
6+}
7+
8
It adds the expiry - which is provided as a number of millisecond to keep the Value for - to the current time. That gives us in return the time until which the Value is valid. We will use that value when the GET command is called:
1@@ -140,3 +150,4 @@ func handleRequest(request []string) (string, error) {
2 }
3- storedKey, err := storage.Get(request[3])
4+
5+ Value, err := storage.Get(request[3])
6 if err != nil {
7@@ -144,3 +155,12 @@ func handleRequest(request []string) (string, error) {
8 }
9- response := encodeBulkString(storedKey)
10+
11+ value := Value.Value
12+ exp := Value.Exp
13+
14+ var response string
15+ if exp == 0 || exp > time.Now().UnixNano()/int64(time.Millisecond) {
16+ response = encodeBulkString(value)
17+ } else {
18+ response = returnNullString()
19+ }
20 return response, nil
21@@ -167 +187,10 @@ func handleResponse(conn net.Conn, response string) {
22 }
23+func returnNullString() string {
24+ return "$-1\r\n"
25+}
26
When the GET command is called we have 3 possibilities - two of which are handled the same. When the expiry is 0 or is later than the current time we return the value. Otherwise the expiry being in the past we return a RESP null string - per the specification - for which we have created a helper function.
We've finally gone through a simple implementation of a reduced feature set of Redis using Go. That was useful for learning Go basics, and getting to know Redis inner workings. It can be interesting to continue implement some more features - maybe in the future!