Photo by Caspar Camille Rubin on Unsplash
In this post, I want to share a pattern that helps in configure/construct a service. You can see the final result code first for easier to get the context
package main
import (
"time"
"github.com/jkaveri/fluent-option/fluentcache"
)
func main() {
connection := "localhost"
password := "JA$2mAe%1@s5"
db := 0
readTimeout := 2 * time.Second
writeTimeout := 2 * time.Second
dialTimeout := 5 * time.Second
// Configure redis cache fluent
redisCache := fluentcache.NewRedisCache(
fluentcache.UseStandalone(connection),
fluentcache.WithDB(db),
fluentcache.WithPassword(password),
fluentcache.WithTimeoutPolicy(
dialTimeout,
readTimeout,
writeTimeout,
),
)
// set key
_ = redisCache.Set("my_key", "my_value")
}
Problem
When design API in Golang I usually create a function that helps to configure the service.
// cache/cache.go
package cache
type RedisCache struct {
connection string
db int
password string
}
// RedisCache create new redis cache
func NewRedisCache(connection, password string, db int) *RedisCache {
return &RedisCache{
connection: connection,
password: password,
db: db,
}
}
func (*RedisCache) Set(key, value string) error {
return nil
}
func (*RedisCache) Get(key string) (string, error) {
return "", nil
}
I skipped some implementation detail to keep the code simple, you only need to focus the NewRedisCache
function
Then I can init my RedisCache
like this:
// main.go
package main
import "github.com/jkaveri/fluent-option/cache"
func main() {
connection := "localhost"
password := "JA$2mAe%1@s5"
db := 0
redisCache := cache.NewRedisCache(connection, password, 0)
// set key
_ = redisCache.Set("my_key", "my_value")
}
The configure looks good enough and I started to use that configure function in many places. Unfortunately, I was got a problem when I want to add more argument into the configure function because of I want to add more feature for my service, the problems are:
- In Go, we don’t have the Optional Argument
Experience tells us that defaulted arguments make it too easy to patch over API design flaws by adding more arguments, resulting in too many arguments with interactions that are difficult to disentangle or even understand – Go at Google: Language Design in the Service of Software Engineering
- In Go, we don’t have Overloading
Experience with other languages told us that having a variety of methods with the same name but different signatures was occasionally useful but that it could also be confusing and fragile in practice – Why does Go not support overloading of methods and operators?
So we only have these options:
-
Add new arguments into existing function and update existing code. This option is worse because of that impact on the existing code. Furthermore, in case your API is the dependency of other packages and this option can is a breaking change
// cachev2.go package cachev2 import "time" type RedisCache struct { connection string db int password string maxRetries int minRetryBackoff time.Duration maxRetryBackoff time.Duration } // RedisCache create new redis cache func NewRedisCache( connection, password string, db, maxRetries int, minRetryBackoff, maxRetryBackoff time.Duration, ) *RedisCache { return &RedisCache{ connection: connection, password: password, db: db, maxRetries: maxRetries, minRetryBackoff: minRetryBackoff, maxRetryBackoff: maxRetryBackoff, } } func (*RedisCache) Set(key, value string) error { return nil } func (*RedisCache) Get(key string) (string, error) { return "", nil }
-
Create a new function with meaning name, creating a new function makes sense but what if we will need more arguments in future? Of course, you can say we need compliance with YAGNI, but sometimes if we have an option that more flexible so that the API more stable. In other words, in a future version of API, the API’s clients don’t struggle about the breaking changes
package cachev3 import "time" type RedisCache struct { connection string db int password string maxRetries int minRetryBackoff time.Duration maxRetryBackoff time.Duration dialTimeout time.Duration readTimeout time.Duration writeTimeout time.Duration } // RedisCache create new redis cache func NewRedisCache(connection, password string, db int) *RedisCache { return &RedisCache{ connection: connection, password: password, db: db, } } func NewRedisCacheWithRetryPolicy( connection, password string, db, maxRetries int, minRetryBackoff, maxRetryBackoff time.Duration, ) *RedisCache { rc := NewRedisCache(connection, password, db) rc.maxRetries = maxRetries rc.minRetryBackoff = minRetryBackoff rc.maxRetryBackoff = maxRetryBackoff return rc } func NewRedisCacheWithTimeoutPolicy( connection, password string, db, maxRetries int, dialTimeout, readTimeout, writeTimeout time.Duration, ) *RedisCache { rc := NewRedisCache(connection, password, db) rc.dialTimeout = dialTimeout rc.readTimeout = readTimeout rc.writeTimeout = writeTimeout return rc } func (*RedisCache) Set(key, value string) error { return nil } func (*RedisCache) Get(key string) (string, error) { return "", nil }
Two solutions above can be applied in some cases it is simple and easy for implementing but it doesn’t flexible because of when you add new arguments you need a new API signature.
Solution
The solution is the application of Closure and the High Order Function.
Please read the code then I will explain more about the pattern
fluentcache/cache.go
package fluentcache
import (
"context"
"crypto/tls"
"net"
"time"
"github.com/go-redis/redis"
)
type ConfigureFunc = func(redisCache *RedisCache)
type DialerFunc = func(
ctx context.Context,
network,
addr string,
) (net.Conn, error)
type OnConnectFunc = func(conn *redis.Conn) error
type RedisCache struct {
sentinel bool
connection string
db int
password string
sentinelAddrs []string
masterName string
sentinelPassword string
dialer DialerFunc
onConnect OnConnectFunc
maxRetries int
minRetryBackoff time.Duration
maxRetryBackoff time.Duration
dialTimeout time.Duration
readTimeout time.Duration
writeTimeout time.Duration
poolSize int
minIdleConns int
maxConnAge time.Duration
poolTimeout time.Duration
idleTimeout time.Duration
idleCheckFrequency time.Duration
tLsConfig *tls.Config
}
// RedisCache create new redis cache
func NewRedisCache(configures ...ConfigureFunc) *RedisCache {
var rc RedisCache
for _, configure := range configures {
configure(&rc)
}
return &rc
}
func (*RedisCache) Set(key, value string) error {
return nil
}
func (*RedisCache) Get(key string) (string, error) {
return "", nil
}
fluentcache/configure.go
package fluentcache
import (
"crypto/tls"
"time"
)
func UseStandalone(connection string) ConfigureFunc {
return func(rc *RedisCache) {
rc.sentinel = false
rc.connection = connection
}
}
func UseSentinelRedis(
masterName string,
sentinelAddrs []string,
sentinelPassword string,
) ConfigureFunc {
return func(rc *RedisCache) {
rc.sentinel = true
rc.masterName = masterName
rc.sentinelAddrs = sentinelAddrs
rc.sentinelPassword = sentinelPassword
}
}
func WithPassword(password string) ConfigureFunc {
return func(rc *RedisCache) {
rc.password = password
}
}
func WithDB(db int) ConfigureFunc {
return func(rc *RedisCache) {
rc.db = db
}
}
func WithDialer(dialer DialerFunc) ConfigureFunc {
return func(rc *RedisCache) {
rc.dialer = dialer
}
}
func OnConnect(onConnectFunc OnConnectFunc) ConfigureFunc {
return func(rc *RedisCache) {
rc.onConnect = onConnectFunc
}
}
func WithRetryPolicy(
maxRetries int,
minRetryBackoff, maxRetryBackoff time.Duration,
) ConfigureFunc {
return func(rc *RedisCache) {
rc.maxRetries = maxRetries
rc.minRetryBackoff = minRetryBackoff
rc.maxRetryBackoff = maxRetryBackoff
}
}
func WithTimeoutPolicy(
dialTimeout, readTimeout, writeTimeout time.Duration,
) ConfigureFunc {
return func(rc *RedisCache) {
rc.dialTimeout = dialTimeout
rc.readTimeout = readTimeout
rc.writeTimeout = writeTimeout
}
}
func WithConnectionPoolPolicy(
poolSize, minIdleConns int,
maxConnAge, poolTimeout, idleTimeout, idleCheckFrequency time.Duration,
) ConfigureFunc {
return func(rc *RedisCache) {
rc.poolSize = poolSize
rc.minIdleConns = minIdleConns
rc.maxConnAge = maxConnAge
rc.poolTimeout = poolTimeout
rc.idleTimeout = idleTimeout
rc.idleCheckFrequency = idleCheckFrequency
}
}
func WithTLSOptions(tlsConfig *tls.Config) ConfigureFunc {
return func(rc *RedisCache) {
rc.tLsConfig = tlsConfig
}
}
As you can see, instead of using many arguments with different types. I only use 1 type is ConfigureFunc
with Variadic function.
The ConfigureFunc
will take the RedisCache
pointer as an argument and then ConfigureFunc
can set the value for the RedisCache
pointer.
With this pattern, my job is defining the fluent configure functions you can see them in fluentcache/configure.go
. My fluent configure functions will return the ConfigureFunc
(High Order Function) and the returned ConfigureFunc
can access the argument of outer function because of Go has support Closure.
With this pattern you can have some advantages:
- Configure service fluently
- Flexible API and reduce breaking changes
- Can be used for optional argument with a default value
Tradeoff
This pattern is good but it has some trade off
- The configure function will not clear with API consumer. I need document it carefully to help API consumer know what API supports
ConfigureFunc
is stateful so it isn’t good if I add newConfigureFunc
without understanding previousConfigureFunc
.
Conclusion
The fluent configure pattern is good but it requires extras effort to implement. So I don’t’ use always, I only use it when I see the API may be changed in the future. For example, I design an API for logging and I know I will change the way I log in future then I will use the fluent configure pattern
p.s: This pattern doesn’t new you can see this pattern in some popular package.