使用Redis解决使用Session登录带来的共享问题

        在学习项目的过程中遇到了使用Session实现登录功能所带来的共享问题,此问题可以使用Redis来解决,也即是加上一层来解决问题。

        接下来介绍一些Session的相关内容并且采用Session实现登录功能(并附上代码),进行分析其存在的问题,并使用Redis来解决这一问题。

一、什么是Session

可以参考一下这个博客的内容,比较详细:

Session详解

        简而言之,session是服务器为了保存用户状态而创建的一个特殊的对象,服务器会为每一个浏览器(客户端)创建一个唯一的session,session有一个JSESSIONID,这个是session的唯一标识。

        当浏览器(客户端)发送请求的时候,会在Cookie中携带JSESSIONID,以便服务器可以找到对应的Session,服务器就能知道客户端的状态了。

比如:在登陆一些网站成功之后,你再访问这个网站的其他内容的时候就不需要重新登陆了,因为此时服务器可以基于浏览器Cookie中的JSESSIONID(或者其他校验技术)来判断你的状态。但是当一段时间未登录之后,你重新访问网站,往往需要重新登录,这是因为浏览器访问时没有携带JSESSIONID,浏览器携带的JSESSIONID对应的session不存在(或者失效)的原因。

二、基于Session实现登录(短信登录)

1.流程:

使用Redis解决使用Session登录带来的共享问题_第1张图片

使用Redis解决使用Session登录带来的共享问题_第2张图片

使用Redis解决使用Session登录带来的共享问题_第3张图片

2.代码实现:

(1)Controller层:(接收请求)

    /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        //发送短信验证码并保存验证码
        return userService.sendCode(phone, session);
    }

    /**
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        // 实现登录功能
        return userService.login(loginForm, session);
    }

(2)Service层:(处理业务逻辑)

    /**
     * 发送短信验证码并保存验证码
     * @param phone
     * @param session
     * @return
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //1.校验手机号是否合法
        boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);
        //2.若不合法,返回错误信息
        if(!phoneInvalid){
            return Result.fail("手机号不合法");
        }
        //3.生成验证码 6位
        String code = RandomUtil.randomNumbers(6);
        //4.保存验证码到session中
        session.setAttribute("code",code);
        //5.发送验证码 (这里可以结合阿里云提供的第三方服务来做,但是为了方便,输出一下就可以)
        log.debug("发送短信验证码成功,验证码:{}",code);
        //6.返回ok
        return Result.ok();

    }
     /**
     * 登录功能
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {

        String phone = loginForm.getPhone();
        //1.校验手机号是否合法
        if(!RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号不合法");
        }
        //2.校验验证码是否一致
        String sessionCode = (String) session.getAttribute("code");
        String userCode = loginForm.getCode();
        //3.验证码不一致返回错误信息
        if(sessionCode == null ||!sessionCode.equals(userCode)){
            return Result.fail("验证码错误");
        }
        //4.根据手机号到数据库查询用户
        User user = query().eq("phone", phone).one();
        //5.若用户不存在则创建用户并保存到数据库
        if(user == null){
            user = createUserByPhone(phone);
        }
        //6.保存用户信息到session中
        UserDTO userDTO = new UserDTO();
        BeanUtils.copyProperties(user,userDTO);
        session.setAttribute("user",userDTO);

        //7.返回ok
        return Result.ok();
    }

(3)LoginInterceptor:(拦截请求,进行登录校验)

public class LoginInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //1.获取session
        HttpSession session = request.getSession();

        //2.获取session里面的user
        User user = (User) session.getAttribute("user");

        //3.用户不存在,则拦截
        if(user == null){
            //4.拦截,返回401
            response.setStatus(401);
            return false;
        }
        //5.存在,保存到ThreadLocal中
        UserDTO userDTO = new UserDTO();
        userDTO.setId(user.getId());
        userDTO.setIcon(user.getIcon());
        userDTO.setNickName(user.getNickName());
        UserHolder.saveUser(userDTO);

        //6.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        //在页面渲染完成返回给用户前,删除用户信息,避免内存泄漏
        UserHolder.removeUser();
    }
}

(4)MVCconfig:(用于注册拦截器,使得拦截器生效)

@Configuration//由Spring创建的对象 可以使用 Autowired
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",
                       "/shop/**",
                       "/shop-type/**",
                       "/blog/hot",
                       "/upload/**",
                       "/voucher/**"
               );
    }
}

三、基于Session实现登录存在的共享问题

1.问题分析

使用Redis解决使用Session登录带来的共享问题_第4张图片

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

        也就是说一台浏览器可能会被负载均衡到不同的服务器上,但是服务器的Session是不共享的,那当浏览器先被负载均衡到服务器A,后负载均衡到服务器B时,浏览器所携带的JSESSIONID只在服务器A生效,而服务器B找不到其对应的Session,也就无法获知用户的状态。这就是基于Session实现登录存在的共享问题。

2.解决方案:

session的替代方案应该满足以下条件:

(1)数据共享:

        首先必须满足数据可共享,否则还是会引发共享问题。这个时候就应该想到常用的思路“加一层”,加上一层中间件,上层都到这个中间件来存取数据,这样不就实现了数据共享吗。

(2)内存存储

        由于每次请求的时候,服务器都需要对请求进行校验,这个操作是十分频繁的,如果可以将数据存储在内存中,就可以极大地提高服务器处理请求的效率。

(3)key、value结构

        服务器应该根据服务器所携带key去找到其对应的value

通过以上分析,使用Redis来解决这个问题是再合适不过的了。

使用Redis实现登录功能总思路:

1.用户提交手机号,后端收到手机号之后生成验证码并以手机号为key,验证码为value,并设置过期时间,存储到redis中。存储完成之后发送验证码给用户。

2.用户填写验证码,浏览器发送协带用户填写的手机号和验证码的请求。后端接收请求,根据用户的手机号到redis中查找对应的验证码值,查找后将redis中的验证码和用户提交的验证码进行比较。

3.验证成功,则生成一个token,将token作为key,用户信息作为value(这里使用redis的hash结构存储),设置过期时间,存入redis中,并返回一个token。

4.此后,前端发送请求的时候,就携带这个token,服务器就可以根据这个token到redis中去获取用户的信息。

5.由于redis是单独的一层,所有浏览器拿到token之后都是去这个redis中查找数据,这就解决了用户被负载均衡到不同服务器时,session引发的共享问题。

四、基于Redis实现登录

1.流程:

使用Redis解决使用Session登录带来的共享问题_第5张图片

使用Redis解决使用Session登录带来的共享问题_第6张图片

简单来说就是把对session存取操作改为对redis的存取操作。

2.代码实现:

(1)Controller层

     /**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        //发送短信验证码并保存验证码
        return userService.sendCode(phone, session);
    }

    /**
     * 登录功能
     * @param loginForm 登录参数,包含手机号、验证码;或者手机号、密码
     */
    @PostMapping("/login")
    public Result login(@RequestBody LoginFormDTO loginForm, HttpSession session){
        // 实现登录功能
        return userService.login(loginForm, session);
    }

    @GetMapping("/me")
    public Result me(){
        //获取当前登录的用户并返回
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }

