一、集群Session共享问题

session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务器时导致数据丢失的问题

tomcat可以进行多台tomcat进行session拷贝,但是数据拷贝保存相同的内容会存在资源浪费,而且会有时间延迟,所以这种方案不可行

session的替代方案应该满足:

  • 数据共享
  • 内存存储
  • key、value结构

这里我们可以使用redis

二、Redis存储验证码和对象

发送短信:

@Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public 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.保存验证码到Redis
        stringRedisTemplate.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. 保存用户信息到Redis
        String 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;
    }
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 获取请求头中的token
        String 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;
    }
    @Override
    public 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;
    }
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 获取请求头中的token
        String 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. 存在 保存用户到ThreadLocal
        UserHolder.saveUser(userDTO);
        stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        return true;
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

我们创建一个拦截全部路径的拦截器来进行token有效期的刷新

我们在登录拦截器里,只需要判断ThreadLocal里是否存在有效的用户,如果有放行,否则拦截。

public class MvcConfig implements WebMvcConfigurer {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public 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来决定它们的执行顺序

到此这篇关于Redis解决Session共享问题的文章就介绍到这了,更多相关Redis解决Session共享内容请搜索萤火虫技术以前的文章或继续浏览下面的相关文章希望大家以后多多支持萤火虫技术!

点赞(264) 打赏

评论列表 共有 0 条评论

暂无评论

微信小程序

微信扫一扫体验

立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部