24

Decorators

 5 years ago
source link: https://www.tuicool.com/articles/hit/FjiAR3Z
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Introduction

In this blogpost I’m going to explain how to keep your code clean using decorators. By applying decorators the open close principle is maintained, code becomes easier to extend and easier to adjust.

Background

According to wikipedia the decorator pattern is a design pattern that allows behaviour to be added to an individual object, without affecting the behavior of other objects from the same class. In go this means that we extend the functionality of a function or method(s) of a struct without changing the original functions or method(s).

Consider the following interface:

type Adder interface {
  Add(x, y int) int
}

We can implement this interface and use it somewhere in our code

package main

type adderImpl struct {}

func (adderImpl) Add(x, y int) int {
  return x + y
}

func main() {
  var a Adder = adderImpl{}

  fmt.Println(a.Add(1,2))
  // prints => 3
}

This is idiomatic go code and works fine for adding numbers. Because we have an interface with 1 function we can also create a function signature for it, which implements the Adder interface. This way we can use it even more easily, it’s the same principle as the http.HandlerFunc from the standard library.

type AdderFunc func(x, y int) int

func (a AdderFunc) Add(x, y int) int {
  return a(x, y)
}

What happens in the code above is that we define a type which is a function. On this function type we implement the original Adder interface and propagate the Add call to the AdderFunc . This way we won’t need to implement the interface everytime on a struct and can just use a function to implement the behaviour.

package main

func main() {
  a := AdderFunc(
    func(x, y int) int {
      return x + y
    },
  )
  fmt.Println(Do(a))
  // prints => 3
}

func Do(adder Adder) int {
  return adder.Add(1, 2)
}

Note the type conversion for the anonymous function to the AdderFunc is required because it’s not able to implicitly convert an anonymous function to the Adder interface directly. If the Do function would accept an AdderFunc instead of an Adder it is possible to pass in the anonymous function directly and the compiler knows how to convert the function to the AdderFunc type implicitly.

Middleware

Let’s say we wan’t to add logging capabilities to the adder function. We can do a naive way and add logging to the original function but this will introduce a dependency on the log package in our business domain logic. It also violates the single responsiblity and open close principle because the code is not only adding numbers anymore but also logging. Below I illustrate an example of the wrong way.

Wrong way

package main

func main() {
  a := AdderFunc(
    func(x, y int) (result int) {
      defer func(t time.Time) {
        log.Printf("took=%v, x=%v, y=%v, result=%v", time.Since(t), x, y, result)
      }(time.Now())
      return x + y
    },
  )
  fmt.Println(Do(a))
  // prints => 2009/11/10 23:00:00 took=0s, x=1, y=2, result=3
  // prints => 3
}

func Do(adder Adder) int {
  return adder.Add(1, 2)
}

There is a lot happening in the code above, after the x + y is added there runs another function using defer which logs the input and output values, aswell as the time it took to calculate x + y . Currently our code is all in main , this is not really a violation but when we start seperating the code in packages the logging code starts to occur everywhere.

A better approach would be to apply the Decorator pattern often called Middleware in Go. This pattern at its simplest is running code before and/or after our original code. The Middleware pattern is the decorator pattern applied in Go . When using custom Middleware functions wrapping our interface we can add behaviour on top of the original behaviour. Our Middleware function takes in an Adder and returns a new decorated Adder .

// Middleware function, this function takes in a `Adder` and returns a new `Adder`.
type AdderMiddleware func(Adder) Adder

func Wraplogger(logger *log.Logger) AdderMiddleware {
  return func(a Adder) Adder {
    // Using `AdderFunc` to implement the `Adder` interface.
    fn := func(x, y int) (result int) {
      defer func(t time.Time) {
        logger.Printf("took=%v, x=%v, y=%v, result=%v", time.Since(t), x, y, result)
      }(time.Now()) 
      // Propogate call to original adder
      return a.Add(x, y)
    }
    // Return a new `Adder` wrapped with the loggin functionality
    return AdderFunc(fn)
  }
}

As you can see the AdderFunc type is used, this type makes sure that the fn function is converted to implement the Adder interface. The fn closure calls the logger after the original Adder is called. The beauty of this is that the WrapLogger function doesn’t know any of the internals of the original Adder it only extends the behaviour of the Adder by loggin the input and output. This way we adhere to the open close principle. By combining the power of first class functions and the middleware pattern we are able to add additional behaviour on top of the original behaviour.

