Implementing a Key-Value Store over TCP in Go

Table of Contents

  1. Introduction
  2. Prerequisites
  3. Setting Up the Project
  4. Creating a TCP Server
  5. Implementing the Key-Value Store
  6. Handling Multiple Clients
  7. Adding Error Handling
  8. Conclusion

Introduction

In this tutorial, we will learn how to implement a simple key-value store over TCP in Go. We will build a server that can handle client requests to store, retrieve, and delete key-value pairs. By the end of this tutorial, you will have a basic understanding of TCP networking, concurrency, and working with key-value stores in Go.

Prerequisites

To follow along with this tutorial, you should have basic knowledge of the Go programming language. Familiarity with networking concepts and basic command-line usage is also beneficial.

Setting Up the Project

Before we start coding, we need to set up a new Go project. Open a terminal and create a new directory for the project:

mkdir key-value-store
cd key-value-store

Next, initialize a Go module:

go mod init github.com/your-username/key-value-store

Now, let’s create the main file for our project:

touch main.go

Open main.go in a text editor and let’s begin implementing our key-value store over TCP.

Creating a TCP Server

First, we need to import the necessary packages for working with TCP connections and handling requests:

package main

import (
	"bufio"
	"fmt"
	"log"
	"net"
	"strings"
)

Next, we’ll define a constant for the server’s address and port number:

const (
	address = "localhost"
	port    = "1234"
)

Now, let’s create a function handleConnection that will be responsible for handling client connections:

func handleConnection(conn net.Conn) {
	defer conn.Close()

	reader := bufio.NewReader(conn)
	writer := bufio.NewWriter(conn)

	// TODO: Implement key-value store functionality
}

The handleConnection function takes a net.Conn object as a parameter, which represents a TCP connection. We wrap the connection’s input and output streams with a bufio.Reader and bufio.Writer for convenient reading and writing.

Implementing the Key-Value Store

Inside the handleConnection function, we can now implement the key-value store functionality. Let’s add a map to store our key-value pairs:

database := make(map[string]string)

We can then enter a loop to continuously read and process client requests:

for {
	// Read client request
	request, err := reader.ReadString('\n')
	if err != nil {
		log.Printf("Error reading request from client: %v", err)
		return
	}

	// Process request
	parts := strings.Split(request, " ")
	command := strings.TrimSpace(parts[0])

	switch command {
	case "GET":
		// TODO: Implement GET command
	case "SET":
		// TODO: Implement SET command
	case "DEL":
		// TODO: Implement DEL command
	default:
		// Unknown command
		writer.WriteString("Unknown command\n")
		writer.Flush()
	}
}

In this example, we split the client request into its parts and extract the command. We then use a switch statement to handle different commands. For now, let’s leave the implementation of the GET, SET, and DEL commands as TODOs.

Handling Multiple Clients

To support multiple clients concurrently, we can modify our code to use goroutines. In the main function, we can create a listener for incoming connections:

func main() {
	listener, err := net.Listen("tcp", address+":"+port)
	if err != nil {
		log.Fatalf("Failed to start server: %v", err)
	}

	log.Printf("Server listening on %s:%s", address, port)

	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Printf("Error accepting connection: %v", err)
			continue
		}

		go handleConnection(conn)
	}
}

By using go handleConnection(conn) inside a goroutine, each incoming connection will be handled concurrently.

Adding Error Handling

It’s important to handle errors properly to provide useful feedback to clients. Let’s update our code with error handling for various scenarios:

func handleConnection(conn net.Conn) {
	// ...

	for {
		request, err := reader.ReadString('\n')
		if err != nil {
			log.Printf("Error reading request from client: %v", err)
			writer.WriteString("Error reading request\n")
			writer.Flush()
			return
		}

		// ...

		switch command {
		case "GET":
			// ...

			value, ok := database[key]
			if !ok {
				writer.WriteString("Key not found\n")
			} else {
				writer.WriteString(value + "\n")
			}

			writer.Flush()
		case "SET":
			// ...

			database[key] = value
			writer.WriteString("OK\n")
			writer.Flush()
		case "DEL":
			// ...

			delete(database, key)
			writer.WriteString("OK\n")
			writer.Flush()
		default:
			writer.WriteString("Unknown command\n")
			writer.Flush()
		}
	}
}

This example demonstrates error handling for reading client requests and handling key not found errors.

Conclusion

In this tutorial, we implemented a simple key-value store over TCP in Go. We covered creating a TCP server, implementing a basic key-value store, handling multiple clients concurrently, and adding error handling. You should now have a good foundation for building more complex networked applications in Go.

Feel free to modify and extend this implementation to fit your needs.