Redis 工具封装:RedisCache 对 Operations 体系的二次封装

本文基于若依3.9.2、SpringBoot3版本。

Redis 工具封装:RedisCache 对 Operations 体系的二次封装

若依对 Redis 缓存封装了一个类:com.ruoyi.common.core.redis.RedisCache。登录态、验证码、参数配置、字典数据、密码错误次数、防重复提交都以它为入口落到 Redis。

这个类本身不含业务逻辑,20 个方法全部委托给 RedisTemplate,是典型的薄封装。薄封装的收益和边界,要从被封装的两层东西看:RedisTemplate 按 Redis 数据类型把命令拆成多套 Operations 接口,key 与 value 的序列化在配置类里统一指定。

本文先看 Operations 体系与若依的序列化配置,再逐组拆解 RedisCache 的方法,最后看若依自己怎么调用,以及方法签名上的 <T> 在运行期对应什么真实类型。

Operations 体系:被封装的对象

RedisTemplate 没有与数据类型无关的 get/set,所有命令按 Redis 数据类型分组,通过 opsForXxx() 取得对应接口再执行。接口与 RedisCache 方法的对应关系如下:

获取方式 返回接口 操作的 Redis 类型 RedisCache 对应方法
opsForValue() ValueOperations String setCacheObject、getCacheObject
opsForList() ListOperations List setCacheList、getCacheList
opsForSet() SetOperations Set getCacheSet
opsForHash() HashOperations Hash setCacheMap、getCacheMap 等六个
boundSetOps(key) BoundSetOperations 绑定 key 的 Set setCacheSet

除数据类型接口外,RedisTemplate 自身还提供一批通用命令:expire、getExpire、hasKey、delete、keys。这些不需要 Operations,直接调模板对象。RedisCache 的「有效时间」与「key 操作」两组方法就落在这一层。

序列化不归 RedisCache 管,由容器里的 RedisTemplate 决定。framework 模块的 RedisConfig 把 key 与 hashKey 配成 StringRedisSerializer,value 与 hashValue 配成基于 FastJson2 的 FastJson2JsonRedisSerializer:

java 复制代码
public byte[] serialize(T t) throws SerializationException {
    if (t == null) {
        return new byte[0];
    }
    return JSON.toJSONString(t, JSONWriter.Feature.WriteClassName).getBytes(DEFAULT_CHARSET);
}

public T deserialize(byte[] bytes) throws SerializationException {
    if (bytes == null || bytes.length <= 0) {
        return null;
    }
    String str = new String(bytes, DEFAULT_CHARSET);
    return JSON.parseObject(str, clazz, AUTO_TYPE_FILTER);
}

写入时打开了 WriteClassName,JSON 里会带上 @type 记录原始类名;读取时 clazz 传的是 Object.class,对象类型靠 @type 还原,AUTO_TYPE_FILTER 用 Constants.JSON_WHITELIST_STR(值为 com.ruoyi)限定允许还原的类名。

key 侧用字符串序列化器,存进 Redis 的 key 与 Java 侧的字符串完全一致,用 redis-cli 能直接看出业务前缀。value 侧统一是 JSON 文本,RedisCache 的所有读写方法都建立在这个约定上。

RedisCache 的类结构

类的声明只有两行有效内容:

java 复制代码
@SuppressWarnings(value = {"unchecked", "rawtypes"})
@Component
public class RedisCache {
    @Autowired
    public RedisTemplate redisTemplate;
}

字段没有写泛型参数,是原生类型 RedisTemplate;方法签名又普遍带 <T>,两者组合会在编译期产生大量 unchecked 与 rawtypes 警告,类上的 @SuppressWarnings 一次性压掉。字段声明为 public,外部可以直接拿到 RedisTemplate 执行 RedisCache 未封装的操作。

