How to Build a Caching Server (REST API) in Golang

Building a High-Performance In-Memory Cache with Go

Sambo Chea
CUBETIQ

--

Building a High-Performance In-Memory Cache with Go + REST API

In today’s world of microservices and cloud computing, caching has become an integral part of many applications. Caching provides fast access to frequently used data, reducing the load on the database and speeding up response times. In this article, we will look at how to build a simple caching server in Golang that can be used to store key-value pairs.

We will start by defining the HTTP endpoints that will be used to interact with the cache server. There will be four endpoints: GET, SET, DELETE, and KEYS. The GET endpoint will be used to retrieve a value stored in the cache by providing a key. The SET endpoint will be used to store a new value in the cache by providing a key-value pair. The DELETE endpoint will be used to remove a key-value pair from the cache. The KEYS endpoint will be used to retrieve all the keys and total sizes that are stored in the cache.

Next, we’ll create a Go program that implements these endpoints and the necessary functionality to store and retrieve data from the cache. The cache will be stored in memory and will be persisted to disk using JSON encoding. This means that the cache data will be saved to a file and loaded back into memory whenever the cache server is restarted.

The cache server will also implement a simple write-through caching strategy. This means that whenever data is stored or deleted from the cache, the changes will be immediately written to disk to ensure that they are not lost in case of a crash or a restart.

To implement the write-through caching strategy, we will use a Go channel to communicate between the HTTP handler functions and the worker function that persists the cache to disk. The worker function will run in a separate goroutine and will listen for changes to the cache data on the channel. Whenever a change is detected, the worker function will write the updated cache data to disk.

Let's get started!

1. Defines your variables

var cache = make(map[string]string)
var lock sync.RWMutex
var writeCacheCh = make(chan map[string]string, 100)

This code defines a concurrent cache in the Go programming language. The cache is implemented using a Go map, with keys of type string and values of type string. The map is stored in the cache variable.

To ensure that the cache is safe for concurrent use, the code uses a sync.RWMutex (read-write mutex). The mutex is stored in the lock variable. In Go, a mutex is a type of lock that is used to synchronize access to shared resources, such as the cache in this case.

The writeCacheCh the variable is a Go channel that is used to write updates to the cache. The channel has a buffer size of 100, which means that it can hold up to 100 messages before it blocks. The channel is used to send messages of type map[string]string, which represent updates to the cache.

This code provides the foundation for a concurrent cache that can be safely read and written from multiple goroutines (Go’s equivalent of threads) at the same time. The lock variable is used to ensure that only one goroutine can modify the cache at any given time, while multiple goroutines can read the cache concurrently. The writeCacheCh channel is used to queue cache updates and ensure that they are applied in a safe and controlled manner.

2. Defines the GET handler

func getHandler(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")

if key == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Key is required"})
return
}

log.Printf("GET key %s", key)
lock.RLock()
value, found := cache[key]
lock.RUnlock()

if !found {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "Key not found"})
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"key": key, "value": value})
}

This code defines a getHandler function in Go that implements an HTTP endpoint for handling GET requests. The function takes in two arguments, a http.ResponseWriter and a http.Request, which represent the HTTP response and the incoming HTTP request, respectively.

The function starts by extracting the key from the URL query parameters. If the key is an empty string, the function returns a 400 Bad Request response with a JSON-encoded error message indicating that a key is required.

Next, the function logs the GET request for the specified key using log.Printf.

To ensure thread safety while accessing the cache map, the function acquires a read lock using lock.RLock before retrieving the value associated with the key in the cache map. The function then releases the lock using lock.RUnlock.

If the key is not found in the cache, the function returns a 404 Not Found response with a JSON-encoded error message indicating that the key was not found.

Otherwise, the function sets the Content-Type header to application/json and returns a JSON-encoded response with the key and it's associated value in the cache.

3. Defines the SET handler

func setHandler(w http.ResponseWriter, r *http.Request) {
key := r.FormValue("key")
value := r.FormValue("value")

if key == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Key is required"})
return
}

log.Printf("SET key %s", key)
lock.Lock()
if cache[key] != "" {
log.Printf("Key %s already exists, overwriting", key)
}
cache[key] = value
lock.Unlock()

// Notify the persister that there are changes
writeCacheCh <- cache

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Value stored"})
}