Middleware can be written for any type of functionality you want to add to an Adder . Consider the case that we also need to cache the results, because adding our numbers takes to much compute time, we can easily add a caching layer on top of our Adder by writing a Middleware function. I’ll be using a sync.Map from the std library here to implement the caching.

NOTEthat this is pure for illustration purposes and caching the results will likely be a performance hit.

func WrapCache(cache *sync.Map) AdderMiddleware {
  return func(a Adder) Adder {
    fn := func(x, y int) int {
      key := fmt.Sprintf("x=%dy=%d", x, y)
      val, ok := cache.Load(key)
      if ok {
        return val.(int)
      }
      result := a.Add(x, y)
      cache.Store(key, result)
      return result
    }
    return AdderFunc(fn)
  }
}

The sole purpose of the WrapCache function is to cache the response of the Adder it’s wrapping. The original Adder has no clue about this caching functionality. If we get a new requirement to only cache even number we only have to change the WrapCache function to cache only on even numbers. Also if we need to use an external cache like redis or memcache we can write a new caching middleware function for this type of cache and use that to wrap the original adder instead of using the WrapCache method. This allows us to easily switch between implementations (also interfaces can be used to depend on only abstractions in the WrapCache method)

Testing

Testing using this pattern becomes easier aswell, we can test the business logic seperatly from the caching logic and logging logic. In our business logic we only have to test the original Adder functionality, in for example the WrapCache function we only have to test if the result is cached, we don’t care about the result itselfs only if it’s correctly cached or not.

Chaining

When you have many functions decorating the original behaviour combining them can become messy.

func main() {
  a = WrapCache(&sync.Map{})(WrapLogger(log.New(os.Stdout, "test", 1))(a))
  a.Add(10, 20)
}

For this we can have an elegant solution called chaining, this solution makes it more human readable to see how the wrapping works.

func Chain(outer AdderMiddleware, middleware ...AdderMiddleware) AdderMiddleware {
  return func(a Adder) Adder {
    topIndex := len(middleware) - 1
    for i := range middleware {
      a = middleware[topIndex-i](a)
    }
    return outer(a)
  }
}

The above code loops through the given middleware functions in reverse order and inserts the Adder in it. With every iteration the Adder is overwritten to the wrapped adder which is used as input for the next middleware . The reason why the loop is in reverse order is because this way the wrapped function is called in the order the middlewares are declared. That means that the first middleware function is the outermost middleware wrapper. Chain is also a middleware function, but instead of having to do all this wrapping manually, the Chain function can be used like this, making the main function way cleaner:

func main() {
  var a Adder = AdderFunc(
    func(x, y int) int {
      return x + y
    },
  )
  a = Chain(
    WrapLogger(log.New(os.Stdout, "test", 1)),
    WrapCache(&sync.Map{}),
  )(a)

  a.Add(10, 20)
}

When reading this codes it more obvious what is happening, adding new middleware functions is also a bit easier and doesn’t make the code messy.

Further thoughts

This pattern can be really usefull to seperate the business logic from things which don’t belong in the business logic, like caching, logging and metrics. It also allows you to think in terms of small composable interfaces and how to cleanly add additional features on top of them without changing the original code and behaviour. In the end your main function starts to look very clean and clear for a future developer. My main functions started to look like this:

gc := geocoder.Chain(
  geocoder.WrapLogger(svcLogger.WithField("geocoder", "google-maps")),
  geocoder.WrapMetrics(mockMetrics),
  geocoder.WrapLRUCache(mockMetrics, 8192),
)(geocoder.Googlemaps(gm, "NL"))

svc := geoparser.Chain(
  geoparser.WrapLogger(svcLogger.WithField("geoparser", "parser")),
  geoparser.WrapMetrics(mockMetrics),
)(geoparser.WrapService(mockMetrics, p, gc))

My functions became easier to test and were also easier to adjust, this pattern allowed for better maintainability overall. The pattern can also be applied to interfaces with multiple functions, but this is left as an exercise for the reader.

Thanks for reading and happy programming (y)!

Running code can be found here playground and gist


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK