专栏前言
本专栏开启,目的在于帮助大家更好的掌握学习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时,它会来一句——兄弟你谁 😂

为了解决上述场景带来的问题,于是我们需要实现分布式session,以此来保证session能被多个tomcat共享。而将用户登录信息存入redis内,是多种实现方式来说最好的一种,也是企业中使用最多的一种方式,它具有以下优点:
- 数据存储在
redis中,不存在安全隐患,且redis使用效率高 -
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方法,根据刚才验证码验证的逻辑,我们可以画出一个大概的代码执行流程图:

代码实现:
@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,只负责刷新token,token是存储在请求头中的authorization中,我们通过request去获取。

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 完成了短信登陆验证功能。










