diff --git a/cache/cache.go b/cache/cache.go index 58697eb..0380db1 100644 --- a/cache/cache.go +++ b/cache/cache.go @@ -1,44 +1,42 @@ +// Package cache provides a simple key-value cache store, supporting +// just a handful of caching operations. package cache import ( "errors" - "io" - "net" "sync" "time" - "github.com/go-redis/redis" - "github.com/nickhstr/goweb/env" + "github.com/nickhstr/goweb/cache/redis" "github.com/nickhstr/goweb/logger" ) -type redisClient interface { - Del(...string) *redis.IntCmd - Get(string) *redis.StringCmd - Set(string, interface{}, time.Duration) *redis.StatusCmd -} +var log = logger.New("cache") +var client Cacher -var log = logger.New("redis") -var client redisClient -var clientInit sync.Once -var noClientMsg = "no redis client available" +// Cacher defines the methods of any cache client. +type Cacher interface { + Del(...string) error + Get(string) ([]byte, error) + Set(string, interface{}, time.Duration) error +} // Del removes data at the given key(s) func Del(keys ...string) error { - clientInit.Do(clientSetup) + // init default Cacher if not set already + CacherInit(nil) log := log.With().Str("operation", "DEL").Logger() var err error if client == nil { - err = errors.New(noClientMsg) - log.Err(err).Msg(err.Error()) + err = noClientLogErr(log) return err } - _, err = client.Del(keys...).Result() + err = client.Del(keys...) if err != nil { - log.Warn(). + log.Info(). Err(err). Msg(err.Error()) } @@ -48,7 +46,8 @@ func Del(keys ...string) error { // Get returns the data stored under the given key. func Get(key string) ([]byte, error) { - clientInit.Do(clientSetup) + // init default Cacher if not set already + CacherInit(nil) log := log.With().Str("operation", "GET").Logger() var ( @@ -57,28 +56,15 @@ func Get(key string) ([]byte, error) { ) if client == nil { - err = errors.New(noClientMsg) - log.Err(err).Msg(err.Error()) + err = noClientLogErr(log) return []byte{}, err } - data, err = client.Get(key).Bytes() + data, err = client.Get(key) if err != nil { - if err.Error() == "redis: nil" { - log.Debug(). - Str("key", key). - Msg("Key not found") - } else if err == io.EOF { - log.Error(). - Str("key", key). - Err(err). - Msg("Redis unavailable") - } else { - log.Warn(). - Str("key", key). - Err(err). - Msg(err.Error()) - } + log.Info(). + Str("key", key). + Msg("Cache key not found") } return data, err @@ -86,20 +72,20 @@ func Get(key string) ([]byte, error) { // Set stores data for a set period of time at the given key. func Set(key string, data []byte, expiration time.Duration) error { - clientInit.Do(clientSetup) + // init default Cacher if not set already + CacherInit(nil) log := log.With().Str("operation", "SET").Logger() var err error if client == nil { - err = errors.New(noClientMsg) - log.Err(err).Msg(err.Error()) + err = noClientLogErr(log) return err } - _, err = client.Set(key, data, expiration).Result() + err = client.Set(key, data, expiration) if err != nil { - log.Warn(). + log.Info(). Err(err). Msg(err.Error()) return err @@ -108,59 +94,30 @@ func Set(key string, data []byte, expiration time.Duration) error { return nil } -func clientSetup() { - if env.Get("REDIS_HOST") == "" || - env.Get("REDIS_PORT") == "" || - env.Get("REDIS_MODE") == "" { - log.Error(). - Str("redis-host", env.Get("REDIS_HOST")). - Str("redis-port", env.Get("REDIS_PORT")). - Str("redis-mode", env.Get("REDIS_MODE")). - Msg("Environment variable(s) not set") - +var cacherInit sync.Once + +// CacherInit sets the Cacher to be used for all cache operations. +// If an init func is supplied, it will be used for setup; otherwise, +// the default Cacher will be used. +// The supplied init function must accept a Cacher as its argument, so +// that `client` may be set. +func CacherInit(init func() Cacher) { + if init == nil { + // default to redis.Cacher + cacherInit.Do(func() { + client = redis.New() + }) return } - if client != nil { - return - } - - addr := net.JoinHostPort( - env.Get("REDIS_HOST", "localhost"), - env.Get("REDIS_PORT", "6379"), - ) - mode := env.Get("REDIS_MODE", "server") - maxRetries := 1 - minRetryBackoff := 8 * time.Millisecond - maxRetryBackoff := 512 * time.Millisecond - onConnect := func(c *redis.Conn) error { - log.Info(). - Str("address", addr). - Str("mode", mode). - Msg("Connected to Redis") - return nil - } + cacherInit.Do(func() { + client = init() + }) +} - switch mode { - case "cluster": - clusterOptions := &redis.ClusterOptions{ - Addrs: []string{addr}, - MaxRetries: maxRetries, - MinRetryBackoff: minRetryBackoff, - MaxRetryBackoff: maxRetryBackoff, - OnConnect: onConnect, - } - client = redis.NewClusterClient(clusterOptions) - case "server": - fallthrough - default: - options := &redis.Options{ - Addr: addr, - MaxRetries: maxRetries, - MinRetryBackoff: minRetryBackoff, - MaxRetryBackoff: maxRetryBackoff, - OnConnect: onConnect, - } - client = redis.NewClient(options) - } +// Creates no-client error, logs it, and returns it +func noClientLogErr(log logger.Logger) error { + err := errors.New("no cache client available") + log.Error().Msg(err.Error()) + return err } diff --git a/cache/cache_test.go b/cache/cache_test.go index 3fefcb8..2b20886 100644 --- a/cache/cache_test.go +++ b/cache/cache_test.go @@ -1,66 +1,41 @@ -package cache +package cache_test import ( - "os" "testing" "time" - "github.com/go-redis/redis" + "github.com/nickhstr/goweb/cache" "github.com/stretchr/testify/assert" ) -type mockClient struct{} +type mockCacher struct{} -func (mc mockClient) Del(key ...string) *redis.IntCmd { - return &redis.IntCmd{} +func (mc mockCacher) Del(key ...string) error { + return nil } -func (mc mockClient) Get(key string) *redis.StringCmd { - return &redis.StringCmd{} +func (mc mockCacher) Get(key string) ([]byte, error) { + return []byte{}, nil } -func (mc mockClient) Set(key string, val interface{}, ttl time.Duration) *redis.StatusCmd { - return &redis.StatusCmd{} -} - -func setupEnv() func() { - ogHost := os.Getenv("REDIS_HOST") - ogPort := os.Getenv("REDIS_PORT") - ogMode := os.Getenv("REDIS_MODE") - - _ = os.Setenv("REDIS_HOST", "localhost") - _ = os.Setenv("REDIS_PORT", "6379") - _ = os.Setenv("REDIS_MODE", "server") - - return func() { - _ = os.Setenv("REDIS_HOST", ogHost) - _ = os.Setenv("REDIS_PORT", ogPort) - _ = os.Setenv("REDIS_MODE", ogMode) - } +func (mc mockCacher) Set(key string, val interface{}, t time.Duration) error { + return nil } func TestDel(t *testing.T) { assert := assert.New(t) - restoreEnv := setupEnv() - defer restoreEnv() - - ogClient := client - defer func() { client = ogClient }() - client = mockClient{} - - err := Del("key") + cache.CacherInit(func() cache.Cacher { + return &mockCacher{} + }) + err := cache.Del("key") assert.Nil(err) } func TestGet(t *testing.T) { assert := assert.New(t) - restoreEnv := setupEnv() - defer restoreEnv() - - ogClient := client - defer func() { client = ogClient }() - client = mockClient{} - - val, err := Get("key") + cache.CacherInit(func() cache.Cacher { + return &mockCacher{} + }) + val, err := cache.Get("key") assert.Nil(err) assert.Equal([]byte{}, val) @@ -68,12 +43,9 @@ func TestGet(t *testing.T) { func TestSet(t *testing.T) { assert := assert.New(t) - restoreEnv := setupEnv() - defer restoreEnv() - - ogClient := client - defer func() { client = ogClient }() - client = mockClient{} + cache.CacherInit(func() cache.Cacher { + return &mockCacher{} + }) - assert.NotPanics(func() { _ = Set("key", []byte{}, 60*time.Second) }) + assert.NotPanics(func() { _ = cache.Set("key", []byte{}, 60*time.Second) }) } diff --git a/cache/redis/redis.go b/cache/redis/redis.go new file mode 100644 index 0000000..fe10c41 --- /dev/null +++ b/cache/redis/redis.go @@ -0,0 +1,101 @@ +// Package redis provides a wrapper around github.com/go-redis/redis, specifically +// to satisfy the cache.Cacher interface. +package redis + +import ( + "io" + "net" + "time" + + "github.com/go-redis/redis" + "github.com/nickhstr/goweb/env" + "github.com/nickhstr/goweb/logger" +) + +var log = logger.New("redis") + +type redisClient interface { + Del(...string) *redis.IntCmd + Get(string) *redis.StringCmd + Set(string, interface{}, time.Duration) *redis.StatusCmd +} + +// Cacher holds a redisClient instance, and satisfies the cache.Cacher interface. +type Cacher struct { + client redisClient +} + +// Del deletes keys. +func (c *Cacher) Del(keys ...string) error { + _, err := c.client.Del(keys...).Result() + return err +} + +// Get returns the data stored under a key. +func (c *Cacher) Get(key string) ([]byte, error) { + data, err := c.client.Get(key).Bytes() + if err != nil { + if err.Error() == "redis: nil" { + log.Debug(). + Str("key", key). + Msg("Key not found") + } else if err == io.EOF { + log.Error(). + Str("key", key). + Err(err). + Msg("Redis unavailable") + } + } + return data, err +} + +// Set stores data under a key for a set amount of time. +func (c *Cacher) Set(key string, val interface{}, t time.Duration) error { + _, err := c.client.Set(key, val, t).Result() + return err +} + +// New returns an instance of Cacher. +func New() *Cacher { + addr := net.JoinHostPort( + env.Get("REDIS_HOST", "localhost"), + env.Get("REDIS_PORT", "6379"), + ) + mode := env.Get("REDIS_MODE", "server") + maxRetries := 1 + minRetryBackoff := 8 * time.Millisecond + maxRetryBackoff := 512 * time.Millisecond + onConnect := func(c *redis.Conn) error { + log.Info(). + Str("address", addr). + Str("mode", mode). + Msg("Connected to Redis") + return nil + } + + var rc redisClient + switch mode { + case "cluster": + clusterOptions := &redis.ClusterOptions{ + Addrs: []string{addr}, + MaxRetries: maxRetries, + MinRetryBackoff: minRetryBackoff, + MaxRetryBackoff: maxRetryBackoff, + OnConnect: onConnect, + } + rc = redis.NewClusterClient(clusterOptions) + case "server": + fallthrough + default: + options := &redis.Options{ + Addr: addr, + MaxRetries: maxRetries, + MinRetryBackoff: minRetryBackoff, + MaxRetryBackoff: maxRetryBackoff, + OnConnect: onConnect, + } + rc = redis.NewClient(options) + } + + return &Cacher{rc} +} diff --git a/dal/fetch.go b/dal/fetch.go index b036ea0..44e9f24 100644 --- a/dal/fetch.go +++ b/dal/fetch.go @@ -86,12 +86,12 @@ func Fetch(fc *FetchConfig) ([]byte, error) { if !fc.NoCache { // Try to get response from cache - cachedResp, err := cache.Get(fc.CacheKey) - if err == nil { + cachedResp, cacheErr := cache.Get(fc.CacheKey) + if cacheErr == nil { log.Info(). Str("url", fetchURL). Str("response-time", time.Since(start).String()). - Bool("redis", true). + Bool("cache", true). Msg("DAL request") return cachedResp, nil @@ -116,7 +116,7 @@ func Fetch(fc *FetchConfig) ([]byte, error) { Str("url", fetchURL). Str("response-time", time.Since(start).String()). Dur("ttl", ttl). - Bool("redis", false). + Bool("cache", false). Msg("DAL request") body, err := ResponseBody(resp)