Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions templates/caching
Original file line number Diff line number Diff line change
@@ -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}}
99 changes: 99 additions & 0 deletions templates_tests/interface_with_caching.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

143 changes: 143 additions & 0 deletions templates_tests/interface_with_caching_test.go
Original file line number Diff line number Diff line change
@@ -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))
})
}
Loading