diff --git a/README.md b/README.md index 737840e..0034aa6 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,10 @@ * [File configuration](#file-configuration) * [Funcmap](#funcmap) * [Cache](#cache) + * [Set data](#set-data) + * [Get data](#get-data) + * [Flush data](#flush-data) + * [Flush tags](#flush-tags) * [Static files](#static-files) * [Cache control headers](#cache-control-headers) * [Cache-buster](#cache-buster) @@ -568,13 +572,7 @@ By default, the cache expiration time will be set according to the configuration You can optionally specify cache tags for the `Page` by setting a slice of strings on `Page.Cache.Tags`. This provides the ability to build in cache invalidation logic in your application driven by events such as entity operations, for example. -The cache client on the `Container` is currently handled by [gocache](https://github.com/eko/gocache) which makes it easy to perform operations such as tag-invalidation, for example: - -```go -c.Cache.Invalidate(ctx, store.InvalidateOptions{ - Tags: []string{"my-tag"}, -}) -``` +You can use the [cache client](#cache) on the `Container` to easily [flush cache tags](#flush-tags), if needed. #### Cache middleware @@ -866,9 +864,90 @@ To include additional custom functions, add to the slice in `GetFuncMap()` and d ## Cache -As previously mentioned, [Redis](https://redis.io/) was chosen as the cache but it can be easily swapped out for something else. [go-redis](https://github.com/go-redis/redis) is used as the underlying client but the `Container` currently only exposes [gocache](https://github.com/eko/gocache) which was chosen because it makes interfacing with the cache client much easier, and it provides a consistent interface if you were to use a cache backend other than Redis. +As previously mentioned, [Redis](https://redis.io/) was chosen as the cache but it can be easily swapped out for something else. [go-redis](https://github.com/go-redis/redis) is used as the underlying client but the `Container` contains a custom client wrapper (`CacheClient`) that makes typical cache operations extremely simple. This wrapper does expose the [go-redis]() client however, at `CacheClient.Client`, in case you have a need for it. -The built-in usage of the cache is currently only for optional [page caching](#cached-responses) but it can be used for practically anything. +The cache functionality within the `CacheClient` is powered by [gocache](https://github.com/eko/gocache) which was chosen because it makes interfacing with the cache service much easier, and it provides a consistent interface if you were to use a cache backend other than Redis. + +The built-in usage of the cache is currently only for optional [page caching](#cached-responses) but it can be used for practically anything. See examples below: + +### Set data + +**Set data with just a key:** + +```go +err := c.Cache. + Set(). + Key("my-key"). + Data(myData). + Save(ctx) +``` + +**Set data within a group:** + +```go +err := c.Cache. + Set(). + Group("my-group") + Key("my-key"). + Data(myData). + Save(ctx) +``` + +**Include cache tags:** + +```go +err := c.Cache. + Set(). + Key("my-key"). + Tags([]string{"tag1", "tag2"}) + Data(myData). + Save(ctx) +``` + +**Include an expiration:** + +```go +err := c.Cache. + Set(). + Key("my-key"). + Expiration(time.Hour * 2) + Data(myData). + Save(ctx) +``` + +### Get data + +```go +data, err := c.Cache. + Get(). + Group("my-group"). + Key("my-key"). + Type(myType). + Fetch(ctx) +``` + +The `Type` method tells the cache what type of data you stored so it can be cast afterwards with: `result, ok := data.(myType)` + +### Flush data + +```go +err := c.Cache. + Flush(). + Group("my-group"). + Key("my-key"). + Exec(ctx) +``` + +### Flush tags + +This will flush all cache entries that were tagged with the given tags. + +```go +err := c.Cache. + Flush(). + Tags([]string{"tag1"}). + Exec(ctx) +``` ## Static files diff --git a/controller/controller.go b/controller/controller.go index 738755f..0dba313 100644 --- a/controller/controller.go +++ b/controller/controller.go @@ -9,10 +9,6 @@ import ( "github.com/mikestefanello/pagoda/middleware" "github.com/mikestefanello/pagoda/services" - "github.com/eko/gocache/v2/marshaler" - - "github.com/eko/gocache/v2/store" - "github.com/labstack/echo/v4" ) @@ -131,10 +127,6 @@ func (c *Controller) cachePage(ctx echo.Context, page Page, html *bytes.Buffer) // The request URL is used as the cache key so the middleware can serve the // cached page on matching requests key := ctx.Request().URL.String() - opts := &store.Options{ - Expiration: page.Cache.Expiration, - Tags: page.Cache.Tags, - } cp := middleware.CachedPage{ URL: key, HTML: html.Bytes(), @@ -142,16 +134,21 @@ func (c *Controller) cachePage(ctx echo.Context, page Page, html *bytes.Buffer) StatusCode: ctx.Response().Status, } - err := marshaler.New(c.Container.Cache).Set(ctx.Request().Context(), key, cp, opts) - if err != nil { - if !context.IsCanceledError(err) { - ctx.Logger().Errorf("failed to cache page: %v", err) - } + err := c.Container.Cache. + Set(). + Group(middleware.CachedPageGroup). + Key(key). + Tags(page.Cache.Tags). + Expiration(page.Cache.Expiration). + Data(cp). + Save(ctx.Request().Context()) - return + switch { + case err == nil: + ctx.Logger().Info("cached page") + case !context.IsCanceledError(err): + ctx.Logger().Errorf("failed to cache page: %v", err) } - - ctx.Logger().Infof("cached page") } // Redirect redirects to a given route name with optional route parameters diff --git a/controller/controller_test.go b/controller/controller_test.go index 49ad2a9..40567a5 100644 --- a/controller/controller_test.go +++ b/controller/controller_test.go @@ -14,10 +14,6 @@ import ( "github.com/mikestefanello/pagoda/services" "github.com/mikestefanello/pagoda/tests" - "github.com/eko/gocache/v2/store" - - "github.com/eko/gocache/v2/marshaler" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -151,8 +147,12 @@ func TestController_RenderPage(t *testing.T) { require.NoError(t, err) // Fetch from the cache - res, err := marshaler.New(c.Cache). - Get(context.Background(), p.URL, new(middleware.CachedPage)) + res, err := c.Cache. + Get(). + Group(middleware.CachedPageGroup). + Key(p.URL). + Type(new(middleware.CachedPage)). + Fetch(context.Background()) require.NoError(t, err) // Compare the cached page @@ -164,14 +164,19 @@ func TestController_RenderPage(t *testing.T) { assert.Equal(t, rec.Body.Bytes(), cp.HTML) // Clear the tag - err = c.Cache.Invalidate(context.Background(), store.InvalidateOptions{ - Tags: []string{p.Cache.Tags[0]}, - }) + err = c.Cache. + Flush(). + Tags([]string{p.Cache.Tags[0]}). + Exec(context.Background()) require.NoError(t, err) // Refetch from the cache and expect no results - _, err = marshaler.New(c.Cache). - Get(context.Background(), p.URL, new(middleware.CachedPage)) + _, err = c.Cache. + Get(). + Group(middleware.CachedPageGroup). + Key(p.URL). + Type(new(middleware.CachedPage)). + Fetch(context.Background()) assert.Error(t, err) }) } diff --git a/middleware/cache.go b/middleware/cache.go index 47b6ba5..c33d123 100644 --- a/middleware/cache.go +++ b/middleware/cache.go @@ -6,13 +6,15 @@ import ( "time" "github.com/mikestefanello/pagoda/context" + "github.com/mikestefanello/pagoda/services" - "github.com/eko/gocache/v2/cache" - "github.com/eko/gocache/v2/marshaler" "github.com/go-redis/redis/v8" "github.com/labstack/echo/v4" ) +// CachedPageGroup stores the cache group for cached pages +const CachedPageGroup = "page" + // CachedPage is what is used to store a rendered Page in the cache type CachedPage struct { // URL stores the URL of the requested page @@ -31,7 +33,7 @@ type CachedPage struct { // ServeCachedPage attempts to load a page from the cache by matching on the complete request URL // If a page is cached for the requested URL, it will be served here and the request terminated. // Any request made by an authenticated user or that is not a GET will be skipped. -func ServeCachedPage(ch *cache.Cache) echo.MiddlewareFunc { +func ServeCachedPage(ch *services.CacheClient) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { // Skip non GET requests @@ -45,11 +47,13 @@ func ServeCachedPage(ch *cache.Cache) echo.MiddlewareFunc { } // Attempt to load from cache - res, err := marshaler.New(ch).Get( - c.Request().Context(), - c.Request().URL.String(), - new(CachedPage), - ) + res, err := ch. + Get(). + Group(CachedPageGroup). + Key(c.Request().URL.String()). + Type(new(CachedPage)). + Fetch(c.Request().Context()) + if err != nil { switch { case err == redis.Nil: diff --git a/middleware/cache_test.go b/middleware/cache_test.go index 36e03a2..f9c6e01 100644 --- a/middleware/cache_test.go +++ b/middleware/cache_test.go @@ -10,8 +10,6 @@ import ( "github.com/stretchr/testify/require" - "github.com/eko/gocache/v2/marshaler" - "github.com/stretchr/testify/assert" ) @@ -25,7 +23,13 @@ func TestServeCachedPage(t *testing.T) { } cp.Headers["a"] = "b" cp.Headers["c"] = "d" - err := marshaler.New(c.Cache).Set(context.Background(), cp.URL, cp, nil) + + err := c.Cache. + Set(). + Group(CachedPageGroup). + Key(cp.URL). + Data(cp). + Save(context.Background()) require.NoError(t, err) // Request the URL of the cached page diff --git a/services/cache.go b/services/cache.go new file mode 100644 index 0000000..90dbc63 --- /dev/null +++ b/services/cache.go @@ -0,0 +1,204 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/eko/gocache/v2/cache" + "github.com/eko/gocache/v2/marshaler" + "github.com/eko/gocache/v2/store" + "github.com/go-redis/redis/v8" + "github.com/mikestefanello/pagoda/config" +) + +type ( + // CacheClient is the client that allows you to interact with the cache + CacheClient struct { + // Client stores the client to the underlying cache service + Client *redis.Client + + // cache stores the cache interface + cache *cache.Cache + } + + // cacheSet handles chaining a set operation + cacheSet struct { + client *CacheClient + key string + group string + data interface{} + expiration time.Duration + tags []string + } + + // cacheGet handles chaining a get operation + cacheGet struct { + client *CacheClient + key string + group string + dataType interface{} + } + + // cacheFlush handles chaining a flush operation + cacheFlush struct { + client *CacheClient + key string + group string + tags []string + } +) + +// NewCacheClient creates a new cache client +func NewCacheClient(cfg config.CacheConfig) (*CacheClient, error) { + c := &CacheClient{} + c.Client = redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%d", cfg.Hostname, cfg.Port), + Password: cfg.Password, + }) + if _, err := c.Client.Ping(context.Background()).Result(); err != nil { + return c, err + } + + cacheStore := store.NewRedis(c.Client, nil) + c.cache = cache.New(cacheStore) + return c, nil +} + +// Close closes the connection to the cache +func (c *CacheClient) Close() error { + return c.Client.Close() +} + +// Set creates a cache set operation +func (c *CacheClient) Set() *cacheSet { + return &cacheSet{ + client: c, + } +} + +// Get creates a cache get operation +func (c *CacheClient) Get() *cacheGet { + return &cacheGet{ + client: c, + } +} + +// Flush creates a cache flush operation +func (c *CacheClient) Flush() *cacheFlush { + return &cacheFlush{ + client: c, + } +} + +// cacheKey formats a cache key with an optional group +func (c *CacheClient) cacheKey(group, key string) string { + if group != "" { + return fmt.Sprintf("%s::%s", group, key) + } + return key +} + +// Key sets the cache key +func (c *cacheSet) Key(key string) *cacheSet { + c.key = key + return c +} + +// Group sets the cache group +func (c *cacheSet) Group(group string) *cacheSet { + c.group = group + return c +} + +// Data sets the data to cache +func (c *cacheSet) Data(data interface{}) *cacheSet { + c.data = data + return c +} + +// Expiration sets the expiration duration of the cached data +func (c *cacheSet) Expiration(expiration time.Duration) *cacheSet { + c.expiration = expiration + return c +} + +// Tags sets the cache tags +func (c *cacheSet) Tags(tags []string) *cacheSet { + c.tags = tags + return c +} + +// Save saves the data in the cache +func (c *cacheSet) Save(ctx context.Context) error { + opts := &store.Options{ + Expiration: c.expiration, + Tags: c.tags, + } + + return marshaler. + New(c.client.cache). + Set(ctx, c.client.cacheKey(c.group, c.key), c.data, opts) +} + +// Key sets the cache key +func (c *cacheGet) Key(key string) *cacheGet { + c.key = key + return c +} + +// Group sets the cache group +func (c *cacheGet) Group(group string) *cacheGet { + c.group = group + return c +} + +// Type sets the expected Go type of the data being retrieved from the cache +func (c *cacheGet) Type(expectedType interface{}) *cacheGet { + c.dataType = expectedType + return c +} + +// Fetch fetches the data from the cache +func (c *cacheGet) Fetch(ctx context.Context) (interface{}, error) { + return marshaler.New(c.client.cache).Get( + ctx, + c.client.cacheKey(c.group, c.key), + c.dataType, + ) +} + +// Key sets the cache key +func (c *cacheFlush) Key(key string) *cacheFlush { + c.key = key + return c +} + +// Group sets the cache group +func (c *cacheFlush) Group(group string) *cacheFlush { + c.group = group + return c +} + +// Tags sets the cache tags +func (c *cacheFlush) Tags(tags []string) *cacheFlush { + c.tags = tags + return c +} + +// Exec flushes the data from the cache +func (c *cacheFlush) Exec(ctx context.Context) error { + if len(c.tags) > 0 { + if err := c.client.cache.Invalidate(ctx, store.InvalidateOptions{ + Tags: c.tags, + }); err != nil { + return err + } + } + + if c.key != "" { + return c.client.cache.Delete(ctx, c.client.cacheKey(c.group, c.key)) + } + + return nil +} diff --git a/services/cache_test.go b/services/cache_test.go new file mode 100644 index 0000000..2debf6f --- /dev/null +++ b/services/cache_test.go @@ -0,0 +1,105 @@ +package services + +import ( + "context" + "testing" + "time" + + "github.com/go-redis/redis/v8" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCacheClient(t *testing.T) { + type cacheTest struct { + Value string + } + // Cache some data + data := cacheTest{Value: "abcdef"} + group := "testgroup" + key := "testkey" + err := c.Cache. + Set(). + Group(group). + Key(key). + Data(data). + Save(context.Background()) + require.NoError(t, err) + + // Get the data + fromCache, err := c.Cache. + Get(). + Group(group). + Key(key). + Type(new(cacheTest)). + Fetch(context.Background()) + require.NoError(t, err) + cast, ok := fromCache.(*cacheTest) + require.True(t, ok) + assert.Equal(t, data, *cast) + + // The same key with the wrong group should fail + _, err = c.Cache. + Get(). + Key(key). + Type(new(cacheTest)). + Fetch(context.Background()) + assert.Error(t, err) + + // Flush the data + err = c.Cache. + Flush(). + Group(group). + Key(key). + Exec(context.Background()) + require.NoError(t, err) + + // The data should be gone + assertFlushed := func() { + // The data should be gone + _, err = c.Cache. + Get(). + Group(group). + Key(key). + Type(new(cacheTest)). + Fetch(context.Background()) + assert.Equal(t, redis.Nil, err) + } + assertFlushed() + + // Set with tags + err = c.Cache. + Set(). + Group(group). + Key(key). + Data(data). + Tags([]string{"tag1"}). + Save(context.Background()) + require.NoError(t, err) + + // Flush the tag + err = c.Cache. + Flush(). + Tags([]string{"tag1"}). + Exec(context.Background()) + require.NoError(t, err) + + // The data should be gone + assertFlushed() + + // Set with expiration + err = c.Cache. + Set(). + Group(group). + Key(key). + Data(data). + Expiration(time.Millisecond). + Save(context.Background()) + require.NoError(t, err) + + // Wait for expiration + time.Sleep(time.Millisecond * 2) + + // The data should be gone + assertFlushed() +} diff --git a/services/container.go b/services/container.go index d1fd7f8..3eb9dc9 100644 --- a/services/container.go +++ b/services/container.go @@ -7,9 +7,6 @@ import ( "entgo.io/ent/dialect" entsql "entgo.io/ent/dialect/sql" - "github.com/eko/gocache/v2/cache" - "github.com/eko/gocache/v2/store" - "github.com/go-redis/redis/v8" _ "github.com/jackc/pgx/v4/stdlib" "github.com/labstack/echo/v4" "github.com/labstack/gommon/log" @@ -31,11 +28,8 @@ type Container struct { // Config stores the application configuration Config *config.Config - // Cache contains the cache interface - Cache *cache.Cache - - // cacheClient stores the client to the underlying cache service - cacheClient *redis.Client + // Cache contains the cache client + Cache *CacheClient // Database stores the connection to the database Database *sql.DB @@ -70,7 +64,7 @@ func NewContainer() *Container { // Shutdown shuts the Container down and disconnects all connections func (c *Container) Shutdown() error { - if err := c.cacheClient.Close(); err != nil { + if err := c.Cache.Close(); err != nil { return err } if err := c.ORM.Close(); err != nil { @@ -114,15 +108,10 @@ func (c *Container) initWeb() { // initCache initializes the cache func (c *Container) initCache() { - c.cacheClient = redis.NewClient(&redis.Options{ - Addr: fmt.Sprintf("%s:%d", c.Config.Cache.Hostname, c.Config.Cache.Port), - Password: c.Config.Cache.Password, - }) - if _, err := c.cacheClient.Ping(context.Background()).Result(); err != nil { - panic(fmt.Sprintf("failed to connect to cache server: %v", err)) + var err error + if c.Cache, err = NewCacheClient(c.Config.Cache); err != nil { + panic(err) } - cacheStore := store.NewRedis(c.cacheClient, nil) - c.Cache = cache.New(cacheStore) } // initDatabase initializes the database