专栏前言

   本专栏开启,目的在于帮助大家更好的掌握学习Redis,同时也是为了记录我自己学习Redis的过程,将会从基础的数据类型开始记录,直到一些更多的应用,如缓存击穿还有分布式锁等。希望大家有问题也可以一起沟通,欢迎一起学习,对于专栏内容有错还望您可以及时指点,非常感谢大家 🌹。

目录

  • 专栏前言
  • 1. 什么是分布式 session?
  • 2. 使用 Redis 实现短信登录
  • 2.1 整体思路
  • 2.1.1 验证码的发送
  • 2.1.2 验证码的验证
  • 2.2 主要代码实现
  • 2.3 解决状态登录刷新问题

1. 什么是分布式 session?

   session,也叫会话。它是服务端用来保留对话信息的,因为HTTP请求是无状态的,什么叫无状态呢?就是任意两次HTTP连接都是没有关系的,上一秒我们还认识,下一秒就不认识了。

  这肯定是不行的,假设我请求一个购物网站,当我点开时它需要我登录,等我看一个物品时它又要我登录,当我想付款时它又要我登录( md 不买了 😅)… 这对用户体验来说,肯定是致命的。所以session应用而生,它可以记录用户的登录信息,以此来验证用户是否登录。

  讲完session,接下来再了解是分布式 session,刚才的场景只适用于单服务器的场景下,也就是只有一个tomcat。那么如果涉及到多服务器的场景下,也就是存在多台tomcat,这就有问题了,因为tomcat存储的session是不能共享的。比如当客户端的请求经过nginx反向代理将请求送到某一台tomcat时,它存储了登录信息,但当nginx把你另外的请求另一台tomcat时,它会来一句——兄弟你谁 😂

【Redis从入门到进阶】第 4 讲:基于 Redis 实现分布式 session_缓存


  为了解决上述场景带来的问题,于是我们需要实现分布式session,以此来保证session能被多个tomcat共享。而将用户登录信息存入redis内,是多种实现方式来说最好的一种,也是企业中使用最多的一种方式,它具有以下优点:

  1. 数据存储在redis中,不存在安全隐患,且redis使用效率高
  2. redis自身可做集群,搭建主从,方便管理

下面我们通过手机号收验证码进行登录注册这个场景来进行实践:

2. 使用 Redis 实现短信登录

2.1 整体思路

2.1.1 验证码的发送

  首先服务器收到前端发送的手机号,我们首先需要判断该手机号的格式是否正确,如果是无效的我们肯定需要用户重新提交手机号。如果有效,我们则需要随机生成一个验证码,为了模拟真实场景我们这里直接将生成的验证码输出到控制台。接下来最重要的一步就是将该验证码存入redis中 ,因为必须保证key的唯一性,所以我们可以将手机号码设为key,验证码作为value存入redis中。当然验证码都有过期时间的,而redis也支持设置过期时间,所以我们可以给该数据设置一个合适的TTL过期时间。

2.1.2 验证码的验证

  当我们得到验证码后,会将手机号和验证码一起发送给服务器。redis会去根据该手机号进行查询然后进行校验。如果不通过则打回,通过的话则会继续去数据库内查询是否存在该用户,不存在的话还会创建该用户(现在的网站基本都是这样)。当然不能这样就完了,我们还需要将该用户的信息存储到redis中,不然下一次换个地方它又不认识你了。我们生成一个随机的token作为key,由于用户有多个信息,所以我们用hash结构来存储user数据作为value,同时设置一个TTL过期时间将其存入redis。当然不能忘记将这个token返回给客户端,不然它下次来了都知道拿什么给服务器认证。同时注意我们登录成功后将用户存入ThreadLocal,这样可以方便我们后续操作。

2.2 主要代码实现

  有了整体思路,那我们就可以去实现我们的代码了,首先我们完善sendCode方法,对于key我们的格式为login:code:phone的形式,TTL过期时间我们设置为2分钟,当然这类字符串信息我们最好将其封装成一个常量,防止其他地方写错,这样也显得更加专业。

//操作 redis 的类,专栏上一章讲解过
	@Resource
    private StringRedisTemplate stringRedisTemplate;
    
    @Override
    public Result sendCode(String phone, HttpSession session) {
        //1.校验手机号 
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.如果不符合返回错误信息
            return Result.fail("手机号格式错误");
        }
        //3.符合生成验证码
        String code = RandomUtil.randomNumbers(6);
        //4.保存验证码到redis  加一个有效时间  2分钟
        stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);

        //5.发送验证码   这里我们将验证码输出到控制台
        log.info("发送短信验证码成功,验证码{}", code);
        return Result.ok();
    }

接下来来实现最主要的login方法,根据刚才验证码验证的逻辑,我们可以画出一个大概的代码执行流程图:

【Redis从入门到进阶】第 4 讲:基于 Redis 实现分布式 session_分布式_02


代码实现:

@Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //1.校验手机号
        String phone = loginForm.getPhone();
        if (RegexUtils.isPhoneInvalid(phone)) {
            //2.如果不符合返回错误信息
            return Result.fail("手机号格式错误");
        }
        // TODO: 2022/11/9 从redis获取验证码并且校验
        String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
        String code = loginForm.getCode();
        //3.不一致则报错
        if (cacheCode == null || !cacheCode.equals(code)) {
            return Result.fail("验证码错误");
        }
        //4.一致,根据手机号查询用户
        log.info(phone);
        //mybatisplus数据库查询
        User user = query().eq("phone", phone).one();
        log.info(user == null ? "不存在" : "存在");
        //5.判断用户是否存在
        if (user == null) {
            log.info("用户不存在");
            //6.不存在,创建新用户并保存
            user = createUserWithPhone(phone);
            log.info("创建用户成功 {}", user.getPhone());
        }
        // TODO: 2022/11/9      7.保存用户信息到redis中
        // TODO: 2022/11/9      7.1 随机生成token,作为登录令牌
        String token = UUID.randomUUID().toString(true);
        //TODO: 2022/11/9       7.2 将User对象转为Hash存储
        UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
        /*这里因为Long和String转换会报错*/   这是一个DAO对象转为Map的方法
        Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
                CopyOptions.create().
                        setIgnoreNullValue(true).
                        setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
        // TODO: 2022/11/9      7.3存储
        String tokenKey = LOGIN_USER_KEY + token;
        stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
        // TODO: 2022/11/9      7.4设置有效期
        stringRedisTemplate.expire(tokenKey, 30, TimeUnit.MINUTES);
        //7.5 登陆成功则删除验证码信息
        stringRedisTemplate.delete(LOGIN_CODE_KEY + phone);
        return Result.ok(token);
    }

下面是createUserWithPhone方法的实现:

private User createUserWithPhone(String phone) {
        User user = new User();
        user.setPhone(phone);
        //随机生成用户名
        user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
        //保存用户到数据库
        save(user);
        return user;
    }

2.3 解决状态登录刷新问题

  考虑一个使用场景,当我们刷淘宝刷着刷着,突然就要你登录,然后你登录以后没用多久突然又要登录,那岂不是非常的烦人!
  这就涉及到token刷新的操作,因为我们将用户登陆信息存储到了redis当中时是设置了一个30分钟的过期时间的,也就是说我们最多只能持续操作30分钟就得重新登陆,这肯定是影响用户体验的。
  为了解决上诉问题,我们可以在用户已登陆状态进行操作时,来将其在redis存储的信息进行刷新,也就是重新置为30分钟,那么这样子只要用户持续在操作,那他的信息就永远不会过期了,这就是token刷新策略。
  那么问题来了,我们怎么知道用户是否进行了操作呢?那么涉及到一个东西——拦截器。对于所有的请求它都会进行拦截,根据用户登录状态和请求的网页来决定是否放行,那我们只需要在拦截器内每次将用户的token刷新不就好了吗?
  但这实际上是存在一定问题的,因为我们的拦截器是有很多路径是放行的,并不是所有的路径都进行拦截,用户如果访问的是放行的路径,那不就不能刷新他的token了嘛?所以我们有一种新的方案——设置两个拦截器。
  第一个拦截器专门负责刷新token,它对于所有的路径都会进行拦截,然后判断用户是否是登录状态,如果已登录则刷新其token,需要注意的是,这个拦截器因为只负责刷新token,所以无论用户是否登录,它都会放行。而第二个拦截器才是用来校验用户是否登录的,未登录且请求未放行的资源将会被打回请求,这一步我们只需要通过ThreadLocal去判断是否存在用户即可。

拦截器 RefreshTokenInterceptor,只负责刷新tokentoken是存储在请求头中的authorization中,我们通过request去获取。

【Redis从入门到进阶】第 4 讲:基于 Redis 实现分布式 session_redis_03

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");
        //2. 如果token是空,直接放行,交给LoginInterceptor处理
        if (StrUtil.isBlank(token)) {
            return true;
        }
        //3.基于token 获取redis的用户
        String key = RedisConstants.LOGIN_USER_KEY + token;
        Map<Object, Object> map = stringRedisTemplate.opsForHash().entries(key);
        if (map.isEmpty()) {
            return true;
        }
        //5.将查询到的Hash数据转换为UserDTO对象
        UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
        // 6.存在,保存用户信息到ThreadLocal
        UserHolder.saveUser(userDTO);

        //7.刷新token的有效期
        stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
        //8.放行
        return true;
    }

拦截器 LoginInterceptor,负责判断用户是否登录

@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
       //1.判断是否需要拦截 (ThreadLocal是否有该用户)
        if (UserHolder.getUser()==null){
            //设置状态码
            response.setStatus(401);
            //需要拦截
            return false;
        }
        return true;
    }

接下里看MvcConfig,为了控制两个拦截器的先后执行顺序,我们给每个拦截器的order方法进行赋值了,越小的越先执行。

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //1.登陆拦截器
        registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns(
                        "/shop/**",
                        "/voucher/**",
                        "/shop-type/**",
                        "/upload/**",
                        "/blog/hot",
                        "/user/code",
                        "/user/login"
                ).order(1);
        //token刷新拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
        //order函数是用来控制 拦截器执行顺序
    }
}

  至此,我们就完成了基于 Redis 完成了短信登陆验证功能。

【Redis从入门到进阶】第 4 讲:基于 Redis 实现分布式 session_缓存_04