day31 Redis实现Session共享与MyBatis-Plus基础

day31 Redis实现Session共享与MyBatis-Plus基础

一、基于 Redis 实现 Session 共享

1.1 为什么用 Redis 替代 Session

传统 Session 方案把登录信息存在 Tomcat 的 Session 中,在多台服务器部署时,Session 无法共享,用户登录状态会丢失。Redis 是独立的内存存储,所有服务器都能访问同一份数据,天然解决 Session 共享问题。

本项目将验证码和用户信息都存入 Redis,登录成功后生成一个 token 返回给前端,前端后续请求在请求头 authorization 中携带该 token,后台从 Redis 取出对应用户信息完成登录校验。

1.2 验证码的存储与校验

发送验证码时,将 6 位随机验证码以手机号为 key 存入 Redis,并设置 2 分钟过期时间。

1
2
3
// 来自 UserServiceImpl.sendCode
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code,
LOGIN_CODE_TTL, TimeUnit.MINUTES); // 2分钟过期

登录时从 Redis 取出验证码与用户输入比对。校验通过后立即删除验证码,避免同一个验证码被重复使用。

1
2
3
4
5
6
7
// 来自 UserServiceImpl.login
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
if (cacheCode == null || !code.equals(cacheCode)) {
return Result.fail("验证码错误");
}
// 使验证码失效
stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);

验证码的 key 为 login:code: + 手机号,TTL 为 2 分钟(LOGIN_CODE_TTL = 2L,单位 MINUTES)。

1.3 用户数据的存储

登录成功后,不能直接把整个 User 对象存入 Redis(包含密码等敏感信息),而是先转为 UserDTO(只保留 id、nickName、icon 等非敏感字段),再以 token 为 key 存入 Redis 的 Hash 结构。

完整步骤:

  1. 生成随机 token 作为 key;
  2. User 转为 UserDTO,再转为 Map;
  3. 用 opsForHash().putAll 存入 Redis;
  4. 设置过期时间(30 分钟)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 来自 UserServiceImpl.login
// 1. 生成 token
String token = UUID.randomUUID(true).toString();
String tokenKey = LOGIN_USER_KEY + token;

// 2. user 转为 map
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userDtoMap = BeanUtil.beanToMap(
userDTO, new HashMap<>(),
CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor(
(fieldName, value) -> value.toString()
)
);

// 3. 存储到 Redis
stringRedisTemplate.opsForHash().putAll(tokenKey, userDtoMap);
// 4. 设置过期时间
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.SECONDS);

读取时通过 opsForHash().entries(key) 取出整个 Hash,再用 BeanUtil.fillBeanWithMap 还原为 UserDTO。

1
2
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

用户数据的 key 为 login:token: + token,TTL 为 36000 秒(即 10 小时,LOGIN_USER_TTL = 36000L)。

1.4 Redis 实现的设计考量

把 Session 换成 Redis 时,需要考虑三个方面:

  • 合适的数据结构:验证码是单值,用 String;用户信息有多个字段,用 Hash,方便单独读写某个字段。

  • 合适的 key:用业务前缀 + 业务标识的形式,如 login:code:、login:token:,便于管理和区分。

  • 存储粒度:只存必要字段,去除密码等敏感信息。本项目用 UserDTO 替代完整 User,就是为了控制存储粒度。

二、双拦截器实现登录校验与 Token 刷新

2.1 单拦截器的问题

如果只用一个拦截器同时完成”刷新 token 过期时间”和”校验登录状态”两件事,会有一个漏洞:用户访问不需要登录的页面时,拦截器被排除,token 的过期时间不会被刷新。这样用户即使一直在浏览公开页面,token 也会在 30 分钟后过期,导致突然被踢下线。

2.2 双拦截器方案

用两个拦截器分工:

