Cache Server

WHAT IS THIS?

This is an tutorial on how to make a simple cache server using Golang, with idiomatic Golang and a minimum of Goroutines.

The idea is to be able to do POST, GET and DELETE to respectively create a new cache key with a value, retrieve the cache value from the key and finally delete it. We also add the opportunity to use scheduling in Golang, using the time.Afterfunc(time.Duration, func) *time.Timer.

THOUGHTPROCESS

Whenever I do sketching out an application, all my ideas goes into one file main.go and over time I will separate and make it more neat.

REQUIREMENTS

  • OS of your choice
  • Text editor of your choice
  • Latest version of Golang

IMPLEMENTATION

In this example I will be using all the steps using Ubuntu, this is no way any endorsement to Ubuntu or Linux, but it is what I have available to me.

OPERATIONAL TODO

Create a folder in your home directory and enter the folder and create a Go module


$ mkdir cache101
$ cd cache101
$ go mod init cache101
$ touch main.go

CODING TODO

Further into these examples, I will be using Visual Code, but feel free to use whatever editor, this is again no endorsement to VSCode.

In VSCode you would open up the cache101 folder and edit main.go

package main


func main() {
    fmt.Println("Cache 101 ©")

}

To test this works you would be running in a shell following piece of code:


$ go run main.go

> Cache 101 ©

Now we are ready to actually begin the implementation, as we are working with a cache we understand we need a map to hold our key and value objects, we have an opportunity to use sync.Map which is a concurrent Map implementation, but without the type safety and potential slower operational time. I will be using a simple map[string]<UserDefinedType> and a sync.Mutex to lock our values, also I will be creating my own type to hold the value and an expiration date. We do want out implementation to remove old entries.

Back in our text editor in main.go

// between our import statement and func main I will create a new struct


type Item struct {
    Value string
    ExpireAt time.Time
}

// Further I will create our Cache struct to hold a map[string]Item and a sync.Mutex


type Cache struct {
    mu sync.Mutex
    Values map[string]Item
}

// Further in our main() I will be initializing the Cache struct


func main() {
    /// .....


    cache := &Cache{Values: make(map[string]Item)} // Use a reference to be able to change state


}

