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
120 changes: 120 additions & 0 deletions cache/cache_semantics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package cache

import (
"errors"
"testing"
"time"
)

// 缓存读取失败必须能区分"键不存在"与"后端不可用"。
// 内存后端不会有后端故障,所有未命中一律是 ErrCacheMiss;
// 凡是拿缓存做凭证判定的链路都按 errors.Is 分流,这里把语义钉死。

type demoVal struct {
Name string `json:"name"`
}

func TestMemoryCacheMissIsSentinel(t *testing.T) {
c := InitWafCache()

var out demoVal
if err := c.GetAs("NOT_EXIST", &out); !errors.Is(err, ErrCacheMiss) {
t.Fatalf("GetAs 未命中应返回 ErrCacheMiss,实际 %v", err)
}
if err := c.GetAsEx("NOT_EXIST", &out, time.Minute); !errors.Is(err, ErrCacheMiss) {
t.Fatalf("GetAsEx 未命中应返回 ErrCacheMiss,实际 %v", err)
}
if err := c.Touch("NOT_EXIST", time.Minute); !errors.Is(err, ErrCacheMiss) {
t.Fatalf("Touch 未命中应返回 ErrCacheMiss,实际 %v", err)
}
if _, err := c.GetString("NOT_EXIST"); !errors.Is(err, ErrCacheMiss) {
t.Fatalf("GetString 未命中应返回 ErrCacheMiss,实际 %v", err)
}
if _, err := c.GetInt("NOT_EXIST"); !errors.Is(err, ErrCacheMiss) {
t.Fatalf("GetInt 未命中应返回 ErrCacheMiss,实际 %v", err)
}
if _, err := c.GetBytes("NOT_EXIST"); !errors.Is(err, ErrCacheMiss) {
t.Fatalf("GetBytes 未命中应返回 ErrCacheMiss,实际 %v", err)
}
if _, err := c.GetExpireTime("NOT_EXIST"); !errors.Is(err, ErrCacheMiss) {
t.Fatalf("GetExpireTime 未命中应返回 ErrCacheMiss,实际 %v", err)
}

// 内存后端永远不会报后端故障
if err := c.GetAs("NOT_EXIST", &out); errors.Is(err, ErrCacheBackend) {
t.Fatal("内存后端不应出现 ErrCacheBackend")
}
ok, err := c.ExistsE("NOT_EXIST")
if ok || err != nil {
t.Fatalf("ExistsE 未命中应返回 (false, nil),实际 (%v, %v)", ok, err)
}
}

func TestMemoryGetAsExRenews(t *testing.T) {
c := InitWafCache()
c.SetWithTTl("K", demoVal{Name: "n1"}, 2*time.Second)

time.Sleep(1200 * time.Millisecond)

// 读取的同时把有效期重置为 2 秒,原到期时刻(约 800ms 后)作废
var out demoVal
if err := c.GetAsEx("K", &out, 2*time.Second); err != nil {
t.Fatalf("GetAsEx 应命中,实际 %v", err)
}
if out.Name != "n1" {
t.Fatalf("取到的值不对:%v", out.Name)
}

time.Sleep(1200 * time.Millisecond)
if err := c.GetAsEx("K", &out, 2*time.Second); err != nil {
t.Fatalf("续期后本应仍然有效,实际 %v", err)
}
}

func TestMemoryGetAsExWithoutTTlDoesNotRenew(t *testing.T) {
c := InitWafCache()
c.SetWithTTl("K", demoVal{Name: "n1"}, time.Second)

var out demoVal
// ttl<=0 表示只读不续期
if err := c.GetAsEx("K", &out, 0); err != nil {
t.Fatalf("应命中,实际 %v", err)
}
time.Sleep(1100 * time.Millisecond)
if err := c.GetAsEx("K", &out, 0); !errors.Is(err, ErrCacheMiss) {
t.Fatalf("未续期则应已过期,实际 %v", err)
}
}

func TestMemoryTouchKeepsValue(t *testing.T) {
c := InitWafCache()
c.SetWithTTl("K", demoVal{Name: "n1"}, time.Second)

if err := c.Touch("K", 3*time.Second); err != nil {
t.Fatalf("Touch 应成功,实际 %v", err)
}
time.Sleep(1200 * time.Millisecond)

var out demoVal
if err := c.GetAs("K", &out); err != nil {
t.Fatalf("Touch 后应仍然有效,实际 %v", err)
}
if out.Name != "n1" {
t.Fatalf("Touch 不应改变值,实际 %v", out.Name)
}
}

