Goのインメモリキャッシュでスライスを扱うときはコピーが必要
まとめ
これを防ぐには、キャッシュするスライスを直前で複製してキャッシュに格納し、キャッシュを参照した結果も直後に複製して使用することが必要になる
サンプルコード
Genericsを使用しているので Go 1.18 以降を使用すると良い
code:util/cache.go
package util
import (
"time"
"github.com/patrickmn/go-cache"
)
func SetSliceT any(c *cache.Cache, k string, v []T, exp time.Duration) { t := make([]T, len(v))
copy(t, v)
c.Set(k, t, exp)
}
func GetSliceT any(c *cache.Cache, k string) ([]T, bool) { res, ok := c.Get(k)
if !ok {
return nil, false
}
arr := res.([]T)
r := make([]T, len(arr))
copy(r, arr)
return r, true
}
使うときは以下のようにする
code:util/cache.go
package main_test
import (
"testing"
"time"
"github.com/dojineko/slice-cache/util"
"github.com/patrickmn/go-cache"
"github.com/stretchr/testify/assert"
)
func Test_SliceCache(t *testing.T) {
t.Parallel()
prepare := func() (*cache.Cache, []string) {
c := cache.New(10*time.Minute, 10*time.Minute)
return c, []string{"foo", "baz", "bar"}
}
// 意図せずバグるかもしれないパターン
t.Run("invalid", func(t *testing.T) {
t.Parallel()
k := "dummy"
c, original := prepare()
c.Set(k, original, cache.DefaultExpiration)
ret, _ := c.Get(k)
returns := ret.([]string)
assert.Equal(t, v, r)
returns1 = "!!!" // スライスの中身を書き換る // ここは書き換わって正解
assert.Equal(t, []string{"foo", "!!!", "bar"}, returns)
// ここが書き換わるのは困る (実行すると二番目が !!! になる)
assert.Equal(t, []string{"foo", "baz", "bar"}, original)
})
// コピーされているため比較的安全なパターン
t.Run("valid", func(t *testing.T) {
k := "dummy"
c, original := prepare()
util.SetSlice(c, k, original, cache.DefaultExpiration)
returns, _ := util.GetSlicestring(c, k) returns1 = "!!!" // スライスの中身を書き換る // ここは書き換わって正解
assert.Equal(t, []string{"foo", "!!!", "bar"}, returns)
// キャッシュを参照した値が変化しても影響なかった
assert.Equal(t, []string{"foo", "baz", "bar"}, original)
})
}
備考
この現象は Slice が Map, Pointer と同じく参照型であるために起こる
patrickmn/go-cache ではポインタの格納が有効なので []*string などポインタのスライスを参照したときは、通常のポインタと同じく、ポインタの先を操作するとスライス自体のコピーとは関係なく変更が伝播する 関連