文章目录
前言
本专栏基于redis的学习将会通过实现一个类似大众点评项目进行redis相关知识点的学习。该项目将会通过短信登录,商户缓存,优惠券秒杀,好友关注等多个模块开发来熟悉,使用以及掌握redis,话不多说,开始第一个模块,短信登录功能。
1. 基于session实现短信登录功能
如下图,该界面就是我们的短信登录界面,首先,我们填写正确的手机号格式,点击发送验证码,客户端接收验证码后,填写验证码进行登录。登录成功后跳转到我们的首页。
接下来我们梳理一下基于session实现短信登录的流程图,分别有发送验证码,短信验证码登录注册以及检验登录状态。
1.1 发送短信验证码
发送短信验证码还是比较简单的,我们获取验证码后将验证码存在session中,在登录时取出来进行比较。由于我们个体如果真的要实现短信验证码发送功能,需要开通阿里云或者腾讯云短信服务,我们就不开通,获取的验证码在控制台打印,输入的时候瞄一眼即可。
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
@Autowired
private IUserService userService;
@Autowired
private RedisTemplate redisTemplate;
/**
* 发送验证码
* @param phone
* @param session
* @return
*/
@Override
public Result sendCode(String phone, HttpSession session) {
//1. 校验手机号
boolean flag = RegexUtils.isPhoneInvalid(phone);
if(flag){
//2. 手机号有误直接返回
return Result.fail("手机号有误");
}
//3. 生成验证码
String code = RandomUtil.randomNumbers(4);
//4. 将验证码保存到session
session.setAttribute("code",code);
//5. 模拟发送验证码
log.info("验证码为:{}",code);
return Result.ok();
}
}
这里有通用的类需要声明一下:
- 结果集类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {
private Boolean success;
private String errorMsg;
private Object data;
private Long total;
public static Result ok(){
return new Result(true, null, null, null);
}
public static Result ok(Object data){
return new Result(true, null, data, null);
}
public static Result ok(List<?> data, Long total){
return new Result(true, null, data, total);
}
public static Result fail(String errorMsg){
return new Result(false, errorMsg, null, null);
}
}
- UserDto类
@Data
public class UserDTO {
private Long id;
private String nickName;
private String icon;
}
1.2 短信验证登录
/**
* 用户登录
* @param loginForm
* @param session
* @return
*/
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1. 校验手机号
String phone = loginForm.getPhone();
boolean flag = RegexUtils.isPhoneInvalid(phone);
if(flag){
return Result.fail("手机号有误");
}
//2. 校验验证码
String code = loginForm.getCode();
String codeInSession = session.getAttribute("code").toString();
//3. 不一致报错
if(codeInSession==null||!code.equals(codeInSession)){
return Result.fail("验证码错误");
}
//4. 一致,根据手机号查询用户信息
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone,phone);
User user = userService.getOne(queryWrapper);
//5. 判断用户是否存在
if(user==null){
//6. 不存在就创建新的用户
user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(5));
userService.save(user);
}
//7. 将用户信息存到session
//dto对象只有用户基本信息,不会暴露其他信息,保证隐私安全
session.setAttribute("userDto", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
1.3 登录校验功能
登录校验功能就是为了检验用户是否登录,如果用户没有登录,在涉及需要用户信息的操作功能上就需要跳转到用户登录界面,如果用户登录,就可以对应用需要用户信息的功能进行操作。
如何对用户是否登录进行判断呢?
我们在用户登录的时候将用户信息存到session中,用户访问我们应用的时候就会携带sessonID,我们就从中获取session信息,如果获取到就证明用户已经登录,如果获取的session为空就表明没有登录。
判断用户是否登录后,我们如何对没有进行登录的用户请求进行拦截,对已经登录的用户进行放行呢?
很好,这个答案就是拦截器,也可以是过滤器。这里我们采用拦截器进行拦截。如下图所示:
定义一个拦截器需要实现HandlerInterceptor,并重载HandlerInterceptor的两个方法。
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 获取session
HttpSession session = request.getSession();
//2. 从session中获取用户信息
UserDTO userDto =(UserDTO) session.getAttribute("userDto");
if (Strings.isBlank(token)) {
response.setStatus(401);
return false;
}
//3. 检查用户是否存在
if(userDto==null){
//4. 不存在就拦截
//401:未授权
response.setStatus(401);
return false;
}
//5. 存在就保存到threadLocal中,调用工具类UserHolder中的方法
UserHolder.saveUser(userDto);
//6. 存在,保存到threadLocal中
UserHolder.saveUser(userDto);
//7. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
定义了拦截器我们就需要去配置一下我们的拦截器webMvcConfig。
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
"/user/login",
"/user/code",
"/blog/hot",
"/upload/**",
"/shop-type/**",
"/shop/**",
"/voucher/**"
);
}
}
这样,我们设置好不需要拦截的路径,用户不登录便可以进行访问,而其他需要用户信息的请求用户不进行登录就无法访问。我们的拦截器就设置好了。
在上述实现中使用了一个ThreadLocal线程变量,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();
}
}
2. 集群的Session问题
session共享问题:多台tomcat服务器并不共享session的存储空间,当请求切换到不同的tomcat服务时导致数据丢失的问题。因此需要将session中的数据放到内存中存储。
session的替代方案应该满足以下几点:
- 数据共享
- 内存存储
- key,value结构
符合条件的就是我们今天要讲的redis
3. 基于redis实现共享session登录
基于redis实现发送短信验证码和短信验证码登录注册的流程图如下所示,我们这里简要介绍一下。
发送短信验证码:将生成的验证码以key,value的结构存储到redis中,这里使用手机号码作为key,使用验证码作为value。
短信验证码登录注册:将页面提交的手机号作为key从redis中取出相对应的code,校验与用户输入的验证码是否一致。校验通过之后将用户信息存储到redis中。这里需要注意的是,用户的信息不仅仅是一个字段,有多个字段以及对于的值,因此这里我们使用集合来存储用户信息。
登录之后需要进行校验操作,具体流程如下所示:
校验登录状态:我们在存储用户信息时,会随机生成一个token,将这个token作为key,用户信息作为value存储到集合Map中。用户登录成功后将token返回给客户端。校验时,从请求头中获取携带的token,将其作为key从redis中获取用户信息。如果用户信息为空就拦截,不为空就调用ThreadLocal保存用户信息,然后放行。
3.1 发送短信验证码
@Override
public Result sendCode(String phone, HttpSession session) {
//1. 校验手机号
boolean flag = RegexUtils.isPhoneInvalid(phone);
if(flag){
//2. 手机号有误直接返回
return Result.fail("手机号有误");
}
//3. 生成验证码
String code = RandomUtil.randomNumbers(4);
//4. 将验证码保存到redis
redisTemplate.opsForValue().set(LOGIN_CODE_KEY+phone,code,LOGIN_CODE_TTL, TimeUnit.MINUTES);
//5. 模拟发送验证码
log.info("验证码为:{}",code);
return Result.ok();
}
3.2 短信验证登录
@Override
public Result login(LoginFormDTO loginForm, HttpSession session) {
//1. 校验手机号
String phone = loginForm.getPhone();
boolean flag = RegexUtils.isPhoneInvalid(phone);
if(flag){
return Result.fail("手机号有误");
}
//2. 校验验证码
String code = loginForm.getCode();
String codeInRedis = redisTemplate.opsForValue().get(LOGIN_CODE_KEY + phone).toString();
//3. 不一致报错
if(codeInRedis==null||!code.equals(codeInRedis)){
return Result.fail("验证码错误");
}
//4. 一致,根据手机号查询用户信息
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getPhone,phone);
User user = userService.getOne(queryWrapper);
//5. 判断用户是否存在
if(user==null){
//6. 不存在就创建新的用户
user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX+RandomUtil.randomString(5));
userService.save(user);
}
//7. 将用户信息存到redis
//7.1 随机生成一个token作为我们的key,即登录令牌
String token = UUID.randomUUID().toString(true);
token = LOGIN_USER_KEY+token;
//7.2 将user对象转为hashMap存储
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO);
//7.3 保存
redisTemplate.opsForHash().putAll(token,userMap);
//7.4 设置redis有效期
redisTemplate.expire(token,LOGIN_USER_TTL,TimeUnit.MINUTES);
//7.5 返回token
return Result.ok(token);
}
3.3 登录校验功能
这里需要注意的是:我们token设置的有效期是三十分钟,用户每次通过拦截器都会刷新token的有效期。目前这样设置会有点小问题,我们在拦截器优化会进行完善。
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 从请求头中获取token
String token = request.getHeader("authorization");
if (Strings.isBlank(token)) {
response.setStatus(401);
return false;
}
//2. 根据token获取用户信息
Map userMap = redisTemplate.opsForHash().entries(token);
//3. 检查用户是否存在
if(userMap.isEmpty()){
//4. 不存在就拦截
//401:未授权
response.setStatus(401);
return false;
}
//5. 将查询到的对象转为userDto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6. 存在,保存到threadLocal中
UserHolder.saveUser(userDTO);
//7. 刷新token的有效期
//每次用户访问通过这个拦截器,就刷新redis的有效时间
redisTemplate.expire(token,LOGIN_USER_TTL, TimeUnit.MINUTES);
//8. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
4. 拦截器优化
4.1 分析原因
我们思考一下,我们目前设置的token的有效期是三十分钟,只有用户请求那些需要拦截器操作的请求才会刷新token,但是,我们想一想,用户会不会在不需要拦截器的功能下逗留超过三十分钟呢?
显然,这是可能的。比如我们逛淘宝,我们是不是不需要登录就能去搜索商品,那假如我们登录后浏览了半个小时商品后,突然想加入购物车,这时候发现我们token过期了,用户登录信息失效了,我们是不是很崩溃。
因此,我们刷新token的条件不能是仅仅根据需要拦截的路径,而是判断用户是否在我们的应用程序当中,如果在,用户提交一次请求我们就需要刷新token时间,也就是对所有请求都进行拦截,然后根据情况进行放行操作。
如何实现呢?那就是再添加一个拦截器拦截所有请求。
原始拦截器图示
优化拦截器
4.2 代码实现
新增拦截器RefreshTokenInterceptor
/**
* 这个拦截器拦截所有的用户请求,但都会放行
* 设置这个拦截器就是避免用户长时间不访问那些需要拦截的请求,导致redis中的数据过期。
*/
public class RefreshTokenInterceptor implements HandlerInterceptor {
//因为这个拦截器不是spring管理的bean,因此我们需要手动去注入这个bean,采用构造方法注入
//谁用我们这个拦截器谁就帮我们注入这个对象---webmvcConfig类中使用了
//因此,webmvcConfig new一个拦截器就必须带参数
private RedisTemplate redisTemplate;
public RefreshTokenInterceptor(RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 从请求头中获取token
String token = request.getHeader("authorization");
if (Strings.isBlank(token)) {
return true;
}
//2. 根据token获取用户信息
Map userMap = redisTemplate.opsForHash().entries(token);
//3. 检查用户是否存在
if(userMap.isEmpty()){
return true;
}
//5. 将查询到的对象转为userDto对象
UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
//6. 存在,保存到threadLocal中
UserHolder.saveUser(userDTO);
//7. 刷新token的有效期
//每次用户访问通过这个拦截器,就刷新redis的有效时间
redisTemplate.expire(token,LOGIN_USER_TTL, TimeUnit.MINUTES);
//8. 放行
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
修改原有拦截器
public class LoginInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//1. 从threadLocal中获取用户,存在就放行,不存在就拦截
UserDTO user = UserHolder.getUser();
if(user==null){
response.setStatus(401);
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
UserHolder.removeUser();
}
}
配置拦截器
@Configuration
public class MvcConfig implements WebMvcConfigurer {
@Autowired
private RedisTemplate redisTemplate;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoginInterceptor()).excludePathPatterns(
"/user/login",
"/user/code",
"/blog/hot",
"/upload/**",
"/shop-type/**",
"/shop/**",
"/voucher/**"
).order(1);
//token刷新的拦截器
registry.addInterceptor(new RefreshTokenInterceptor(redisTemplate)).addPathPatterns("/**").order(0);
}
}
4.3 拦截器优化总结
-
RefreshTokenInterceptor这个拦截器不是spring管理的bean,因此我们需要手动去注入这个bean,采用构造方法注入
而谁用我们这个拦截器谁就帮我们注入这个对象—webmvcConfig类中使用了,因此,webmvcConfig new一个拦截器就必须带参数
-
配置两个拦截器就会存在先后问题,因此,我们在拦截器后添加order属性,以此将拦截所有路径的拦截器放在前面。