黑马点评-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内存

java 复制代码
 public static final String LOGIN_CODE_KEY = "login:code:";
 public static final Long LOGIN_CODE_TTL = 2L;
java 复制代码
// 自动注入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();
}

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

  • 存储验证码时不再存储到session中,而是将手机号作为key存储到Redis的String结构中,校验验证码时从Redis中获取验证码
  • 存储用户信息时不再是存储到session中,而是随机生成一个token(登录令牌)作为key,将用户信息转换为HashMap对象存储到Redis的Hash结构中
  • 使用StringRedisTemplate向Redis中存数据时要求key和value都是String类型,所以将对象的每个属性转换成Map集合的元素时要求key和value都是String类型
java 复制代码
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 30L;
java 复制代码
@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);
}

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

javascript 复制代码
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
    },
)

登录状态刷新问题

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

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

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

java 复制代码
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();
    }
}

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

java 复制代码
@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/**"
        );
    }
}

登陆状态刷新优化

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

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

java 复制代码
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();
    }
}

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

java 复制代码
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;
    }
}

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

  • 拦截器的执行顺序默认按照添加的顺序执行,但也可以由order来指定顺序(数字越小优先级越高),另外如果拦截器未设置拦截路径则默认是拦截所有路径
java 复制代码
@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);

    }
}
相关推荐
Abladol-aj1 小时前
并发和并行的基础知识
java·linux·windows
清水白石0081 小时前
从一个“支付状态不一致“的bug,看大型分布式系统的“隐藏杀机“
java·数据库·bug
吾日三省吾码6 小时前
JVM 性能调优
java
弗拉唐7 小时前
springBoot,mp,ssm整合案例
java·spring boot·mybatis
oi778 小时前
使用itextpdf进行pdf模版填充中文文本时部分字不显示问题
java·服务器
少说多做3438 小时前
Android 不同情况下使用 runOnUiThread
android·java
知兀8 小时前
Java的方法、基本和引用数据类型
java·笔记·黑马程序员
蓝黑20209 小时前
IntelliJ IDEA常用快捷键
java·ide·intellij-idea
Ysjt | 深9 小时前
C++多线程编程入门教程(优质版)
java·开发语言·jvm·c++