Files
team-api/internal/logic/common/cache.go
T

195 lines
5.5 KiB
Go

package common
import (
"context"
"encoding/json"
"time"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/util/gconv"
)
// Cache provides a two-level caching mechanism:
// L1: In-memory cache (GoFrame gcache, handles Go structs natively)
// L2: Redis cache (stores JSON-serialized values, deserializes on read)
//
// Write: Set L1 (native) + Set L2 (JSON)
// Read: Get L1 → miss → Get L2 (JSON → struct) → miss → call fn → Set L1+L2
type Cache struct {
prefix string
ttl time.Duration
}
// NewCache creates a new Cache instance with the given prefix and default TTL.
func NewCache(prefix string, ttl time.Duration) *Cache {
return &Cache{
prefix: prefix,
ttl: ttl,
}
}
// fullKey returns the full cache key with prefix.
func (c *Cache) fullKey(key string) string {
return c.prefix + ":" + key
}
// Set sets a value in both L1 (memory) and L2 (Redis) caches.
// L1 stores the value natively (Go struct pointers work).
// L2 stores a JSON-serialized copy for cross-process compatibility.
func (c *Cache) Set(ctx context.Context, key string, value any, ttl ...time.Duration) {
expire := c.ttl
if len(ttl) > 0 && ttl[0] > 0 {
expire = ttl[0]
}
fullKey := c.fullKey(key)
// L1: memory cache (native Go struct storage)
gcache.Set(ctx, fullKey, value, expire)
// L2: Redis cache (JSON serialized)
ttlSeconds := int64(expire.Seconds())
jsonBytes, err := json.Marshal(value)
if err != nil {
g.Log().Warningf(ctx, "[Cache] JSON marshal failed key=%s: %v", fullKey, err)
return
}
if ttlSeconds > 0 {
_, _ = g.Redis().Do(ctx, "SETEX", fullKey, ttlSeconds, string(jsonBytes))
} else {
_, _ = g.Redis().Do(ctx, "SET", fullKey, string(jsonBytes))
}
}
// Get retrieves a value: L1 → L2 → miss.
// L1 returns native Go types. L2 deserializes JSON back to the original type.
func (c *Cache) Get(ctx context.Context, key string) (any, bool) {
fullKey := c.fullKey(key)
// Try L1: memory (native Go struct)
val, err := gcache.Get(ctx, fullKey)
if err == nil && val != nil {
return val.Interface(), true
}
// Try L2: Redis (JSON → deserialize)
redisVal, err := g.Redis().Do(ctx, "GET", fullKey)
if err == nil && !redisVal.IsNil() {
jsonStr := redisVal.String()
if jsonStr != "" {
// Unmarshal JSON to get the original Go value
var raw any
if unmarshalErr := json.Unmarshal([]byte(jsonStr), &raw); unmarshalErr == nil {
gcache.Set(ctx, fullKey, raw, c.ttl)
return raw, true
}
// Fallback: treat as plain string (backward compat)
gcache.Set(ctx, fullKey, jsonStr, c.ttl)
return jsonStr, true
}
}
return nil, false
}
// GetJSON retrieves a JSON-serialized value from L2 and unmarshals it into target.
// Use this when caching Go struct pointers to avoid type assertion panics.
func (c *Cache) GetJSON(ctx context.Context, key string, target any) bool {
fullKey := c.fullKey(key)
// Try L1: memory (native Go struct — correct type already)
val, err := gcache.Get(ctx, fullKey)
if err == nil && val != nil {
// L1 has native value; copy via JSON round-trip to populate target
if jsonBytes, err := json.Marshal(val.Interface()); err == nil {
return json.Unmarshal(jsonBytes, target) == nil
}
}
// Try L2: Redis (JSON string)
redisVal, err := g.Redis().Do(ctx, "GET", fullKey)
if err == nil && !redisVal.IsNil() {
jsonStr := redisVal.String()
if jsonStr != "" {
if unmarshalErr := json.Unmarshal([]byte(jsonStr), target); unmarshalErr != nil {
g.Log().Warningf(ctx, "[Cache] JSON unmarshal failed key=%s: %v", fullKey, unmarshalErr)
return false
}
// Backfill L1
gcache.Set(ctx, fullKey, target, c.ttl)
return true
}
}
return false
}
// GetOrSet retrieves a value or calls fn to set it if missing.
func (c *Cache) GetOrSet(ctx context.Context, key string, fn func(ctx context.Context) (any, error)) (any, error) {
if val, ok := c.Get(ctx, key); ok {
return val, nil
}
val, err := fn(ctx)
if err != nil {
return nil, err
}
c.Set(ctx, key, val)
return val, nil
}
// Delete removes a value from both L1 and L2.
func (c *Cache) Delete(ctx context.Context, key string) {
fullKey := c.fullKey(key)
// L1
gcache.Remove(ctx, fullKey)
// L2
_, _ = g.Redis().Do(ctx, "DEL", fullKey)
// Publish invalidation for other instances
_, _ = g.Redis().Do(ctx, "PUBLISH", "cache:invalidate", fullKey)
}
// DeleteByPattern removes all cache entries matching the pattern.
// Uses SCAN + DEL to properly handle wildcard patterns (Redis DEL does not support wildcards).
func (c *Cache) DeleteByPattern(ctx context.Context, pattern string) {
fullPattern := c.fullKey(pattern)
// L2: Redis — SCAN matching keys and DEL them
cursor := int64(0)
for {
result, err := g.Redis().Do(ctx, "SCAN", cursor, "MATCH", fullPattern, "COUNT", 100)
if err != nil {
g.Log().Warningf(ctx, "[Cache] SCAN failed pattern=%s: %v", fullPattern, err)
break
}
slice := result.Slice()
if len(slice) < 2 {
break
}
cursor = gconv.Int64(slice[0])
keys := gconv.Strings(slice[1])
if len(keys) > 0 {
delArgs := make([]any, len(keys))
for i, k := range keys {
delArgs[i] = k
}
_, _ = g.Redis().Do(ctx, "DEL", delArgs...)
}
if cursor == 0 {
break
}
}
// L1: memory cache — gcache doesn't support pattern delete,
// but entries will expire via TTL anyway
}
// PublishInvalidation publishes a cache invalidation message via Redis Pub/Sub.
func PublishInvalidation(ctx context.Context, fullKey string) {
_, _ = g.Redis().Do(ctx, "PUBLISH", "cache:invalidate", fullKey)
}