Applying the decorator pattern in Go.
The Decorator Pattern…
allows you to supplement an existing function with additional functionality. Usually in the form of a wrapper that takes and returns a function with the same function signature of the function we’re wrapping/decorating. The reason we can get away with this is because in Go, functions are first-class citzens. This means they can be assigned to values, passed as arguments to other functions and returned from other functions.
We’ll start with a more common and easily digestible example first and move on to a more performance-related example afterwards.
HTTP middle-ware
Considering the custom interface type http.Handler
, we know we can use anything as this interface type as long as it implements Serve.HTTP
.
The idea is to pass a type that satisfies this interface and returns it. Essentially intercepting the HTTP request in our example in order to wrap supplementary functionality around the handler before it’s returned for use.
func logWrapper(next http.Handler) http.Handler{
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request){
log.Println("log something important before...")
//line below satisfies interface of return type
next.ServeHTTP(w, r)
log.Println("or after...")
})
}
This accomplishes adding logging to the handler so whatever needs to be logged will be logged before the handler is used, afterwards or both if needed.
Another benefit of such wrappers is the flexibility to choose whether or not you want to invoke the original handler at all based on a conditional.
func checkSession(next http.Handler) http.Handler{
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request){
_,err := req.Cookie("session")
if err != nil{
log.Println("no cookie named session detected")
http.Redirect(w, req, "/",302)
// user is redirected and original handler is never invoked.
return
}
log.Println("session detected")
next.ServeHTTP(w,req)
})
}
So…
http.Handle("/home", dashboard)
becomes…
http.Handle("/home", checkSession(dashboard))
And the user is redirected to the landing page of the web application as opposed to their dashboard if a session cookie was not detected.
You can now re-use the checkSession
wrapper anywhere else you need some sort of session checking logic.
Calulating Pi
I wan’t to preface this by saying that the following code came from Tensor programming’s YT channel.
It’s also worth mentioning this example also requires a fundamental understanding of Go’s concurrency features. More specifically goroutines and channels.
Lets consider the function pi
:
func pi(n int) float64{
ch := make(chan float64)
for k := 0; k < n; k++ {
go func(ch chan float64, k float64){
ch <- 4 * math.Pow(-1, k) / (2*k + 1)
}(ch, float64(k))
}
result := 0.0
for k := 0; k < n; k++ {
result += <-ch
}
return result
}
Every go routine spawned within the first for loop sends the result of the mathematical calulation it performs into the channel. In the second loop we add every thing that comes out of the channel into the result.
func main(){
fmt.Println(pi(1000000)) //output : 3.141591653589774
}
Now what if we want to determine the amount of time it took to perform the calculation? We can use a decorator function for this. Also lets create a custom type for our pi function so we don’t have to type func(int) float64
in our params and return values everytime we create a new decorator.
type piFunc func(int) float64
func wrapLogger(pi piFunc, logger *log.Logger) piFunc{
return func(n int) float64{
fn := func(n int) (result float64){
defer func(t time.Time) {
logger.Printf("took=%v, n=%v, result=%v", time.Since(t),n,result)
}(time.Now())
return pi(n)
}
return fn(n)
}
}
wrapLogger
will take a piFunc
, a reference to a new logger, and returns a piFunc
. Taking and returning the piFunc
are the minimum decorator requirements. Adding additional params to decorators is ok and not uncommon.
Inside our return statements func literal, we create an fn
variable but, this time the return value in our func literal is named.
There are three things we need to understand about our defer function.
1- If you’re new to Go, defer functions get executed after their surrounding functions are returned.
2- We are intentionally using a named return value (result float64)
because the logger in the deferred function needs a way to access the result of return pi(n)
3- Notice the defer function takes a time.Time
param we call t
, the logger prints the time since t
, and the defer function is passed time.Now()
as t
. This is done in order to record the elapsed time between the starting and ending of pi(n)
inside that block. In other words, the time it took to perform the calculation.
So now we can pass a logger that prints to Stdout
and we get the following….
func main(){
piLogged := wrapLogger(pi,log.New(os.Stdout, "test", 1))
piLogged(1000)
}
//output
test run
2019/08/05 took=2.319982ms, n=1000, result=3.140592653839794
Optimizing performance with caching
Ideally, we don’t wan’t to have to perform the calculation again if we don’t have to. So why don’t we store the results in a map and add some functionality that allows the operation to recall the value of a previously used param value.
It’s worth mentioning that map
types are not thread safe by default. luckily the sync pkg
has a thread-safe version of the map type. This lets us safely spin up goroutines created by pi()
.
func wrapCache(fun piFunc, cache *sync.Map) piFunc {
return func(n int) float64 {
fn := func(n int) float64 {
key := fmt.Sprintf("n=%d", n)
val, ok := cache.Load(key)
if ok {
return val.(float64)
}
result := fun(n)
cache.Store(key, result)
return result
}
return fn(n)
}
}
In this example our reference to a sync.Map
acts as our cache and is named accordingly. The n
param passed to our return statement’s func literal gets loaded into the map as a key. That way later we can check the value for that map key and see if we’ve previousy stored a value for n
.
Notice cache.Load()
returns a second value of type bool which we store in ok
. This returns true if the value already exists in the map. Checking it lets us return the value for that key if it already exists and skips the execution and assignment of result := fun(n)
. Because of that, the cacheWrapper
needs to wrap before the logger in our entry point. Otherwise the value from the cache won’t get properly recorded into the logger.
func main() {
f := wrapCache(pi, &sync.Map{})
g := wrapLogger(f, log.New(os.Stdout, "test run\n", 1))
}
This gives the program a performance boost because reading the value from the cache
is faster than performing the calculation again.
But don’t take my word for it, the proof is in the pudding :
func main(){
f := wrapCache(pi, &sync.Map{})
g := wrapLogger(f, log.New(os.Stdout, "test run\n", 1))
g(100000)
g(20000)
g(100000)
}
faris$ go run main.go
test run
2019/08/05 took=257.663275ms, n=100000, result=3.141582653589719
test run
2019/08/05 took=25.834234ms, n=20000, result=3.141542653589824
test run
2019/08/05 took=513.245µs, n=100000, result=3.141582653589719
Notice that the first and last test run both received the same value for n
.
The first test run took 257.6 milliseconds where the final run took 513.245 microseconds. 1 millisecond = 1000 microseconds so we’ve essentially cut the time down from 257.6 milliseconds to about half a millisecond. Increasing the performance of that particular operation by almost 100%. In this example we used the decorator pattern for benchmarking and performance optimization.
Much love,
-Faris