As we want this to be available to us through HTTP, I will add the http.ServeHTTP interface to our Cache type, and further implement POST, GET, DELETE inside ServeHTTP() I will be adding a HTTP listener using http.ListenAndServe()` and build some tests using generic Golang test framework.


// somewhere near the type Cache struct { ... }

func (c *Cache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    c.mu.Lock()
    defer c.mu.Unlock()
    key := r.URL[1:] // r.URL will return /..., what [1:] is to offset it by one, basically skipping /
    switch r.Method {
        case "POST":
        case "GET":
        case "DELETE":
        default:
            http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
    }
}


func main() {
    // .... means after previous written code


    log.Println("starting up server on port :8080")
    if err := http.ListenAndServe(":8080", cache); err != nil {
        log.Fatal(err)
    }
}

Now, if we run the application go run main.go, we will see the log message, and we can in another shell session, do some cURL exercises.



$ curl -iv http://localhost:8080
> # should show the HTTP headers and 200

$ curl -iv -X PUT http://localhost:8080
> # should return 405

$ CTRL+^C to exit application

So far so good, now we also locked the mutex everytime, we have an request to say to other goroutines, stop you have to come again later. In Golang.


GET OPERATION

Here we will delve into the yet boring, but so important GET part of the ServeHTTP

Basically, we want to look into the map, and retrieve the value of the key, and exit early if the map is empty with StatusNotFound. If the Cache does contain a key, we have to validate the ExpireAt against the current time, it will soon become clear, when we get to the code.


// In ServeHTTP 

func (c *Cache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // ...

    case "GET": 
    now := time.Now().UTC()
    cachedValue, ok := c.Values[key]
    if !ok {
        log.Printf("cached value is not found, key %s", key)
        http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
        return
    }
    if now.Before(cachedValue.ExpireAt) {
        fmt.Fprintf(w, "%s", cachedValue)
    } else {
        delete(c.Values, key) // delete the key from the Values of Cache.
        http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
        return
    }
}

Phew, this was a mouthful of code.

Let’s write some test, after this brief walkthrough. We define a variable to hold our current time in UTC, we look into the Values map and store that into two variables cachedValue and ok We then test if ok is false, that means our Values map didn’t contain our key and we exit to the user with NotFound.

If we found the value we will check that our current time is before the expired time, if we are before the expired time, return the value to the user. Else we remove the key from the Values map, and return NotFound

To make our tests work a bit easier, lets rewrite the now.Before into a new function, where we determine the current time, which will be good for our test cases.


func validateTime(now, other time.Time) bool {
    return now.Before(other)
}
// In ServeHTTP 

func (c *Cache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // ...

    case "GET": 
    now := time.Now().UTC()
    cachedValue, ok := c.Values[key]
    if !ok {
        log.Printf("cached value is not found, key %s", key)
        http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
        return
    }
    if validateTime(now, cachedValue.ExpireAt) {
        fmt.Fprintf(w, "%s", cachedValue)
    } else {
        delete(c.Values, key) // delete the key from the Values of Cache.
        http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
        return
    }
}

POST OPERATION

Lets look at how we would store a value in our Cache.

The idea is the application receive a new value using HTTP and POST method and can take whatever is sent in and store it, we would basically infer that all incoming data is a UTF8 string.

Lets jump into our ServeHTTP function and write that code.


func (c *Cache) ServeHTTP(...) {

    ///.... This code will be in the switch statement


    case "POST":
    bufReader := bufio.NewScanner(r.Body)
    var out strings.Builder
    for bufReader.Scan() {
        out.WriteString(bufReader.Text())
    }
    log.Printf("Received %d bytes", out.Len())
    item := &item{Value: out.String, ExpiredAt: now.Add(30*time.Minute)}
    c.Values[key] = item
    w.WriteHeader(http.StatusCreated)
}

Here we are using bufio to read from Request Body, and storing it in a strings.Builder in the hope of making this more efficient when handling large amount of data, we could add a TODO to enforce a maximum width, where we can change the maximum of data we allow based on the size of our host.

With the POST and GET we should be able to write our first test, which will check for a value, post a value and get a value.

TESTS

Now, we can look into writing our first tests, create a new file main_test.go. Side note, as we already can see our main.go is getting big, and we should really separate our main logic with our handler logic, we will come to that later. We also need to separate the logic from ServeHTTP into its own function, so we can test with a determined time.Time


// filename: main_test.go


func TestCache(t *testing.T) {
    collection := &cache{Values: make(map[string]Item)}
    server := httptest.NewServer(collection)

    t.Cleanup(func() {
        server.Close()
    })

    t.Run("check for non existent value and return 404", func(t *testing.T) {
        resp, err := http.DefaultClient.Get(server.URL+ "/na", "application/text", nil)
        if err != nil {
            t.Error(err)
        }

        if resp.StatusCode != http.StatusNotFound {
            t.Fail()
        }

    })

    t.Run("POST value to key 'cache101'", func(t *testing.T) {
        resp, err := http.DefaultClient.Post(server.URL+"/cache101", "text/plain", strings.NewReader("Hello, World"))
        if err != nil {
            t.Error(err)
        }

        if resp.StatusCode != http.StatusCreated {
            t.Fail()
        }
    })


    t.Run("GET value from key '/cache101'", func(t*testing.T) {
        resp, err := http.DefaultClient.Get(server.URL+"/cache101", "application/text", nil)
        if err != nil {
            t.Error(err)
        }

        if resp.StatusCode != http.StatusOK {
            t.Fail()
        }
    })


    // Add some values to our cache
}

SCHEDULE CLEANUP

After this rudimentary test, we could look at how to implement a clean up schedule using Golangs builtin time library.


// Somewhere in your main.go file add this function

func (c *Cache) schedule() *time.Timer {
    return time.AfterFunc(30 * time.Second, func() {
        for k, v := range c.Values {
            if time.Now().UTC().After(v.ExpiredAt) {
                c.mu.Lock()
                delete(c.Values, k)
                c.mu.Unlock()
            }
        }
        c.Schedule()
    })
}



// add in your main func

func main() {
    /// ....


    scCh := c.schedule()

    // add below code in your error handler for ListenAndServe, before the log.Fatal.

    if err := http.ListenAndServe(...); err != nil {
        scCh.Stop()
        // ...
    }
}
    

Now we have very easy added a schedule, of course you have the possibility to change the durations and what else, but this will give you a very rudimentary HTTP cache for your projects, if you need it, you could export the Cache struct and use it as a embedded cache in other projects, you could add Expires, Pragma, Cache-Control and much much more.

I left the DELETE as an exercise for the reader, based on the above, deletion should be very straightforward.

Thank you for reading this far, and good luck with your coding.

REFERENCES

CHANGELOG

  • 2021-07-18 First draft
  • 2021-07-19 Added POST, Schedule and a simple test