文章目录
- 一、集群Session共享问题
- 二、Redis存储验证码和对象
- 三、解决状态登录刷新问题
一、集群Session共享问题
session共享问题:多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务器时导致数据丢失的问题
tomcat可以进行多台tomcat进行session拷贝,但是数据拷贝保存相同的内容会存在资源浪费,而且会有时间延迟,所以这种方案不可行
session的替代方案应该满足:
- 数据共享
- 内存存储
- key、value结构
这里我们可以使用redis
二、Redis存储验证码和对象
发送短信:
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public Result sendCode(String phone, HttpSession session) {
// 1.校验手机号
if (phone == null || str.matches("^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$")) {
// 2.如何不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到Redis
stringRedisTemplate.opsForValue().set("login:code:" + phone,code,2, TimeUnit.MINUTES);
//具体的发送逻辑 在这里就不实现了
return Result.ok();
}
首先,我们会校验前端传来的手机号格式,如果格式不正确直接返回。使用hutool的工具类生成6位随机验证码,然后将验证码作为value存入到Redis中,为了避免key重复,我们设置了固定格式的key,并且设置一个2分钟的超时时间,超过两分钟验证码自动失效。
登录功能:
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1. 校验手机号
String phone = loginForm.getPhone();
if (phone == null || str.matches("^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$")) {
// 如何不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
// 2. 校验验证码
String cacheCode = stringRedisTemplate.opsForValue().get("login:code:" + phone);
String code = loginForm.getCode();
// 3. 不一致,报错
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
String token = UUID.randomUUID().toString(true);
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO,new HashMap<>()
, CopyOptions.create().setIgnoreNullValue(true).setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));
stringRedisTemplate.opsForHash().putAll("login:token:" + token,userMap);
stringRedisTemplate.expire("login:token:" + token,30,TimeUnit.MINUTES);
return Result.ok(token);
}
我们在进行登录时,首先会对手机号格式进行检验,如果手机号格式正确,我们从Redis中获取验证码和客户端传来的验证码进行比较,如果一致我们就放行,先去数据库查询该用户信息,如果用户不存在进行保存。
可能有的同学会有疑问,为什么这里要进行这么麻烦的操作呢?
因为我们UserDTO中的id是Long类型的,会报Long转String类型转换异常,因为我们这里使用的是StringRedisTemplate
该类型要求key和value都是String类型,但是我们将对象转为Map时,id为Long类型,所以就出现了该问题,两种方案:1.自定义Map手动put 2.使用BeanUtil,自定义规则
我们需要将用户对象存储在Redis中,这里用什么作为key呢?我们这里用token作为key,将token返回给客户端,客户端后面请求的时候使用该token来获取value。
我们value保存对象时,使用什么存储呢?
1.String:
2.Hash:
我们这里使用Hash存储对象,因为Hash结构可以将对象中的每个字段独立存储,可以针对单个字段做CRUD,并且占用内存更少。
我们使用UUID随机生成token,但是我们value是哈希结构,我们使用BeanUtil将对象转为Hash存储,因为Redis是在内存存储的,如果一直只存会存在内存不够用的情况,所以我们这里仍然需要设置一个超时时间,那么设置多长时间呢?我们这里模仿Session的只要超过30分钟不访问就会销毁。
但是我们现在设置的是,从设置开始不管有没有用户访问30分钟后都会销毁,这样肯定是不行的,我们需要和session一样,只要有用户访问我们就需要更新超时时间,那么怎么做呢?可以借助拦截器
我们的拦截器不是Spring创建的对象,所以我们无法使用注入的方式获取StringRedisTemplate对象,我们需要使用构造方法的方法,那么谁来调用呢?
我们可以在MvcConfig注册拦截器时传入StringRedisTemplate对象
由于我们多处都需要用到ThreadLocal存储的对象,所以我们将ThreadLocal封装成一个工具类:
public class UserHolder {
private static final ThreadLocal<UserDTO> tl = new ThreadLocal<>();
public static void saveUser(UserDTO user){
tl.set(user);
}
public static UserDTO getUser(){
return tl.get();
}
public static void removeUser(){
tl.remove();
}
}
public class LoginInterceptor implements HandlerInterceptor {
private StringRedisTemplate stringRedisTemplate;
public LoginInterceptor(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 获取请求头中的token
String token = request.getHeader("authorization");
if(StrUtil.isBlank(token)) {
response.setStatus(401);
return false;
}
// 2. 使用token获取Redis中的对象
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries("login:token:" + token);
// 3. 判断用户是否存在
if(userMap == null) {
response.setStatus(401);
return false;
}
// 4. 将Hash 格式转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 5. 将用户存入ThreadLocal中
UserHolder.saveUser(userDTO);
// 6. 刷新token超时时间
stringRedisTemplate.expire("login:token:" + token,30,TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
大家需要注意的是我们需要remove ThreadLocal,因为ThreadLocal可能会存在内存泄露问题,因为强软引用的问题,这里我们不具体介绍。
三、解决状态登录刷新问题
但是这样会存在一些问题,该拦截器只会拦截需要登录的路径,其他路径是不会拦截了,也就不会进行token有效期的刷新了。怎么解决呢? 新加一个全部路径的拦截器
public class RefreshTokenInterceptor implements HandlerInterceptor {
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");
if (StrUtil.isBlank(token)) {
return true;
}
// 2. 基于token获取Redis中的用户
Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
// 3. 判断用户是否存在
if(userMap == null) {
return true;
}
// 将查询到的Hash转为UserDTO对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
// 5. 存在 保存用户到ThreadLocal
UserHolder.saveUser(userDTO);
stringRedisTemplate.expire(RedisConstants.LOGIN_USER_KEY + token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
我们创建一个拦截全部路径的拦截器来进行token有效期的刷新
我们在登录拦截器里,只需要判断ThreadLocal里是否存在有效的用户,如果有放行,否则拦截。
public class MvcConfig implements WebMvcConfigurer {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor())
.excludePathPatterns(
"/user/code",
"/user/login",
"/blog/hot",
"/shop/**",
"/shop-type/**",
"/upload/**",
"/voucher/**"
);
registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**");
}
}
我们在注册刷新Token的拦截器,并且增加所有路径。
但是我们如何保证刷新Token的拦截器在登录拦截器之前执行呢?其实在MvcConfig中注册拦截器的顺序也就是拦截的顺序,但是这样不保险
其实我们在addInterceptor时会生成一个拦截器注册器对象
拦截器注册器中又有一个order属性,默认都是0,这个值决定拦截器的执行顺序,值越小执行优先级越高。
我们可以通过设置order来决定它们的执行顺序