发送短信验证码的流程中,只有一个地方发生了变化,就是保存验证码的时候,不再是保存到session,而是保存到redis。
而且保存到redis时,key不再是code了,而是以手机号为key。
RedisConstants.java
PS:阅读的时候这个类先不用管,先看下面代码
public class RedisConstants {
public static final String LOGIN_CODE_KEY = "login:code:";
public static final Long LOGIN_CODE_TTL = 2L; // 有效期
public static final String LOGIN_USER_KEY = "login:token:";
public static final Long LOGIN_USER_TTL = 30L;
}
UserServiceImpl.java
短信登录
@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
// 4.1 跟之前保存到session的代码差不多,不过要注意的是,这个key需要加一个业务前缀,形成层次。因为之前也分析过了,redis是大家都来存的一个空间,如果大家都直接将手机号作为key,很有可能其他业务也是这么做的,就产生冲突了
// 这里是做login业务,然后这个业务是做code验证的
// 4.2 这里的key最好设置一个有效期,例如:验证码五分钟内有效。如果有人没事干,一直在这里狂点,redis中就存了无数条数据,而且还不删除,终有一天redis就会被占满。因此为了避免这样的问题发生,我们存入redis的key一定要设置一个有效期
// set方法参数列表中就可以传入有效期,ctrl + p可以查看参数。第一种方式:时间(long) + 单位(TimeUnit);第二种方式:Duration
// 这里就设置为2分钟。这个有点类似于redis中的 set key value ex
// 4.3 但是需要注意的是:login:code 和 2 尽量都给它定义为常量。这样写看起来有点漏,万一取的时候key写错了怎么办。
// stringRedisTemplate.opsForValue().set("login:code:" + phone, code, 2, TimeUnit.MINUTES);
// 因此我们就可以将常量定义在在utils.RedisConstants
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY + phone, code, LOGIN_CODE_TTL, TimeUnit.MINUTES);
log.debug("发送短信验证码成功,验证码:{}", code);
return Result.ok();
}
登录注册
PS:存储value的时候
opsForValue()
拿到的是对字符串的操作,opsForHash()
拿到的就是跟哈希有关的操作。
PS:在spring中并不是以命令作为方法名,方法名类似于Java的HashMap的方法名。
hashKey和value就是对应字段和字段值,事实上value中可能会有多个字段,一个 put()
只能存一个字段,如果有多个字段,put()
就需要执行多次,这样其实就不太好了,等于你是与服务器做多次交互。
因此推荐大家的方案是使用 putAll()
, putAll()
相当于 hmset
,里面可以存好几个键值对,上面的 UserDTO
中有多个属性,就可以一次性的将属性全部存进去了。
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号
String phone = loginForm.getPhone();
if (RegexUtils.isPhoneInvalid(phone)) {
// 2.如果不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.从redis获取验证码并校验
String cacheCode = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone);
String code = loginForm.getCode();
if (cacheCode == null || !cacheCode.equals(code)) {
// 不一致,报错
return Result.fail("验证码错误");
}
// 4.一致,根据手机号查询用户 select * from tb_user where phone = ?
User user = query().eq("phone", phone).one();
// 5.判断用户是否存在
if (user == null) {
// 6.不存在,创建新用户并保存
user = createUserWithPhone(phone);
}
// 7.保存用户信息到 redis中
// 7.1.随机生成token,这里使用的是UUID,作为登录令牌
// UUID有两:一个Java自带的,一个Hutool提供的。toString()有两个重载方法,toString(true)表示不带下划线,参数名叫isSimple。如果是toString(),那就是带下划线。
String token = UUID.randomUUID().toString(true);
// 7.2.以token为key,将User对象转为HashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
// 使用BeanUtil(hutool提供的),它里面有个方法叫beanToMap,它的作用就是将一个bean转为Map
// 随着时间增长,用户会越来越多
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
// 7.3.存储
// token最好也加一个前缀
String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
// 7.4.设置token有效期,那这个有效期设置多久呢?可以参考session,以前基于session登录,有效期是30分钟,因此我们也可以给token设置30分钟的有效期,但是这里和string类型的set方法不一样,不能存的时候同时设置有效期,只能先存,然后再来设置有效期
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.返回token
return Result.ok(token);
}
但是session登录不是到了30分钟就结束了,只要你不停的访问,有效期就一直是30分钟,只有你超过30分钟不访问我,我才会将登录状态剔除。但是我们这个有效期是只要过了30分钟,redis就会把你剔除。那我们该如何解决这个问题呢?
解决状态登录刷新问题
我们所有的请求进来后都需要经过拦截器拦截校验,只要经过了校验,第一:证明它是登录的用户;第二:它在活跃着(在访问)。
既然满足了这两个条件,就可以去更新一下redis的有效期。这样redis的token就不会过期了。
只有当用户什么都不干,它就不会触发拦截器,那么这种情况下超过了30分钟,这个key才会被剔除。
一、初始方案思路总结
在这个方案中,他确实可以使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的
二、优化方案
既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。
三、代码
RefreshTokenInterceptor
public class RefreshTokenInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
// 这里只能使用构造函数,因为这个类的对象是我们手动new出来的,而不是通过Component一些注解构建的,即不是spring创建的,此时是没有人帮你自动做依赖注入的,因此不能再使用@Autowired或者@Resource之类的注解。
// 那么我们使用构造函数注入,谁来帮我们注入?那就看是谁使用了RefreshTokenInterceptor,即在MvcConfig拦截器中用到它了。在MvcConfig类上加了@Configuration,代表这个类将来是spring帮我们注入的,那么由spring来构建这个类的对象,它可以直接做依赖注入。
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");
// 使用hutool中的StrUtil类
if (StrUtil.isBlank(token)) {
return true;
}
// 2.基于TOKEN获取redis中的用户
String key = LOGIN_USER_KEY + token;
// 注意取的时候不能用get方法,因为get方法只能取哈希中的一个键值。但我们现在想取的是哈希中所有的键值对,此时就应该使用entries(),它的返回值就是一个Map,一个key中整个哈希键值都会返回。
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(key);
// 3.判断用户是否存在
if (userMap.isEmpty()) {
return true;
}
// 5.将查询到的hash数据转为UserDTO,由于存进去的时候是一个Map,因此取出来的时候肯定也是一个Map。存进去的时候使用的是BeanUtil,因此取出来的时候应该也使用BeanUtil。
// 第三个参数:isIgnoreError,你要不要忽略转换中的错误。那肯定不忽略,因此写false
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 6.存在,保存用户信息到 ThreadLocal
UserHolder.saveUser(userDTO);
// 7.刷新token有效期
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
// 8.放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 移除用户
UserHolder.removeUser();
}
}
LoginInterceptor
public class LoginInterceptor implements HandlerInterceptor {
@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.java
PS:我们希望 RefreshTokenInterceptor拦截器
先执行,因为只有它先执行了,拿到了用户保存到了ThreadLocal,那么 LoginInterceptor拦截器
才能做拦截的判断,那么控制拦截器的顺序呢?
在添加拦截器的时候我们可以跟进去看一眼
这个拦截器其实会被注册为 InterceptorRegistration
继续跟进,这个注册器中有一个order,就是拦截器的执行顺序。默认情况下所有拦截器的执行顺序都是零,都是0的情况下就是按照添加顺序执行,因此简单来说我们只需要先添加 RefreshTokenInterceptor拦截器
,但是如果想控制的严谨一些,就可以使用 order()
进行修改执行顺序,数字越小先执行。
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(
"/shop/**",
"/voucher/**",
"/shop-type/**",
"/upload/**",
"/blog/hot",
"/user/code",
"/user/login"
).order(1);
// token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
}
}
重启服务器进行测试
回到redis图形化界面中查看,可以发现存储成功
四、可能遇到的错误
回到代码区查看报错:class java.lang.Long cannot be cast to class java.lang.String
不能将Long转化为String。
UserMap来自UserDTO,UserDTO中只有id是Long,也就是说Long类型的id无法存储到redis中去,因此报错。往下看,这个错是StringRedisSerializer中报的错误。
那为什么会有这样的错误呢?我们使用的RedisTemplate是StringRedisTemplate,StringRedisTemplate要求你的key和value都是String。
而我们把数据转成Map的时候,那个字段id是Long类型,因此就出现这个问题。
因此存储到哈希结构中的Map的key和value都应该要为String结构。
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
有两种解决办法:
- 不用工具类了,我们直接自己new一个Map,然后将对象里面的字段名作为key,值作为value,value不为字符串的需要改为字符串。
- 还是用这个工具,这个工具是可以自定义的,默认情况下你的值是什么数据类型,这里就用什么数据类型。但它也允许你做自定义,
copyOptions
参数。
做法:首先传对象,再传一个Map,这里直接new一个空的HashMap,copyOptions
就是数据拷贝时的一个选项。
通过 CopyOptions.create()
就创建出来了一个 CopyOptions
,但是这个地方创建出来的是默认的,但我们要自定义,它后面允许你有各种各样的set。
例如:
setIgnoreNullValue
—— 忽略一些空的值;
setFieldValueEditor
—— 对字段值的一个修改器,它允许你修改字段值,参数需要传入一个函数:BiFunction
,这个函数有两个参数:字段名和字段值,返回值是你修改后的字段值
完整代码
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
因此,当我们碰到一个错误的时候不要着急,而是自己分析,产生问题的原因在哪。
此时重启代码,然后登陆,可以发现登陆成功。
回到redis客户端,可以发现也存储成功,存储用户的时候就是用哈希进行存储的。
并且右上角也能看见有效期的时间。
我们也可以重新请求,看看有效期会不会刷新。
查看 /user/me
请求头,会发现携带了 Authorization
,这样一来后台才能验证我们是否登录。
此时这个登录流程完全按照我们所期待的方式运行了。
总结
Redis代替session需要考虑的问题:
-
选择合适的数据结构
-
选择合适的key
-
选择合适的存储粒度。
我们在存储的时候并没有存完整的用户信息,而是将敏感信息去掉了,并且还节约了内存空间。