How to Build a Caching Server (REST API) in Golang
Building a High-Performance In-Memory Cache with Go
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:
- It converts the map
c
into a JSON representation by callingjson.Marshal
on it. If there is an error, it logs the error message with the text "Error marshaling cache". - 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". - 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!