195 lines
5.5 KiB
Go
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)
|
|
}
|