Redis is well known for a in-memory data store which is widely used as cache, database and message broker. In this article, we will implement a simple Redis client in Golang.
Let’s look at the basic parts like,
- Listening to a port
- Accepting connections
- Reading the commands
- Parsing the commands
- Handling the commands
Listening to a port
We can use the net
package in Golang to listen to a port. We can use the net.Listen
function to listen to a port.
package main
import (
"bufio"
"fmt"
"net"
)
func main() {
ln, err := net.Listen("tcp", ":6379")
if err != nil {
fmt.Println("Error listening:", err.Error())
return
}
defer ln.Close()
fmt.Println("Listening on :6379")
}
This will start a server listening on port 6379. Simlar to Redis we are listening on the same port.
Accepting inputs
We can use the ln.Accept
function to accept the incoming connections. We can use a for
loop to keep accepting the connections.
for {
conn, err := ln.Accept()
if err != nil {
fmt.Println("Error accepting: ", err.Error())
return
}
fmt.Println("Accepted connection")
}
The accepted connection will be stored in the conn
variable. We can use this connection to read and write data.
Reading the commands
We can use the bufio
package to read the data from the connection. We can use the bufio.NewReader
function to create a new reader.
reader := bufio.NewReader(conn)
This will create a new reader that we can use to read the data from the connection.
Parsing the commands
RESP Commands come in the format
*<number of arguments>\r\n
$<length of argument>\r\n
<argument>\r\n
We can write a parser which reads from the reader and parses the commands. We can use the strings.Split
function to split the command.
type Command struct {
Name string
Args []string
}
func ParseRESP(reader *bufio.Reader) (*Command, error) {
// Read the first line
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
// validate RESP array
if line[0] != '*' {
return nil, fmt.Errorf("invalid RESP array")
}
// Read the number of arguments
numArgs := 0
fmt.Sscanf(line[1:], "%d", &numArgs)
command := &Command{}
// Read the arguments from RESP array
for i := 0; i < numArgs; i++ {
line, err = reader.ReadString('\n')
if err != nil {
return nil, err
}
if line[0] != '$' {
return nil, fmt.Errorf("invalid RESP bulk string")
}
argLen := 0
fmt.Sscanf(line[1:], "%d", &argLen)
arg := make([]byte, argLen+2) // +2 to read the \r\n
_, err = reader.Read(arg)
if err != nil {
return nil, err
}
argStr := string(arg[:argLen])
if i == 0 {
// First line is the command name
command.Name = strings.ToUpper(argStr)
} else {
// Other lines are the arguments
command.Args = append(command.Args, argStr)
}
}
return command, nil
}
This will parse the RESP commands and return a Command
struct.
Handling the commands
We can use a switch
statement to handle the commands. We can use the command.Name
to switch between the commands.
func HandleCommand(command *Command) string {
switch command.Name {
case "PING":
return "+PONG\r\n"
case "ECHO":
return "$" + strconv.Itoa(len(command.Args[0])) + "\r\n" + command.Args[0] + "\r\n"
case "SET":
setHandler(command)
return "+OK\r\n"
case "GET":
return getHandler(command)
default:
return "-ERR unknown command\r\n"
}
}
This will handle the commands and return the response.
Putting it all together
We can put all the parts together to create a simple Redis client.
package main
import (
"bufio"
"fmt"
"net"
"strconv"
"strings"
)
type Command struct {
Name string
Args []string
}
func main() {
// Listen to port 6379
ln, err := net.Listen("tcp", ":6379")
if err != nil {
fmt.Println("Error listening:", err.Error())
return
}
defer ln.Close()
fmt.Println("Listening on :6379")
for {
// Accept incoming connections
conn, err := ln.Accept()
if err != nil {
fmt.Println("Error accepting: ", err.Error())
return
}
fmt.Println("Accepted connection")
// Handle the connection
go handleConnection(conn)
}
}
func handleConnection(conn net.Conn) {
reader := bufio.NewReader(conn)
defer conn.Close()
// For loop to Read from same connection multiple times
for {
// Parse the command
command, err := ParseRESP(reader)
if err != nil {
fmt.Println("Error parsing: ", err.Error())
break
}
// Handle the command
response := HandleCommand(command)
// Write the response
conn.Write([]byte(response))
}
}
func ParseRESP(reader *bufio.Reader) (*Command, error) {
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
if line[0] != '*' {
return nil, fmt.Errorf("invalid RESP array")
}
numArgs := 0
fmt.Sscanf(line[1:], "%d", &numArgs)
command := &Command{}
for i := 0; i < numArgs; i++ {
line, err = reader.ReadString('\n')
if err != nil {
return nil, err
}
if line[0] != '$' {
return nil, fmt.Errorf("invalid RESP bulk string")
}
argLen := 0
fmt.Sscanf(line[1:], "%d", &argLen)
arg := make([]byte, argLen+2)
_, err = reader.Read(arg)
if err != nil {
return nil, err
}
argStr := string(arg[:argLen])
if i == 0 {
command.Name = strings.ToUpper(argStr)
} else {
command.Args = append(command.Args, argStr)
}
}
return command, nil
}
func HandleCommand(command *Command) string {
switch command.Name {
case "PING":
return "+PONG\r\n"
case "ECHO":
return "$" + strconv.Itoa(len(command.Args[0])) + "\r\n" + command.Args[0] + "\r\n"
case "SET":
setHandler(command)
return "+OK\r\n"
case "GET":
return getHandler(command)
default:
return "-ERR unknown command\r\n"
}
}
func setHandler(command *Command) {
// Implement the SET command to save value
}
func getHandler(command *Command) string {
// Implement the GET command to get value
return ""
}
This will create a simple Redis client in Golang. We can implement the setHandler
and getHandler
functions to handle the SET
and GET
commands.
Although this is a simple implementation and also following RESP protocol, we can use the same pattern for any custom usecases of a listener that performs some action based on the input.