拦截器 拦截范围 职责
RefreshInterceptor 所有请求 /** 从请求头取 token,查 Redis 得到用户信息,存入 ThreadLocal,并刷新 token 过期时间
LoginInterceptor 需登录的请求(排除公开路径) 从 ThreadLocal 取用户,不存在则返回 401

RefreshInterceptor(order=0)的核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 来自 RefreshInterceptor.preHandle
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true; // 没有 token 直接放行,交给下一个拦截器判断
}
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
if (userMap.isEmpty()) {
return true;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
UserHolder.saveUser(userDTO); // 存入 ThreadLocal
stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.SECONDS); // 刷新过期时间
return true;

LoginInterceptor(order=1)只做登录判断:

1
2
3
4
5
6
7
// 来自 LoginInterceptor.preHandle
UserDTO userDTO = UserHolder.getUser();
if (userDTO == null) {
response.setStatus(401);
return false;
}
return true;

请求结束后,RefreshInterceptor.afterCompletion 负责清除 ThreadLocal,避免内存泄漏。

2.3 拦截器执行顺序

在 MvcConfig 中注册两个拦截器,通过 .order() 控制执行顺序,数字越小优先级越高:

1
2
3
4
5
6
7
8
// 来自 MvcConfig.addInterceptors
registry.addInterceptor(refreshInterceptor).addPathPatterns("/**").order(0);

registry.addInterceptor(loginInterceptor).addPathPatterns("/**")
.excludePathPatterns(
"/user/code", "/user/login", "/blog/hot",
"/shop/**", "/shop-type/**", "/voucher/**", "/upload/**"
).order(1);

执行流程:

1
2
3
4
5
6
7
8
9
请求到达
↓
RefreshInterceptor(order=0):取 token → 查 Redis → 存 ThreadLocal → 刷新 TTL → 放行
↓
LoginInterceptor(order=1):ThreadLocal 有用户?→ 有则放行,无则 401
↓
Controller 处理
↓
RefreshInterceptor.afterCompletion:清除 ThreadLocal

三、字符串判空:StrUtil.isBlank

拦截器中获取请求头里的 token 后,需要判断是否为空。这里不用 == null,而是用 Hutool 的 StrUtil.isBlank()。

1
2
3
4
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {
return true;
}

三种判空方式的区别:

方式 判断 null 判断空串 "" 判断空白字符 " "
token == null 是 否 否
StrUtil.isEmpty(token) 是 是 否
StrUtil.isBlank(token) 是 是 是

StrUtil.isBlank() 能同时覆盖 null、空串和仅含空白字符(空格、制表符等)的情况,最适合做请求参数的非空校验。

四、踩坑:StringRedisTemplate 存入 Long 报错

问题现象

存入用户数据时运行报错。

原因

StringRedisTemplate 的 key 和 value 都必须是 String 类型。而 BeanUtil.beanToMap(userDTO) 转换出的 Map<String, Object> 中,id 字段是 Long 类型,存入 Hash 时会类型不匹配。

解决

在 beanToMap 时通过 CopyOptions 的 setFieldValueEditor 把所有字段值统一转为 String:

1
2
3
4
5
6
Map<String, Object> userDtoMap = BeanUtil.beanToMap(
userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, value) -> value.toString())
);

这样存入 Redis Hash 的所有 value 都是字符串,读取时再按需转换即可。

五、MyBatis-Plus 基础用法

MyBatis-Plus(MP)是 MyBatis 的增强工具,只做增强不做改变,单表 CRUD 无需手写 SQL。

5.1 Mapper 层:继承 BaseMapper

Mapper 接口继承 BaseMapper<T>,泛型 T 为该接口操作的实体类,即可自动拥有增删改查方法,无需编写 XML。

1
2
3
// 来自 UserMapper
public interface UserMapper extends BaseMapper<User> {
}

常用方法(来自 BaseMapper):

方法 作用
insert(entity) 插入一条记录
deleteById(id) 根据 ID 删除
updateById(entity) 根据 ID 更新
selectById(id) 根据 ID 查询
selectList(wrapper) 条件查询列表

5.2 Service 层:继承 IService 与 ServiceImpl

MP 还提供通用 Service 层。Service 接口继承 IService<T>,实现类继承 ServiceImpl<Mapper, T>:

1
2
3
4
5
// 来自 IUserService
public interface IUserService extends IService<User> {
Result sendCode(String phone, HttpSession session);
Result login(LoginFormDTO loginForm, HttpSession session);
}
1
2
3
4
5
// 来自 UserServiceImpl
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
// 自定义业务方法
}

继承后可直接使用 IService 提供的方法,例如本项目中用 query().eq("phone", phone).one() 按手机号查询用户,用 save(user) 新增用户。

方法 作用
save(entity) 插入一条记录
removeById(id) 根据 ID 删除
updateById(entity) 根据 ID 更新
getById(id) 根据 ID 查询
list() 查询所有
query() 返回链式查询构造器

本项目在 MybatisConfig 中注册了 PaginationInnerInterceptor 分页插件,后续分页查询可直接使用 MP 的 page 方法。

今日总结

模块 核心要点
Redis 存验证码 key 为 login:code:+手机号,String 结构,TTL 2 分钟;登录校验后立即删除
Redis 存用户数据 key 为 login:token:+token,Hash 结构,用 UserDTO 去除敏感信息,TTL 10 小时
双拦截器 RefreshInterceptor(order=0)拦截所有请求,存 ThreadLocal 并刷新 TTL;LoginInterceptor(order=1)校验登录状态
字符串判空 优先用 StrUtil.isBlank(),覆盖 null、空串和空白字符
StringRedisTemplate value 必须是 String,存对象时用 CopyOptions 把所有字段转 String
MyBatis-Plus Mapper 继承 BaseMapper<T>,Service 继承 IService<T>/ServiceImpl,单表 CRUD 免手写 SQL