@Component 让容器把它注册为单例,其他 Bean 用 @Autowired 注入即可。RedisCache 位于 common 模块,依赖由 ruoyi-common 引入的 spring-boot-starter-data-redis 提供,只用到 RedisTemplate 接口本身;连接工厂、序列化器、连接池这些运行时配置都在 framework 模块,common 不关心。这个分层让 common 里的工具类和异常体系能直接使用缓存,不必依赖 framework。

方法按 Redis 数据类型分六组:有效时间、key 操作、Object 操作、List 操作、Set 操作、Map/Hash 操作。

有效时间与 key 操作

这组方法是 RedisTemplate 通用命令的透传,参数被 final 修饰:

java 复制代码
public boolean expire(final String key, final long timeout) {
    return expire(key, timeout, TimeUnit.SECONDS);
}

public boolean expire(final String key, final long timeout, final TimeUnit unit) {
    return redisTemplate.expire(key, timeout, unit);
}

public long getExpire(final String key) {
    return redisTemplate.getExpire(key);
}

public Boolean hasKey(String key) {
    return redisTemplate.hasKey(key);
}

public Collection<String> keys(final String pattern) {
    return redisTemplate.keys(pattern);
}

单参数的 expire 默认按秒,内部委托给三参数版本。expire 的返回值是「过期时间是否设置成功」,key 不存在时返回 false,这一点与「设置成功」的直觉不完全一致,false 不一定是出错。

getExpire 返回的 long 直接来自 TTL 命令:正常返回剩余秒数,-1 表示 key 存在但没有过期时间,-2 表示 key 不存在。调用方若要判断「有没有设过期」,得区分这两个负值。

hasKey 的返回类型是包装类 Boolean,与 deleteObject(String) 一致。调用处写 if (redisCache.hasKey(key)) 会自动拆箱,EXISTS 命令只会返回 0 或 1,拆箱不会遇到 null,但这个签名把包装类型暴露到了对外 API 上。

keys 的 pattern 支持 *、?、[abc] 三种通配符,底层是 KEYS 命令。KEYS 需要遍历整个 key 空间,复杂度与 key 数量成正比,而 Redis 执行命令是单线程的,key 多时这条命令会把其他请求一起卡住。若依的调用点都是已知前缀,如 login_tokens:*、sys_config:*、sys_dict:*,key 数量由业务规模决定,量级可控时风险不大。key 空间无法预估时应改用 SCAN 游标遍历,RedisTemplate 没有直接对应的方法,需要通过 execute 拿到连接调用 scan。

final 只出现在部分方法上,hasKey、keys 都没有修饰。final 对方法参数的作用是禁止在方法体内重新赋值,Java 按值传递,方法内改动不会影响调用方,防御意义有限,风格统一比是否使用更重要。

Object 操作

基本对象(Integer、String、实体类等)的存取与删除,是若依使用频率最高的一组:

java 复制代码
public <T> void setCacheObject(final String key, final T value) {
    redisTemplate.opsForValue().set(key, value);
}

public <T> void setCacheObject(final String key, final T value, final Integer timeout, final TimeUnit timeUnit) {
    redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}

public <T> T getCacheObject(final String key) {
    ValueOperations<String, T> operation = redisTemplate.opsForValue();
    return operation.get(key);
}

public boolean deleteObject(final String key) {
    return redisTemplate.delete(key);
}

public boolean deleteObject(final Collection collection) {
    return redisTemplate.delete(collection) > 0;
}

setCacheObject 有两个重载。带 timeout 的版本底层是一条带过期选项的 SET 命令(SET key value PX ...),写入与设置过期在同一次往返中完成。分两步先 set 再 expire 会有中间状态:如果进程在两条命令之间挂掉,留下的 key 没有过期时间,会一直占着内存。

getCacheObject 先把 opsForValue() 的结果赋给局部变量再取值,与直接链式调用的效果相同,多一步的原因是把 unchecked 转换集中在一处,避免每个调用点重复。局部变量的泛型参数 <String, T> 是编译期声明,运行期没有校验,这一点在下文单独展开。

