Added custom cache client for much easier cache operations.

This commit is contained in:
mikestefanello 2022-01-13 21:13:41 -05:00
parent 09c6df7f52
commit e0a65ca007
8 changed files with 451 additions and 64 deletions

View File

@ -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

View File

@ -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

View File

@ -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)
})
}

View File

@ -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:

View File

@ -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

204
services/cache.go Normal file
View File

@ -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
}

105
services/cache_test.go Normal file
View File

@ -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()
}

View File

@ -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