(2)Service层:(处理业务逻辑)

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    /**
     * 发送短信验证码并保存验证码
     * @param phone
     * @param session
     * @return
     */
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //1.校验手机号是否合法
        boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);

        //2.若不合法,返回错误信息
        if(!phoneInvalid){
            return Result.fail("手机号不合法");
        }

        //3.生成验证码 6位
        String code = RandomUtil.randomNumbers(6);

        //4.保存验证码到redis中
        stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY+phone,code,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);

        //5.发送验证码 (这里可以结合阿里云提供的第三方服务来做,但是为了方便,输出一下就可以)
        log.debug("发送短信验证码成功,验证码:{}",code);

        //6.返回ok
        return Result.ok();
    }

    /**
     * 登录功能
     * @param loginForm
     * @param session
     * @return
     */
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {

        String phone = loginForm.getPhone();
        //1.校验手机号是否合法
        if(!RegexUtils.isPhoneInvalid(phone)){
            return Result.fail("手机号不合法");
        }
        //2.TODO 从Redis获取验证码并校验验证码是否一致
        String reidsCode = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY+phone);
        String userCode = loginForm.getCode();
        //3.验证码不一致返回错误信息
        if(reidsCode == null ||!reidsCode.equals(userCode)){
            return Result.fail("验证码错误");
        }
        //4.根据手机号到数据库查询用户
        User user = query().eq("phone", phone).one();
        //5.若用户不存在则创建用户并保存到数据库
        if(user == null){
            user = createUserByPhone(phone);
        }
        //6.保存用户信息到redis中
        //6.1 生成随机token 作为登录令牌
        String token = UUID.randomUUID().toString(true);
        //6.2 将UserDTO转为 HashMap
        UserDTO userDTO = new UserDTO();
        BeanUtils.copyProperties(user,userDTO);

        MapusertMap = new HashMap<>();
        usertMap.put("id", userDTO.getId().toString());
        usertMap.put("nickName", userDTO.getNickName());
        usertMap.put("icon", userDTO.getIcon());
        //6.3 存储到redis中
        String tokenKey = RedisConstants.LOGIN_USER_KEY+token;
        stringRedisTemplate.opsForHash().putAll(tokenKey,usertMap);
        //6.4 设置有效期
        stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);

        //7.返回token
        return Result.ok(token);
    }

    /**
     * 根据手机号创建用户
     * @param phone
     * @return
     */
    private User createUserByPhone(String phone) {
        User user = new User();
        user.setPhone(phone);
        user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(6));
        save(user);
        return user;
    }

(3)LoginInterceptor:(拦截请求,进行登录校验)

      @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.获取请求头中的token
        String token = request.getHeader("authorization");
        if(StringUtils.isEmpty(token)){
            //token 为空 拦截,返回401
            response.setStatus(401);
            return false;
        }

        //2.通过token获取redis中的用户信息
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        Map userMap = stringRedisTemplate.opsForHash()
                .entries(tokenKey);

        //3.用户不存在,则拦截
        if(userMap.isEmpty()){
            //4.拦截,返回401
            response.setStatus(401);
            return false;
        }
        //5. 将HashMap转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);

        //6.存在,保存到ThreadLocal中
        UserHolder.saveUser(userDTO);

        //7.重置token有效期
        stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES)
        //8.放行
        return true;
    }

(4)MVCconfig:(用于注册拦截器,使得拦截器生效)

@Configuration//由Spring创建的对象 可以使用 Autowired
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",
                       "/shop/**",
                       "/shop-type/**",
                       "/blog/hot",
                       "/upload/**",
                       "/voucher/**"
               );
    }
}

你可能感兴趣的:(java,redis,登录,session)