diff --git a/cache/redis.go b/cache/redis.go new file mode 100644 index 0000000..addaedc --- /dev/null +++ b/cache/redis.go @@ -0,0 +1,119 @@ +package cache + +import ( + "context" + "time" + + "github.com/go-gost/core/cache" + "github.com/go-gost/core/logger" + "github.com/go-redis/redis/v8" +) + +// defaultKeyPrefix is prepended to cache keys when RedisOptions.KeyPrefix is empty. +const defaultKeyPrefix = "gost:cache:" + +// RedisOptions configures a Redis cache. +type RedisOptions struct { + // Addr is the Redis server address (host:port). + Addr string + // DB is the Redis database number. + DB int + // Username for Redis authentication. Optional. + Username string + // Password for Redis authentication. Optional. + Password string + // DefaultTTL is applied to entries stored without an explicit TTL. + // A non-positive value means entries never expire by default. + DefaultTTL time.Duration + // KeyPrefix is prepended to all cache keys. Defaults to "gost:cache:". + KeyPrefix string + // Logger is used for debug logging. Optional. + Logger logger.Logger +} + +// redisCache is a cache.Cache backed by a Redis server. +// +// Redis expires keys natively, so an expired entry is a true miss here — the +// interface's serve-stale contract (returning expired entries from Get) does +// not apply to this backend. +type redisCache struct { + client *redis.Client + defaultTTL time.Duration + keyPrefix string + logger logger.Logger +} + +// NewRedisCache creates a Redis-backed cache. Close releases the underlying +// client connections. +func NewRedisCache(opts RedisOptions) cache.Cache { + keyPrefix := opts.KeyPrefix + if keyPrefix == "" { + keyPrefix = defaultKeyPrefix + } + + return &redisCache{ + client: redis.NewClient(&redis.Options{ + Addr: opts.Addr, + Username: opts.Username, + Password: opts.Password, + DB: opts.DB, + }), + defaultTTL: opts.DefaultTTL, + keyPrefix: keyPrefix, + logger: opts.Logger, + } +} + +// Get returns the entry for key, or cache.ErrNotFound if the key is absent +// (including keys Redis has already expired). +func (c *redisCache) Get(ctx context.Context, key string) (*cache.Entry, error) { + k := c.keyPrefix + key + + pipe := c.client.Pipeline() + getCmd := pipe.Get(ctx, k) + ttlCmd := pipe.PTTL(ctx, k) + if _, err := pipe.Exec(ctx); err != nil { + if err == redis.Nil { + return nil, cache.ErrNotFound + } + return nil, err + } + + entry := &cache.Entry{ + Data: []byte(getCmd.Val()), + } + if ttl := ttlCmd.Val(); ttl > 0 { + entry.Expiration = time.Now().Add(ttl) + } + return entry, nil +} + +// Set stores data under key. A non-positive TTL (after applying the default) +// stores the entry without expiration. +func (c *redisCache) Set(ctx context.Context, key string, data []byte, opts ...cache.SetOption) error { + var options cache.SetOptions + for _, opt := range opts { + opt(&options) + } + + ttl := options.TTL + if ttl <= 0 { + ttl = c.defaultTTL + } + if ttl < 0 { + ttl = 0 // redis: 0 = no expiration + } + + return c.client.Set(ctx, c.keyPrefix+key, data, ttl).Err() +} + +// Delete removes key from the cache. Deleting an absent key is a no-op. +func (c *redisCache) Delete(ctx context.Context, key string) error { + return c.client.Del(ctx, c.keyPrefix+key).Err() +} + +// Close releases the Redis client. Implements io.Closer so the registry +// closes the client on hot reload. +func (c *redisCache) Close() error { + return c.client.Close() +} diff --git a/config/config.go b/config/config.go index 4c2e18d..966ad39 100644 --- a/config/config.go +++ b/config/config.go @@ -324,11 +324,22 @@ type RewriterConfig struct { Plugin *PluginConfig `yaml:",omitempty" json:"plugin,omitempty"` } -// CacheConfig defines a named cache store. Only the Memory backend is -// currently supported; other backends (Redis, file, plugin) are follow-ups. +// CacheConfig defines a named cache store. Memory and Redis backends are +// supported; other backends (file, plugin) are follow-ups. type CacheConfig struct { Name string `json:"name"` Memory *MemoryCache `yaml:",omitempty" json:"memory,omitempty"` + Redis *RedisCache `yaml:",omitempty" json:"redis,omitempty"` +} + +// RedisCache configures the Redis cache backend. +type RedisCache struct { + Addr string `json:"addr"` + DB int `yaml:",omitempty" json:"db,omitempty"` + Username string `yaml:",omitempty" json:"username,omitempty"` + Password string `yaml:",omitempty" json:"password,omitempty"` + TTL time.Duration `yaml:",omitempty" json:"ttl,omitempty"` + Key string `yaml:",omitempty" json:"key,omitempty"` // key prefix, default "gost:cache:" } // MemoryCache configures the in-memory cache backend. diff --git a/config/parsing/cache/parse.go b/config/parsing/cache/parse.go index 07de53e..6055eef 100644 --- a/config/parsing/cache/parse.go +++ b/config/parsing/cache/parse.go @@ -9,9 +9,9 @@ import ( "github.com/go-gost/x/config" ) -// ParseCache converts a CacheConfig into a cache.Cache. Only the memory -// backend is currently supported. Returns nil when cfg is nil or no backend -// is configured. +// ParseCache converts a CacheConfig into a cache.Cache. Memory and Redis +// backends are supported. Returns nil when cfg is nil or no backend is +// configured. func ParseCache(cfg *config.CacheConfig) cache.Cache { if cfg == nil { return nil @@ -28,5 +28,17 @@ func ParseCache(cfg *config.CacheConfig) cache.Cache { }) } + if cfg.Redis != nil { + return xcache.NewRedisCache(xcache.RedisOptions{ + Addr: cfg.Redis.Addr, + DB: cfg.Redis.DB, + Username: cfg.Redis.Username, + Password: cfg.Redis.Password, + DefaultTTL: cfg.Redis.TTL, + KeyPrefix: cfg.Redis.Key, + Logger: logger.Default(), + }) + } + return nil }