diff --git a/README.md b/README.md index 745045a..9e02467 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,6 @@ import "github.com/hertz-contrib/cache" package main import ( - "cache" - "cache/persist" "context" "fmt" "net/http" @@ -45,6 +43,8 @@ import ( "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app/server" + "github.com/hertz-contrib/cache" + "github.com/hertz-contrib/cache/persist" ) func main() { @@ -84,8 +84,6 @@ func main() { package main import ( - "cache" - "cache/persist" "context" "net/http" "time" @@ -93,6 +91,8 @@ import ( "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app/server" "github.com/go-redis/redis/v8" + "github.com/hertz-contrib/cache" + "github.com/hertz-contrib/cache/persist" ) func main() { diff --git a/README_CN.md b/README_CN.md index d599cd3..569f20e 100644 --- a/README_CN.md +++ b/README_CN.md @@ -34,8 +34,6 @@ import "github.com/hertz-contrib/cache" package main import ( - "cache" - "cache/persist" "context" "fmt" "net/http" @@ -44,6 +42,8 @@ import ( "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app/server" + "github.com/hertz-contrib/cache" + "github.com/hertz-contrib/cache/persist" ) func main() { @@ -83,8 +83,6 @@ func main() { package main import ( - "cache" - "cache/persist" "context" "net/http" "time" @@ -92,6 +90,8 @@ import ( "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app/server" "github.com/go-redis/redis/v8" + "github.com/hertz-contrib/cache" + "github.com/hertz-contrib/cache/persist" ) func main() { diff --git a/cache.go b/cache.go index 62bd494..d20e2cc 100644 --- a/cache.go +++ b/cache.go @@ -41,7 +41,6 @@ package cache import ( - "cache/persist" "context" "encoding/gob" "errors" @@ -55,6 +54,7 @@ import ( "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/common/hlog" "github.com/cloudwego/hertz/pkg/protocol" + "github.com/hertz-contrib/cache/persist" "golang.org/x/sync/singleflight" ) @@ -77,9 +77,11 @@ type GetCacheStrategyByRequest func(ctx context.Context, c *app.RequestContext) const ( errMissingCacheStrategy = "[CACHE] cache strategy is nil" getCacheErrorFormat = "[CACHE] get cache error: %s, cache key: %s" - setCacheKeyErrorFormat = "[CACHE] set cache key error" + setCacheKeyErrorFormat = "[CACHE] set cache key error: %s, cache key: %s" getRequestUriIgnoreQueryOrderErrorFormat = "[CACHE] getRequestUriIgnoreQueryOrder error: %s" writeResponseErrorFormat = "[CACHE] write response error: %s" + singleFlightErrorFormat = "[CACHE] call the function in-flight error: %s" + fallbackCacheKeyFormat = "[CACHE] Fallback to default cache key: %s" ) // NewCache user must pass getCacheKey to describe the way to generate cache key @@ -151,7 +153,7 @@ func newCache( } inFlight := false - rawRespCache, _, _ := sfGroup.Do(cacheKey, func() (interface{}, error) { + rawRespCache, err, _ := sfGroup.Do(cacheKey, func() (interface{}, error) { if options.singleFlightForgetTimeout > 0 { forgetTimer := time.AfterFunc(options.singleFlightForgetTimeout, func() { sfGroup.Forget(cacheKey) @@ -176,6 +178,10 @@ func newCache( return respCache, nil }) + if err != nil { + hlog.CtxErrorf(ctx, singleFlightErrorFormat, err) + } + if !inFlight { replyWithCache(ctx, c, options, rawRespCache.(*ResponseCache)) options.shareSingleFlightCallback(ctx, c) @@ -183,36 +189,72 @@ func newCache( } } -// NewCacheByRequestURI a shortcut function for caching response by uri -func NewCacheByRequestURI(defaultCacheStore persist.CacheStore, defaultExpire time.Duration, opts ...Option) app.HandlerFunc { - options := newOptions(opts...) +// KeyStrategy defines the interface for cache key generation strategies. +type KeyStrategy interface { + GenerateKey(c *app.RequestContext) (string, error) +} - var cacheStrategy GetCacheStrategyByRequest - if options.ignoreQueryOrder { - cacheStrategy = func(ctx context.Context, c *app.RequestContext) (bool, Strategy) { - newUri, err := getRequestUriIgnoreQueryOrder(c.Request.URI().String()) - if err != nil { - hlog.CtxErrorf(ctx, getRequestUriIgnoreQueryOrderErrorFormat, err) - newUri = c.Request.URI().String() - } +// ByURI implements KeyStrategy using the request URI. +type ByURI struct{} - return true, Strategy{ - CacheKey: newUri, - } +func (s *ByURI) GenerateKey(c *app.RequestContext) (string, error) { + return string(c.Request.RequestURI()), nil +} + +// ByURIWithIgnoreQueryOrder implements KeyStrategy using the request URI with ordered query parameters. +type ByURIWithIgnoreQueryOrder struct{} + +func (s *ByURIWithIgnoreQueryOrder) GenerateKey(c *app.RequestContext) (string, error) { + return getRequestUriIgnoreQueryOrder(string(c.Request.RequestURI())) +} + +// ByPath implements KeyStrategy using the request path. +type ByPath struct{} + +func (s *ByPath) GenerateKey(c *app.RequestContext) (string, error) { + return b2s(c.Request.Path()), nil +} + +// NewCacheByKeyStrategy is a shortcut function for caching responses based on configurable key generation strategies. +func NewCacheByKeyStrategy(defaultCacheStore persist.CacheStore, defaultExpire time.Duration, strategy KeyStrategy, opts ...Option) app.HandlerFunc { + cacheStrategy := func(ctx context.Context, c *app.RequestContext) (bool, Strategy) { + cacheKey, err := strategy.GenerateKey(c) + if err != nil { + hlog.CtxErrorf(ctx, getRequestUriIgnoreQueryOrderErrorFormat, err) + cacheKey = string(c.Request.RequestURI()) + hlog.CtxErrorf(ctx, fallbackCacheKeyFormat, err) } - } else { - cacheStrategy = func(ctx context.Context, c *app.RequestContext) (bool, Strategy) { - return true, Strategy{ - CacheKey: c.Request.URI().String(), - } + return true, Strategy{ + CacheKey: cacheKey, } } - options.getCacheStrategyByRequest = cacheStrategy + var options []Option + options = append(options, WithCacheStrategyByRequest(cacheStrategy)) + options = append(options, opts...) - return newCache(defaultCacheStore, defaultExpire, options) + return NewCache(defaultCacheStore, defaultExpire, options...) +} + +// NewCacheByRequestURI a shortcut function for caching response by uri. +func NewCacheByRequestURI(store persist.CacheStore, duration time.Duration, opts ...Option) app.HandlerFunc { + strategy := &ByURI{} + return NewCacheByKeyStrategy(store, duration, strategy, opts...) +} + +// NewCacheByRequestURIWithIgnoreQueryOrder a shortcut function for caching response by uri and ignore query param order. +func NewCacheByRequestURIWithIgnoreQueryOrder(store persist.CacheStore, duration time.Duration, opts ...Option) app.HandlerFunc { + strategy := &ByURIWithIgnoreQueryOrder{} + return NewCacheByKeyStrategy(store, duration, strategy, opts...) +} + +// NewCacheByRequestPath a shortcut function for caching response by url path, means will discard the query params. +func NewCacheByRequestPath(store persist.CacheStore, duration time.Duration, opts ...Option) app.HandlerFunc { + strategy := &ByPath{} + return NewCacheByKeyStrategy(store, duration, strategy, opts...) } +// getRequestUriIgnoreQueryOrder returns a URI with query parameters sorted alphabetically by key and value. func getRequestUriIgnoreQueryOrder(requestURI string) (string, error) { parsedUrl, err := url.ParseRequestURI(requestURI) if err != nil { @@ -242,17 +284,6 @@ func getRequestUriIgnoreQueryOrder(requestURI string) (string, error) { return parsedUrl.Path + "?" + strings.Join(queryVals, "&"), nil } -// NewCacheByRequestPath a shortcut function for caching response by url path, means will discard the query params -func NewCacheByRequestPath(defaultCacheStore persist.CacheStore, defaultExpire time.Duration, opts ...Option) app.HandlerFunc { - opts = append(opts, WithCacheStrategyByRequest(func(ctx context.Context, c *app.RequestContext) (bool, Strategy) { - return true, Strategy{ - CacheKey: b2s(c.Request.Path()), - } - })) - - return NewCache(defaultCacheStore, defaultExpire, opts...) -} - func init() { gob.Register(&ResponseCache{}) } @@ -266,16 +297,19 @@ type ResponseCache struct { func (c *ResponseCache) fillWithCacheWriter(cacheWriter *responseCacheWriter, withoutHeader bool) { c.Status = cacheWriter.StatusCode() - c.Data = cacheWriter.Body() + body := cacheWriter.Body() + buf := make([]byte, len(body)) + copy(buf, body) + c.Data = buf if !withoutHeader { c.Header = make(map[string][]string) - for _, val := range cacheWriter.Header.GetHeaders() { - if c.Header.Values(b2s(val.GetKey())) != nil { - c.Header.Add(b2s(val.GetKey()), b2s(val.GetValue())) + cacheWriter.Header.VisitAll(func(key, value []byte) { + if c.Header.Get(b2s(key)) != "" { + c.Header.Add(b2s(key), b2s(value)) } else { - c.Header.Set(b2s(val.GetKey()), b2s(val.GetValue())) + c.Header.Set(b2s(key), b2s(value)) } - } + }) } } diff --git a/cache_test.go b/cache_test.go index 0638257..86fe822 100644 --- a/cache_test.go +++ b/cache_test.go @@ -41,9 +41,9 @@ package cache import ( - "cache/persist" "context" "fmt" + "io" "math/rand" "net/http" "sync" @@ -52,10 +52,12 @@ import ( "time" "github.com/cloudwego/hertz/pkg/app" + "github.com/cloudwego/hertz/pkg/app/server" "github.com/cloudwego/hertz/pkg/common/config" "github.com/cloudwego/hertz/pkg/common/test/assert" "github.com/cloudwego/hertz/pkg/common/ut" "github.com/cloudwego/hertz/pkg/route" + "github.com/hertz-contrib/cache/persist" ) func hertzHandler(middleware app.HandlerFunc, withRand bool) *route.Engine { @@ -245,3 +247,69 @@ func TestPrefixKey(t *testing.T) { w2 := ut.PerformRequest(handler, "GET", requestPath, nil) assert.NotEqual(t, w1.Body, w2.Body) } + +func TestNewCache_Memory(t *testing.T) { + h := server.New( + server.WithHostPorts("127.0.0.1:9233")) + original := map[string][]byte{ + "/tmp-cache/ping1": []byte("{\"data\":{\"num\":1111111111}}"), + "/tmp-cache/ping2": []byte("{\"data\":{\"num\":2222222222222222222}}"), + "/tmp-cache/ping3": []byte("{\"data\":{\"num\":3333333333333333333333333333}}"), + } + h.Use(NewCache(persist.NewMemoryStore(time.Second), 3*time.Second, + WithCacheStrategyByRequest(func(ctx context.Context, c *app.RequestContext) (bool, Strategy) { + return true, Strategy{ + CacheKey: c.Request.URI().String(), + CacheDuration: 5 * time.Second, + } + }))) + h.GET("/tmp-cache/*path", func(ctx context.Context, c *app.RequestContext) { + if data, ok := original[string(c.Request.Path())]; ok { + _, _ = c.Response.BodyWriter().Write(data) + return + } + }) + go h.Spin() + + tests := []struct { + want []byte + url string + }{ + { + want: original["/tmp-cache/ping1"], + url: "http://127.0.0.1:9233/tmp-cache/ping1", + }, + { + want: original["/tmp-cache/ping2"], + url: "http://127.0.0.1:9233/tmp-cache/ping2", + }, + { + want: original["/tmp-cache/ping3"], + url: "http://127.0.0.1:9233/tmp-cache/ping3", + }, + } + + for i := 0; i < 10; i++ { + for _, tt := range tests { + t.Run("cache data", func(t *testing.T) { + resp, err := http.Get(tt.url) + assert.Nil(t, err) + body, err := io.ReadAll(resp.Body) + assert.Nil(t, err) + got := body + assert.DeepEqual(t, string(tt.want), string(got)) + }) + } + } +} + +func TestCacheByURIWithIgnoreQueryOrder(t *testing.T) { + memoryStore := persist.NewMemoryStore(1 * time.Minute) + cacheURIMiddleware := NewCacheByRequestURIWithIgnoreQueryOrder(memoryStore, 3*time.Second) + handler := hertzHandler(cacheURIMiddleware, false) + + w1 := ut.PerformRequest(handler, "GET", "/cache?uid=u1&b=2&a=1", nil) + w2 := ut.PerformRequest(handler, "GET", "/cache?a=1&uid=u1&b=2", nil) + + assert.DeepEqual(t, w1.Body, w2.Body) +} diff --git a/example/memory/memory_example.go b/example/memory/memory_example.go index 094792b..02786f2 100644 --- a/example/memory/memory_example.go +++ b/example/memory/memory_example.go @@ -41,14 +41,14 @@ package main import ( - "cache" - "cache/persist" "context" "net/http" "time" "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app/server" + "github.com/hertz-contrib/cache" + "github.com/hertz-contrib/cache/persist" ) func main() { diff --git a/example/options/main.go b/example/options/main.go index c1b84c5..7bfd6c9 100644 --- a/example/options/main.go +++ b/example/options/main.go @@ -41,8 +41,6 @@ package main import ( - "cache" - "cache/persist" "context" "fmt" "net/http" @@ -51,6 +49,8 @@ import ( "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app/server" + "github.com/hertz-contrib/cache" + "github.com/hertz-contrib/cache/persist" ) func main() { diff --git a/example/redis/redis_example.go b/example/redis/redis_example.go index 1bb7eaf..87bca02 100644 --- a/example/redis/redis_example.go +++ b/example/redis/redis_example.go @@ -41,8 +41,6 @@ package main import ( - "cache" - "cache/persist" "context" "net/http" "time" @@ -50,6 +48,8 @@ import ( "github.com/cloudwego/hertz/pkg/app" "github.com/cloudwego/hertz/pkg/app/server" "github.com/go-redis/redis/v8" + "github.com/hertz-contrib/cache" + "github.com/hertz-contrib/cache/persist" ) func main() { diff --git a/go.mod b/go.mod index 259ffdc..2e534a0 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module cache +module github.com/hertz-contrib/cache go 1.18 diff --git a/options.go b/options.go index f8e06f3..7185935 100644 --- a/options.go +++ b/options.go @@ -59,18 +59,17 @@ type Options struct { singleFlightForgetTimeout time.Duration shareSingleFlightCallback OnShareSingleFlightCallback - ignoreQueryOrder bool - prefixKey string - withoutHeader bool + prefixKey string + withoutHeader bool } // OnHitCacheCallback define the callback when use cache -type OnHitCacheCallback app.HandlerFunc +type OnHitCacheCallback func(ctx context.Context, c *app.RequestContext) var defaultHitCacheCallback = func(ctx context.Context, c *app.RequestContext) {} // OnMissCacheCallback define the callback when use cache -type OnMissCacheCallback app.HandlerFunc +type OnMissCacheCallback func(ctx context.Context, c *app.RequestContext) var defaultMissCacheCallback = func(ctx context.Context, c *app.RequestContext) {} @@ -162,15 +161,6 @@ func WithSingleFlightForgetTimeout(forgetTimeout time.Duration) Option { } } -// IgnoreQueryOrder will ignore the queries order in url when generate cache key . This option only takes effect in CacheByRequestURI function -func IgnoreQueryOrder(b bool) Option { - return Option{ - F: func(o *Options) { - o.ignoreQueryOrder = b - }, - } -} - // WithPrefixKey will prefix the key func WithPrefixKey(prefix string) Option { return Option{ diff --git a/options_test.go b/options_test.go new file mode 100644 index 0000000..443b0fa --- /dev/null +++ b/options_test.go @@ -0,0 +1,125 @@ +/* + * Copyright 2022 CloudWeGo Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * The MIT License (MIT) + * + * Copyright (c) 2021 cyhone + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * +* This file may have been modified by CloudWeGo authors. All CloudWeGo +* Modifications are Copyright 2022 CloudWeGo Authors. +*/ + +package cache + +import ( + "context" + "testing" + "time" + + "github.com/cloudwego/hertz/pkg/app" + "github.com/cloudwego/hertz/pkg/common/test/assert" +) + +func TestOptions(t *testing.T) { + options := Options{ + getCacheStrategyByRequest: func(ctx context.Context, c *app.RequestContext) (bool, Strategy) { + return true, Strategy{ + CacheKey: "test-key1", + } + }, + hitCacheCallback: defaultHitCacheCallback, + missCacheCallback: defaultMissCacheCallback, + beforeReplyWithCacheCallback: defaultBeforeReplyWithCacheCallback, + shareSingleFlightCallback: defaultShareSingleFlightCallback, + singleFlightForgetTimeout: 1 * time.Second, + prefixKey: "prefix1", + withoutHeader: false, + } + + w, x, y, z := "", "", "", "" + + f, strategy := options.getCacheStrategyByRequest(nil, nil) + assert.DeepEqual(t, "test-key1", strategy.CacheKey) + assert.True(t, f) + options.hitCacheCallback(nil, nil) + assert.DeepEqual(t, "", w) + options.missCacheCallback(nil, nil) + assert.DeepEqual(t, "", x) + options.beforeReplyWithCacheCallback(nil, nil) + assert.DeepEqual(t, "", y) + options.shareSingleFlightCallback(nil, nil) + assert.DeepEqual(t, "", z) + assert.DeepEqual(t, 1*time.Second, options.singleFlightForgetTimeout) + assert.DeepEqual(t, "prefix1", options.prefixKey) + assert.False(t, options.withoutHeader) + + opts := make([]Option, 0) + opts = append(opts, + WithCacheStrategyByRequest(func(ctx context.Context, c *app.RequestContext) (bool, Strategy) { + return true, Strategy{ + CacheKey: "test-key2", + } + }), + WithOnHitCache(func(ctx context.Context, cc *app.RequestContext) { + w = "W" + }), + WithOnMissCache(func(ctx context.Context, cc *app.RequestContext) { + x = "X" + }), + WithBeforeReplyWithCache(func(c *app.RequestContext, cache *ResponseCache) { + y = "Y" + }), + WithSingleFlightForgetTimeout(2*time.Second), + WithOnShareSingleFlight(func(ctx context.Context, cc *app.RequestContext) { + z = "Z" + }), + WithoutHeader(true), + WithPrefixKey("prefix2"), + ) + + options.Apply(opts) + + f, strategy = options.getCacheStrategyByRequest(nil, nil) + assert.DeepEqual(t, "test-key2", strategy.CacheKey) + assert.True(t, f) + options.hitCacheCallback(nil, nil) + assert.DeepEqual(t, "W", w) + options.missCacheCallback(nil, nil) + assert.DeepEqual(t, "X", x) + options.beforeReplyWithCacheCallback(nil, nil) + assert.DeepEqual(t, "Y", y) + options.shareSingleFlightCallback(nil, nil) + assert.DeepEqual(t, "Z", z) + assert.DeepEqual(t, 2*time.Second, options.singleFlightForgetTimeout) + assert.DeepEqual(t, "prefix2", options.prefixKey) + assert.True(t, options.withoutHeader) +} diff --git a/persist/memory.go b/persist/memory.go index d6be55d..2f0adfc 100644 --- a/persist/memory.go +++ b/persist/memory.go @@ -46,9 +46,14 @@ import ( "reflect" "time" + "github.com/cloudwego/hertz/pkg/common/hlog" "github.com/jellydator/ttlcache/v2" ) +const ( + setTTLErrorFormat = "[CACHE] set ttl for memory store error: %s" +) + // MemoryStore local memory cache store type MemoryStore struct { Cache *ttlcache.Cache @@ -57,7 +62,9 @@ type MemoryStore struct { // NewMemoryStore allocate a local memory store with default expiration func NewMemoryStore(defaultExpiration time.Duration) *MemoryStore { cacheStore := ttlcache.NewCache() - _ = cacheStore.SetTTL(defaultExpiration) + if err := cacheStore.SetTTL(defaultExpiration); err != nil { + hlog.Errorf(setTTLErrorFormat, err) + } // disable SkipTTLExtensionOnHit default cacheStore.SkipTTLExtensionOnHit(true)