From a433b27cc5147bff8ac210ef18214793183fa1fe Mon Sep 17 00:00:00 2001 From: thinkgos Date: Thu, 29 Aug 2024 14:04:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E4=BB=B6=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 8 +- .github/workflows/codeql.yml | 8 +- .github/workflows/pr_review_dog.yml | 2 +- cache/cache.go | 209 ++++++++++++++++ cache/cache_test.go | 369 ++++++++++++++++++++++++++++ cache/encoding.go | 45 ++++ cache/logger.go | 28 +++ cache/persist/memory/memory.go | 46 ++++ cache/persist/memory/memory_test.go | 85 +++++++ cache/persist/persist.go | 22 ++ cache/persist/redis/redis.go | 42 ++++ cache/persist/redis/redis_test.go | 83 +++++++ cache/pool.go | 43 ++++ examples/cache/custom/custom.go | 32 +++ examples/cache/memory/memory.go | 30 +++ examples/cache/redis/redis.go | 34 +++ go.mod | 8 + go.sum | 8 + testdata/template.html | 5 + 19 files changed, 1099 insertions(+), 8 deletions(-) create mode 100644 cache/cache.go create mode 100644 cache/cache_test.go create mode 100644 cache/encoding.go create mode 100644 cache/logger.go create mode 100644 cache/persist/memory/memory.go create mode 100644 cache/persist/memory/memory_test.go create mode 100644 cache/persist/persist.go create mode 100644 cache/persist/redis/redis.go create mode 100644 cache/persist/redis/redis_test.go create mode 100644 cache/pool.go create mode 100644 examples/cache/custom/custom.go create mode 100644 examples/cache/memory/memory.go create mode 100644 examples/cache/redis/redis.go create mode 100644 testdata/template.html diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5515149..3b7a6d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - go-version: ["1.21.x"] + go-version: ["1.21.x", "1.22.x"] os: [ubuntu-latest, windows-latest, macos-latest] steps: @@ -48,19 +48,21 @@ jobs: uses: actions/cache@v4 with: path: | - ${{ steps.vars.outputs.go_cache }} + ${{ steps.vars.outputs.GO_CACHE }} ~/go/pkg/mod key: ${{ runner.os }}-${{ matrix.go-version }}-go-ci-${{ hashFiles('**/go.sum') }} restore-keys: | - ${{ runner.os }}-${{ matrix.go-version }}-go-ci + ${{ runner.os }}-${{ matrix.go-version }}-go-ci- - name: Unit test run: | go test -v -race -coverprofile=coverage -covermode=atomic ./... - name: Upload coverage to Codecov + if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v4 with: files: ./coverage flags: unittests + token: ${{ secrets.CODECOV_TOKEN }} verbose: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index a00884e..f8d1fe9 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -3,12 +3,12 @@ name: CodeQL on: push: paths-ignore: - - '**.md' + - "**.md" pull_request: paths-ignore: - - '**.md' + - "**.md" schedule: - - cron: '0 5 * * 6' + - cron: "0 5 * * 6" jobs: analyze: @@ -48,4 +48,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 \ No newline at end of file + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/pr_review_dog.yml b/.github/workflows/pr_review_dog.yml index 5d13af0..3abe700 100644 --- a/.github/workflows/pr_review_dog.yml +++ b/.github/workflows/pr_review_dog.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: "1.21.x" + go-version: "1.22.x" - name: Check out code into the Go module directory uses: actions/checkout@v4 diff --git a/cache/cache.go b/cache/cache.go new file mode 100644 index 0000000..72c5f1a --- /dev/null +++ b/cache/cache.go @@ -0,0 +1,209 @@ +package cache + +import ( + "bytes" + "crypto/sha1" + "net/url" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/sync/singleflight" + + "github.com/things-go/gin-contrib/cache/persist" +) + +// PageCachePrefix default page cache key prefix +var PageCachePrefix = "gincache.page.cache:" + +// Logger logger interface +type Logger interface { + Errorf(format string, args ...any) +} + +// Encoding interface +type Encoding interface { + Marshal(v any) ([]byte, error) + Unmarshal(data []byte, v any) error +} + +// Config for cache +type Config struct { + // store the cache backend to store response + store persist.Store + // expire the cache expiration time + expire time.Duration + // rand duration for expire + rand func() time.Duration + // generate key for store, bool means need cache or not + generateKey func(c *gin.Context) (string, bool) + // group single flight group + group *singleflight.Group + // logger debug + logger Logger + // encoding default: JSONEncoding + encode Encoding +} + +// Option custom option +type Option func(c *Config) + +// WithGenerateKey custom generate key ,default is GenerateRequestURIKey. +func WithGenerateKey(f func(c *gin.Context) (string, bool)) Option { + return func(c *Config) { + if f != nil { + c.generateKey = f + } + } +} + +// WithSingleflight custom single flight group, default is private single flight group. +func WithSingleflight(group *singleflight.Group) Option { + return func(c *Config) { + if group != nil { + c.group = group + } + } +} + +// WithRandDuration custom rand duration for expire, default return zero +// expiration time always expire + rand() +func WithRandDuration(rand func() time.Duration) Option { + return func(c *Config) { + if rand != nil { + c.rand = rand + } + } +} + +// WithLogger custom logger, default is Discard. +func WithLogger(l Logger) Option { + return func(c *Config) { + if l != nil { + c.logger = l + } + } +} + +// WithEncoding custom Encoding, default is JSONEncoding. +func WithEncoding(encode Encoding) Option { + return func(c *Config) { + if encode != nil { + c.encode = encode + } + } +} + +// Cache user must pass store and store expiration time to cache and with custom option. +// default caching response with uri, which use PageCachePrefix +func Cache(store persist.Store, expire time.Duration, opts ...Option) gin.HandlerFunc { + cfg := Config{ + store: store, + expire: expire, + rand: func() time.Duration { return 0 }, + generateKey: GenerateRequestUri, + group: new(singleflight.Group), + logger: NewDiscard(), + encode: JSONEncoding{}, + } + for _, opt := range opts { + opt(&cfg) + } + + return func(c *gin.Context) { + key, needCache := cfg.generateKey(c) + if !needCache { + c.Next() + return + } + + // read cache first + bodyCache := poolGet() + defer poolPut(bodyCache) + bodyCache.encoding = cfg.encode + + if err := cfg.store.Get(key, bodyCache); err != nil { + // BodyWriter in order to dup the response + bodyWriter := &BodyWriter{ResponseWriter: c.Writer} + c.Writer = bodyWriter + + inFlight := false + // use single flight to avoid Hotspot Invalid + bc, _, shared := cfg.group.Do(key, func() (any, error) { + c.Next() + inFlight = true + bc := getBodyCacheFromBodyWriter(bodyWriter, cfg.encode) + if !c.IsAborted() && bodyWriter.Status() < 300 && bodyWriter.Status() >= 200 { + if err = cfg.store.Set(key, bc, cfg.expire+cfg.rand()); err != nil { + cfg.logger.Errorf("set cache key error: %s, cache key: %s", err, key) + } + } + return bc, nil + }) + if !inFlight && shared { + c.Abort() + responseWithBodyCache(c, bc.(*BodyCache)) + } + } else { + c.Abort() + responseWithBodyCache(c, bodyCache) + } + } +} + +// GenerateKeyWithPrefix generate key with GenerateKeyWithPrefix and u, +// if key is larger than 200,it will use sha1.Sum +// key like: prefix+u or prefix+sha1(u) +func GenerateKeyWithPrefix(prefix, key string) string { + if len(key) > 200 { + d := sha1.Sum([]byte(key)) + return prefix + string(d[:]) + } + return prefix + key +} + +// GenerateRequestUri generate key with PageCachePrefix and request uri +func GenerateRequestUri(c *gin.Context) (string, bool) { + return GenerateKeyWithPrefix(PageCachePrefix, url.QueryEscape(c.Request.RequestURI)), true +} + +// GenerateRequestPath generate key with PageCachePrefix and request Path +func GenerateRequestPath(c *gin.Context) (string, bool) { + return GenerateKeyWithPrefix(PageCachePrefix, url.QueryEscape(c.Request.URL.Path)), true +} + +// BodyWriter dup response writer body +type BodyWriter struct { + gin.ResponseWriter + dupBody bytes.Buffer +} + +// Write writes the data to the connection as part of an HTTP reply. +func (w *BodyWriter) Write(b []byte) (int, error) { + w.dupBody.Write(b) + return w.ResponseWriter.Write(b) +} + +// WriteString the string into the response body. +func (w *BodyWriter) WriteString(s string) (int, error) { + w.dupBody.WriteString(s) + return w.ResponseWriter.WriteString(s) +} + +func getBodyCacheFromBodyWriter(writer *BodyWriter, encode Encoding) *BodyCache { + return &BodyCache{ + writer.Status(), + writer.Header().Clone(), + writer.dupBody.Bytes(), + encode, + } +} + +func responseWithBodyCache(c *gin.Context, bodyCache *BodyCache) { + c.Writer.WriteHeader(bodyCache.Status) + for k, v := range bodyCache.Header { + for _, vv := range v { + c.Writer.Header().Add(k, vv) + } + } + c.Writer.Write(bodyCache.Data) // nolint: errcheck +} diff --git a/cache/cache_test.go b/cache/cache_test.go new file mode 100644 index 0000000..a040c07 --- /dev/null +++ b/cache/cache_test.go @@ -0,0 +1,369 @@ +package cache + +import ( + "bytes" + "fmt" + "math/rand" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/patrickmn/go-cache" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/sync/singleflight" + + "github.com/things-go/gin-contrib/cache/persist" + "github.com/things-go/gin-contrib/cache/persist/memory" + redisStore "github.com/things-go/gin-contrib/cache/persist/redis" +) + +var longLengthThan200Key = "/" + strings.Repeat("qwertyuiopasdfghjklzxcvbnm", 8) +var enableRedis = false + +var newStore = func(defaultExpiration time.Duration) persist.Store { + if enableRedis { + redisHost := os.Getenv("REDIS_HOST") + if redisHost == "" { + redisHost = "localhost" + } + port := os.Getenv("REDIS_PORT") + if port == "" { + port = "6379" + } + return redisStore.NewStore(redis.NewClient(&redis.Options{ + Addr: redisHost + ":" + port, + })) + } + return memory.NewStore(cache.New(defaultExpiration, time.Minute*10)) +} + +func init() { + gin.SetMode(gin.TestMode) +} + +func performRequest(target string, router *gin.Engine) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, target, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + return w +} + +func TestCache(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.GET("/cache/ping", Cache(store, time.Second*3), func(c *gin.Context) { + c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().UnixNano())) + }) + + w1 := performRequest("/cache/ping", r) + w2 := performRequest("/cache/ping", r) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, http.StatusOK, w2.Code) + assert.Equal(t, w1.Body.String(), w2.Body.String()) +} + +func TestCacheNoNeedCache(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.GET("/cache/ping", + Cache(store, + time.Second*3, + WithGenerateKey(func(c *gin.Context) (string, bool) { + return "", false + }), + WithRandDuration(func() time.Duration { + return time.Duration(rand.Intn(5)) * time.Second + }), + WithSingleflight(&singleflight.Group{}), + WithLogger(NewDiscard()), + WithEncoding(JSONEncoding{}), + ), + func(c *gin.Context) { + c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().UnixNano())) + }, + ) + + w1 := performRequest("/cache/ping", r) + time.Sleep(time.Millisecond * 10) + w2 := performRequest("/cache/ping", r) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, http.StatusOK, w2.Code) + assert.NotEqual(t, w1.Body.String(), w2.Body.String()) +} + +func TestCacheExpire(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.GET("/cache/ping", Cache(store, time.Second), func(c *gin.Context) { + c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().UnixNano())) + }) + + w1 := performRequest("/cache/ping", r) + time.Sleep(time.Second * 3) + w2 := performRequest("/cache/ping", r) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, http.StatusOK, w2.Code) + assert.NotEqual(t, w1.Body.String(), w2.Body.String()) +} + +func TestCacheHtmlFile(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.LoadHTMLFiles("../testdata/template.html") + r.GET("/cache/html", Cache(store, time.Second*3), func(c *gin.Context) { + c.HTML(http.StatusOK, "template.html", gin.H{"value": fmt.Sprint(time.Now().UnixNano())}) + }) + + w1 := performRequest("/cache/html", r) + w2 := performRequest("/cache/html", r) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, http.StatusOK, w2.Code) + assert.Equal(t, w1.Body.String(), w2.Body.String()) +} + +func TestCacheHtmlFileExpire(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.LoadHTMLFiles("../testdata/template.html") + r.GET("/cache/html", Cache(store, time.Second*1), func(c *gin.Context) { + c.HTML(http.StatusOK, "template.html", gin.H{"value": fmt.Sprint(time.Now().UnixNano())}) + }) + + w1 := performRequest("/cache/html", r) + time.Sleep(time.Second * 3) + w2 := performRequest("/cache/html", r) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, http.StatusOK, w2.Code) + assert.NotEqual(t, w1.Body.String(), w2.Body.String()) +} + +func TestCacheAborted(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.GET("/cache/aborted", Cache(store, time.Second*3), func(c *gin.Context) { + c.AbortWithStatusJSON(http.StatusOK, map[string]int64{"time": time.Now().UnixNano()}) + }) + + w1 := performRequest("/cache/aborted", r) + time.Sleep(time.Millisecond * 500) + w2 := performRequest("/cache/aborted", r) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, http.StatusOK, w2.Code) + assert.NotEqual(t, w1.Body.String(), w2.Body.String()) +} + +func TestCacheStatus400(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.GET("/cache/400", Cache(store, time.Second*3), func(c *gin.Context) { + c.String(http.StatusBadRequest, fmt.Sprint(time.Now().UnixNano())) + }) + + w1 := performRequest("/cache/400", r) + time.Sleep(time.Millisecond * 500) + w2 := performRequest("/cache/400", r) + + assert.Equal(t, http.StatusBadRequest, w1.Code) + assert.Equal(t, http.StatusBadRequest, w2.Code) + assert.NotEqual(t, w1.Body.String(), w2.Body.String()) +} + +func TestCacheStatus207(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.GET("/cache/207", Cache(store, time.Second*3), func(c *gin.Context) { + c.String(http.StatusMultiStatus, fmt.Sprint(time.Now().UnixNano())) + }) + + w1 := performRequest("/cache/207", r) + time.Sleep(time.Millisecond * 500) + w2 := performRequest("/cache/207", r) + + assert.Equal(t, http.StatusMultiStatus, w1.Code) + assert.Equal(t, http.StatusMultiStatus, w2.Code) + assert.Equal(t, w1.Body.String(), w2.Body.String()) +} + +func TestCacheLongKey(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.GET(longLengthThan200Key, Cache(store, time.Second*3), func(c *gin.Context) { + c.String(http.StatusOK, fmt.Sprint(time.Now().UnixNano())) + }) + + w1 := performRequest(longLengthThan200Key, r) + w2 := performRequest(longLengthThan200Key, r) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, http.StatusOK, w2.Code) + assert.Equal(t, w1.Body.String(), w2.Body.String()) +} + +func TestCacheWithRequestPath(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.GET("/cache_with_path", Cache(store, time.Second*3, WithGenerateKey(GenerateRequestPath)), func(c *gin.Context) { + c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().UnixNano())) + }) + + w1 := performRequest("/cache_with_path?foo=1", r) + w2 := performRequest("/cache_with_path?foo=2", r) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, http.StatusOK, w2.Code) + assert.Equal(t, w1.Body.String(), w2.Body.String()) +} + +func TestCacheWithRequestURI(t *testing.T) { + store := newStore(time.Second * 60) + + r := gin.New() + r.GET("/cache_with_uri", Cache(store, time.Second*3), func(c *gin.Context) { + c.String(http.StatusOK, "pong "+fmt.Sprint(time.Now().UnixNano())) + }) + + w1 := performRequest("/cache_with_uri?foo=1", r) + w2 := performRequest("/cache_with_uri?foo=1", r) + w3 := performRequest("/cache_with_uri?foo=2", r) + + assert.Equal(t, http.StatusOK, w1.Code) + assert.Equal(t, http.StatusOK, w2.Code) + assert.Equal(t, http.StatusOK, w3.Code) + assert.Equal(t, w1.Body.String(), w2.Body.String()) + assert.NotEqual(t, w2.Body.String(), w3.Body.String()) +} + +type memoryDelayStore struct { + *memory.Store +} + +func newDelayStore(c *cache.Cache) *memoryDelayStore { + return &memoryDelayStore{memory.NewStore(c)} +} + +func (c *memoryDelayStore) Set(key string, value any, expires time.Duration) error { + time.Sleep(time.Millisecond * 3) + return c.Store.Set(key, value, expires) +} + +func TestCacheInSingleflight(t *testing.T) { + store := newDelayStore(cache.New(60*time.Second, time.Minute*10)) + + r := gin.New() + r.GET("/singleflight", Cache(store, time.Second*5), func(c *gin.Context) { + c.String(http.StatusOK, "OK") + }) + + outp := make(chan string, 10) + + for i := 0; i < 5; i++ { + go func() { + resp := performRequest("/singleflight", r) + outp <- resp.Body.String() + }() + } + time.Sleep(time.Millisecond * 500) + for i := 0; i < 5; i++ { + go func() { + resp := performRequest("/singleflight", r) + outp <- resp.Body.String() + }() + } + time.Sleep(time.Millisecond * 500) + + for i := 0; i < 10; i++ { + v := <-outp + assert.Equal(t, "OK", v) + } +} + +func TestBodyWrite(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + writer := &BodyWriter{c.Writer, bytes.Buffer{}} + c.Writer = writer + + c.Writer.WriteHeader(http.StatusNoContent) + c.Writer.WriteHeaderNow() + c.Writer.WriteString("foo") // nolint: errcheck + assert.Equal(t, http.StatusNoContent, c.Writer.Status()) + assert.Equal(t, "foo", w.Body.String()) + assert.Equal(t, "foo", writer.dupBody.String()) + assert.True(t, c.Writer.Written()) + c.Writer.WriteString("bar") // nolint: errcheck + assert.Equal(t, http.StatusNoContent, c.Writer.Status()) + assert.Equal(t, "foobar", w.Body.String()) + assert.Equal(t, "foobar", writer.dupBody.String()) + assert.True(t, c.Writer.Written()) +} + +func TestDiscard(_ *testing.T) { + l := NewDiscard() + l.Debugf("") + l.Infof("") + l.Errorf("") + l.Warnf("") + l.DPanicf("") + l.Fatalf("") +} + +func TestJSONEncoding(t *testing.T) { + want := BodyCache{ + Status: 2, + Header: nil, + Data: []byte{1, 20, 3, 90}, + encoding: nil, + } + + encode := JSONEncoding{} + + data, err := encode.Marshal(want) + require.NoError(t, err) + + got := BodyCache{} + err = encode.Unmarshal(data, &got) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestJSONGzipEncoding(t *testing.T) { + want := BodyCache{ + Status: 2, + Header: nil, + Data: []byte{1, 20, 3, 90}, + encoding: nil, + } + + encode := JSONGzipEncoding{} + + data, err := encode.Marshal(want) + require.NoError(t, err) + + got := BodyCache{} + err = encode.Unmarshal(data, &got) + require.NoError(t, err) + require.Equal(t, want, got) +} diff --git a/cache/encoding.go b/cache/encoding.go new file mode 100644 index 0000000..49d84a6 --- /dev/null +++ b/cache/encoding.go @@ -0,0 +1,45 @@ +package cache + +import ( + "bytes" + "compress/gzip" + "encoding/json" +) + +type JSONEncoding struct{} + +func (JSONEncoding) Marshal(v any) ([]byte, error) { + return json.Marshal(v) +} + +func (JSONEncoding) Unmarshal(data []byte, v any) error { + return json.Unmarshal(data, v) +} + +type JSONGzipEncoding struct{} + +func (JSONGzipEncoding) Marshal(v any) ([]byte, error) { + buf := &bytes.Buffer{} + writer, err := gzip.NewWriterLevel(buf, gzip.BestCompression) + if err != nil { + return nil, err + } + err = json.NewEncoder(writer).Encode(v) + if err != nil { + writer.Close() + return nil, err + } + writer.Close() + return buf.Bytes(), nil +} + +func (JSONGzipEncoding) Unmarshal(data []byte, v any) error { + reader, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return err + } + defer func() { + reader.Close() + }() + return json.NewDecoder(reader).Decode(v) +} diff --git a/cache/logger.go b/cache/logger.go new file mode 100644 index 0000000..2beaaca --- /dev/null +++ b/cache/logger.go @@ -0,0 +1,28 @@ +package cache + +var _ Logger = (*Discard)(nil) + +// Discard is an logger on which all Write calls succeed +// without doing anything. +type Discard struct{} + +// NewDiscard a discard logger on which always succeed without doing anything +func NewDiscard() Discard { return Discard{} } + +// Debugf implement Logger interface. +func (d Discard) Debugf(string, ...any) {} + +// Infof implement Logger interface. +func (d Discard) Infof(string, ...any) {} + +// Errorf implement Logger interface. +func (d Discard) Errorf(string, ...any) {} + +// Warnf implement Logger interface. +func (d Discard) Warnf(string, ...any) {} + +// DPanicf implement Logger interface. +func (d Discard) DPanicf(string, ...any) {} + +// Fatalf implement Logger interface. +func (d Discard) Fatalf(string, ...any) {} diff --git a/cache/persist/memory/memory.go b/cache/persist/memory/memory.go new file mode 100644 index 0000000..94f9878 --- /dev/null +++ b/cache/persist/memory/memory.go @@ -0,0 +1,46 @@ +package memory + +import ( + "reflect" + "time" + + "github.com/patrickmn/go-cache" + + "github.com/things-go/gin-contrib/cache/persist" +) + +// Store memory store +type Store struct { + Cache *cache.Cache +} + +// NewStore new memory store +func NewStore(c *cache.Cache) *Store { + return &Store{c} +} + +// Set implement persist.Store interface +func (c *Store) Set(key string, value any, expire time.Duration) error { + c.Cache.Set(key, value, expire) + return nil +} + +// Get implement persist.Store interface +func (c *Store) Get(key string, value any) error { + val, found := c.Cache.Get(key) + if !found { + return persist.ErrCacheMiss + } + + v := reflect.ValueOf(value) + if v.Type().Kind() == reflect.Ptr && v.Elem().CanSet() { + v.Elem().Set(reflect.Indirect(reflect.ValueOf(val))) + } + return nil +} + +// Delete implement persist.Store interface +func (c *Store) Delete(key string) error { + c.Cache.Delete(key) + return nil +} diff --git a/cache/persist/memory/memory_test.go b/cache/persist/memory/memory_test.go new file mode 100644 index 0000000..4927cf2 --- /dev/null +++ b/cache/persist/memory/memory_test.go @@ -0,0 +1,85 @@ +package memory + +import ( + "testing" + "time" + + "github.com/patrickmn/go-cache" + "github.com/stretchr/testify/require" + + "github.com/things-go/gin-contrib/cache/persist" +) + +type cacheFactory func(*testing.T, time.Duration) persist.Store + +// Test typical cache interactions +func typicalGetSet(t *testing.T, newCache cacheFactory) { + var err error + storeCache := newCache(t, time.Hour) + + value := "foo" + err = storeCache.Set("value", value, time.Hour) + require.NoError(t, err) + + value = "" + err = storeCache.Get("value", &value) + require.NoError(t, err) + require.Equal(t, "foo", value) +} + +func expiration(t *testing.T, newCache cacheFactory) { + // memcached does not support expiration times less than 1 second. + var err error + storeCache := newCache(t, time.Second) + + value := 10 + // Test Set w/ short time + err = storeCache.Set("int", value, time.Second) + require.NoError(t, err) + time.Sleep(2 * time.Second) + err = storeCache.Get("int", &value) + require.ErrorIs(t, err, persist.ErrCacheMiss) + + // Test Set w/ longer time. + err = storeCache.Set("int", value, time.Hour) + require.NoError(t, err) + time.Sleep(2 * time.Second) + err = storeCache.Get("int", &value) + require.NoError(t, err) + + // Test Set w/ forever. + err = storeCache.Set("int", value, -1) + require.NoError(t, err) + time.Sleep(2 * time.Second) + err = storeCache.Get("int", &value) + require.NoError(t, err) +} + +func emptyCache(t *testing.T, newCache cacheFactory) { + var err error + storeCache := newCache(t, time.Hour) + + err = storeCache.Get("notexist", time.Second) + require.Error(t, err) + require.ErrorIs(t, err, persist.ErrCacheMiss) + + err = storeCache.Delete("notexist") + require.NoError(t, err) +} + +var newInMemoryStore = func(_ *testing.T, defaultExpiration time.Duration) persist.Store { + return NewStore(cache.New(defaultExpiration, time.Minute*10)) +} + +// Test typical cache interactions +func Test_Memory_typicalGetSet(t *testing.T) { + typicalGetSet(t, newInMemoryStore) +} + +func Test_Memory_Expiration(t *testing.T) { + expiration(t, newInMemoryStore) +} + +func Test_Memory_Empty(t *testing.T) { + emptyCache(t, newInMemoryStore) +} diff --git a/cache/persist/persist.go b/cache/persist/persist.go new file mode 100644 index 0000000..7988428 --- /dev/null +++ b/cache/persist/persist.go @@ -0,0 +1,22 @@ +package persist + +import ( + "errors" + "time" +) + +// ErrCacheMiss cache miss error +var ErrCacheMiss = errors.New("persist: cache miss") + +// Store is the interface of a Cache backend +type Store interface { + // Get retrieves an item from the Cache. Returns the item or nil, and a bool indicating + // whether the key was found. + Get(key string, value any) error + + // Set sets an item to the Cache, replacing any existing item. + Set(key string, value any, expire time.Duration) error + + // Delete removes an item from the Cache. Does nothing if the key is not in the Cache. + Delete(key string) error +} diff --git a/cache/persist/redis/redis.go b/cache/persist/redis/redis.go new file mode 100644 index 0000000..a50714b --- /dev/null +++ b/cache/persist/redis/redis.go @@ -0,0 +1,42 @@ +package redis + +import ( + "context" + "time" + + "github.com/redis/go-redis/v9" + + "github.com/things-go/gin-contrib/cache/persist" +) + +// Store redis store +type Store struct { + Redisc *redis.Client +} + +// NewStore new redis store +func NewStore(client *redis.Client) *Store { + return &Store{client} +} + +// Set implement persist.Store interface +func (store *Store) Set(key string, value any, expire time.Duration) error { + return store.Redisc.Set(context.Background(), key, value, expire).Err() +} + +// Get implement persist.Store interface +func (store *Store) Get(key string, value any) error { + err := store.Redisc.Get(context.Background(), key).Scan(value) + if err != nil { + if err == redis.Nil { + return persist.ErrCacheMiss + } + return err + } + return nil +} + +// Delete implement persist.Store interface +func (store *Store) Delete(key string) error { + return store.Redisc.Del(context.Background(), key).Err() +} diff --git a/cache/persist/redis/redis_test.go b/cache/persist/redis/redis_test.go new file mode 100644 index 0000000..edab697 --- /dev/null +++ b/cache/persist/redis/redis_test.go @@ -0,0 +1,83 @@ +package redis + +import ( + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + "github.com/stretchr/testify/require" + + "github.com/things-go/gin-contrib/cache/persist" +) + +// Test typical cache interactions +func Test_Memory_typicalGetSet(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + + defer mr.Close() + + storeCache := NewStore(redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + })) + + value := "foo" + err = storeCache.Set("value", value, time.Hour) + require.NoError(t, err) + + value = "" + err = storeCache.Get("value", &value) + require.NoError(t, err) + require.Equal(t, "foo", value) +} + +func Test_Memory_Expiration(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + + storeCache := NewStore(redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + })) + + value := 10 + + // Test Set w/ short time + err = storeCache.Set("int", value, time.Second) + require.NoError(t, err) + mr.FastForward(time.Second) + err = storeCache.Get("int", &value) + require.ErrorIs(t, err, persist.ErrCacheMiss) + + // Test Set w/ longer time. + err = storeCache.Set("int", value, time.Hour) + require.NoError(t, err) + mr.FastForward(time.Second) + err = storeCache.Get("int", &value) + require.NoError(t, err) + + // Test Set w/ forever. + err = storeCache.Set("int", value, -1) + require.NoError(t, err) + mr.FastForward(time.Second) + err = storeCache.Get("int", &value) + require.NoError(t, err) +} + +func Test_Memory_Empty(t *testing.T) { + mr, err := miniredis.Run() + require.NoError(t, err) + defer mr.Close() + + storeCache := NewStore(redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + })) + + err = storeCache.Get("notexist", time.Hour) + require.Error(t, err) + require.ErrorIs(t, err, persist.ErrCacheMiss) + + err = storeCache.Delete("notexist") + require.NoError(t, err) +} diff --git a/cache/pool.go b/cache/pool.go new file mode 100644 index 0000000..8d3d27d --- /dev/null +++ b/cache/pool.go @@ -0,0 +1,43 @@ +package cache + +import ( + "encoding" + "net/http" + "sync" +) + +var cachePool = &sync.Pool{ + New: func() any { return &BodyCache{Header: make(http.Header)} }, +} + +// Get implement Pool interface +func poolGet() *BodyCache { + return cachePool.Get().(*BodyCache) +} + +// Put implement Pool interface +func poolPut(c *BodyCache) { + c.Data = c.Data[:0] + c.Header = make(http.Header) + c.encoding = nil + cachePool.Put(c) +} + +// BodyCache body cache store +type BodyCache struct { + Status int + Header http.Header + Data []byte + encoding Encoding +} + +var _ encoding.BinaryMarshaler = (*BodyCache)(nil) +var _ encoding.BinaryUnmarshaler = (*BodyCache)(nil) + +func (b *BodyCache) MarshalBinary() ([]byte, error) { + return b.encoding.Marshal(b) +} + +func (b *BodyCache) UnmarshalBinary(data []byte) error { + return b.encoding.Unmarshal(data, b) +} diff --git a/examples/cache/custom/custom.go b/examples/cache/custom/custom.go new file mode 100644 index 0000000..a2e32e6 --- /dev/null +++ b/examples/cache/custom/custom.go @@ -0,0 +1,32 @@ +package main + +import ( + "time" + + "github.com/gin-gonic/gin" + inmemory "github.com/patrickmn/go-cache" + + "github.com/things-go/gin-contrib/cache" + "github.com/things-go/gin-contrib/cache/persist/memory" +) + +func main() { + app := gin.New() + + app.GET("/hello/:a/:b", + cache.Cache( + memory.NewStore(inmemory.New(time.Minute, time.Minute*10)), + 5*time.Second, + cache.WithGenerateKey(func(c *gin.Context) (string, bool) { + a := c.Param("a") + b := c.Param("b") + return cache.GenerateKeyWithPrefix(cache.PageCachePrefix, a+":"+b), true + }), + ), + func(c *gin.Context) { + c.String(200, "hello world") + }) + if err := app.Run(":8080"); err != nil { + panic(err) + } +} diff --git a/examples/cache/memory/memory.go b/examples/cache/memory/memory.go new file mode 100644 index 0000000..725a707 --- /dev/null +++ b/examples/cache/memory/memory.go @@ -0,0 +1,30 @@ +package main + +import ( + "log" + "time" + + "github.com/gin-gonic/gin" + inmemory "github.com/patrickmn/go-cache" + + "github.com/things-go/gin-contrib/cache" + "github.com/things-go/gin-contrib/cache/persist/memory" +) + +func main() { + app := gin.New() + + app.GET("/hello", + cache.Cache( + memory.NewStore(inmemory.New(time.Minute, time.Minute*10)), + 5*time.Second, + ), + func(c *gin.Context) { + log.Println("dfadfadfadf") + c.String(200, "hello world") + }, + ) + if err := app.Run(":8080"); err != nil { + panic(err) + } +} diff --git a/examples/cache/redis/redis.go b/examples/cache/redis/redis.go new file mode 100644 index 0000000..ff837a3 --- /dev/null +++ b/examples/cache/redis/redis.go @@ -0,0 +1,34 @@ +package main + +import ( + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + + "github.com/things-go/gin-contrib/cache" + redisStore "github.com/things-go/gin-contrib/cache/persist/redis" +) + +func main() { + app := gin.New() + + store := redisStore.NewStore(redis.NewClient(&redis.Options{ + Network: "tcp", + Addr: "localhost:6379", + })) + + app.GET("/hello", + cache.Cache( + store, + 5*time.Second, + cache.WithGenerateKey(cache.GenerateRequestPath), + ), + func(c *gin.Context) { + c.String(200, "hello world") + }, + ) + if err := app.Run(":8080"); err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod index ee90283..15bc43e 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,29 @@ module github.com/things-go/gin-contrib go 1.21 require ( + github.com/alicebob/miniredis/v2 v2.33.0 github.com/casbin/casbin/v2 v2.98.0 github.com/gin-gonic/gin v1.10.0 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/redis/go-redis/v9 v9.6.1 github.com/stretchr/testify v1.9.0 github.com/things-go/limiter v0.1.5 github.com/thinkgos/http-signature-go v0.2.2 go.uber.org/zap v1.27.0 + golang.org/x/sync v0.7.0 gorm.io/gorm v1.25.11 ) require ( + github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/casbin/govaluate v1.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -38,6 +45,7 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.8.0 // indirect golang.org/x/crypto v0.23.0 // indirect diff --git a/go.sum b/go.sum index 749ecbd..36439a7 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,10 @@ github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 h1:uvdUDbHQHO github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= github.com/alicebob/miniredis/v2 v2.33.0 h1:uvTF0EDeu9RLnUEG27Db5I68ESoIxTiXbNUiji6lZrA= github.com/alicebob/miniredis/v2 v2.33.0/go.mod h1:MhP4a3EU7aENRi9aO+tHfTBZicLqQevyi/DJpoj6mi0= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -61,6 +65,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -105,6 +111,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/testdata/template.html b/testdata/template.html new file mode 100644 index 0000000..e68c665 --- /dev/null +++ b/testdata/template.html @@ -0,0 +1,5 @@ + +

+ {{ .value }} +

+ \ No newline at end of file