This code defines a setHandler function in Go that handles an HTTP request to set a key-value pair in a cache.

The function first retrieves the key and value from the request, by calling r.FormValue("key") and r.FormValue("value") respectively. If the key is an empty string, a Bad Request (HTTP status code 400) response is sent back with a JSON object containing the error message "Key is required".

The function then logs the key and acquires a lock to update the cache by calling lock.Lock(). The code checks if the key already exists in the cache, and if so, logs a message "Key %s already exists, overwriting". The value is then stored in the cache using the key as the index. The lock is released with a call to lock.Unlock().

The function then sends the current state of the cache over the channel writeCacheCh which could be used by another part of the system to persist the cache. Finally, an HTTP OK (HTTP status code 200) response with a JSON object containing the message "Value stored" is sent back to the client.

Note that the code uses the json package to encode and send JSON data in the HTTP response, and the log package to log messages during the execution.

4. Defines the DELETE handler

func deleteHandler(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")

log.Printf("DELETE key %s", key)

if key == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Key is required"})
return
}

lock.Lock()
delete(cache, key)
lock.Unlock()

// Notify the persister that there are changes
writeCacheCh <- cache

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Key deleted"})
}

The code defines the deleteHandler function in Go. This function is an HTTP handler that implements the deletion of a key-value pair in a cache.

The function first retrieves the key value from the query parameters of the HTTP request using r.URL.Query().Get("key"). If the key is not provided in the request, the function returns an error with a Bad Request status code and a JSON response that contains the error message "Key is required".

The function logs the deletion operation using log.Printf("DELETE key %s", key).

The function then acquires a lock using lock.Lock() and deletes the key-value pair from the cache using delete(cache, key). The lock is then released using lock.Unlock(). This ensures that the deletion operation is atomic, i.e., it is executed as a single, uninterruptible step and prevents race conditions.

Finally, the function sends the updated cache to the persister by sending it through the writeCacheCh channel, and returns an HTTP response with a status code of OK and a JSON message that says "Key deleted".

5. Defines the KEYS handler

func getKeysHandler(w http.ResponseWriter, r *http.Request) {
keys := make([]string, 0, len(cache))
for k := range cache {
keys = append(keys, k)
}

// Get the total size of the cache that store in memory (in bytes)
cacheSize := 0
for _, v := range cache {
cacheSize += len(v)
}

json.NewEncoder(w).Encode(map[string]interface{}{"keys": keys, "size": cacheSize})
}

This code implements an HTTP handler function that returns a JSON response containing the keys and the size (in bytes) of the cache.

The function starts by creating a slice named “keys” with an initial capacity equal to the number of elements in the “cache” map. It then uses a for loop to iterate over the keys in the “cache” map and adds each key to the “keys” slice.

Next, the function calculates the size of the cache by iterating over each value in the “cache” map and adding the length of the value to the “cacheSize” variable.

Finally, the function writes a JSON response containing the “keys” slice and the “cacheSize” variable using the json.NewEncoder(w).Encode() function. The response is sent to the client using the provided "http.ResponseWriter" argument "w".

6. Defines the Persistence Cache function

func persistCache(c map[string]string) {
bytes, err := json.Marshal(c)
if err != nil {
fmt.Println("Error marshaling cache:", err)
return
}
err = ioutil.WriteFile("cache.json", bytes, 0644)
if err != nil {
fmt.Println("Error writing cache to disk:", err)
}

fmt.Println("Cache persisted")
}

The function persistCache takes a map c of string keys and string values, representing a cache of data. The function performs the following steps:

  1. It converts the map c into a JSON representation by calling json.Marshal on it. If there is an error, it logs the error message with the text "Error marshaling cache".
  2. It writes the JSON representation to a file named “cache.json” on disk by calling ioutil.WriteFile. If there is an error, it logs the error message with the text "Error writing cache to disk".
  3. Finally, it logs a message “Cache persisted” to indicate that the cache has been successfully persisted to disk.

7. Defines the Persistence Cache Worker function

func persistCacheWorker() {
for {
select {
case c := <-writeCacheCh:
persistCache(c)
}
}
}

The function persistCacheWorker is a worker that listens on the writeCacheCh channel for any updates to the cache. When an update is received, it calls the persistCache function with the updated cache as an argument. The persistCache function serializes the cache as a JSON object and writes it to a file named cache.json on disk.

