init
This commit is contained in:
Vendored
+163
@@ -0,0 +1,163 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/logger"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CacheService 缓存服务接口
|
||||
type CacheService interface {
|
||||
// Get 获取缓存值,如果存在则反序列化到 dest 并返回 true
|
||||
Get(ctx context.Context, key string, dest any) bool
|
||||
// Set 设置缓存值
|
||||
Set(ctx context.Context, key string, value any, ttl time.Duration) error
|
||||
// Delete 删除缓存
|
||||
Delete(ctx context.Context, key string) error
|
||||
// DeleteByPattern 按模式删除缓存
|
||||
DeleteByPattern(ctx context.Context, pattern string) error
|
||||
// Exists 检查缓存是否存在
|
||||
Exists(ctx context.Context, key string) bool
|
||||
}
|
||||
|
||||
// redisCache Redis 缓存实现
|
||||
type redisCache struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewRedisCache 创建 Redis 缓存实例
|
||||
func NewRedisCache() CacheService {
|
||||
return &redisCache{
|
||||
client: database.RedisClient,
|
||||
}
|
||||
}
|
||||
|
||||
// Get 获取缓存值
|
||||
func (c *redisCache) Get(ctx context.Context, key string, dest any) bool {
|
||||
if c.client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
val, err := c.client.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
if err != redis.Nil {
|
||||
logger.Warn("缓存获取失败", zap.String("key", key), zap.Error(err))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if err := json.Unmarshal([]byte(val), dest); err != nil {
|
||||
logger.Warn("缓存反序列化失败", zap.String("key", key), zap.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Set 设置缓存值
|
||||
func (c *redisCache) Set(ctx context.Context, key string, value any, ttl time.Duration) error {
|
||||
if c.client == nil {
|
||||
return nil // Redis 未启用,跳过缓存
|
||||
}
|
||||
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
logger.Warn("缓存序列化失败", zap.String("key", key), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.client.Set(ctx, key, data, ttl).Err(); err != nil {
|
||||
logger.Warn("缓存设置失败", zap.String("key", key), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除缓存
|
||||
func (c *redisCache) Delete(ctx context.Context, key string) error {
|
||||
if c.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.client.Del(ctx, key).Err(); err != nil {
|
||||
logger.Warn("缓存删除失败", zap.String("key", key), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteByPattern 按模式删除缓存(使用 SCAN 避免阻塞 Redis)
|
||||
func (c *redisCache) DeleteByPattern(ctx context.Context, pattern string) error {
|
||||
if c.client == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var cursor uint64
|
||||
var deleted int
|
||||
|
||||
for {
|
||||
// 使用 SCAN 命令迭代查找匹配的键
|
||||
keys, nextCursor, err := c.client.Scan(ctx, cursor, pattern, 100).Result()
|
||||
if err != nil {
|
||||
logger.Warn("缓存键扫描失败", zap.String("pattern", pattern), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 删除找到的键
|
||||
if len(keys) > 0 {
|
||||
if err := c.client.Del(ctx, keys...).Err(); err != nil {
|
||||
logger.Warn("缓存批量删除失败", zap.Strings("keys", keys), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
deleted += len(keys)
|
||||
}
|
||||
|
||||
// 更新游标
|
||||
cursor = nextCursor
|
||||
|
||||
// 游标为 0 表示遍历完成
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if deleted > 0 {
|
||||
logger.Debug("缓存批量删除完成", zap.String("pattern", pattern), zap.Int("count", deleted))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists 检查缓存是否存在
|
||||
func (c *redisCache) Exists(ctx context.Context, key string) bool {
|
||||
if c.client == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.client.Exists(ctx, key).Val() > 0
|
||||
}
|
||||
|
||||
// 全局缓存实例
|
||||
var globalCache CacheService
|
||||
|
||||
// Init 初始化全局缓存实例
|
||||
func Init() {
|
||||
globalCache = NewRedisCache()
|
||||
}
|
||||
|
||||
// GetCache 获取全局缓存实例
|
||||
func GetCache() CacheService {
|
||||
if globalCache == nil {
|
||||
Init()
|
||||
}
|
||||
return globalCache
|
||||
}
|
||||
Vendored
+287
@@ -0,0 +1,287 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/config"
|
||||
)
|
||||
|
||||
// KeyBuilder 缓存键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 多项目共用 Redis 时,通过前缀避免 key 冲突
|
||||
// 使用场景: 多个小项目共用一台 Redis 服务器,每个项目设置不同前缀
|
||||
type KeyBuilder struct {
|
||||
prefix string // 项目/站点前缀,如 "site_a"
|
||||
_separator string // 分隔符,默认 ":"
|
||||
_cacheType string // 缓存类型标识,如 "cache"
|
||||
}
|
||||
|
||||
// KeyBuilderOption 配置选项
|
||||
type KeyBuilderOption func(*KeyBuilder)
|
||||
|
||||
// WithPrefix 设置前缀(项目/站点别名)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 核心配置,用于区分不同项目
|
||||
// 示例: WithPrefix("site_a") -> 所有 key 自动添加 "site_a" 前缀
|
||||
func WithPrefix(prefix string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
kb.prefix = prefix
|
||||
}
|
||||
}
|
||||
|
||||
// WithSeparator 设置分隔符
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 可自定义分隔符,适应不同命名风格
|
||||
// 示例: WithSeparator(":") -> "site_a:user:1"
|
||||
func WithSeparator(separator string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
kb._separator = separator
|
||||
}
|
||||
}
|
||||
|
||||
// WithCacheType 设置缓存类型标识
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 区分缓存用途,便于管理
|
||||
// 示例: WithCacheType("session") -> "session_site_a_user:1"
|
||||
func WithCacheType(cacheType string) KeyBuilderOption {
|
||||
return func(kb *KeyBuilder) {
|
||||
kb._cacheType = cacheType
|
||||
}
|
||||
}
|
||||
|
||||
// NewKeyBuilder 创建键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 链式配置,灵活易用
|
||||
func NewKeyBuilder(opts ...KeyBuilderOption) *KeyBuilder {
|
||||
kb := &KeyBuilder{
|
||||
prefix: "",
|
||||
_separator: ":",
|
||||
_cacheType: "cache",
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(kb)
|
||||
}
|
||||
return kb
|
||||
}
|
||||
|
||||
// Build 构建完整键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动拼接前缀,避免手动拼接错误
|
||||
// 格式: {cacheType}{separator}{prefix}{separator}{key}
|
||||
// 示例: kb.Build("user:1") -> "cache_site_a_user:1"
|
||||
func (kb *KeyBuilder) Build(key string) string {
|
||||
parts := []string{}
|
||||
if kb._cacheType != "" {
|
||||
parts = append(parts, kb._cacheType)
|
||||
}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildTemp 构建临时缓存键名(带过期时间)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 区分临时和永久缓存,便于批量管理
|
||||
// 格式: "temp{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildTemp(key string) string {
|
||||
parts := []string{"temp"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildPerm 构建永久缓存键名(不带过期时间)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 永久存储的数据使用不同标识
|
||||
// 格式: "perm{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildPerm(key string) string {
|
||||
parts := []string{"perm"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildLock 构建分布式锁键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 锁使用独立命名空间,避免与业务缓存冲突
|
||||
// 格式: "lock{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildLock(key string) string {
|
||||
parts := []string{"lock"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildCounter 构建计数器键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 计数器使用独立命名空间
|
||||
// 格式: "counter{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildCounter(key string) string {
|
||||
parts := []string{"counter"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildSession 构建会话键名
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 会话缓存统一管理
|
||||
// 格式: "session{separator}{prefix}{separator}{key}"
|
||||
func (kb *KeyBuilder) BuildSession(key string) string {
|
||||
parts := []string{"session"}
|
||||
if kb.prefix != "" {
|
||||
parts = append(parts, kb.prefix)
|
||||
}
|
||||
parts = append(parts, key)
|
||||
return strings.Join(parts, kb._separator)
|
||||
}
|
||||
|
||||
// BuildPattern 构建匹配模式(用于 SCAN/Keys)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 批量查询/删除时使用
|
||||
// 示例: kb.BuildPattern("user:*") -> "cache_site_a_user:*"
|
||||
func (kb *KeyBuilder) BuildPattern(pattern string) string {
|
||||
return kb.Build(pattern)
|
||||
}
|
||||
|
||||
// GetPrefix 获取当前前缀
|
||||
func (kb *KeyBuilder) GetPrefix() string {
|
||||
return kb.prefix
|
||||
}
|
||||
|
||||
// SetPrefix 动态设置前缀
|
||||
// 评分: ⭐⭐⭐⭐
|
||||
// 理由: 运行时可切换前缀,适用于多租户场景
|
||||
func (kb *KeyBuilder) SetPrefix(prefix string) *KeyBuilder {
|
||||
kb.prefix = prefix
|
||||
return kb
|
||||
}
|
||||
|
||||
// ===== 全局键名构建器 =====
|
||||
|
||||
var globalKeyBuilder *KeyBuilder
|
||||
|
||||
// InitKeyBuilder 初始化全局键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 应用启动时配置,全局统一命名
|
||||
// 参数: prefix 站点别名,如果为空则自动从配置读取
|
||||
// 示例:
|
||||
//
|
||||
// InitKeyBuilder("site_a") // 手动指定
|
||||
// InitKeyBuilder("") // 自动从配置读取
|
||||
// InitKeyBuilder("", WithSeparator(":")) // 自动读取 + 自定义分隔符
|
||||
func InitKeyBuilder(prefix string, opts ...KeyBuilderOption) {
|
||||
// 如果 prefix 为空,尝试从配置读取
|
||||
if prefix == "" {
|
||||
cfg := config.Get()
|
||||
if cfg != nil {
|
||||
prefix = cfg.GetSiteName()
|
||||
}
|
||||
}
|
||||
|
||||
opts = append([]KeyBuilderOption{WithPrefix(prefix)}, opts...)
|
||||
globalKeyBuilder = NewKeyBuilder(opts...)
|
||||
}
|
||||
|
||||
// AutoInitKeyBuilder 自动从配置初始化键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 无需手动传入参数,完全依赖配置文件
|
||||
// 配置示例:
|
||||
//
|
||||
// app:
|
||||
// site_name: "site_a"
|
||||
// env: "prod"
|
||||
func AutoInitKeyBuilder(opts ...KeyBuilderOption) {
|
||||
InitKeyBuilder("", opts...)
|
||||
}
|
||||
|
||||
// GetKeyBuilder 获取全局键名构建器
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 如果未初始化,自动从配置创建
|
||||
func GetKeyBuilder() *KeyBuilder {
|
||||
if globalKeyBuilder == nil {
|
||||
// 自动从配置初始化
|
||||
AutoInitKeyBuilder()
|
||||
}
|
||||
return globalKeyBuilder
|
||||
}
|
||||
|
||||
// K 快捷构建键名(使用全局构建器)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 简短易用,减少代码量
|
||||
// 示例: cache.K("user:1") -> 自动添加前缀
|
||||
func K(key string) string {
|
||||
return GetKeyBuilder().Build(key)
|
||||
}
|
||||
|
||||
// KTemp 快捷构建临时缓存键名
|
||||
func KTemp(key string) string {
|
||||
return GetKeyBuilder().BuildTemp(key)
|
||||
}
|
||||
|
||||
// KPerm 快捷构建永久缓存键名
|
||||
func KPerm(key string) string {
|
||||
return GetKeyBuilder().BuildPerm(key)
|
||||
}
|
||||
|
||||
// KLock 快捷构建锁键名
|
||||
func KLock(key string) string {
|
||||
return GetKeyBuilder().BuildLock(key)
|
||||
}
|
||||
|
||||
// KCounter 快捷构建计数器键名
|
||||
func KCounter(key string) string {
|
||||
return GetKeyBuilder().BuildCounter(key)
|
||||
}
|
||||
|
||||
// KSession 快捷构建会话键名
|
||||
func KSession(key string) string {
|
||||
return GetKeyBuilder().BuildSession(key)
|
||||
}
|
||||
|
||||
// ===== 带键名构建器的缓存操作 =====
|
||||
|
||||
// SetWithPrefix 带前缀的缓存设置
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动添加前缀,简化调用
|
||||
func SetWithPrefix(ctx context.Context, key string, value any, ttl time.Duration, prefix string) error {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return GetCache().Set(ctx, kb.Build(key), value, ttl)
|
||||
}
|
||||
|
||||
// GetWithPrefix 带前缀的缓存获取
|
||||
func GetWithPrefix(ctx context.Context, key string, dest any, prefix string) bool {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return GetCache().Get(ctx, kb.Build(key), dest)
|
||||
}
|
||||
|
||||
// DeleteWithPrefix 带前缀的缓存删除
|
||||
func DeleteWithPrefix(ctx context.Context, key string, prefix string) error {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return GetCache().Delete(ctx, kb.Build(key))
|
||||
}
|
||||
|
||||
// LockWithPrefix 带前缀的分布式锁
|
||||
func LockWithPrefix(ctx context.Context, key string, ttl time.Duration, prefix string) (bool, error) {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return Lock(ctx, kb.BuildLock(key), ttl)
|
||||
}
|
||||
|
||||
// UnlockWithPrefix 带前缀的锁释放
|
||||
// 注意: 此函数不检查 Token,仅用于向后兼容,建议使用 NewLock/Unlock 组合
|
||||
func UnlockWithPrefix(ctx context.Context, key string, prefix string) error {
|
||||
kb := NewKeyBuilder(WithPrefix(prefix))
|
||||
return UnlockByKey(ctx, kb.BuildLock(key))
|
||||
}
|
||||
Vendored
+214
@@ -0,0 +1,214 @@
|
||||
package cache_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/cache"
|
||||
)
|
||||
|
||||
func TestKeyBuilder(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(
|
||||
cache.WithPrefix("test_site"),
|
||||
cache.WithSeparator(":"),
|
||||
cache.WithCacheType("cache"),
|
||||
)
|
||||
|
||||
// 测试 Build
|
||||
result := kb.Build("user:1")
|
||||
expected := "cache:test_site:user:1"
|
||||
if result != expected {
|
||||
t.Errorf("Build = %s, want %s", result, expected)
|
||||
}
|
||||
|
||||
// 测试 BuildTemp
|
||||
tempResult := kb.BuildTemp("token")
|
||||
if tempResult != "temp:test_site:token" {
|
||||
t.Errorf("BuildTemp = %s, want temp:test_site:token", tempResult)
|
||||
}
|
||||
|
||||
// 测试 BuildPerm
|
||||
permResult := kb.BuildPerm("config")
|
||||
if permResult != "perm:test_site:config" {
|
||||
t.Errorf("BuildPerm = %s, want perm:test_site:config", permResult)
|
||||
}
|
||||
|
||||
// 测试 BuildLock
|
||||
lockResult := kb.BuildLock("order:123")
|
||||
if lockResult != "lock:test_site:order:123" {
|
||||
t.Errorf("BuildLock = %s, want lock:test_site:order:123", lockResult)
|
||||
}
|
||||
|
||||
// 测试 BuildCounter
|
||||
counterResult := kb.BuildCounter("visit")
|
||||
if counterResult != "counter:test_site:visit" {
|
||||
t.Errorf("BuildCounter = %s, want counter:test_site:visit", counterResult)
|
||||
}
|
||||
|
||||
// 测试 BuildSession
|
||||
sessionResult := kb.BuildSession("sid123")
|
||||
if sessionResult != "session:test_site:sid123" {
|
||||
t.Errorf("BuildSession = %s, want session:test_site:sid123", sessionResult)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderNoPrefix(t *testing.T) {
|
||||
// 无前缀的构建器
|
||||
kb := cache.NewKeyBuilder()
|
||||
|
||||
result := kb.Build("user:1")
|
||||
expected := "cache:user:1"
|
||||
if result != expected {
|
||||
t.Errorf("Build without prefix = %s, want %s", result, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderSetPrefix(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(cache.WithPrefix("site_a"))
|
||||
|
||||
// 动态修改前缀
|
||||
kb.SetPrefix("site_b")
|
||||
|
||||
result := kb.Build("user:1")
|
||||
if result != "cache:site_b:user:1" {
|
||||
t.Errorf("SetPrefix failed: %s", result)
|
||||
}
|
||||
|
||||
// 验证链式调用
|
||||
kb2 := kb.SetPrefix("site_c")
|
||||
if kb2 != kb {
|
||||
t.Error("SetPrefix should return same builder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderGetPrefix(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(cache.WithPrefix("my_site"))
|
||||
|
||||
prefix := kb.GetPrefix()
|
||||
if prefix != "my_site" {
|
||||
t.Errorf("GetPrefix = %s, want my_site", prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderBuildPattern(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(cache.WithPrefix("site_a"))
|
||||
|
||||
result := kb.BuildPattern("user:*")
|
||||
if result != "cache:site_a:user:*" {
|
||||
t.Errorf("BuildPattern = %s, want cache:site_a:user:*", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderCustomSeparator(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(
|
||||
cache.WithPrefix("site"),
|
||||
cache.WithSeparator("_"),
|
||||
)
|
||||
|
||||
result := kb.Build("user:1")
|
||||
if result != "cache_site_user:1" {
|
||||
t.Errorf("Custom separator = %s, want cache_site_user:1", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyBuilderCustomCacheType(t *testing.T) {
|
||||
kb := cache.NewKeyBuilder(
|
||||
cache.WithPrefix("site"),
|
||||
cache.WithCacheType("session"),
|
||||
)
|
||||
|
||||
result := kb.Build("user:1")
|
||||
if result != "session:site:user:1" {
|
||||
t.Errorf("Custom cache type = %s, want session:site:user:1", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobalKeyBuilder(t *testing.T) {
|
||||
// 初始化全局构建器
|
||||
cache.InitKeyBuilder("global_site")
|
||||
|
||||
// 测试 K 函数
|
||||
result := cache.K("user:1")
|
||||
if result != "cache:global_site:user:1" {
|
||||
t.Errorf("K = %s, want cache:global_site:user:1", result)
|
||||
}
|
||||
|
||||
// 测试 KTemp
|
||||
temp := cache.KTemp("token")
|
||||
if temp != "temp:global_site:token" {
|
||||
t.Errorf("KTemp = %s, want temp:global_site:token", temp)
|
||||
}
|
||||
|
||||
// 测试 KPerm
|
||||
perm := cache.KPerm("config")
|
||||
if perm != "perm:global_site:config" {
|
||||
t.Errorf("KPerm = %s, want perm:global_site:config", perm)
|
||||
}
|
||||
|
||||
// 测试 KLock
|
||||
lock := cache.KLock("order:123")
|
||||
if lock != "lock:global_site:order:123" {
|
||||
t.Errorf("KLock = %s, want lock:global_site:order:123", lock)
|
||||
}
|
||||
|
||||
// 测试 KCounter
|
||||
counter := cache.KCounter("visit")
|
||||
if counter != "counter:global_site:visit" {
|
||||
t.Errorf("KCounter = %s, want counter:global_site:visit", counter)
|
||||
}
|
||||
|
||||
// 测试 KSession
|
||||
session := cache.KSession("sid")
|
||||
if session != "session:global_site:sid" {
|
||||
t.Errorf("KSession = %s, want session:global_site:sid", session)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetKeyBuilder(t *testing.T) {
|
||||
// 获取全局构建器(如果未初始化会自动初始化)
|
||||
kb := cache.GetKeyBuilder()
|
||||
if kb == nil {
|
||||
t.Error("GetKeyBuilder should not return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithPrefixFunc(t *testing.T) {
|
||||
opt := cache.WithPrefix("test")
|
||||
kb := cache.NewKeyBuilder()
|
||||
opt(kb)
|
||||
|
||||
if kb.GetPrefix() != "test" {
|
||||
t.Errorf("WithPrefix failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithSeparatorFunc(t *testing.T) {
|
||||
opt := cache.WithSeparator("_")
|
||||
kb := cache.NewKeyBuilder()
|
||||
opt(kb)
|
||||
|
||||
result := kb.Build("key")
|
||||
// 分隔符应该是 _
|
||||
if !contains(result, "_") {
|
||||
t.Errorf("WithSeparator failed, result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithCacheTypeFunc(t *testing.T) {
|
||||
opt := cache.WithCacheType("custom")
|
||||
kb := cache.NewKeyBuilder()
|
||||
opt(kb)
|
||||
|
||||
result := kb.Build("key")
|
||||
if !contains(result, "custom") {
|
||||
t.Errorf("WithCacheType failed, result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Vendored
+316
@@ -0,0 +1,316 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/database"
|
||||
"github.com/EthanCodeCraft/xlgo-core/utils"
|
||||
)
|
||||
|
||||
// 分布式锁错误
|
||||
var (
|
||||
ErrLockNotHeld = errors.New("锁未被当前客户端持有")
|
||||
ErrLockExpired = errors.New("锁已过期")
|
||||
ErrRedisNotReady = errors.New("Redis 未初始化")
|
||||
)
|
||||
|
||||
// LockToken 锁令牌(用于安全释放锁)
|
||||
type LockToken struct {
|
||||
Key string // 锁的键名
|
||||
Token string // 锁的唯一标识(UUID)
|
||||
}
|
||||
|
||||
// lockScript 加锁 Lua 脚本
|
||||
// 返回: 1 表示成功加锁,0 表示锁已被占用
|
||||
const lockScript = `
|
||||
if redis.call("exists", KEYS[1]) == 0 then
|
||||
redis.call("set", KEYS[1], ARGV[1], "PX", ARGV[2])
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
|
||||
// unlockScript 解锁 Lua 脯本
|
||||
// 只有持有正确 Token 的客户端才能解锁
|
||||
// 返回: 1 表示成功解锁,0 表示 Token 不匹配(锁不属于该客户端)
|
||||
const unlockScript = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
redis.call("del", KEYS[1])
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
|
||||
// extendScript 续期 Lua 脚本
|
||||
// 只有持有正确 Token 的客户端才能续期
|
||||
// 返回: 1 表示成功续期,0 表示 Token 不匹配或锁不存在
|
||||
const extendScript = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
redis.call("pexpire", KEYS[1], ARGV[2])
|
||||
return 1
|
||||
else
|
||||
return 0
|
||||
end
|
||||
`
|
||||
|
||||
// NewLock 创建分布式锁
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 UUID 作为 Token,保证锁的安全释放
|
||||
// 参数: key 锁名称,ttl 锁定时长
|
||||
// 返回: LockToken 用于后续解锁或续期
|
||||
func NewLock(ctx context.Context, key string, ttl time.Duration) (*LockToken, error) {
|
||||
if database.RedisClient == nil {
|
||||
return nil, ErrRedisNotReady
|
||||
}
|
||||
|
||||
token := utils.UUID()
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, lockScript, []string{key}, token, ttlMs).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if result.(int64) == 1 {
|
||||
return &LockToken{Key: key, Token: token}, nil
|
||||
}
|
||||
|
||||
return nil, nil // 锁已被其他客户端持有
|
||||
}
|
||||
|
||||
// Lock 简化的加锁函数(返回 bool)
|
||||
// 注意: 使用此函数无法安全释放锁,建议使用 NewLock
|
||||
func Lock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
token, err := NewLock(ctx, key, ttl)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return token != nil, nil
|
||||
}
|
||||
|
||||
// Unlock 安全释放锁
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 使用 Lua 脚本保证只有锁的持有者才能释放
|
||||
func Unlock(ctx context.Context, token *LockToken) error {
|
||||
if database.RedisClient == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, unlockScript, []string{token.Key}, token.Token).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.(int64) == 0 {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnlockByKey 按键名释放锁(不安全,仅用于旧代码兼容)
|
||||
// 注意: 此函数不检查 Token,任何客户端都能释放锁
|
||||
func UnlockByKey(ctx context.Context, key string) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ExtendLock 续期锁
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 长任务执行时防止锁过期被其他客户端抢占
|
||||
// 参数: token 锁令牌,ttl 新的过期时间
|
||||
func ExtendLock(ctx context.Context, token *LockToken, ttl time.Duration) error {
|
||||
if database.RedisClient == nil {
|
||||
return ErrRedisNotReady
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
ttlMs := int64(ttl / time.Millisecond)
|
||||
|
||||
result, err := database.RedisClient.Eval(ctx, extendScript, []string{token.Key}, token.Token, ttlMs).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.(int64) == 0 {
|
||||
return ErrLockNotHeld
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// TryLock 尝试获取锁,失败时等待重试
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 高并发场景常用,避免立即失败
|
||||
func TryLock(ctx context.Context, key string, ttl time.Duration, retryInterval time.Duration, maxRetry int) (*LockToken, error) {
|
||||
for i := 0; i < maxRetry; i++ {
|
||||
token, err := NewLock(ctx, key, ttl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token != nil {
|
||||
return token, nil
|
||||
}
|
||||
time.Sleep(retryInterval)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// WithLock 使用分布式锁执行函数(自动管理锁)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 自动获取、续期、释放锁,避免忘记释放
|
||||
// 参数: key 锁名称,ttl 锁定时长,fn 业务函数
|
||||
// 注意: 如果任务执行时间超过 ttl,需要设置更长的 ttl 或使用 WithLockAutoExtend
|
||||
func WithLock(ctx context.Context, key string, ttl time.Duration, fn func() error) error {
|
||||
token, err := NewLock(ctx, key, ttl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil {
|
||||
return nil // 未获取到锁,跳过执行
|
||||
}
|
||||
defer Unlock(ctx, token)
|
||||
|
||||
return fn()
|
||||
}
|
||||
|
||||
// WithLockAutoExtend 使用分布式锁执行函数(自动续期)
|
||||
// 评分: ⭐⭐⭐⭐⭐
|
||||
// 理由: 长任务执行时自动续期,防止锁过期
|
||||
// 参数: key 锁名称,initialTTL 初始锁定时长,extendInterval 续期间隔,fn 业务函数
|
||||
func WithLockAutoExtend(ctx context.Context, key string, initialTTL time.Duration, extendInterval time.Duration, fn func() error) error {
|
||||
token, err := NewLock(ctx, key, initialTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if token == nil {
|
||||
return nil // 未获取到锁,跳过执行
|
||||
}
|
||||
|
||||
// 启动续期协程
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
ticker := time.NewTicker(extendInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-done:
|
||||
return
|
||||
case <-ticker.C:
|
||||
// 续期锁(每次续期为 initialTTL)
|
||||
if err := ExtendLock(ctx, token, initialTTL); err != nil {
|
||||
return // 续期失败,停止续期
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 执行业务函数
|
||||
err = fn()
|
||||
|
||||
// 停止续期并释放锁
|
||||
done <- struct{}{}
|
||||
Unlock(ctx, token)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// IsLocked 检查锁是否被占用(不获取锁)
|
||||
func IsLocked(ctx context.Context, key string) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
return false, nil
|
||||
}
|
||||
return database.RedisClient.Exists(ctx, key).Val() > 0, nil
|
||||
}
|
||||
|
||||
// GetLockTTL 获取锁的剩余过期时间
|
||||
func GetLockTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// ForceUnlock 强制释放锁(危险操作,仅用于管理场景)
|
||||
// 注意: 此函数不检查 Token,强制删除锁
|
||||
func ForceUnlock(ctx context.Context, key string) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
// ===== 计数器操作 =====
|
||||
|
||||
// Incr 自增计数器
|
||||
func Incr(ctx context.Context, key string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.Incr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// IncrBy 指定增量自增
|
||||
func IncrBy(ctx context.Context, key string, value int64) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.IncrBy(ctx, key, value).Result()
|
||||
}
|
||||
|
||||
// Decr 自减计数器
|
||||
func Decr(ctx context.Context, key string) (int64, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.Decr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// GetTTL 获取键的剩余过期时间
|
||||
func GetTTL(ctx context.Context, key string) (time.Duration, error) {
|
||||
if database.RedisClient == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return database.RedisClient.TTL(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetExpire 设置键的过期时间
|
||||
func SetExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
||||
if database.RedisClient == nil {
|
||||
return false, nil
|
||||
}
|
||||
return database.RedisClient.Expire(ctx, key, ttl).Result()
|
||||
}
|
||||
|
||||
// GetRaw 获取原始字符串值(不反序列化)
|
||||
func GetRaw(ctx context.Context, key string) (string, error) {
|
||||
if database.RedisClient == nil {
|
||||
return "", nil
|
||||
}
|
||||
return database.RedisClient.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// SetRaw 设置原始值(不序列化)
|
||||
func SetRaw(ctx context.Context, key string, value string, ttl time.Duration) error {
|
||||
if database.RedisClient == nil {
|
||||
return nil
|
||||
}
|
||||
return database.RedisClient.Set(ctx, key, value, ttl).Err()
|
||||
}
|
||||
Vendored
+147
@@ -0,0 +1,147 @@
|
||||
package cache_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/EthanCodeCraft/xlgo-core/cache"
|
||||
)
|
||||
|
||||
func TestLockToken(t *testing.T) {
|
||||
token := &cache.LockToken{
|
||||
Key: "test_lock",
|
||||
Token: "abc123",
|
||||
}
|
||||
|
||||
if token.Key != "test_lock" {
|
||||
t.Error("LockToken Key failed")
|
||||
}
|
||||
if token.Token != "abc123" {
|
||||
t.Error("LockToken Token failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockErrors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test ErrLockNotHeld - 当 Redis 未初始化时,先检查 nil token
|
||||
err := cache.Unlock(ctx, nil)
|
||||
// 当 Redis 未初始化时,会返回 ErrRedisNotReady
|
||||
if err != cache.ErrLockNotHeld && err != cache.ErrRedisNotReady {
|
||||
t.Errorf("Unlock with nil token should return ErrLockNotHeld or ErrRedisNotReady, got %v", err)
|
||||
}
|
||||
|
||||
// Test IsLocked without Redis (should return false)
|
||||
locked, err := cache.IsLocked(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("IsLocked error: %v", err)
|
||||
}
|
||||
if locked {
|
||||
t.Error("IsLocked should return false without Redis")
|
||||
}
|
||||
|
||||
// Test GetLockTTL without Redis
|
||||
ttl, err := cache.GetLockTTL(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("GetLockTTL error: %v", err)
|
||||
}
|
||||
if ttl != 0 {
|
||||
t.Error("GetLockTTL should return 0 without Redis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncrDecr(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test Incr without Redis
|
||||
n, err := cache.Incr(ctx, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("Incr error: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("Incr should return 0 without Redis")
|
||||
}
|
||||
|
||||
// Test IncrBy
|
||||
n, err = cache.IncrBy(ctx, "counter", 10)
|
||||
if err != nil {
|
||||
t.Errorf("IncrBy error: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("IncrBy should return 0 without Redis")
|
||||
}
|
||||
|
||||
// Test Decr
|
||||
n, err = cache.Decr(ctx, "counter")
|
||||
if err != nil {
|
||||
t.Errorf("Decr error: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("Decr should return 0 without Redis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetExpire(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
ok, err := cache.SetExpire(ctx, "test_key", time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("SetExpire error: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("SetExpire should return false without Redis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRawSetRaw(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Test SetRaw
|
||||
err := cache.SetRaw(ctx, "test_key", "test_value", time.Minute)
|
||||
if err != nil {
|
||||
t.Errorf("SetRaw error: %v", err)
|
||||
}
|
||||
|
||||
// Test GetRaw
|
||||
val, err := cache.GetRaw(ctx, "test_key")
|
||||
if err != nil {
|
||||
t.Errorf("GetRaw error: %v", err)
|
||||
}
|
||||
if val != "" {
|
||||
t.Error("GetRaw should return empty string without Redis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKFunctions(t *testing.T) {
|
||||
// Test various K functions
|
||||
key := cache.K("user:1")
|
||||
if key == "" {
|
||||
t.Error("K should not return empty string")
|
||||
}
|
||||
|
||||
tempKey := cache.KTemp("token")
|
||||
if tempKey == "" {
|
||||
t.Error("KTemp should not return empty string")
|
||||
}
|
||||
|
||||
permKey := cache.KPerm("config")
|
||||
if permKey == "" {
|
||||
t.Error("KPerm should not return empty string")
|
||||
}
|
||||
|
||||
lockKey := cache.KLock("order:123")
|
||||
if lockKey == "" {
|
||||
t.Error("KLock should not return empty string")
|
||||
}
|
||||
|
||||
counterKey := cache.KCounter("visit")
|
||||
if counterKey == "" {
|
||||
t.Error("KCounter should not return empty string")
|
||||
}
|
||||
|
||||
sessionKey := cache.KSession("sid")
|
||||
if sessionKey == "" {
|
||||
t.Error("KSession should not return empty string")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user