👨🎓作者简介:一位大四、研0学生,正在努力准备大四暑假的实习
🌌上期文章:Redis:原理+项目实战——Redis实战1(session实现短信登录(并剖析问题))
📚订阅专栏:Redis速成
希望文章对你们有所帮助
Redis实现短信登录
- 基于Redis实现共享session项目
- Redis替代session的业务流程
- 发送短信验证码
- 短信验证码登录与注册
- 校验登录状态
- 关键点实现
- 基于Redis实现短信登录
- 发送验证码
- 登录验证功能
- 解决状态登录刷新的问题——登录拦截器的优化
基于Redis实现共享session项目
Redis替代session的业务流程
发送短信验证码
这个大致的流程是跟session的业务流程差不多的,无非就是验证码不再保存到session中,而是保存到了Redis中,Redis的结构是key-value的,且value是很多种类型的,在这里我们选择最简单的String类型即可。
一个需要考虑的问题是key的选取,在session中我们选用了“code”来作为key,但在这里却不行。这是因为每一个不同的浏览器在发送请求的时候都会有一个不同的独立的session,也就是说Tomcat的内部维护了很多的session,互相之间是不会干扰的。但是Redis是一个共享的内存空间,如果直接使用key是肯定会造成覆盖这种不好的局面的,所以我们不能直接选用“code”来作为key。
容易发现,每个手机号都不一样,因此我们可以直接用手机号作为key。
短信验证码登录与注册
最终的用户信息不再保存到session中,而是保存都Redis中去了,同样要考虑key跟value的选择:
(1)value的选取:
我们要保存的是用户的信息,这是一个对象。Redis中的String可以将用户信息以JSON字符串的形式来保存,Hash可以将对象中的每个字段独立存储。具体的大家可以看我之前的文章:
Redis:原理速成+项目实战——Redis常见命令(数据结构、常见命令总结)
明显我们用Hash结构是最合适的。
(2)key的选取:
这里并不建议用phone作为key,而是以随机token(服务器生成的令牌)为key来存储用户数据,具体原因会在后面进行解释。
在之前我们校验登录状态的时候,是从cookie中获取session再得到用户信息,而现在我们校验登录的时候要访问的凭证就是这个随机token了,但Tomcat不会将这个token自动写到浏览器上面。
所以我们把数据保存到Redis以后还需要手动的把token返回到前端,流程就得修改:
1、提交手机号和验证码
2、校验验证码
3、根据手机号查询用户信息
4、用户保存到Redis
5、返回token给客户端(重要一步)
校验登录状态
我们不再是从浏览器中的cookie指定的session来获取用户信息,而是以随机token为key来从Redis中获取信息,流程如下:
1、用户发送请求并携带token
2、从Redis中获取用户(以随机token为key)
3、判断用户是否存在:
(1)没有这个用户就拦截
(2)有这个用户就保存用户信息到ThreadLocal,并放行
关键点实现
我们在校验登录状态的时候,需要携带token,这是如何做到的呢?这就涉及到了前端的知识了:
在login方法的axios请求的相应里,我们将登录凭证直接放到了session中,而我们之后的每次请求都要携带这个token,我们可以在axios里面进行实现:
所以,我们的key肯定不能再选择手机号了,因为这种存储到前端代码的行为并不是安全的。
基于Redis实现短信登录
发送验证码
直接将上一篇文章的代码进行修改:
//通过Resource注解注入SpringData提供的API
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
if (RegexUtils.isPhoneInvalid(phone)) {
return Result.fail("手机号格式错误");
}
String code = RandomUtil.randomNumbers(6);
/**
* session.setAttribute("code", code);
* 保存验证码到session,这个过程被替代成保存到Redis!
*/
/**
* 保存验证码到Redis,其中key是phone(加一下业务前缀防止冲突),value是String类型
* 我们要对key设置一下有效期为2分钟,防止网站被无限的注册攻击而导致内存爆炸
* 代码中的前缀、有效时间用常量来替代,常量另外保存到其他的类中,看起来更规范
*/
//stringRedisTemplate.opsForValue().set("login:code" + phone, code, 2, TimeUnit.MINUTES);
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
log.debug("成功发送短信验证码:{}", code);
return Result.ok();
}
登录验证功能
根据流程更新login方法:
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
//不符合,返回错误信息
return Result.fail("手机号格式错误");
}
// TODO 从Redis中获取验证码来做校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)){
//不一致,报错
return Result.fail("验证码错误");
}
//一致,根据手机号查询用户,这里要单表查询
//mybatis-plus可以帮助我们很快的实现:
//1、继承类ServiceImpl<实体类的Mapper,实体类>
//2、实体类中要加入注解@TableName(),表示从哪个数据库取的
//3、调用query()方法可以直接实现select * from 表
//4、调用eq方法验证查询出来的数据中,列名为phone的列有没有值与phone相同的
//5、可以通过one()查询出一个用户,也可以list()查询出多个用户,这里显然只会有一个
User user = query().eq("phone", phone).one();
//判断用户是否存在
if (user == null){
//不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
/**
* session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
* 之前保存用户信息到session中,现在改成保存到Redis中去
*/
//随机生成token,作为登录令牌
String token = UUID.randomUUID().toString(true);
/**
* 将User对象转换为Hash存储
* 1、转换成UserDTO
* 2、将其转换成Map的形式
* 3、用Hash结构的putAll方法,因为UserDTO还是包含了多个字段和字段值
* 4、要给token设置一个有效期,30min没操作就退出登录(效仿session),putAll没有对应的参数选择,要单独用expire()设
* 5、要注意一个细节,每次用户操作了就要重新去更新这30min(这里我们可以用拦截器来看用户什么时候操作了系统)
*/
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
String tokenKey = LOGIN_USER_KEY + token;//LOGIN_USER_KEY="login:login"
//存储
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
//设置有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);//LOGIN_USER_TTL=30L
//返回token
return Result.ok(token);
}
从之前的session改为现在的token,我们拦截器当然也要进行修改,将放行的一些条件进行修改:
private StringRedisTemplate stringRedisTemplate;
//这里只能自己写构造函数来注入,没办法用@autowired注解,因为LoginInterceptor的对象是手动new出来的
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取请求头中的token
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {//是否为空
response.setStatus(401);
return false;
}
//基于token获取Redis中的用户
String key = RedisConstants.LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(key);
//判断用户是否存在
if (userMap.isEmpty()) {
//不存在,拦截,并返回401错误码
response.setStatus(401);
return false;
}
//将查询到的Hash数据转回DTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//存在,保存用户信息到ThreadLocal
UserHolder.saveUser((UserDTO) userDTO);
//刷新token的有效期
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//放行
return true;
}
容易发现,上面的代码没有使用@AutoWired进行注入,这是因为我们的这个类并不是Spring托管的类。
我们的MvcConfig也要进行修改:
这里我们可以用@Resource注解来获取StringRedisTemplate对象,这是因为我们的类已经加上了@Configuration注解,这样类已经是被Spring给托管了,可以使用该注解。
运行后,我们打开Redis的数据库,确实是把验证码给成功保存下来了:
但是我们在登录的时候会报类型转换错误的异常,这个出错出现在
stringRedisTemplate.opsForHash().putAll(tokenKey + token, userMap);
报错信息显示Long无法转换为String类型,说明我们的UserMap的类型出现了问题,UserMap来自于UserDTO,因此问题出现在了UserDTO这里:
这边的UserDTO中的id是Long类型的,而查看我们的StringRedisTemplate的源码:
它要求我们的key和value都是String类型的,因此我们需要修改代码,使得两者的类型要能够匹配:
方法一:不用BeanUtil.beanToMap方法,自行创建一个方法,手动将UserDTO里面的id先转换成String类型,然后存入Map。(这是我的方法,其实我觉得这个方法是最适合的,也容易想到)
方法二:继续使用BeanUtil.beanToMap这个方法,这个方法它允许我们对key和value做自定义。(这个方法是黑马程序员的讲解者提出的方法,我感觉跟炫技一样,这就是大佬)
这里就写一下第二个方法:
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create() //CopyOptions表示做数据拷贝时候的选项
.setIgnoreNullValue(true) //忽略空值
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));//转换为字符串
成功登录,同时我们也可以直接看到token的信息,登录验证也正是用这里的token来进行逻辑判断的。
我们可以总结一下Redis代替session需要考虑的三个问题:
1、数据结构的选取
2、key的选取
3、选择合适的存储粒度
解决状态登录刷新的问题——登录拦截器的优化
上述代码实现完还有一点小问题,之前的拦截器并不会拦截掉一切路径,而是所有需要登录的路径,那么会出现一个问题:我们的首页并不需要登录就可以直接访问,那么已经登录过的用户一直在首页进行操作,拦截器中的登录状态并不会刷新,就可能造成明明一直在操作系统,却被视为不算是在登录状态。
解决方法是再加上一个拦截器,用户的请求要先经过这个拦截器,这个拦截器会拦截一切的路径,所以我们可以在这个拦截器里面进行token有效期的刷新操作:
1、获取token
2、查询Redis的用户
3、保存到ThreadLocal
4、刷新token有效期
5、放行
这样的话,一切的请求都会触发刷新的操作。
那么之前的拦截器只需要查询ThreadLocal的用户,存在则继续,不存在则拦截。
我们可以在之前代码的基础上这样修改代码:
1、新增加一个拦截器,放行一切:
package com.hmdp.utils;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
//这里只能自己写构造函数来注入,没办法用@autowired注解,因为LoginInterceptor的对象是手动new出来的
public RefreshTokenInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("authorization");
if (StrUtil.isBlank(token)) {//为空也直接放行,判断交给下一个拦截器
return true;
}
//基于token获取Redis中的用户
String key = LOGIN_USER_KEY + token;
Map<Object, Object> userMap = stringRedisTemplate.opsForHash()
.entries(key);
//判断用户是否存在
if (userMap.isEmpty()) {//不存在也放行
return true;
}
//将查询到的Hash数据转回DTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//存在,保存用户信息到ThreadLocal
UserHolder.saveUser(userDTO);
//刷新token的有效期
stringRedisTemplate.expire(key, RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
//放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//移除用户,防止内存泄漏
UserHolder.removeUser();
}
}
2、修改之前的拦截器,只需要进行用户的判断就可以了
package com.hmdp.utils;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.hmdp.dto.UserDTO;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import static com.hmdp.utils.RedisConstants.LOGIN_USER_KEY;
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//判断是否需要去拦截(ThreadLocal中是否有用户)
if (UserHolder.getUser() == null){
//没有,需要拦截,设置状态码
response.setStatus(401);
//拦截
return false;
}
//有用户,则放行
return true;
}
}
3、重新配置拦截器:
package com.hmdp.config;
import com.hmdp.utils.LoginInterceptor;
import com.hmdp.utils.RefreshTokenInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
//添加拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
//登录拦截器
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"'user/me",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
).order(1);//order越大,执行优先级越小,表示更靠后的拦截器
//token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
现在就实现了需求了,大家可以去不断的对系统进行操作,并且观察每个key的TTL。