This worker is running in a loop, waiting for updates on the channel, and once an update is received, it immediately persists the updated cache to disk. This ensures that the cache is persisted to disk in a timely manner, which can be important in the event of a crash or power outage.

Note that if there are frequent updates to the cache, the persistCache the function will be called frequently, which may lead to performance degradation as the cache is being persisted to disk more often than needed. In such cases, it may be a good idea to add a rate-limiting mechanism to the worker to ensure that it does not persist in the cache too frequently.

8. Defines the Load Persistence Cache function

func loadCache() error {
bytes, err := ioutil.ReadFile("cache.json")
if err != nil {
return err
}
return json.Unmarshal(bytes, &cache)
}

The function loadCache is responsible for reading the cache from a persistent storage, typically from a file.

It uses the ioutil.ReadFile function from the Go standard library to read the contents of the file named "cache.json". If the file does not exist or if there is an error in reading it, an error is returned.

If the file was read successfully, the function uses the json.Unmarshal function from the Go standard library to parse the contents of the file and store it in the cache map. The json.Unmarshal the function takes two arguments: the first is the JSON-encoded data, and the second is a pointer to the map where the decoded data should be stored.

The function returns the error returned by json.Unmarshal, which will be nil if the JSON data was decoded successfully.

9. Defines the router to serve the endpoints

func NewRouter() *http.ServeMux {
router := http.NewServeMux()
router.HandleFunc("/keys", getKeysHandler)
router.HandleFunc("/get", getHandler)
router.HandleFunc("/set", setHandler)
router.HandleFunc("/delete", deleteHandler)

printRoutes()
return router
}

The NewRouter the function creates a new instance of the http.ServeMux type, which is a multiplexer that matches incoming HTTP requests against a list of registered routes, and dispatches them to the associated handler.

In this function, four routes are registered to the http.ServeMux instance using the HandleFunc method. The first route is for getting all keys of the cache, which is registered to the getKeysHandler function. The second route is for getting a value from the cache using a key, which is registered to the getHandler function. The third route is for setting a value in the cache using a key and a value, which is registered to the setHandler function. The fourth route is for deleting a key-value pair from the cache, which is registered to the deleteHandler function.

Finally, the function calls printRoutes which might be a function that logs or displays the registered routes in some way, and returns the http.ServeMux instance.

Wrapping up

Here is my completed code for implementing the caching server:

package main

import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"sync"
)

var cache = make(map[string]string)
var lock sync.RWMutex
var writeCacheCh = make(chan map[string]string, 100)

func getHandler(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")

if key == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Key is required"})
return
}

log.Printf("GET key %s", key)
lock.RLock()
value, found := cache[key]
lock.RUnlock()

if !found {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]string{"error": "Key not found"})
return
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"key": key, "value": value})
}

func setHandler(w http.ResponseWriter, r *http.Request) {
key := r.FormValue("key")
value := r.FormValue("value")

if key == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Key is required"})
return
}

log.Printf("SET key %s", key)
lock.Lock()
if cache[key] != "" {
log.Printf("Key %s already exists, overwriting", key)
}
cache[key] = value
lock.Unlock()

// Notify the persister that there are changes
writeCacheCh <- cache

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Value stored"})
}

func deleteHandler(w http.ResponseWriter, r *http.Request) {
key := r.URL.Query().Get("key")

log.Printf("DELETE key %s", key)

if key == "" {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Key is required"})
return
}

lock.Lock()
delete(cache, key)
lock.Unlock()

// Notify the persister that there are changes
writeCacheCh <- cache

w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"message": "Key deleted"})
}

func getKeysHandler(w http.ResponseWriter, r *http.Request) {
keys := make([]string, 0, len(cache))
for k := range cache {
keys = append(keys, k)
}

// Get the total size of the cache that store in memory (in bytes)
cacheSize := 0
for _, v := range cache {
cacheSize += len(v)
}

json.NewEncoder(w).Encode(map[string]interface{}{"keys": keys, "size": cacheSize})
}

func persistCacheWorker() {
for {
select {
case c := <-writeCacheCh:
persistCache(c)
}
}
}