两个 deleteObject 的区别在返回值语义:单 key 版本直接返回 delete 的 Boolean,多 key 版本返回 delete(collection) > 0,只要删掉任意一个 key 就是 true。传空集合时 delete 返回 0,结果是 false,不会抛异常,按前缀清理时即使没匹配到 key 也能安全调用。

List、Set 与 Hash

List 只有整表写入与全量读取两个方法:

java 复制代码
public <T> long setCacheList(final String key, final List<T> dataList) {
    Long count = redisTemplate.opsForList().rightPushAll(key, dataList);
    return count == null ? 0 : count;
}

public <T> List<T> getCacheList(final String key) {
    return redisTemplate.opsForList().range(key, 0, -1);
}

rightPushAll 是追加操作,同一个 key 连续写两次会得到两份数据,不是覆盖。方法名里的 set 容易让人以为是整体替换,实际语义更接近 append。返回值是写入后列表的长度,Long 可能为 null(pipeline 中被排队的命令结果为 null),这里做了拆箱保护,代价是 null 与 0 两种情况被合并成 0。

range(key, 0, -1) 取全部元素,-1 表示列表末尾。元素多时这条命令会一次性把整个列表拉回客户端并反序列化,没有分页能力。

Set 的写入方式与其他类型不同:

java 复制代码
public <T> BoundSetOperations<String, T> setCacheSet(final String key, final Set<T> dataSet) {
    BoundSetOperations<String, T> setOperation = redisTemplate.boundSetOps(key);
    Iterator<T> it = dataSet.iterator();
    while (it.hasNext()) {
        setOperation.add(it.next());
    }
    return setOperation;
}

public <T> Set<T> getCacheSet(final String key) {
    return redisTemplate.opsForSet().members(key);
}

写入用的是 boundSetOps(key),返回的 BoundSetOperations 已经绑定 key,后续 add、remove、size 都不必再传 key。与 opsForSet() 相比,绑定版本适合对同一个 key 连续操作。

代价是循环里每个元素调一次 add,对应 N 次 SADD 网络往返。SetOperations 有可变参数版本 add(K key, V... values),一次命令提交全部元素,往返只有一次。setCacheSet 选了绑定接口,又把遍历写在方法里,两个优势都没有利用到。

返回值也与其他 setCacheXxx 不同:这里返回 BoundSetOperations 而不是 void 或长度,调用方可以接着对同一个 key 操作。若依自身没有调用这个方法,风格不一致的代价暂时没有暴露。

Hash 的六个方法覆盖整表写入、单字段读写、批量读与单字段删除:

java 复制代码
public <T> void setCacheMap(final String key, final Map<String, T> dataMap) {
    if (dataMap != null) {
        redisTemplate.opsForHash().putAll(key, dataMap);
    }
}

public <T> Map<String, T> getCacheMap(final String key) {
    return redisTemplate.opsForHash().entries(key);
}

public <T> void setCacheMapValue(final String key, final String hKey, final T value) {
    redisTemplate.opsForHash().put(key, hKey, value);
}

public <T> T getCacheMapValue(final String key, final String hKey) {
    HashOperations<String, String, T> opsForHash = redisTemplate.opsForHash();
    return opsForHash.get(key, hKey);
}

public <T> List<T> getMultiCacheMapValue(final String key, final Collection<Object> hKeys) {
    return redisTemplate.opsForHash().multiGet(key, hKeys);
}

public boolean deleteCacheMapValue(final String key, final String hKey) {
    return redisTemplate.opsForHash().delete(key, hKey) > 0;
}

setCacheMap 先判断 dataMap 是否为 null,putAll 传 null 会抛 NullPointerException,这是六个 Map 方法里唯一做入参校验的。getCacheMap 用 entries 对应 HGETALL,一次取回整个 Hash。