// 键名里可能直接跟着凭证本体,日志只能出现前缀加 8 个字符
func TestMaskCacheKey(t *testing.T) {
cases := []struct{ in, want string }{
{"CACHE_TOKEN0d42ce0c1f2a3b4c5d6e7f8091a2b3c4", "CACHE_TOKEN0d42ce0c..."},
{"CACHE_TOKEN_BINDFAIL_0d42ce0c1f2a3b4c", "CACHE_TOKEN_BINDFAIL_0d42ce0c..."},
{"CACHE_TOKEN", "CACHE_TOKEN"},
{"CACHE_X_abc", "CACHE_X_abc"},
}
for _, tc := range cases {
if got := maskCacheKey(tc.in); got != tc.want {
t.Fatalf("maskCacheKey(%q)=%q,期望 %q", tc.in, got, tc.want)
}
}
}
30 changes: 30 additions & 0 deletions cache/cache_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,43 @@ type CacheStore interface {
// GetAs 将缓存值反序列化到 out(out 必须为指针)。
// 对于 Redis 等非内存后端,Get() 返回的是 map[string]interface{},
// 无法直接类型断言为具体 struct;应使用 GetAs 代替。
//
// 返回值区分两种失败:ErrCacheMiss(键不存在)与 ErrCacheBackend(后端不可用),
// 用 errors.Is 判定。其余错误来自反序列化。
GetAs(key string, out interface{}) error
// GetAsEx 读取并同时续期,一次往返完成。ttl<=0 时等价于 GetAs。
// 每请求都要读一次的凭证类缓存用它,可省掉单独的查剩余时间与续期两次往返。
GetAsEx(key string, out interface{}, ttl time.Duration) error
// Touch 只续期不读取值。键不存在返回 ErrCacheMiss。
Touch(key string, ttl time.Duration) error
GetBytes(key string) ([]byte, error)
GetString(key string) (string, error)
GetInt(key string) (int, error)
// IsKeyExist 后端错误一律按 false 返回。
// 需要区分"确实不存在"与"这次没读到"的场景(尤其是安全判定)用 ExistsE。
IsKeyExist(key string) bool
// ExistsE 与 IsKeyExist 相同,但把后端错误单独返回。
ExistsE(key string) (bool, error)
Remove(key string) interface{}
GetExpireTime(key string) (time.Time, error)
ListAvailableKeys() map[string]time.Duration
ListAvailableKeysWithPrefix(prefix string) map[string]time.Duration
}

// BackendStats 缓存后端运行状况,供运行诊断读取
type BackendStats struct {
Backend string `json:"backend"`
ErrCount uint64 `json:"err_count"`
LastErrAt time.Time `json:"last_err_at"`
LastErr string `json:"last_err"`
}

// BackendStater 可选能力:后端实现了才有错误统计(内存后端不会失败,无需实现)
type BackendStater interface {
BackendStats() BackendStats
}

// BackendDescriber 可选能力:用于启动日志打印当前缓存后端形态(不含口令)
type BackendDescriber interface {
Describe() string
}
33 changes: 33 additions & 0 deletions cache/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package cache

import "errors"

// 缓存读取失败分两类,调用方必须能区分:
// 键不存在是正常结果,后端不可用是故障。凡是拿缓存做凭证判定的链路
// (管理端令牌、访问会话),把后者当成前者就等于把一次网络抖动判成"凭证失效"。
var (
// ErrCacheMiss 键不存在(含已过期)
ErrCacheMiss = errors.New("缓存中不存在该键")
// ErrCacheBackend 缓存后端本次不可用(超时、连接异常、连接池等待超时等)
ErrCacheBackend = errors.New("缓存后端不可用")
)

// maskCacheKey 键名可能直接带凭证(如令牌缓存键就是前缀+令牌本体),
// 日志只保留大写前缀与其后 8 个字符。
func maskCacheKey(key string) string {
// 前缀扫描上限:现有最长前缀 CACHE_TOKEN_BINDFAIL_ 是 21 个字符。
// 设上限是为了万一某类键的值本身带大写,也不会被整段当成前缀留在日志里。
const maxPrefix = 24
i := 0
for i < len(key) && i < maxPrefix && (key[i] == '_' || (key[i] >= 'A' && key[i] <= 'Z')) {
i++
}
if i >= len(key) {
return key
}
rest := key[i:]
if len(rest) > 8 {
return key[:i] + rest[:8] + "..."
}
return key[:i] + rest
}
10 changes: 9 additions & 1 deletion cache/factory.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
package cache

import "time"

// RedisCacheConfig Redis连接配置
type RedisCacheConfig struct {
Host string
Port int
Password string
Port int
DB int
// PoolSize 连接池大小,<=0 时用 go-redis 默认值(10 × GOMAXPROCS)
PoolSize int
// PoolTimeout 等待空闲连接的上限,<=0 时用 go-redis 默认值(ReadTimeout + 1s)
PoolTimeout time.Duration
// OpTimeout 单次操作总上限,<=0 时用 defaultRedisOpTimeout
OpTimeout time.Duration
}

// NewCacheStore 根据cacheType创建缓存实例
Expand Down
Loading
Loading