func persistCache(c map[string]string) {
bytes, err := json.Marshal(c)
if err != nil {
fmt.Println("Error marshaling cache:", err)
return
}
err = ioutil.WriteFile("cache.json", bytes, 0644)
if err != nil {
fmt.Println("Error writing cache to disk:", err)
}

fmt.Println("Cache persisted")
}

func loadCache() error {
bytes, err := ioutil.ReadFile("cache.json")
if err != nil {
return err
}
return json.Unmarshal(bytes, &cache)
}

type ExtendedRequest struct {
*http.Request
}

type ExtendedResponseWriter struct {
http.ResponseWriter
}

func (r *ExtendedRequest) Methods(methods ...string) bool {
for _, method := range methods {
if r.Method == method {
return true
}
}
return false
}

func (w *ExtendedResponseWriter) MethodNotAllowedResponse() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]string{"error": "Method not allowed"})
}

func routerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

if r.Method == "GET" {
next.ServeHTTP(w, r)
return
}

path := r.URL.Path

switch path {
case "/get":
if !(&ExtendedRequest{r}).Methods("GET") {
(&ExtendedResponseWriter{w}).MethodNotAllowedResponse()
return
}
case "/set":
if !(&ExtendedRequest{r}).Methods("POST") {
(&ExtendedResponseWriter{w}).MethodNotAllowedResponse()
return
}
case "/delete":
if !(&ExtendedRequest{r}).Methods("DELETE") {
(&ExtendedResponseWriter{w}).MethodNotAllowedResponse()
return
}
default:
if !(&ExtendedRequest{r}).Methods("GET") {
(&ExtendedResponseWriter{w}).MethodNotAllowedResponse()
return
}
}

next.ServeHTTP(w, r)
})
}

func printRoutes() {
fmt.Println("Available routes:")
fmt.Println("\n------------------------------------------------------------------------")
fmt.Println("| Method | Route | Description |")
fmt.Println("|--------|---------------------------------|-----------------------------|")
fmt.Println("| GET | /keys | Retrieve all keys of cache |")
fmt.Println("| GET | /get?key={key} | Retrieve value for given key|")
fmt.Println("| POST | /set?key={key}&value={value} | Add new key-value to cache |")
fmt.Println("| DELETE | /delete?key={key} | Delete key from cache |")
fmt.Println("--------------------------------------------------------------------------")
}

func NewRouter() *http.ServeMux {
router := http.NewServeMux()
router.HandleFunc("/keys", getKeysHandler)
router.HandleFunc("/get", getHandler)
router.HandleFunc("/set", setHandler)
router.HandleFunc("/delete", deleteHandler)

printRoutes()
return router
}

func main() {
err := loadCache()
if err != nil && !os.IsNotExist(err) {
fmt.Println("Error loading cache:", err)
os.Exit(1)
}

port := os.Getenv("PORT")
host := os.Getenv("HOST")

if port == "" {
port = "8080"
}

addr := fmt.Sprintf("%s:%s", host, port)

go persistCacheWorker()

router := NewRouter()
fmt.Println("Starting caching server listening on", addr)
err = http.ListenAndServe(addr, routerMiddleware(router))
if err != nil {
fmt.Println("Error starting server:", err)
}
}

Conclusion

In conclusion, the code above demonstrates the creation of a basic in-memory key-value cache using the Go programming language. The cache can be used to store key-value pairs and retrieve them later. The cache was built with HTTP endpoints for setting, getting, deleting, and listing keys, and these endpoints were registered with an HTTP router. The cache also supports persistence by writing its contents to a disk whenever changes are made, and it can reload the contents from the disk on startup.

By using Go’s built-in support for concurrency, we were able to make the cache thread-safe, ensuring that multiple requests can be processed simultaneously without interfering with each other. This is important because in a real-world scenario, the cache would likely be serving many requests concurrently, and we need to make sure that the cache’s data remains consistent across all requests.

This implementation is a simple starting point for building a more robust cache, but it provides a solid foundation for exploring more advanced concepts such as data partitioning, replication, and persistence. The code serves as a practical example of how Go’s concurrency and HTTP handling features can be used to build high-performance and scalable systems.

Thank you for taking the time to read this article. I hope that it has been informative and helpful. If you have found any mistakes or inaccuracies in the article, I would greatly appreciate it if you could bring it to my attention. This will help me improve the quality of my writing and ensure that my readers have access to accurate and up-to-date information. Thank you again for your time and support!

--

--