getMultiCacheMapValue 用 multiGet(HMGET)一次取多个字段,比循环调用 getCacheMapValue 少 N 次往返,适合批量翻译场景。返回值顺序与传入的 hKeys 一致,不存在的字段对应位置是 null,方法没有过滤。参数类型是 Collection<Object>,原因是 HashOperations 在这段代码里是原生类型,multiGet 的 Collection<HK> 形参随之退化成 Collection<Object>。

deleteCacheMapValue 用 delete(...) > 0 判断是否删掉了字段,字段不存在时返回 false。

若依中的使用方式

key 的前缀集中定义在 CacheConstants,一个常量对应一类业务数据:

java 复制代码
public static final String LOGIN_TOKEN_KEY = "login_tokens:";
public static final String CAPTCHA_CODE_KEY = "captcha_codes:";
public static final String SYS_CONFIG_KEY = "sys_config:";
public static final String SYS_DICT_KEY = "sys_dict:";
public static final String REPEAT_SUBMIT_KEY = "repeat_submit:";
public static final String RATE_LIMIT_KEY = "rate_limit:";
public static final String PWD_ERR_CNT_KEY = "pwd_err_cnt:";

常量值自带末尾的冒号,拼接时直接 CacheConstants.SYS_CONFIG_KEY + configKey。冒号做分隔符是 Redis 客户端的通用习惯,Spring Cache 的默认 key 格式也用双冒号区分 cacheName 与 key。命名上的空闲作用没有落到 Redis 层:这些前缀没有对应的 Hash 结构或命名空间,全部是平铺的 key,只能靠前缀字符串区分。

登录态是最典型的用法,TokenService 用带 TTL 的重载一次写入对象与过期时间:

java 复制代码
public LoginUser getLoginUser(HttpServletRequest request) {
    String token = getToken(request);
    if (StringUtils.isNotEmpty(token)) {
        try {
            Claims claims = parseToken(token);
            String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
            String userKey = getTokenKey(uuid);
            LoginUser user = redisCache.getCacheObject(userKey);
            return user;
        } catch (Exception e) {
            log.error("获取用户信息异常'{}'", e.getMessage());
        }
    }
    return null;
}

public void refreshToken(LoginUser loginUser) {
    loginUser.setLoginTime(System.currentTimeMillis());
    loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
    String userKey = getTokenKey(loginUser.getToken());
    redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}

JWT 里只放一个 uuid,用户对象整体存在 Redis,key 是 login_tokens: 加 uuid。续期不是调用 expire 改 TTL,而是把 LoginUser 重新写一遍,TTL 与对象内的 expireTime 字段由同一次方法调用一起更新,两者不会出现一个已续期、另一个没续期的情况。

验证码、密码错误次数、防重复提交用的都是同一组方法,区别只在 TTL 与值类型:

场景 key 值 TTL
验证码 captcha_codes: + uuid 验证码字符串 Constants.CAPTCHA_EXPIRATION,2 分钟
密码错误次数 pwd_err_cnt: + 用户名 Integer 每次密码错误时写回,TTL 为配置的锁定分钟数
防重复提交 repeat_submit: + URL + 参数 请求参数的 Map 注解 @RepeatSubmit 的 interval,毫秒

密码错误次数的自增在 Java 侧完成,读取当前值、加 1、带 TTL 写回,不是 Redis 的 INCR。并发登录请求下两个线程可能读到同一个旧值,写回时少计一次。防重复提交的解释器用的是 getCacheObject + setCacheObject 两步,同样没有原子性。

按前缀批量清理出现在两处,写法一致:

java 复制代码
public void clearConfigCache() {
    Collection<String> keys = redisCache.keys(CacheConstants.SYS_CONFIG_KEY + "*");
    redisCache.deleteObject(keys);
}

先 keys 拿到全部匹配的 key,再整体删除。KEYS 与 DELETE 是两条独立命令,之间新增的 key 不会被删除;key 数量大时 KEYS 的阻塞时间也正是这条链路的成本所在。

common 模块的 DictUtils 是静态工具类,没有注入入口,通过 SpringUtils 从容器按类型取 RedisCache:

