• 黑马点评-02使用Redis代替session,Redis + token机制实现


    Redis代替session

    session共享问题

    每个Tomcat中都有一份属于自己的session,所以多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时可能会导致数据丢失

    用户第一次访问1号tomcat并把自己的信息存放session域中, 如果第二次访问到了2号tomcat就无法获取到在1号服务器存放的信息,导致登录拦截功能会出问题

    session拷贝: 每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session实现session的共享

    • 每台服务器中都有完整的一份session数据导致服务器压力过大
    • session拷贝数据时,可能会出现延迟

    使用Redis替换session可以实现服务器共享数据的问题

    • redis的三大特点: 数据共享(所有的服务器都可以在里面查询数据),内存存储(高性能),键值对的存储结构
      在这里插入图片描述

    存储用户信息key的结构

    如果存入的数据(登陆用户的信息)比较简单,可以考虑使用Redis的String或hash结构存储数据

    • String结构: 使用JSON字符串来保存登录的用户信息,信息比较直观但不易修改数据, 并且会额外的存储一些字符占用内存

    在这里插入图片描述

    • Hash结构: 将对象中的每个属性独立存储,既可以针对单个字段做CRUD, 内存占用少只会存储数据本身不用保存序列化对象信息或者JSON的一些额外字符串

    在这里插入图片描述

    基于Redis实现短信登录

    Redis的key是共享的,key要具有唯一性避免其他服务器在存储数据的时候出现key重复value覆盖的问题,用户发起请求时key还要方便携带

    在这里插入图片描述

    第一步: 修改sendCode方法,验证码不再保存到session中而是保存到Redis中(手机号作为key),验证码需要设置一个有效期节省Redsi内存

     public static final String LOGIN_CODE_KEY = "login:code:";
     public static final Long LOGIN_CODE_TTL = 2L;
    
    • 1
    • 2
    // 自动注入StringRedisTemplate客户端操作Redis
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public Result sendCode(String phone, HttpSession session) throws MessagingException {
        // 验证邮箱的格式
        if (RegexUtils.isEmailInvalid(phone)) {
            return Result.fail("邮箱格式不正确");
        }
        // 生成验证码并保存到Redis,执行set key value ex 120
        String code = MailUtils.achieveCode();
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
        log.info("发送登录验证码:{}", code);
        // 发送验证码,注意子类继承的方法不能比父类抛出更多的异常
        MailUtils.sendTestMail(phone, code);
        return Result.ok();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    第二步: 修改login方法,将用户信息都存储到Redis中

    • 校验验证码时从Redis中获取验证码
    • 存储用户信息时不再是存储到session中,而是随机生成一个token(登录令牌)作为key,将用户信息转换为HashMap对象存储到Redis的Hash结构中
    • 使用StringRedisTemplate向Redis中存数据时要求key和value都是String类型,所以将对象的每个属性转换成Map集合的元素时要求key和value都是String类型
    public static final String LOGIN_USER_KEY = "login:token:";
    public static final Long LOGIN_USER_TTL = 30L;
    
    • 1
    • 2
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) { 
        // 1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2.如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        // 3.从redis获取验证码并和用户提交的验证码校验比对
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        String code = loginForm.getCode();
        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中
        // 7.1.随机生成token作为登录令牌
        String token = UUID.randomUUID().toString(true);
        // 7.2.将UserDTO对象的属性转为HashMap集合中的元素,这样一次可以存储多个键值对
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        // 手动将UserDTO的每个属性及其值都转化为String类型并存储到HashMap集合
        HashMap<String, String > userMap = new HashMap<>();
        userMap.put("icon", userDTO.getIcon());
        userMap.put("id", String.valueOf(userDTO.getId()));
        userMap.put("nickName", userDTO.getNickName());
        // 使用万能工具类将userDTO对象的属性转化为HashMap集合中的元素,创建CopyOptions用来自定义key和value的类型    
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create()
                        .setIgnoreNullValue(true)
                        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        // 7.3.调用putAll方法将HashMap集合存储到Redis当中
        String tokenKey = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        // 7.4.设置tokenKey有效期为30分钟
        stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 7.5 登陆成功则删除验证码信息
        stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
        // 8.返回token
        return Result.ok(token);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50

    第三步:前端将后端返回的token保存到浏览器当中

    login(){
        if(!this.radio){
            this.$message.error("请先确认阅读用户协议!");
            return
        }
        if(!this.form.phone || !this.form.code){
            this.$message.error("手机号和验证码不能为空!");
            return
        }
        axios.post("/user/login", this.form)
            .then(({data}) => { // data是后端随机生成的token
            if(data){
                // 保存taken到浏览器当中
                sessionStorage.setItem("token", data);
            }
            // 跳转到首页,info.html是用户详情页
            location.href = "/index.html"
        })
    }
    
    // request拦截器,每次发请求都会将用户token放入请求头中
    let token = sessionStorage.getItem("token");
    axios.interceptors.request.use(
        config => {
            if(token) config.headers['authorization'] = token
            return config
        },
    )
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    登录状态刷新问题

    我们保存到Redis中的tokenKey的有效期为30分钟,在这段时间内根据用户有无操作决定是否刷新tokenKey的有效期

    • 如果用户有操作就需要刷新tokenKey的有效期,如果用户没有任何操作30分钟后tokenKey会消失,此时登录校验时就无法获取登录用户的信息,用户需要重新登录

    在这里插入图片描述

    第一步: 修改登陆拦截器,通过拦截器拦截到的请求来证明用户是否在操作

    public class LoginInterceptor implements HandlerInterceptor {
        //@Autowired,这里不能自动装配,因为LoginInterceptor是我们手动在WebConfig里new出来的并不受容器的管理
        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");
            //2. 如果token是空表示未登录需要拦截
            if (StrUtil.isBlank(token)) {
                response.setStatus(401);
                return false;
            }
            //3. 基于token作为key获取Redis的Hash结构中保存的用户数据,返回的是一个Map集合
            String key = RedisConstants.LOGIN_USER_KEY + token;
            Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
            //4. 判断Map集合中有没有元素,没有则拦截
            if (userMap.isEmpty()) {
                response.setStatus(401);
                return false;
            }
            //5. 将查询到的Hash结构的数据转化为UserDto对象,fasle表示不忽略转化时的错误 
            UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
            //6. 将用户信息保存到UserHolder类的ThreadLocal中
            UserHolder.saveUser(userDTO);
            //7. 刷新token有效期为30分钟
            stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
            //8. 放行
            return true;
        }
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            // 移除用户
            UserHolder.removeUser();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38

    第二步: 在配置类MvcConfig注册拦截器使其生效

    @Configuration
    public class MvcConfig implements WebMvcConfigurer {
        @Autowired
        private StringRedisTemplate stringRedisTemplate;
        @Override
        public void addInterceptors(InterceptorRegistry registry) {
            // 注册登录拦截器
            registry.addInterceptor(new LoginInterceptor(stringRedisTemplate))
                .excludePathPatterns(// 排除不需要拦截的路径
                "/user/code",
                "/user/login",
                "/blog/hot",
                "/shop/**",
                "/shop-type/**",
                "/upload/**",
                "/voucher/**"
            );
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    登陆状态刷新优化

    LoginInterceptor拦截器只能拦截需要登陆校验的路径,若当前用户访问了一些不会被拦截的路径,此时登录拦截器就不会生效,那么令牌刷新动作也就不会执行

    在这里插入图片描述

    第一步: 编写RefreshTokenInterceptor拦截器拦截所有路径: 负责基于token获取用户信息,然后保存用户的信息到ThreadLocal当中,同时刷新令牌的有效期

    public class RefreshTokenInterceptor implements HandlerInterceptor {
        // 这里并不是自动装配,因为RefreshTokenInterceptor是我们手动在WebConfig里new出来的
        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中的用户信息
            String key  = LOGIN_USER_KEY + token;
            Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
            // 3.判断用户是否存在
            if (userMap.isEmpty()) {
                // 用户不存在也放行交给LoginInterceptor处理
                return true;
            }
            // 5.将从Redis中查询到的Hash结构的数据转为UserDTO对象,fasle表示不忽略转化时的错误
            UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
            // 6.将用户信息保存到UserHolder类的ThreadLocal中
            UserHolder.saveUser(userDTO);
            // 7.刷新token有效期为30分钟
            stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
            // 8.放行
            return true;
        }
    
        @Override
        public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
            // 移除用户
            UserHolder.removeUser();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39

    第二步: 修改LoginInterceptor登陆拦截器只负责登录校验,即只需要判断UserHolder类的ThreadLocal中是否存在用户信息,存在放行不存在则拦截

    public class LoginInterceptor implements HandlerInterceptor {
        @Override
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            // 判断UserHolder类的ThreadLocal中是否有用户
            if (UserHolder.getUser() == null) {
                // 用户信息不存在则拦截并设置状态码
                response.setStatus(401);
                return false;
            }
            // 用户信息存在则放行
            return true;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    第二步: 在配置类MvcConfig注册两个拦截器,并设置它们的执行顺序和拦截路径

    • 拦截器的执行顺序默认按照添加的顺序执行,但也可以由order来指定顺序(数字越小优先级越高),另外如果拦截器未设置拦截路径则默认是拦截所有路径
    @Configuration
    public class MvcConfig implements WebMvcConfigurer {
        //自动装配
        @Autowired
        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/**"
                    ).order(1);        
            // 注册刷新token的拦截器,RefreshTokenInterceptor是我们手动new出来的,只能通过构造方法为其注入StringRedisTemplate
            //registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
            registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).order(0);
    
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
  • 相关阅读:
    针对discuz3.2的渗透测试
    前端后端的爱恨情仇
    音频采集的相关基础知识
    开发者福利:免费好用的API推荐
    浅谈Spring中JDK动态代理和CGLIB动态代理
    java设计模式之策略模式
    arch linux 安装 vsftpd 配置虚拟用户
    nginx和apache哪个支持的并发高,为什么
    MAC电脑运行windows程序或者游戏怎么办,crossover介绍,
    小程序与公众号:编程的异同
  • 原文地址:https://blog.csdn.net/qq_57005976/article/details/133623478