摘要:
传统的Session机制在用户数量增加时面临扩展性差、负载均衡困难、安全隐患、依赖客户端Cookie和状态管理复杂等问题。为解决这些问题,采用Redis替代Session实现用户登录。
Redis具有优异的读写速度,提升了项目性能,并解决了多服务器部署时的互通问题。具体实现包括:发送验证码时检查手机号格式、生成并保存验证码到Redis;登录校验时查询或创建用户,保存用户信息到Redis并设置有效期;编写拦截器实现用户信息刷新和登录状态保持。
通过优化拦截器,确保用户在访问未被拦截的功能时仍保持登录状态。Redis的使用显著提高了系统的性能和安全性。
传统的session存在以下问题:
- 扩展性问题:随着用户数量增加,服务器负载加重,可能导致性能下降。
- 难以处理负载均衡:在分布式系统中,不同服务器间的Session共享和管理困难。
- 安全隐患:Session ID容易被盗,可能导致账号安全问题。
- 依赖客户端Cookie:如果客户端禁用Cookie,功能会受限。
- 状态管理复杂:Session是有状态的,影响服务器扩展性和维护。
所以采用redi代替session来实现用户登录,得益于redis优异的读写速度,一方面提高了项目的性能(登录和访问都是大数量级的操作),另一方面解决了多台服务器部署时无法互通的问题,提高了扩展性
一,发送验证码
(1)检查手机号格式,其实就是使用正则表达式来进行匹配,如果这里一直出现手机号格式错误的问题,可以考虑更换正则表达式,网上随意搜索即可。
(2)利用hutool生成随机6位数字作为验证码
(3)保存验证码到redis,并设置过期时间
public Result sendCode(String phone, HttpSession session) {
//1.校验手机号
if(RegexUtils.isPhoneInvalid(phone)){
//2.不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
//3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
//4.保存验证码到redis,并设置过期时间
stringRedisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone, code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
//5,发送验证码
log.info("发送短信验证码成功,验证码:{}", code);
return Result.ok();
}
二,登录校验
(1)通过手机号在数据库中查询用户,如果不存在则创建新用户保存
(2)保存userDTO到redis,key为业务名拼接随机UUID(token)生成,采用Hash类型存储对象。
(3)使用hutool工具包的BeanUtil.beanToMap()方法将对象转换为map,因为对象中id类型为Long会出现类型转换异常,所以必须指定值的转换规则
Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create() //指定装换规则
.setIgnoreNullValue(true)//忽略空值
//将值转换为字符串
.setFieldValueEditor((fieldName, fieldValue)-> fieldValue.toString()));
(4)设置有效期,同时引出问题:只有当用户在30分钟内什么都没有做,我们才在redis中剔除这个信息,但是如何判断用户持续活跃?解决方法:如果用户访问被拦截说明用户是活跃的,我们要重新更新过期时间
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1.校验手机号
if(RegexUtils.isPhoneInvalid(loginForm.getPhone())){
//2.不符合,返回错误信息
return Result.fail("手机号格式错误!");
}
//3.校验验证码
if (RegexUtils.isCodeInvalid(loginForm.getCode())){
//4.不符合,返回错误信息
return Result.fail("验证码格式错误!");
}
//redis中获取code并且校验
String code = stringRedisTemplate.opsForValue().get(LOGIN_CODE_KEY + loginForm.getPhone());
if (code==null||!code.equals(loginForm.getCode())){
return Result.fail("验证码错误!");
}
//5.符合,根据手机号查询用户
User user = query().eq("phone", loginForm.getPhone()).one();
//6.判断用户是否存在
if(user == null){
//7.不存在,创建新用户并保存
user = new User();
user.setPhone(loginForm.getPhone());
user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(10));
save(user);
}
//8.保存用户信息到redis并返回
//拼接redis中的key
String token = UUID.randomUUID().toString(true);
String key = LOGIN_USER_KEY+token;
//隐藏用户敏感信息
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> map = BeanUtil.beanToMap(userDTO, new HashMap<>(),
CopyOptions.create()
.setIgnoreNullValue(true)
.setFieldValueEditor((fieldName, fieldValue)-> fieldValue.toString()));
stringRedisTemplate.opsForHash().putAll(key, map);
stringRedisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
return Result.ok(token);
}
}
三,编写拦截器
(1)实现HandlerInterceptor接口,并实现pre和after方法
(2)取出请求头中的token,然后拼接常量作为key从redis中取出用户信息(map),通过万能hutool包中的BeanUtil.fillBeanWitMap将map转换为userDTO对象,最后保存到本地线程。
(3)重置redis中用户信息的过期时间为session的30分钟
(4)注册拦截器并设置拦截排除路径,如:"/user/login".....等等
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("authorization");
//判断token是否存在
if(StrUtil.isBlank(token)){
response.setStatus(401);
return false;
}
//redis中获取用户信息
String key = LOGIN_USER_KEY+token;
Map<Object, Object> userDTOMap = redisTemplate.opsForHash().entries(key);
//判断用户是否存在
if(userDTOMap.isEmpty()){
response.setStatus(401);
return false;
}
UserDTO userDTO = BeanUtil.fillBeanWithMap(userDTOMap, new UserDTO(), false);
UserHolder.saveUser(userDTO);
redisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
四,拦截器优化
问题:我们通过拦截器刷新redis用户信息来保证用户登录状态,但是拦截器是没有拦截所有路径的,这就会导致用户在访问未被拦截的功能,如:浏览点评等,达到过期时间也会自动登出
解决方法:众嗦粥滋,没有什么是加一层中间层解决不了的,如果有就再加一层 ,所以我们在原拦截器前再加一个拦截器,作用仅用于刷新用户token不执行拦截,原来的拦截器只需要检查本地线程是否有用户信息即可。
小tips:拦截器注册时可通过order(整数)指定拦截器执行优先级,整数值越小,优先级越高
@Component
public class RefreshTokenInterceptor implements HandlerInterceptor {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String token = request.getHeader("authorization");
//判断token是否存在
if(StrUtil.isBlank(token)){
return true;
}
//redis中获取用户信息
String key = LOGIN_USER_KEY+token;
Map<Object, Object> userDTOMap = redisTemplate.opsForHash().entries(key);
//判断用户是否存在
if(userDTOMap.isEmpty()){
return true;
}
//将userDTOMap中的userDTO键值对填充到Bean中
UserDTO userDTO = BeanUtil.fillBeanWithMap(userDTOMap, new UserDTO(), false);
//保存信息到本地线程
UserHolder.saveUser(userDTO);
//用户访问说明活跃,刷新有效期
redisTemplate.expire(key, LOGIN_USER_TTL, TimeUnit.MINUTES);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}