java 复制代码
public static void setDictCache(String key, List<SysDictData> dictDatas) {
    SpringUtils.getBean(RedisCache.class).setCacheObject(getCacheKey(key), dictDatas);
}

public static List<SysDictData> getDictCache(String key) {
    JSONArray arrayCache = SpringUtils.getBean(RedisCache.class).getCacheObject(getCacheKey(key));
    if (StringUtils.isNotNull(arrayCache)) {
        return arrayCache.toList(SysDictData.class);
    }
    return null;
}

public static void clearDictCache() {
    Collection<String> keys = SpringUtils.getBean(RedisCache.class).keys(CacheConstants.SYS_DICT_KEY + "*");
    SpringUtils.getBean(RedisCache.class).deleteObject(keys);
}

每次调用都执行一遍 getBean,单例的取用是一次容器缓存命中,开销可以忽略。getDictCache 用 JSONArray 接收集合结果再 toList,而不是直接声明为 List<SysDictData>,原因是集合写入 Redis 后是一个裸 JSON 数组,反序列化回来就是 JSONArray,List<SysDictData> 只是编译期声明。

并非所有缓存操作都走 RedisCache。缓存监控页直接注入 RedisTemplate:

java 复制代码
@Autowired
private RedisTemplate<String, String> redisTemplate;

CacheController 用 RedisCallback 拿连接执行 INFO、DBSIZE 这类原生命令,用 keys + delete 实现按名称、按 key、全量清理。这些能力 RedisCache 都没有封装,public 字段又把底层模板直接开放出来,封装边界靠约定维持。

<T> 在运行期对应什么类型

getCacheObject 声明为 <T> T,调用处写 LoginUser user = redisCache.getCacheObject(userKey) 就能拿到 LoginUser,不需要强转。这条类型链路上,编译器做的和运行期实际发生的并不一致。

方法内部把原生的 ValueOperations 赋给 ValueOperations<String, T>,是 unchecked 转换,编译器只发警告、不做校验;泛型擦除后方法返回 Object,LoginUser 的转换由编译器在调用点插入 checkcast,校验发生在运行期;运行期到底是什么类型,取决于 JSON 里的 @type 与反序列化白名单,与调用点写的 T 无关。

同一个 getCacheObject 对不同的值,实际返回的类型如下:

写入的值 Redis 中的 JSON getCacheObject 的实际返回类型
LoginUser 对象 {"@type":"com.ruoyi...LoginUser",...} LoginUser
List<SysDictData> [{"@type":"com.ruoyi...SysDictData",...}] JSONArray,元素是 SysDictData
String "abc" String
Integer 5 Integer

单个对象能还原成原类型,是因为 @type 写在了对象本身。集合的 JSON 是裸数组,外层没有 @type,反序列化得到的是 JSONArray,只有元素带 @type。

JSONArray 实现了 List,所以 List<SysDictData> list = redisCache.getCacheObject(key) 这种写法在赋值这一步不会报错,编译期的 <T> 让代码看起来像是拿到了 ArrayList<SysDictData>。真正的差别在后续使用:直接遍历并强转元素依赖 @type 命中了白名单,一旦元素类型来自 com.ruoyi 之外的包,白名单拒绝还原,元素退化为 JSONObject,强转时抛 ClassCastException,而这个错误在编译期完全看不出来。

DictUtils 显式用 JSONArray 接收集合,再 toList(SysDictData.class) 逐元素转换,把「反序列化得到什么」和「业务需要的类型」分成两步,不依赖 @type 一定生效。代价是每次读取都要做一次集合转换,元素多时有额外开销。

单对象的读取没有这层保护。TokenService 里 LoginUser user = redisCache.getCacheObject(userKey) 直接依赖 @type,LoginUser 在 com.ruoyi 之下,白名单覆盖得到,这条路是通的;如果后续把用户对象换成第三方 DTO,运行时行为会静默变化。

