专栏前言
本专栏开启,目的在于帮助大家更好的掌握学习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
完成了短信登陆验证功能。