From 2aec3048fac304c85ee40cc0379bcba9b9a14882 Mon Sep 17 00:00:00 2001 From: Krish Srivastava Date: Tue, 22 Jul 2025 14:04:49 +0530 Subject: [PATCH 1/2] Add caching template for gowrap - Add templates/caching with in-memory caching using go-cache - Cache successful method results based on method name + parameters - Skip caching for void methods and error results - Thread-safe implementation with configurable expiration - Comprehensive test coverage for all caching scenarios - Follows existing gowrap template patterns and conventions --- README.md | 1 + templates/caching | 83 ++++++++++ templates_tests/interface_with_caching.go | 99 ++++++++++++ .../interface_with_caching_test.go | 143 ++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 templates/caching create mode 100644 templates_tests/interface_with_caching.go create mode 100644 templates_tests/interface_with_caching_test.go diff --git a/README.md b/README.md index 53504f4..d22f845 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ List of available templates: - [twirp\_validate](https://github.com/hexdigest/gowrap/tree/master/templates/twirp_validate) runs `func Validate() error` method on each argument if it's present and wraps returned error with twirp.Malformed error - [grpc\_validate](https://github.com/hexdigest/gowrap/tree/master/templates/grpc_validate) runs `func Validate() error` method on each argument if it's present and returns [InvalidArgument](https://github.com/grpc/grpc-go/blob/9d8d97a245af2d4bc743585418e1b4aebada0637/codes/codes.go#L49) error in case when validation failed - [elastic apm](https://github.com/hexdigest/gowrap/tree/master/templates/elasticapm) instruments the source interface with elastic apm spans + - [caching](https://github.com/hexdigest/gowrap/tree/master/templates/caching) instruments the source interface with in-memory caching using go-cache library By default GoWrap places the `//go:generate` instruction into the generated code. This allows you to regenerate decorators' code just by typing `go generate ./...` when you change the source interface type declaration. diff --git a/templates/caching b/templates/caching new file mode 100644 index 0000000..ad72962 --- /dev/null +++ b/templates/caching @@ -0,0 +1,83 @@ +import ( + "fmt" + "sync" + "time" + "github.com/patrickmn/go-cache" +) + +{{ $decorator := (or .Vars.DecoratorName (printf "%sWithCaching" .Interface.Name)) }} + +// {{$decorator}} implements {{.Interface.Type}} instrumented with caching +type {{$decorator}} struct { + {{.Interface.Type}} + _cache *cache.Cache + _mutex sync.RWMutex +} + +// New{{$decorator}} creates a new instance with caching enabled +func New{{$decorator}}(base {{.Interface.Type}}, defaultExpiration, cleanupInterval time.Duration) *{{$decorator}} { + return &{{$decorator}}{ + {{.Interface.Name}}: base, + _cache: cache.New(defaultExpiration, cleanupInterval), + } +} + +{{range $method := .Interface.Methods}} + // {{$method.Name}} implements {{$.Interface.Type}} + func (_d *{{$decorator}}) {{$method.Declaration}} { + {{- if and $method.HasResults (not $method.ReturnsError)}} + _key := "{{$method.Name}}" + {{- range $param := $method.Params}} + _key += fmt.Sprintf(":%v", {{$param.Name}}) + {{- end}} + + _d._mutex.RLock() + if _cached, _found := _d._cache.Get(_key); _found { + _d._mutex.RUnlock() + return _cached.({{(index $method.Results 0).Type}}) + } + _d._mutex.RUnlock() + + {{$method.ResultsNames}} = _d.{{$.Interface.Name}}.{{$method.Call}} + + _d._mutex.Lock() + _d._cache.Set(_key, {{(index $method.Results 0).Name}}, cache.DefaultExpiration) + _d._mutex.Unlock() + + return + {{- else if and $method.HasResults $method.ReturnsError}} + _key := "{{$method.Name}}" + {{- range $param := $method.Params}} + _key += fmt.Sprintf(":%v", {{$param.Name}}) + {{- end}} + + _d._mutex.RLock() + if _cached, _found := _d._cache.Get(_key); _found { + _d._mutex.RUnlock() + _result := _cached.([]interface{}) + {{- range $i, $result := $method.Results}} + {{- if ne $result.Name "err"}} + {{$result.Name}} = _result[{{$i}}].({{$result.Type}}) + {{- else}} + {{$result.Name}} = nil + {{- end}} + {{- end}} + return + } + _d._mutex.RUnlock() + + {{$method.ResultsNames}} = _d.{{$.Interface.Name}}.{{$method.Call}} + + if err == nil { + _d._mutex.Lock() + _d._cache.Set(_key, []interface{}{ {{- range $i, $result := $method.Results}}{{if $i}}, {{end}}{{if ne $result.Name "err"}}{{$result.Name}}{{else}}nil{{end}}{{end}} }, cache.DefaultExpiration) + _d._mutex.Unlock() + } + + return + {{- else}} + _d.{{$.Interface.Name}}.{{$method.Call}} + return + {{- end}} + } +{{end}} \ No newline at end of file diff --git a/templates_tests/interface_with_caching.go b/templates_tests/interface_with_caching.go new file mode 100644 index 0000000..302f1b9 --- /dev/null +++ b/templates_tests/interface_with_caching.go @@ -0,0 +1,99 @@ +// Code generated by gowrap. DO NOT EDIT. +// template: ../templates/caching +// gowrap: http://github.com/hexdigest/gowrap + +package templatestests + +//go:generate gowrap gen -p github.com/hexdigest/gowrap/templates_tests -i TestInterface -t ../templates/caching -o interface_with_caching.go -l "" + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/patrickmn/go-cache" +) + +// TestInterfaceWithCaching implements TestInterface instrumented with caching +type TestInterfaceWithCaching struct { + TestInterface + _cache *cache.Cache + _mutex sync.RWMutex +} + +// NewTestInterfaceWithCaching creates a new instance with caching enabled +func NewTestInterfaceWithCaching(base TestInterface, defaultExpiration, cleanupInterval time.Duration) *TestInterfaceWithCaching { + return &TestInterfaceWithCaching{ + TestInterface: base, + _cache: cache.New(defaultExpiration, cleanupInterval), + } +} + +// Channels implements TestInterface +func (_d *TestInterfaceWithCaching) Channels(chA chan bool, chB chan<- bool, chanC <-chan bool) { + _d.TestInterface.Channels(chA, chB, chanC) + return +} + +// ContextNoError implements TestInterface +func (_d *TestInterfaceWithCaching) ContextNoError(ctx context.Context, a1 string, a2 string) { + _d.TestInterface.ContextNoError(ctx, a1, a2) + return +} + +// F implements TestInterface +func (_d *TestInterfaceWithCaching) F(ctx context.Context, a1 string, a2 ...string) (result1 string, result2 string, err error) { + _key := "F" + _key += fmt.Sprintf(":%v", ctx) + _key += fmt.Sprintf(":%v", a1) + _key += fmt.Sprintf(":%v", a2) + + _d._mutex.RLock() + if _cached, _found := _d._cache.Get(_key); _found { + _d._mutex.RUnlock() + _result := _cached.([]interface{}) + result1 = _result[0].(string) + result2 = _result[1].(string) + err = nil + return + } + _d._mutex.RUnlock() + + result1, result2, err = _d.TestInterface.F(ctx, a1, a2...) + + if err == nil { + _d._mutex.Lock() + _d._cache.Set(_key, []interface{}{result1, result2, nil}, cache.DefaultExpiration) + _d._mutex.Unlock() + } + + return +} + +// NoError implements TestInterface +func (_d *TestInterfaceWithCaching) NoError(s1 string) (s2 string) { + _key := "NoError" + _key += fmt.Sprintf(":%v", s1) + + _d._mutex.RLock() + if _cached, _found := _d._cache.Get(_key); _found { + _d._mutex.RUnlock() + return _cached.(string) + } + _d._mutex.RUnlock() + + s2 = _d.TestInterface.NoError(s1) + + _d._mutex.Lock() + _d._cache.Set(_key, s2, cache.DefaultExpiration) + _d._mutex.Unlock() + + return +} + +// NoParamsOrResults implements TestInterface +func (_d *TestInterfaceWithCaching) NoParamsOrResults() { + _d.TestInterface.NoParamsOrResults() + return +} diff --git a/templates_tests/interface_with_caching_test.go b/templates_tests/interface_with_caching_test.go new file mode 100644 index 0000000..94f64e1 --- /dev/null +++ b/templates_tests/interface_with_caching_test.go @@ -0,0 +1,143 @@ +package templatestests + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type cachingTestImpl struct { + fCallCount int32 + noErrorCallCount int32 + contextNoErrorCount int32 + noParamsCallCount int32 + channelsCallCount int32 + shouldError bool +} + +var errCaching = errors.New("caching test error") + +func (c *cachingTestImpl) F(ctx context.Context, a1 string, a2 ...string) (result1, result2 string, err error) { + atomic.AddInt32(&c.fCallCount, 1) + if c.shouldError { + return "", "", errCaching + } + return "result1_" + a1, "result2_" + a1, nil +} + +func (c *cachingTestImpl) NoError(s string) string { + atomic.AddInt32(&c.noErrorCallCount, 1) + return "noerror_" + s +} + +func (c *cachingTestImpl) ContextNoError(ctx context.Context, a1 string, a2 string) { + atomic.AddInt32(&c.contextNoErrorCount, 1) +} + +func (c *cachingTestImpl) NoParamsOrResults() { + atomic.AddInt32(&c.noParamsCallCount, 1) +} + +func (c *cachingTestImpl) Channels(chA chan bool, chB chan<- bool, chanC <-chan bool) { + atomic.AddInt32(&c.channelsCallCount, 1) +} + +func TestTestInterfaceWithCaching_F(t *testing.T) { + ctx := context.Background() + + t.Run("caches successful results", func(t *testing.T) { + impl := &cachingTestImpl{} + wrapped := NewTestInterfaceWithCaching(impl, time.Hour, time.Hour) + + // First call + r1, r2, err := wrapped.F(ctx, "test") + assert.NoError(t, err) + assert.Equal(t, "result1_test", r1) + assert.Equal(t, "result2_test", r2) + assert.Equal(t, int32(1), atomic.LoadInt32(&impl.fCallCount)) + + // Second call with same params - should be cached + r1, r2, err = wrapped.F(ctx, "test") + assert.NoError(t, err) + assert.Equal(t, "result1_test", r1) + assert.Equal(t, "result2_test", r2) + assert.Equal(t, int32(1), atomic.LoadInt32(&impl.fCallCount)) // Still 1 + }) + + t.Run("different parameters create different cache keys", func(t *testing.T) { + impl := &cachingTestImpl{} + wrapped := NewTestInterfaceWithCaching(impl, time.Hour, time.Hour) + + // Call with "test1" + r1, r2, err := wrapped.F(ctx, "test1") + assert.NoError(t, err) + assert.Equal(t, "result1_test1", r1) + assert.Equal(t, "result2_test1", r2) + assert.Equal(t, int32(1), atomic.LoadInt32(&impl.fCallCount)) + + // Call with "test2" - different param, should call impl + r1, r2, err = wrapped.F(ctx, "test2") + assert.NoError(t, err) + assert.Equal(t, "result1_test2", r1) + assert.Equal(t, "result2_test2", r2) + assert.Equal(t, int32(2), atomic.LoadInt32(&impl.fCallCount)) + }) + + t.Run("errors are not cached", func(t *testing.T) { + impl := &cachingTestImpl{shouldError: true} + wrapped := NewTestInterfaceWithCaching(impl, time.Hour, time.Hour) + + // First call - should error + _, _, err := wrapped.F(ctx, "test") + assert.Equal(t, errCaching, err) + assert.Equal(t, int32(1), atomic.LoadInt32(&impl.fCallCount)) + + // Second call - should call impl again (errors not cached) + _, _, err = wrapped.F(ctx, "test") + assert.Equal(t, errCaching, err) + assert.Equal(t, int32(2), atomic.LoadInt32(&impl.fCallCount)) + }) +} + +func TestTestInterfaceWithCaching_NoError(t *testing.T) { + t.Run("caches single return value methods", func(t *testing.T) { + impl := &cachingTestImpl{} + wrapped := NewTestInterfaceWithCaching(impl, time.Hour, time.Hour) + + // First call + result := wrapped.NoError("test") + assert.Equal(t, "noerror_test", result) + assert.Equal(t, int32(1), atomic.LoadInt32(&impl.noErrorCallCount)) + + // Second call - should be cached + result = wrapped.NoError("test") + assert.Equal(t, "noerror_test", result) + assert.Equal(t, int32(1), atomic.LoadInt32(&impl.noErrorCallCount)) // Still 1 + }) +} + +func TestTestInterfaceWithCaching_NoReturns(t *testing.T) { + t.Run("methods without return values are not cached", func(t *testing.T) { + impl := &cachingTestImpl{} + wrapped := NewTestInterfaceWithCaching(impl, time.Hour, time.Hour) + + // First call + wrapped.ContextNoError(context.Background(), "a", "b") + assert.Equal(t, int32(1), atomic.LoadInt32(&impl.contextNoErrorCount)) + + // Second call - should call impl again (no caching) + wrapped.ContextNoError(context.Background(), "a", "b") + assert.Equal(t, int32(2), atomic.LoadInt32(&impl.contextNoErrorCount)) + + // NoParamsOrResults + wrapped.NoParamsOrResults() + assert.Equal(t, int32(1), atomic.LoadInt32(&impl.noParamsCallCount)) + + wrapped.NoParamsOrResults() + assert.Equal(t, int32(2), atomic.LoadInt32(&impl.noParamsCallCount)) + }) +} From 3b8a5a53d1a9bd22c83cba77e30eead1c1f99df2 Mon Sep 17 00:00:00 2001 From: Krish Srivastava <82054542+retr0-kernel@users.noreply.github.com> Date: Wed, 27 May 2026 01:20:57 +0530 Subject: [PATCH 2/2] Fix formatting in caching template for key generation --- templates/caching | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/templates/caching b/templates/caching index ad72962..62b3762 100644 --- a/templates/caching +++ b/templates/caching @@ -28,7 +28,7 @@ func New{{$decorator}}(base {{.Interface.Type}}, defaultExpiration, cleanupInter {{- if and $method.HasResults (not $method.ReturnsError)}} _key := "{{$method.Name}}" {{- range $param := $method.Params}} - _key += fmt.Sprintf(":%v", {{$param.Name}}) + _key += fmt.Sprintf(":%#v", {{$param.Name}}) {{- end}} _d._mutex.RLock() @@ -48,7 +48,7 @@ func New{{$decorator}}(base {{.Interface.Type}}, defaultExpiration, cleanupInter {{- else if and $method.HasResults $method.ReturnsError}} _key := "{{$method.Name}}" {{- range $param := $method.Params}} - _key += fmt.Sprintf(":%v", {{$param.Name}}) + _key += fmt.Sprintf(":%#v", {{$param.Name}}) {{- end}} _d._mutex.RLock() @@ -80,4 +80,4 @@ func New{{$decorator}}(base {{.Interface.Type}}, defaultExpiration, cleanupInter return {{- end}} } -{{end}} \ No newline at end of file +{{end}}