文章目录
- 一、集群Session共享问题
- 二、Redis存储验证码和对象
- 三、解决状态登录刷新问题
一、集群Session共享问题
session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务器时导致数据丢失的问题
tomcat可以进行多台tomcat进行session拷贝,但是数据拷贝保存相同的内容会存在资源浪费,而且会有时间延迟,所以这种方案不可行
session的替代方案应该满足:
- 数据共享
- 内存存储
- key、value结构
这里我们可以使用redis
二、Redis存储验证码和对象
发送短信:
@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result sendCode(String phone, HttpSession session) {// 1.校验手机号if (phone == null || str.matches("^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$")) {// 2.如何不符合,返回错误信息return Result.fail("手机号格式错误!");}// 3.符合,生成验证码String code = RandomUtil.randomNumbers(6);// 4.保存验证码到RedisstringRedisTemplate.opsForValue().set("login:code:" + phone,code,2, TimeUnit.MINUTES);//具体的发送逻辑 在这里就不实现了return Result.ok();}
首先,我们会校验前端传来的手机号格式,如果格式不正确直接返回。使用hutool的工具类生成6位随机验证码,然后将验证码作为value存入到Redis中,为了避免key重复,我们设置了固定格式的key,并且设置一个2分钟的超时时间,超过两分钟验证码自动失效。
登录功能:
public Result login(LoginFormDTO loginForm, HttpSession session) {// 1. 校验手机号String phone = loginForm.getPhone();if (phone == null || str.matches("^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$")) {// 如何不符合,返回错误信息return Result.fail("手机号格式错误!");}// 2. 校验验证码String cacheCode = stringRedisTemplate.opsForValue().get("login:code:" + phone);String code = loginForm.getCode();// 3. 不一致,报错if(cacheCode == null || !cacheCode.equals(code)) {return Result.fail("验证码错误!");}// 4. 一致,根据手机号查询用户 select * from tb_user where phone = ?User user = query().eq("phone", phone).one();// 5. 判断用户是否存在if (user == null) {// 6. 不存在,创建用户并保存user = createUserWithPhone(phone);}// 7. 保存用户信息到RedisString token = UUID.randomUUID().toString(true);UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>(), CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));stringRedisTemplate.opsForHash().putAll("login:token:" + token,userMap);stringRedisTemplate.expire("login:token:" + token,30,TimeUnit.MINUTES);return Result.ok(token);}
我们在进行登录时,首先会对手机号格式进行检验,如果手机号格式正确,我们从Redis中获取验证码和客户端传来的验证码进行比较,如果一致我们就放行,先去数据库查询该用户信息,如果用户不存在进行保存。
可能有的同学会有疑问,为什么这里要进行这么麻烦的操作呢?
因为我们UserDTO中的id是Long类型的,会报Long转String类型转换异常,因为我们这里使用的是StringRedisTemplate
该类型要求key和value都是String类型,但是我们将对象转为Map时,id为Long类型,所以就出现了该问题,两种方案:1.自定义Map手动put 2.使用BeanUtil,自定义规则
我们需要将用户对象存储在Redis中,这里用什么作为key呢?我们这里用token作为key,将token返回给客户端,客户端后面请求的时候使用该token来获取value。
我们value保存对象时,使用什么存储呢?
1.String:
2.Hash:
我们这里使用Hash存储对象,因为Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且占用内存更少。
我们使用UUID随机生成token,但是我们value是哈希结构,我们使用BeanUtil将对象转为Hash存储,因为Redis是在内存存储的,如果一直只存会存在内存不够用的情况,所以我们这里仍然需要设置一个超时时间,那么设置多长时间呢?我们这里模仿Session的只要超过30分钟不访问就会销毁。
但是我们现在设置的是,从设置开始不管有没有用户访问30分钟后都会销毁,这样肯定是不行的,我们需要和session一样,只要有用户访问我们就需要更新超时时间,那么怎么做呢?可以借助拦截器
我们的拦截器不是Spring创建的对象,所以我们无法使用注入的方式获取StringRedisTemplate对象,我们需要使用构造方法的方法,那么谁来调用呢?
我们可以在MvcConfig注册拦截器时传入StringRedisTemplate对象
由于我们多处都需要用到ThreadLocal存储的对象,所以我们将ThreadLocal封装成一个工具类:
public class UserHolder {private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();public static void saveUser(UserDTO user){tl.set(user);}public static UserDTO getUser(){return tl.get();}public static void removeUser(){tl.remove();}
}
public class LoginInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1. 获取请求头中的tokenString token = request.getHeader("authorization");if(StrUtil.isBlank(token)) {response.setStatus(401);return false;}// 2. 使用token获取Redis中的对象Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries("login:token:" + token);// 3. 判断用户是否存在if(userMap == null) {response.setStatus(401);return false;}// 4. 将Hash 格式转为UserDTO对象UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 5. 将用户存入ThreadLocal中UserHolder.saveUser(userDTO);// 6. 刷新token超时时间stringRedisTemplate.expire("login:token:" + token,30,TimeUnit.MINUTES);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
大家需要注意的是我们需要remove ThreadLocal,因为ThreadLocal可能会存在内存泄露问题,因为强软引用的问题,这里我们不具体介绍。
三、解决状态登录刷新问题
但是这样会存在一些问题,该拦截器只会拦截需要登录的路径,其他路径是不会拦截了,也就不会进行token有效期的刷新了。怎么解决呢? 新加一个全部路径的拦截器
public class RefreshTokenInterceptor implements HandlerInterceptor {private StringRedisTemplate stringRedisTemplate;public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {this.stringRedisTemplate = stringRedisTemplate;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1. 获取请求头中的tokenString token = request.getHeader("authorization");if (StrUtil.isBlank(token)) {return true;}// 2. 基于token获取Redis中的用户Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);// 3. 判断用户是否存在if(userMap == null) {return true;}// 将查询到的Hash转为UserDTO对象UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);// 5. 存在 保存用户到ThreadLocalUserHolder.saveUser(userDTO);stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {UserHolder.removeUser();}
}
我们创建一个拦截全部路径的拦截器来进行token有效期的刷新
我们在登录拦截器里,只需要判断ThreadLocal里是否存在有效的用户,如果有放行,否则拦截。
public class MvcConfig implements WebMvcConfigurer {@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(new LoginInterceptor()).excludePathPatterns("/user/code","/user/login","/blog/hot","/shop/**","/shop-type/**","/upload/**","/voucher/**");registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**");}
}
我们在注册刷新Token的拦截器,并且增加所有路径。
但是我们如何保证刷新Token的拦截器在登录拦截器之前执行呢?其实在MvcConfig中注册拦截器的顺序也就是拦截的顺序,但是这样不保险
其实我们在addInterceptor时会生成一个拦截器注册器对象
拦截器注册器中又有一个order属性,默认都是0,这个值决定拦截器的执行顺序,值越小执行优先级越高。
我们可以通过设置order来决定它们的执行顺序