白名单本身是安全性设计。@type 由 JSON 内容决定,不做限制时可以通过构造 @type 让反序列化加载任意类,形成类似 FastJson autoType 的反序列化漏洞。JSONReader.autoTypeFilter("com.ruoyi") 只放行框架自己的类,代价是跨包存对象时类型还原失效。

思考

薄封装的收益与边界 :RedisCache 的价值集中在三点------把分散在五个 Operations 接口上的命令收拢成统一的 setCacheXxx/getCacheXxx 命名,把原生类型的泛型警告挡在类内部,给少数方法补上 null 处理。代价是封装没有形成真正的边界:redisTemplate 字段是 public,监控页直接注入 RedisTemplate 绕过它;setCacheList 用追加语义却叫 set;setCacheSet 逐元素写入,丢了批量命令的往返优势;分布式锁、SCAN、pipeline、SETNX 这些常用能力都没有覆盖。对框架自身的几个场景(登录态、验证码、配置、字典)这套方法足够,作为通用 redis 工具类则偏薄。判断标准是调用方的确定性:使用方只有自己几个 Service、场景固定时,薄封装加公开字段是省事的选择;面向团队开放的工具类则应收紧字段可见性,并让方法名与 Redis 命令语义对齐。

手写工具类与声明式缓存的取舍 :RedisConfig 上开启了 @EnableCaching,但整个项目没有一个 @Cacheable、@CacheEvict、@CachePut,缓存全部通过 RedisCache 手写。原因是这些场景要的能力注解式缓存表达不了:登录态要按 token 精确设置 TTL 并在续期时重写整个对象;验证码要一次性消费,读后立即删除;密码错误次数和防重复提交依赖「读---判断---写」的自定义流程;按前缀批量清理需要拿到 key 集合。@Cacheable 以方法返回值为缓存单位,TTL 只能按 cacheName 统一配置,清理靠 key 生成规则推导,这些需求都落在它的表达范围之外。反过来,手写带来的问题是缓存逻辑分散在各个 Service 里,key 的拼接与清理必须靠 CacheConstants 加人工约定保持一致,没有编译期检查。

泛型声明与真实类型的分离 :<T> T 让调用点读起来像类型安全,实际类型由序列化格式和白名单决定,两者之间没有任何编译期约束。若依选择保留这种写法,换来调用处的简洁;风险被转移到统一序列化器上------白名单覆盖 com.ruoyi,框架内部对象都能还原,越界使用才会出问题。另一种做法是让 getCacheObject 显式接收 Class<T> 参数、由 FastJson2 按目标类型反序列化,类型由调用方保证而不是由存储内容保证,代价是每个调用点都要多传一个类对象,且无法复用写入时的 @type。若依选了前者,用调用简洁换掉了类型确定性,这类取舍在框架代码里可以接受,在需要长期维护的业务代码里,把类型写在调用点上更稳妥。

相关推荐
PaperData43 分钟前
2000-2025年地级市科技支行设立数据
数据库·科技
wno7041 小时前
Springboot国际化信息(i18n)解析
java·spring boot·后端
实泽有之,无泽虚之1 小时前
ORA-12514:TNS:监听程序当前无法识别连接描述符中请求的服务问题原因及解决办法
数据库·oracle
eleven_4u1 小时前
QPS如何计算,如何部署架构
java·linux·运维·jvm·中间件
牧瀬クリスだ1 小时前
Redis Hash实战:高效存储对象与购物车
java·数据库·redis·算法·哈希算法
文丑颜不良啊1 小时前
LeetCode1141~1160题目解析
java·leetcode
A小码哥1 小时前
【外】水星3号实测 1068 token 每秒靠谱吗
数据库
木井巳1 小时前
【动态规划: 路径问题】下降路径最小和
java·算法·leetcode·动态规划·推荐算法
秋饼1 小时前
MySQL 分库分表完全指南:ShardingSphere-JDBC 实战、分片键选型、场景边界、20 个生产坑与 30 道面试点
java·mysql·面试·shardingsphere·分库分表·后端架构