Modernising go-cachewrapper
Almost four years ago, in April 2014, I wrote a small Go microservice for serving files directly from an OpenStack Swift installation that wasn’t directly exposed on the internet.
From this, I extracted a small Go package “go-cachewrapper”, which provides a simple HTTP wrapper that adds cache headers to responses.
It was one of the earliest Go packages I’d written, and looking back on it (and wanting to reuse it for another service), I decided to modernise it.
In the past four years, my understanding of Go has increased a lot, and others have shared newer techniques, as we learn more and more about where the syntax can be shaped, and made idiomatic.
Functional Options
A few months after that code was committed, Dave Cheney wrote “Functional options for friendly APIs”.
The short-version is that it involves passing closures to functions, which provide arguments for the call.
At the time, I had been using Sean Treadway’s streadyway/amqp
package, which
is really helpful for getting Go talking to a RabbitMQ service, but the API
leaves a fair bit to be desired…
The Channel.Consume method is one of many that is hard to read.
func (ch *Channel) Consume(queue, consumer string, autoAck, exclusive,
noLocal, noWait bool, args Table) (<-chan Delivery, error)
The example code for using the package contains this single call:
deliveries, err := c.channel.Consume(
queue.Name, // name
c.tag, // consumerTag,
false, // noAck
false, // exclusive
false, // noLocal
false, // noWait
nil, // arguments
)
You need to know the order of all the options, and what type they are,
irrespective of whether or not you need the options (note all the false
values).
Last year, while building an HTTP-based client for an internal service, refactoring got us to a method…
func (c Client) call(method, path string, status int, body []byte, v interface{},
opts ...RequestOption) error {
req, err := c.createRequest(method, path, body)
if err != nil {
return errors.Wrap(err, "failed to createRequest")
}
for _, o := range opts {
o(req)
}
...
resp, err := http.DefaultClient.Do(req)
...
The opts
argument takes a variadic number of RequestOption
s, which are
declared as:
type RequestOption func(r *http.Request)
They take a *http.Request
and are responsible for modifying it.
This allowed me to write custom requests for some calls including
// WithSecurityToken will add a request header with the provided token to
// requests.
func WithSecurityToken(t string) RequestOption {
return func(r *http.Request) {
r.Header.Set("X-SecurityHeader", t)
}
}
And, this means that it’s easy for requests that need to make that call, to append the option, and the code doesn’t need arguments for every possible option.
Modernising go-cachewrapper
In the 2014 version of the code, the wrapper function accepted a struct with all the possible options, and it was actually fairly easy to configure the bits you needed.
package main
import (
"fmt"
"log"
"net/http"
"time"
cw "github.com/bigkevmcd/go-cachewrapper"
)
func helloWorld(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!\n")
}
const cacheTime = time.Minute * 60
func main() {
options := cw.CacheOptions{MaxAge: cacheTime, NoTransform: true}
http.Handle("/", cw.Cached(http.HandlerFunc(helloWorld), options))
log.Fatal(http.ListenAndServe(":8000", nil))
}
And so, I looked to take what I’ve learned in the past four years, and see what I’d change to improve the API.
Of course, starting with a test (or a table of them).
var cacheOptionFuncTests = []struct {
f optionFunc
h string
}{
{Immutable(), "max-age=31536000"},
{Private(), "private"},
{MaxAge(time.Hour * 24 * 13), "max-age=1123200"},
{NoCache(), "no-cache"},
{NoStore(), "no-store"},
{MustRevalidate(), "must-revalidate"},
{ProxyRevalidate(), "proxy-revalidate"},
{SharedMaxAge(time.Hour * 13), "s-maxage=46800"},
}
func TestOptionFuncs(t *testing.T) {
for _, tt := range cacheOptionFuncTests {
co := CacheOptions{}
tt.f(&co)
if msg := co.String(); tt.h != msg {
t.Errorf("got '%s', wanted '%s'", msg, tt.h)
}
}
}
cacheOptionFuncTests
is a table test with functions, and the stringified
version of the pragmas that are generated.
The test creates a CacheOptions
value, and applies the closure, to it, and
compares the string.
One of the changes since 2014, is using t.Errorf
instead of t.Fatalf
in
table tests, that was a mistake at the time, I think table tests should test all
the elements in the table, and report the failing ones, rather than failing
fast.
Implementing the optionFunc
s for the tests is fairly trivial, I’ll cover two
of them here…
// MaxAge configures the maximum-age cache option.
func MaxAge(d time.Duration) optionFunc {
return func(o *CacheOptions) {
o.MaxAge = d
}
}
// NoTransform configures the the no-transform pragma.
func NoTransform() optionFunc {
return func(o *CacheOptions) {
o.NoTransform = true
}
}
The original TestCacheControl
test needs to be modernised, to pass in the
configuration options.
func TestCacheControl(t *testing.T) {
w := httptest.NewRecorder()
r, _ := http.NewRequest("POST", "http://example.com/foo", nil)
handler := func(w http.ResponseWriter, r *http.Request) {}
opts := []optionFunc{MaxAge(time.Hour * 24 * 13), NoTransform()}
cached := Cached(http.HandlerFunc(handler), opts...)
cached.ServeHTTP(w, r)
wanted := "no-transform, max-age=1123200"
if pragmas := w.Header().Get("Cache-Control"); pragmas != wanted {
t.Fatalf("Cache-Control header: got %s, wanted '%s'", pragmas, wanted)
}
}
Note that I can create a slice-literal with the closures, and pass them in as
variadic parameters, with the opts...
syntax.
Backwards compatiblity
These changes are not backwards compatible with the previous code, if this was important, it would be simple to add an option that accepts the entire configuration.
// Config takes a CacheOptions value and replaces the existing options.
func Config(co CacheOptions) optionFunc {
return func(o *CacheOptions) {
*o = co
}
}
Additional testing
Originally when I wrote this, I intended for handlers to be able to overwrite the cache control headers, but I didn’t explicitly write a test for this.
func TestMaintainsExistingCacheOptions(t *testing.T) {
w := httptest.NewRecorder()
r, _ := http.NewRequest("POST", "http://example.com/foo", nil)
handler := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "must-revalidate") }
}
opts := []optionFunc{MaxAge(time.Hour * 24 * 13), NoTransform()}
cached := Cached(http.HandlerFunc(handler), opts...)
cached.ServeHTTP(w, r)
wanted := "must-revalidate"
if pragmas := w.Header().Get("Cache-Control"); pragmas != wanted {
t.Fatalf("Cache-Control header: got %s, wanted '%s'", pragmas, wanted)
}
}
This works because I set the headers before calling the wrapped handler.
// ServeHTTP sets the header and passes the request and response to the
// wrapped http.Handler
func (c *CacheControl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", c.options.String())
c.handler.ServeHTTP(w, r)
}
All this is a reminder that Go’s http.Handler
abstraction is really powerful,
and belies the simplicity of the exposed interface.