From 05a299b76c7c08ac1190113e385214a2dab56b09 Mon Sep 17 00:00:00 2001 From: Niek den Breeje Date: Tue, 31 Aug 2021 16:31:16 +0200 Subject: [PATCH] Add simple in-memory cache (#2231) * Add simple in-memory cache * Support configuring cache expiration duration * Support preinitializing cache with items * Register cache --- cache/cache.go | 65 +++++++++++++++++++++++++ cache/cache_test.go | 111 ++++++++++++++++++++++++++++++++++++++++++ cache/default.go | 68 ++++++++++++++++++++++++++ cache/options.go | 40 +++++++++++++++ cache/options_test.go | 43 ++++++++++++++++ cmd/cmd.go | 5 ++ cmd/options.go | 16 ++++++ options.go | 9 ++++ 8 files changed, 357 insertions(+) create mode 100644 cache/cache.go create mode 100644 cache/cache_test.go create mode 100644 cache/default.go create mode 100644 cache/options.go create mode 100644 cache/options_test.go diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 00000000..452201d9 --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,65 @@ +package cache + +import ( + "context" + "errors" + "time" +) + +var ( + // DefaultCache is the default cache. + DefaultCache Cache = NewCache() + // DefaultExpiration is the default duration for items stored in + // the cache to expire. + DefaultExpiration time.Duration = 0 + + // ErrItemExpired is returned in Cache.Get when the item found in the cache + // has expired. + ErrItemExpired error = errors.New("item has expired") + // ErrKeyNotFound is returned in Cache.Get and Cache.Delete when the + // provided key could not be found in cache. + ErrKeyNotFound error = errors.New("key not found in cache") +) + +// Cache is the interface that wraps the cache. +// +// Context specifies the context for the cache. +// Get gets a cached value by key. +// Put stores a key-value pair into cache. +// Delete removes a key from cache. +type Cache interface { + Context(ctx context.Context) Cache + Get(key string) (interface{}, time.Time, error) + Put(key string, val interface{}, d time.Duration) error + Delete(key string) error +} + +// Item represents an item stored in the cache. +type Item struct { + Value interface{} + Expiration int64 +} + +// Expired returns true if the item has expired. +func (i *Item) Expired() bool { + if i.Expiration == 0 { + return false + } + + return time.Now().UnixNano() > i.Expiration +} + +// NewCache returns a new cache. +func NewCache(opts ...Option) Cache { + options := NewOptions(opts...) + items := make(map[string]Item) + + if len(options.Items) > 0 { + items = options.Items + } + + return &memCache{ + opts: options, + items: items, + } +} diff --git a/cache/cache_test.go b/cache/cache_test.go new file mode 100644 index 00000000..728b7c9b --- /dev/null +++ b/cache/cache_test.go @@ -0,0 +1,111 @@ +package cache + +import ( + "context" + "testing" + "time" +) + +var ( + ctx context.Context = context.TODO() + key string = "test" + val interface{} = "hello go-micro" +) + +// TestMemCache tests the in-memory cache implementation. +func TestCache(t *testing.T) { + t.Run("CacheGetMiss", func(t *testing.T) { + if _, _, err := NewCache().Context(ctx).Get(key); err == nil { + t.Error("expected to get no value from cache") + } + }) + + t.Run("CacheGetHit", func(t *testing.T) { + c := NewCache() + + if err := c.Context(ctx).Put(key, val, 0); err != nil { + t.Error(err) + } + + if a, _, err := c.Context(ctx).Get(key); err != nil { + t.Errorf("Expected a value, got err: %s", err) + } else if a != val { + t.Errorf("Expected '%v', got '%v'", val, a) + } + }) + + t.Run("CacheGetExpired", func(t *testing.T) { + c := NewCache() + e := 20 * time.Millisecond + + if err := c.Context(ctx).Put(key, val, e); err != nil { + t.Error(err) + } + + <-time.After(25 * time.Millisecond) + if _, _, err := c.Context(ctx).Get(key); err == nil { + t.Error("expected to get no value from cache") + } + }) + + t.Run("CacheGetValid", func(t *testing.T) { + c := NewCache() + e := 25 * time.Millisecond + + if err := c.Context(ctx).Put(key, val, e); err != nil { + t.Error(err) + } + + <-time.After(20 * time.Millisecond) + if _, _, err := c.Context(ctx).Get(key); err != nil { + t.Errorf("expected a value, got err: %s", err) + } + }) + + t.Run("CacheDeleteMiss", func(t *testing.T) { + if err := NewCache().Context(ctx).Delete(key); err == nil { + t.Error("expected to delete no value from cache") + } + }) + + t.Run("CacheDeleteHit", func(t *testing.T) { + c := NewCache() + + if err := c.Context(ctx).Put(key, val, 0); err != nil { + t.Error(err) + } + + if err := c.Context(ctx).Delete(key); err != nil { + t.Errorf("Expected to delete an item, got err: %s", err) + } + + if _, _, err := c.Context(ctx).Get(key); err == nil { + t.Errorf("Expected error") + } + }) +} + +func TestCacheWithOptions(t *testing.T) { + t.Run("CacheWithExpiration", func(t *testing.T) { + c := NewCache(Expiration(20 * time.Millisecond)) + + if err := c.Context(ctx).Put(key, val, 0); err != nil { + t.Error(err) + } + + <-time.After(25 * time.Millisecond) + if _, _, err := c.Context(ctx).Get(key); err == nil { + t.Error("expected to get no value from cache") + } + }) + + t.Run("CacheWithItems", func(t *testing.T) { + c := NewCache(Items(map[string]Item{key: {val, 0}})) + + if a, _, err := c.Context(ctx).Get(key); err != nil { + t.Errorf("Expected a value, got err: %s", err) + } else if a != val { + t.Errorf("Expected '%v', got '%v'", val, a) + } + }) +} diff --git a/cache/default.go b/cache/default.go new file mode 100644 index 00000000..b8b1d324 --- /dev/null +++ b/cache/default.go @@ -0,0 +1,68 @@ +package cache + +import ( + "context" + "sync" + "time" +) + +type memCache struct { + opts Options + sync.RWMutex + ctx context.Context + + items map[string]Item +} + +func (c *memCache) Context(ctx context.Context) Cache { + c.ctx = ctx + return c +} + +func (c *memCache) Get(key string) (interface{}, time.Time, error) { + c.RWMutex.Lock() + defer c.RWMutex.Unlock() + + item, found := c.items[key] + if !found { + return nil, time.Time{}, ErrKeyNotFound + } + if item.Expired() { + return nil, time.Time{}, ErrItemExpired + } + + return item.Value, time.Unix(0, item.Expiration), nil +} + +func (c *memCache) Put(key string, val interface{}, d time.Duration) error { + var e int64 + if d == DefaultExpiration { + d = c.opts.Expiration + } + if d > 0 { + e = time.Now().Add(d).UnixNano() + } + + c.RWMutex.Lock() + defer c.RWMutex.Unlock() + + c.items[key] = Item{ + Value: val, + Expiration: e, + } + + return nil +} + +func (c *memCache) Delete(key string) error { + c.RWMutex.Lock() + defer c.RWMutex.Unlock() + + _, found := c.items[key] + if !found { + return ErrKeyNotFound + } + + delete(c.items, key) + return nil +} diff --git a/cache/options.go b/cache/options.go new file mode 100644 index 00000000..298db0b9 --- /dev/null +++ b/cache/options.go @@ -0,0 +1,40 @@ +package cache + +import "time" + +// Options represents the options for the cache. +type Options struct { + Expiration time.Duration + Items map[string]Item +} + +// Option manipulates the Options passed. +type Option func(o *Options) + +// Expiration sets the duration for items stored in the cache to expire. +func Expiration(d time.Duration) Option { + return func(o *Options) { + o.Expiration = d + } +} + +// Items initializes the cache with preconfigured items. +func Items(i map[string]Item) Option { + return func(o *Options) { + o.Items = i + } +} + +// NewOptions returns a new options struct. +func NewOptions(opts ...Option) Options { + options := Options{ + Expiration: DefaultExpiration, + Items: make(map[string]Item), + } + + for _, o := range opts { + o(&options) + } + + return options +} diff --git a/cache/options_test.go b/cache/options_test.go new file mode 100644 index 00000000..c97532b9 --- /dev/null +++ b/cache/options_test.go @@ -0,0 +1,43 @@ +package cache + +import ( + "testing" + "time" +) + +func TestOptions(t *testing.T) { + testData := map[string]struct { + set bool + expiration time.Duration + items map[string]Item + }{ + "DefaultOptions": {false, DefaultExpiration, map[string]Item{}}, + "ModifiedOptions": {true, time.Second, map[string]Item{"test": {"hello go-micro", 0}}}, + } + + for k, d := range testData { + t.Run(k, func(t *testing.T) { + var opts Options + + if d.set { + opts = NewOptions( + Expiration(d.expiration), + Items(d.items), + ) + } else { + opts = NewOptions() + } + + // test options + for _, o := range []Options{opts} { + if o.Expiration != d.expiration { + t.Fatalf("Expected expiration '%v', got '%v'", d.expiration, o.Expiration) + } + + if o.Items["test"] != d.items["test"] { + t.Fatalf("Expected items %#v, got %#v", d.items, o.Items) + } + } + }) + } +} diff --git a/cmd/cmd.go b/cmd/cmd.go index 40be693e..95950621 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -9,6 +9,7 @@ import ( "github.com/asim/go-micro/v3/auth" "github.com/asim/go-micro/v3/broker" + "github.com/asim/go-micro/v3/cache" "github.com/asim/go-micro/v3/client" "github.com/asim/go-micro/v3/config" "github.com/asim/go-micro/v3/debug/profile" @@ -265,6 +266,8 @@ var ( } DefaultConfigs = map[string]func(...config.Option) (config.Config, error){} + + DefaultCaches = map[string]func(...cache.Option) cache.Cache{} ) func init() { @@ -285,6 +288,7 @@ func newCmd(opts ...Option) Cmd { Tracer: &trace.DefaultTracer, Profile: &profile.DefaultProfile, Config: &config.DefaultConfig, + Cache: &cache.DefaultCache, Brokers: DefaultBrokers, Clients: DefaultClients, @@ -298,6 +302,7 @@ func newCmd(opts ...Option) Cmd { Auths: DefaultAuths, Profiles: DefaultProfiles, Configs: DefaultConfigs, + Caches: DefaultCaches, } for _, o := range opts { diff --git a/cmd/options.go b/cmd/options.go index 0eb70d83..40ff6420 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -5,6 +5,7 @@ import ( "github.com/asim/go-micro/v3/auth" "github.com/asim/go-micro/v3/broker" + "github.com/asim/go-micro/v3/cache" "github.com/asim/go-micro/v3/client" "github.com/asim/go-micro/v3/config" "github.com/asim/go-micro/v3/debug/profile" @@ -28,6 +29,7 @@ type Options struct { Registry *registry.Registry Selector *selector.Selector Transport *transport.Transport + Cache *cache.Cache Config *config.Config Client *client.Client Server *server.Server @@ -38,6 +40,7 @@ type Options struct { Profile *profile.Profile Brokers map[string]func(...broker.Option) broker.Broker + Caches map[string]func(...cache.Option) cache.Cache Configs map[string]func(...config.Option) (config.Config, error) Clients map[string]func(...client.Option) client.Client Registries map[string]func(...registry.Option) registry.Registry @@ -82,6 +85,12 @@ func Broker(b *broker.Broker) Option { } } +func Cache(c *cache.Cache) Option { + return func(o *Options) { + o.Cache = c + } +} + func Config(c *config.Config) Option { return func(o *Options) { o.Config = c @@ -155,6 +164,13 @@ func NewBroker(name string, b func(...broker.Option) broker.Broker) Option { } } +// New cache func +func NewCache(name string, c func(...cache.Option) cache.Cache) Option { + return func(o *Options) { + o.Caches[name] = c + } +} + // New client func func NewClient(name string, b func(...client.Option) client.Client) Option { return func(o *Options) { diff --git a/options.go b/options.go index 1df3a16b..dafc9e50 100644 --- a/options.go +++ b/options.go @@ -6,6 +6,7 @@ import ( "github.com/asim/go-micro/v3/auth" "github.com/asim/go-micro/v3/broker" + "github.com/asim/go-micro/v3/cache" "github.com/asim/go-micro/v3/client" "github.com/asim/go-micro/v3/cmd" "github.com/asim/go-micro/v3/config" @@ -24,6 +25,7 @@ import ( type Options struct { Auth auth.Auth Broker broker.Broker + Cache cache.Cache Cmd cmd.Cmd Config config.Config Client client.Client @@ -51,6 +53,7 @@ func newOptions(opts ...Option) Options { opt := Options{ Auth: auth.DefaultAuth, Broker: broker.DefaultBroker, + Cache: cache.DefaultCache, Cmd: cmd.DefaultCmd, Config: config.DefaultConfig, Client: client.DefaultClient, @@ -80,6 +83,12 @@ func Broker(b broker.Broker) Option { } } +func Cache(c cache.Cache) Option { + return func(o *Options) { + o.Cache = c + } +} + func Cmd(c cmd.Cmd) Option { return func(o *Options) { o.Cmd = c