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 | // 来自 UserServiceImpl.sendCode |
登录时从 Redis 取出验证码与用户输入比对。校验通过后立即删除验证码,避免同一个验证码被重复使用。
1 | // 来自 UserServiceImpl.login |
验证码的 key 为
login:code:+ 手机号,TTL 为 2 分钟(LOGIN_CODE_TTL = 2L,单位MINUTES)。
1.3 用户数据的存储
登录成功后,不能直接把整个 User 对象存入 Redis(包含密码等敏感信息),而是先转为 UserDTO(只保留 id、nickName、icon 等非敏感字段),再以 token 为 key 存入 Redis 的 Hash 结构。
完整步骤:
- 生成随机
token作为 key; User转为UserDTO,再转为Map;- 用
opsForHash().putAll存入 Redis; - 设置过期时间(30 分钟)。
1 | // 来自 UserServiceImpl.login |
读取时通过 opsForHash().entries(key) 取出整个 Hash,再用 BeanUtil.fillBeanWithMap 还原为 UserDTO。
1 | Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token); |
用户数据的 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 | // 来自 RefreshInterceptor.preHandle |
LoginInterceptor(order=1)只做登录判断:
1 | // 来自 LoginInterceptor.preHandle |
请求结束后,RefreshInterceptor.afterCompletion 负责清除 ThreadLocal,避免内存泄漏。
2.3 拦截器执行顺序
在 MvcConfig 中注册两个拦截器,通过 .order() 控制执行顺序,数字越小优先级越高:
1 | // 来自 MvcConfig.addInterceptors |
执行流程:
1 | 请求到达 |
三、字符串判空:StrUtil.isBlank
拦截器中获取请求头里的 token 后,需要判断是否为空。这里不用 == null,而是用 Hutool 的 StrUtil.isBlank()。
1 | String token = request.getHeader("authorization"); |
三种判空方式的区别:
| 方式 | 判断 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 | Map<String, Object> userDtoMap = BeanUtil.beanToMap( |
这样存入 Redis Hash 的所有 value 都是字符串,读取时再按需转换即可。
五、MyBatis-Plus 基础用法
MyBatis-Plus(MP)是 MyBatis 的增强工具,只做增强不做改变,单表 CRUD 无需手写 SQL。
5.1 Mapper 层:继承 BaseMapper
Mapper 接口继承 BaseMapper<T>,泛型 T 为该接口操作的实体类,即可自动拥有增删改查方法,无需编写 XML。
1 | // 来自 UserMapper |
常用方法(来自 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 | // 来自 IUserService |
1 | // 来自 UserServiceImpl |
继承后可直接使用 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 |