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.