Bootstrap

【Redis | 黑马点评 + 思维导图】短信登陆

项目概述

在这里插入图片描述

  • 短信登录

这一块我们会使用redis共享session来实现

  • 商户查询缓存

通过本章节,我们会理解缓存击穿,缓存穿透,缓存雪崩等问题,让小伙伴的对于这些概念的理解不仅仅是停留在概念上,更是能在代码中看到对应的内容

  • 优惠卷秒杀

通过本章节,我们可以学会Redis的计数器功能, 结合Lua完成高性能的redis操作,同时学会Redis分布式锁的原理,包括Redis的三种消息队列

  • 附近的商户

我们利用Redis的GEOHash来完成对于地理坐标的操作

  • UV统计

主要是使用Redis来完成统计功能

  • 用户签到

使用Redis的BitMap数据统计功能

  • 好友关注

基于Set集合的关注、取消关注,共同关注等等功能,这一块知识咱们之前就讲过,这次我们在项目中来使用一下

  • 达人探店

基于List来完成点赞列表的操作,同时基于SortedSet来完成点赞的排行榜功能

项目的架构

本项目核心在于理解redis的用法,所以没有使用微服务技术。此项目采用的是前后端分离的模式,也就是说我们会把前端和后端分别部署,而不是全部放在一起往tomcat一扔。后端部署在tomcat上,前端部署在Nginx服务器上,这也是一般企业级开发的标准做法。移动端和PC端在发请求的时候首先请求我们的页面其实都是向我们的Nginx发起请求得到一些相关的静态资源,然后页面再通过Ajax向我们的服务端发起请求去查询数据,这些数据可能来自与我们的Redis集群或者Mysql集群,然后再把查询到的数据返回给前端,前端进行相关的渲染就可以了(这也是一种典型的前后端分离的架构模式)。

手机或者app端发起请求,请求我们的nginx服务器,nginx基于七层模型走的是HTTP协议,可以实现基于Lua直接绕开tomcat访问redis,也可以作为静态资源服务器,轻松扛下上万并发, 负载均衡到下游tomcat服务器,打散流量,我们都知道一台4核8G的tomcat,在优化和处理简单业务的加持下,大不了就处理1000左右的并发, 经过nginx的负载均衡分流后,利用集群支撑起整个项目,同时nginx在部署了前端项目后,更是可以做到动静分离,进一步降低tomcat服务的压力,这些功能都得靠nginx起作用,所以nginx是整个项目中重要的一环。

在tomcat支撑起并发流量后,我们如果让tomcat直接去访问Mysql,根据经验Mysql企业级服务器只要上点并发,一般是16或32 核心cpu,32 或64G内存,像企业级mysql加上固态硬盘能够支撑的并发,大概就是4000起~7000左右,上万并发, 瞬间就会让Mysql服务器的cpu,硬盘全部打满,容易崩溃,所以我们在高并发场景下,会选择使用mysql集群,同时为了进一步降低Mysql的压力,同时增加访问的性能,我们也会加入Redis,同时使用Redis集群使得Redis对外提供更好的服务。

在这里插入图片描述

项目前置准备

一些前置的数据库、前端项目、后端项目准备这里就不提及了,直接在b站的视频下方有相关的网盘资源:
在这里插入图片描述

导入后端项目的时候要记得把application.yml中mysql、redis的相关配置改成自己的。在访问http://localhost:8081/shop-type/list之后显示下图数据即代表导入成功;
在这里插入图片描述

然后我们简单的来看一下这个后端项目,首先在pom文件里可以看到如下几个依赖;

  • spring-boot-starter-data-redis:redis启动依赖
  • commons-pool2:redis的连接池依赖
  • lombok:提供一些快速开发注解
  • mybatis-plus-boot-starter:简化Mybatis开发
  • hutool-all:里面包含各种各样的工具类,例如:json处理、字符串工具类、数字工具类、日期工具类等等···

在这里插入图片描述

  • MybatisConfig中是MyBatisPlus的分页配置
  • WebExceptionAdvice中是通用的异常处理

在这里插入图片描述
MyBatisPlus可以帮助我们做一些简单的单表查询,而复杂的表查询我们还是要借助mapper文件来实现,也就是图中resource下的mapper文件

其他的结构也很常规在瑞吉外卖中也都出现过,这里就不多赘述

这里的mapper相当于Dao层接口,在原来我们会在每一个接口上使用@Mapper注解,但是这里没有:
在这里插入图片描述
这是因为此项目在启动文件中配置了@MapperScan,他扫描到了我们的mapper文件夹,会自动为文件夹中的mapper接口创建实现类:
在这里插入图片描述

基于Session实现短信登录流程

发送验证码:

用户在提交手机号后,会校验手机号是否合法,如果不合法,则要求用户重新输入手机号

如果手机号合法,后台此时生成对应的验证码,同时将验证码保存到Session中(为将来的登录验证做准备),然后再通过短信的方式将验证码发送给用户

这个地方其实服务端和浏览器都作了很多工作没有提及,比如说服务器端将验证码保存到了Session(web服务器内存中的某一个Session)中以后,会将响应头中塞入一个cookie,这个cookie会记录这个Session对应的jsessionid。在以后浏览器请求对应网站的时候都会带上这个cookie,然后我们就可以凭借它使用这个Session中的数据。

短信验证码登录、注册:

用户将验证码和手机号进行输入,后台从session中拿到当前验证码,然后和用户输入的验证码进行校验,如果不一致,则无法通过校验,如果一致,则后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,无论是否存在,都会将用户信息保存到session中,方便后续获得当前登录信息

session机制采用的是在服务器端保持 HTTP 状态信息的方案(它是基于cookie的)。为了加速session的读取和存储,web服务器中会开辟一块内存用来保存服务器端所有的session,每个session都会有一个唯一标识sessionid,根据客户端传过来的jsessionid(cookie中),找到对应的服务器端的session。为了防止服务器端的session过多导致内存溢出,web服务器默认会给每个session设置一个有效期, (30分钟)若有效期内客户端没有访问过该session,服务器就认为该客户端已离线并删除该session。
(要从一次会话的角度去理解Session)

校验登录状态:

用户在请求时候,会从cookie中携带着JsessionId到后台,后台通过JsessionId从session中拿到用户信息,如果没有用户信息,则进行拦截,如果有用户信息,则将用户信息保存到threadLocal中,并且放行

如果Session中有用户信息,证明这个用户是曾经登陆过的,当然判断完之后不能直接放行,我们这个登录校验不能白校验,在后续的业务当中一定会用到当前登录的用户的信息,所以我们此时将用户的信息缓存起来再放行是一个更好的选择。那我们的用户信息缓存到哪里呢?我们一般情况下会将用户信息缓存到ThreadLocal当中,ThreadLocal是一个线程域对象,在我们的业务当中,每一个请求到达我们的微服务都会是一个独立的线程。如果说我们没有用ThreadLocal,而是直接将用户信息保存到本地变量,那就可能会出现多线程并发的修改问题,而ThreadLocal会将数据保存到每一个线程的内部,在内部创建一个map进行保存。这样一来每一个线程都有自己独立的空间,相互之间没有干扰。

在这里插入图片描述

实现发送短信验证码功能

页面流程

在这里插入图片描述

注意:我们可以发现请求路径是http://localhost:8080/api/user/code?phone=xxxxx,这个/api是一个标记用来被Nginx拦截。
在这里插入图片描述
还有一个注意点:我们的后端服务器处于8081端口但是为什么我们请求的却是8080端口的Nginx服务器?
因为这里使用了Nginx的反向代理,它最终会把你的请求转发给8081端口

首先我们在Controller层中找到对应的接口(在UserController中):
在这里插入图片描述
我们在这里直接返回Service层的处理结果,然后在Service层中去编写具体的逻辑:

	/**
     * 发送手机验证码
     */
    @PostMapping("code")
    public Result sendCode(@RequestParam("phone") String phone, HttpSession session) {
        // TODO 发送短信验证码并保存验证码
        return userService.sendCode(phone,session);
    }

完善一下Service层的接口:

public interface IUserService extends IService<User> {
    Result sendCode(String phone, HttpSession httpSession);
}

在对应的实现类中完善业务逻辑:

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IUserService {
    @Override
    public Result sendCode(String phone, HttpSession httpSession) {
        return null;
    }
}

接下来我们的业务逻辑就根据如下的这张图来写;
在这里插入图片描述

首先是校验手机号,这个在我们的项目中已经提供了对应的工具类;
在这里插入图片描述

  • RegexPatterns:这个工具类中主要定义了几个不同场景下的正则匹配规则
    在这里插入图片描述

  • RegexUtils:这个工具类中根据正则匹配规则进行相关的校验
    在这里插入图片描述

    mismatch是校验方法,校验是否不符合正则格式;
    在这里插入图片描述

在校验完手机号码之后,会生成手机验证码,生成手机验证码我们可以使用hutool-all。接下来我们将验证码通过HttpSession 对象存储在session中。在短信发送这一块,一般是使用阿里云或者华为云等服务实现,这里我们为了简化开发,直接使用的日志记录一下。最后我们返回成功结果。

Result是一个通用的结果对象;
在这里插入图片描述

最后整体代码如下

  • 发送验证码
    @Override
    public Result sendCode(String phone, HttpSession session) {
        // 1.校验手机号
        if (RegexUtils.isPhoneInvalid(phone)) {
            // 2.如果不符合,返回错误信息
            return Result.fail("手机号格式错误!");
        }
        // 3.符合,生成验证码
        String code = RandomUtil.randomNumbers(6);

        // 4.保存验证码到 session
        session.setAttribute("code",code);
        // 5.发送验证码
        log.debug("发送短信验证码成功,验证码:{}", code);
        // 返回ok
        return Result.ok();
    }

实现短信验证码登录和注册功能

我们发现在点击登录按钮之后,浏览器会发送一个POST请求给/user/login,其中请求参数中有电话号码以及验证码用于和服务端session中的验证码进行比对
在这里插入图片描述

接下来我们就按照浏览器的请求接口完善我们Controller层的相关代码:
在这里插入图片描述
我们发现它会接收requestbody中的数据对象:
在这里插入图片描述
这里之所以有一个password是因为用户除了可以使用短信验证码登录也可以使用密码登录:
在这里插入图片描述

和前面一样我们还是把业务逻辑放在Service层去做,在这里我们只是简单的调用Service层的接口。

这里的代码流程我们还是参考下图做:
在这里插入图片描述
首先我们验证验证码是否吻合:

		//用户手机号
        String phone = loginForm.getPhone();
        //用户验证码
        String code = loginForm.getCode();
        //用户密码
        String password = loginForm.getPassword();
        //验证验证码
        if (!((String)session.getAttribute("code")).equals(code)){
            return Result.fail("您输入的验证码错误");
        }

优化:其实在验证验证码是否吻合之前,我们还应该对手机号进行验证。也就是说我们在session中不仅要存放code验证码,还要存放phone手机号码,否则用户可能在拿到验证码之后修改手机号,登录到别人的账号中。

接下来我们根据手机号去查找用户。这里有三种方法:

方法一:

//一致,根据手机号查询用户
User user = query().eq("phone", phone).one();

注意这里的query()并不是凭空而来,它是IService中的一个default方法:
在这里插入图片描述
我们当前的UserServiceImpl类实现了IUserService,而IUserService继承了IService所以是可以直接使用的。

方法二:

		QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("phone",phone);
        User user = userMapper.selectOne(queryWrapper);

方法三:

QueryWrapper的基础上使用lambda:

		LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        lambdaQueryWrapper.eq(User::getPhone,phone);
        User user = userMapper.selectOne(lambdaQueryWrapper);

整体代码如下:

	@Autowired
    private UserMapper userMapper;
    @Override
    public Result login(LoginFormDTO loginForm, HttpSession session) {
        //用户手机号
        String phone = loginForm.getPhone();
        //用户验证码
        String code = loginForm.getCode();
        //用户密码
        String password = loginForm.getPassword();
        //验证验证码
        if (!((String)session.getAttribute("code")).equals(code)){
            return Result.fail("您输入的验证码错误");
        }
        //然后我们再去数据库中查询是否存在这个用户

//        方法一
//        User user = query().eq("phone", phone).one();

//        方法二
//        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
//        queryWrapper.eq("phone",phone);
//        User user = userMapper.selectOne(queryWrapper);

//        方法三
        LambdaQueryWrapper<User> lambdaQueryWrapper = new LambdaQueryWrapper<>();
        lambdaQueryWrapper.eq(User::getPhone,phone);
        User user = userMapper.selectOne(lambdaQueryWrapper);

//        判断用户是否存在
        if (user == null){
            log.debug("此用户不存在,正在创建新用户");
            //创建新用户
            user = createUserByPhone(phone);
        }

        //如果存在则直接将用户保存到session
        session.setAttribute("user",user);

        return Result.ok();
    }

    private User createUserByPhone(String phone) {
        User user = new User();
        user.setPhone(phone);
        user.setNickName(SystemConstants.USER_NICK_NAME_PREFIX + RandomUtil.randomString(10));
        userMapper.insert(user);
        return user;
    }

注意我们这里的随机用户名前缀,使用的是SystemConstants中的常量:
在这里插入图片描述

此时我们登陆进去之后会发现立马退出来了,这是因为我们还没有做登陆校验功能

实现登录校验拦截器

登陆验证事实上就是以下的一个请求:
在这里插入图片描述
他回来查询当前登录的用户信息,如果你能给它返回用户校验就算是成功了,其对应的流程我们之前也分析过:
在这里插入图片描述
用户的请求会带上cookie,登陆的凭证其实就是sessionID,就在cookie当中。带着这个信息到了服务端之后,服务端会基于这个ID得到session,再从session中取出用户判断一下这个用户是否存在,如果有则代表登陆成功,服务端再把用户返回给前端。

不过实际上这么做是有问题的。我们之前所说的/user/me是UserController里面的,前端会向我们的UserController发送请求,UserController中编写相关的校验逻辑。但是如果随着后续业务的开发,越来越多的业务中可能都要校验用户,那么难道我们在每一个Controller中都去写校验逻辑吗?
在这里插入图片描述
其实我们有更好的实现方法,在我们的SpringMVC中,拦截器可以帮我们在所有的Controller执行之前实现一些需求:
在这里插入图片描述

我们将前面的方案进行改进:
在这里插入图片描述

我们在拦截器里拦截到了用户信息之后,可以把它保存在ThreadLocal中,因为ThreadLocal是一个线程域对象,每一个进入tomcat的请求都是一个独立的线程。ThreadLocal就会在线程内开辟一块内存空间去保存对应的用户,这样的话每个线程相互不干扰

我们了解一下tomcat的运行原理
在这里插入图片描述
当用户发起请求时,会访问我们给tomcat注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,tomcat也不例外,当监听线程知道用户想要和tomcat连接连接时,那会由监听线程创建socket连接,socket都是成对出现的,用户通过socket互相传递数据,当tomcat端的socket接受到数据后,此时监听线程会从tomcat的线程池中取出一个线程执行用户请求,在我们的服务部署到tomcat后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到tomcat端的socket,再将数据写回到用户端的socket,完成请求和响应.
通过以上讲解,我们可以得知 每个用户其实对应都是去找tomcat线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据

我们将这个拦截器写在Utils工具包中,同时SpringMVC的拦截器我们需要实现一个接口HandlerInterceptor中的方法:
在这里插入图片描述

  • preHandle:前置拦截
  • postHandle:在Controller执行之后
  • afterCompletion:是在渲染之后,返回给用户之前

什么是在渲染之后?可以了解一下SpringMVC的执行原理加深理解

这里我们实现第一、三两个方法就可以了。在preHandle中我们进行登录校验(也就是我们流程图中的内容),在afterCompletion中我们销毁对应的用户信息,避免内存的泄露

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //从session中获取用户
        UserDTO user = (UserDTO) request.getSession().getAttribute("user");
        //如果用户不存在就拦截
        if (user == null){
            response.setStatus(401);
            //return false就代表拦截  return true代表放行
            return false;
        }

        //如果用户存在则存储+放行
        UserHolder.saveUser(user);

        response.setStatus(200);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

这里的UserHolder是我们对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();
    }
}

这样我们每次使用的时候就不用单独去创建ThreadLocal了。注意我们在ThreadLocal中存储东西的时候是不需要记录键的,直接存储值就可以了,具体原因可以参考ThreadLocal的原理。

定义好了拦截器之后,我们还要使用拦截器,我们创建一个SpringMVC的配置类MvcConfig,这个配置类要实现WebMvcConfigurer接口,然后使用@Configuration注解就可以生效了。

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        InterceptorRegistration interceptorRegistration = registry.addInterceptor(new LoginInterceptor());
        interceptorRegistration.excludePathPatterns(
                "/user/code",
                "/user/login",
                "blog/hot",
                "/upload/**",
                "/shop-type/**",
                "/voucher/**",
                "/shop/**"
        );
    }
}

我们在这个SpringMVC配置类中实现了addInterceptors方法,也就是添加拦截器的方法。我们在这个方法中注册我们的拦截器实例,然后使用excludePathPatterns配置了哪些请求路径不进行拦截。

做完这些之后我们还差一步:/user/me接口的完善。在这个接口中我们获取用户信息然后返回给前端。

	@GetMapping("/me")
    public Result me(){
        // TODO 获取当前登录的用户并返回
        UserDTO user = UserHolder.getUser();
        return Result.ok(user);
    }

隐藏用户敏感信息

可以发现我们通过浏览器可以观察到用户的全部信息:
在这里插入图片描述

这是因为我们当时在处理/user/me接口的时候直接返回了用户的实例;
在这里插入图片描述

这样极为不靠谱,所以我们应当在返回用户信息之前,将用户的敏感信息进行隐藏,采用的核心思路就是书写一个UserDto对象,这个UserDto对象就没有敏感信息了,我们在返回前,将有用户敏感信息的User对象转化成没有敏感信息的UserDto对象,那么就能够避免这个尴尬的问题了

在登录方法处修改

//7.保存用户信息到session中
session.setAttribute("user", BeanUtil.copyProperties(user,UserDTO.class));

在拦截器处

//5.存在,保存用户信息到Threadlocal
UserHolder.saveUser((UserDTO) user);

在UserHolder处:将user对象换成UserDTO

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();
    }
}

上面是完整的处理过程
这个问题在前面我们其实已经进行了处理,但是有一个地方需要完善一下

我们只需要改进在登陆的时候将UserDTO存到Session中去就行:
在这里插入图片描述

本来我们应该是new 一个UserDTO然后手动往里面添加属性,不过这里我们可以使用一个工具类帮我们完成这一个任务;

//7.保存用户信息到session中
session.setAttribute("user", BeanUtil.copyProperties(user,UserDTO.class));

此方法的第一个参数是数据源,然后再把目标的数据类型告诉他(可以给一个实例对象,或者class字节码),就可以帮你完成拷贝。

注意这里是BeanUtil而不是BeanUtils,否则会发现copyProperties返回的是void

改进后我们再在浏览器中查看返回的数据:
在这里插入图片描述

集群的Session共享问题

每个tomcat中都有一份属于自己的session,假设用户第一次访问第一台tomcat,并且把自己的信息存放到第一台服务器的session中,但是第二次这个用户访问到了第二台tomcat,那么在第二台服务器上,肯定没有第一台服务器存放的session,所以此时 整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?早期的方案是session拷贝,就是说虽然每个tomcat上都有不同的session,但是每当任意一台服务器的session修改时,都会同步给其他的Tomcat服务器的session,这样的话,就可以实现session的共享了

但是这种方案具有两个大问题

1、每台服务器中都有完整的一份session数据,服务器压力过大。

2、session拷贝数据时,可能会出现延迟

所以咱们后来采用的方案都是基于redis来完成,我们把session换成redis,redis数据本身就是共享的,就可以避免session共享的问题了

在这里插入图片描述

基于Redis实现共享Session登录

先来看看我们前面做的发送短信验证码功能,我们本来是将验证码保存到session中,现在我们保存到redis中即可:
在这里插入图片描述
但是这里有几个注意点:我们利用redis来存储数据,那么到底使用哪种结构呢?由于存入的数据比较简单,我们可以考虑使用String,或者是使用哈希,如下图,如果使用String,同学们注意他的value,用多占用一点空间,如果使用哈希,则他的value中只会存储他数据本身,如果不是特别在意内存,其实使用String就可以啦。
在这里插入图片描述

确定了value的结构,接下来我们key用什么结构呢?前面我们在session中直接使用的code作为key,因为session有一个特点:每一个不同的浏览器都有一个独立的session,也就是说在我们的tomcat内部维护了很多很多的session,不同的浏览器携带手机号来的时候都有自己独立的session,他们都用code作为key但是互相之间不干扰。而我们的redis则不同,他是一个共享的内存空间,不管是谁发请求过来在我们的服务端只有一个redis,如果我们的key都为code,那么验证码就会相互覆盖造成数据的丢失。

一句话说,我们使用redis的时候key要有以下两个要求:

  • 唯一性
  • 方便携带

如果我们采用phone:手机号这个的数据来存储当然是可以的,但是如果把这样的敏感数据存储到redis中并且从页面中带过来毕竟不太合适,所以我们在后台生成一个随机串token,然后让前端带来这个token就能完成我们的整体逻辑了

整体流程如下图:
在这里插入图片描述
在这里插入图片描述

这里有个小注意点:
我们把token返回给了前端,前端以后每次请求都要携带token,怎么做到这一点的呢?
(这里和session的区别要清楚,我们的session是把服务端的jsessionid给响应头返回给客户端,然后客户端再发送请求的时候会把这个jsessionid放在请求头的cookie中。而token与他们不一样,我们token的传递是借助于我们自定义的请求头authorization
在这里插入图片描述
我们来看看前端代码:
在这里插入图片描述
localStorage 和 sessionStorage 属性允许在浏览器中存储 key/value 对的数据。
sessionStorage 用于临时保存同一窗口(或标签页)的数据,在关闭窗口或标签页之后将会删除这些数据。
提示: 如果你想在浏览器窗口关闭后还保留数据,可以使用 localStorage 属性, 该数据对象没有过期时间,今天、下周、明年都能用,除非你手动去删除。
很明显这里就是前端将后端返回的token存储到了本地浏览器中,接下来:
在这里插入图片描述
这里使用了axios的拦截器,每当发送请求的时候都会增加一个请求头authorization,里面存放着我们的token。

确认了redis的实现流程之后,我们来修改我们的代码:

发送短信验证码部分

我们改进的地方有两个:

  • 将验证码保存到redis中
  • 将保存的key-value设置过期时间

在UserServiceImpl的sendCode方法中,我们注入stringRedisTemplate:

//接下来我们将验证码保存到Session中
//        httpSession.setAttribute("code",randomString);
        // 改进后将验证码保存到redis中
        stringRedisTemplate.opsForValue().set(RedisConstants.LOGIN_CODE_KEY + phone,randomString,RedisConstants.LOGIN_CODE_TTL, TimeUnit.MINUTES);

我们在这里不能直接把手机号当作key,因为有可能其他业务也是这么做的,所以我们在这里加一个前缀。在Redis中使用的常量我们单独放在一个类RedisConstants中:

public class RedisConstants {
    public static final String LOGIN_CODE_KEY = "login:code:";
    public static final Long LOGIN_CODE_TTL = 2L;
}

我们在这里设置了key的前缀常量以及键值对过期时间常量。

给我们的数据添加过期时间是因为如果不加的话,数据堆积终有一天redis会被占满,同时也避免了用户申请了验证码之后可以一直使用的情况。

短信登录与注册部分

修改内容概述:

  • 在校验验证码的时候从redis中获取验证码
  • 查询完用户之后,将用户信息保存到redis中
    • 存储的时候要用hash结构去存储
    • key是随机的一个token
    • 设置有效期(否则也会有过度占用内存的情况出现)
    • 这里的有效期我们参考session的30分钟(在不做任何操作的情况下session在30分钟之后会被踢出)
    • 这里存储的时候token最好也要加一个前缀
  • 将token返回给前端

在UserServiceImpl的login方法中:

		//从redis中获取验证码进行校验
        String s = stringRedisTemplate.opsForValue().get(RedisConstants.LOGIN_CODE_KEY + phone);
        //验证验证码
        if (s == null || !s.equals(code)){
            return Result.fail("您输入的验证码错误");
        }

随机的token这里建议使用uuid来实现

UUID类java.util以及我们导入的hutool里面都有,这里我们使用hutool里面的:

//使用UUID生成token
String token = UUID.randomUUID().toString(true);

这里的toString里面使用true,代表不会生成带有中划线的字符串

在我们用hash结构存储用户信息的时候发现:
在这里插入图片描述
这里有三种put方法,如果我们选择第一个依次进行填充的话会与服务器进行多次交互,这样不太好影响效率.所以这里我们使用putAll方法,然后我们再借助BeanUtil这个工具类(也是hutool包下的)将我们的UserDTO转化为Map类型,完整代码如下:

		//改进:将用户数据保存到redis中
        //使用UUID生成token
        String token = UUID.randomUUID().toString(true);
        //使用hash结构存储用户信息userdto
        String tokenKey = RedisConstants.LOGIN_USER_KEY + token;
        Map<String, Object> userMap = BeanUtil.beanToMap(dto);
        stringRedisTemplate.opsForHash().putAll(tokenKey,userMap);
        stringRedisTemplate.expire(tokenKey,RedisConstants.LOGIN_USER_TTL,TimeUnit.MINUTES);
        return Result.ok(token);

改到这里其实还有漏洞,我们的session有效期30分钟是指用户无操作30分钟之后 ,session才会退出.而我们设置的却是只要过了30分钟我们就把用户信息删除。所以我们要实现的是只要用户在不断的访问,我们就要不断更新redis中token的有效期。但是我们怎么知道用户有没有访问呢?这里我们就可以借助前面我们写过的校验登录状态来进行确认我们可以这么理解:所有的请求进来以后都要经过拦截器的拦截和校验,只要经过了这个校验就可以证明,这个用户登陆着且是活跃状态的。既然满足了这两个条件我们就可以更新一下我们redis的有效期。也就是说只要是登录的用户在不断的访问,我们就可以不断地去更新有效期。只有当用户什么操作都不干,他就不会触发拦截器,redis的有效期不会更新,则30分钟之后被移除

所以我们在修改校验登陆状态的业务逻辑的时候,在原有逻辑的基础上我们还要添加更新token有效期的业务逻辑

校验登陆状态部分

修改内容概述:

  • 获取请求头中的token
  • 根据token获取redis中的用户信息
  • 将查询到的Hash数据转化为UserDTO对象(我们ThreadLocal规定的泛型是UserDTO,我们只有转化了之后才能存进ThreadLocal中)
  • 刷新token的有效期

在这里插入图片描述
我们在拦截器中要大量用到redis所以我们要注入StringRedisTemplate。但是这个地方的注入我们不能使用@Resource、@Autowired等注解进行注入,我们只能使用构造函数来去注入(也就是说谁用谁就把依赖注入进来)。因为拦截器类的对象是我们自己手动new出来的,不是我们加注解构建的。也就是说这个类的对象不是由Spring创建的,而是由我们自己创建的,而我们自己创建的对象是不能使用Spring中的自动装配的。

拦截器类上直接加@Component然后再使用相关注解进行注入是行不通的,因为拦截器在bean初始化之前就执行了,所以就算加了@Component注解把拦截器放入IOC容器中进行管理也是拿不到容器里面的内容的。

在这里插入图片描述
我们在MVC的配置类中使用到了这个拦截器,那么我们就在那里进行注入:
在这里插入图片描述
然后我们就可以去完成我们的需求了。

首先我们在请求头中拿到token,如果没有则进行拦截;

		String token = request.getHeader("authorization");
        // 如果token为空则进行拦截
        if (StrUtil.isBlank(token)){
            response.setStatus(401);
            //return false就代表拦截  return true代表放行
            return false;
        }

然后我们再基于token去取redis里面的用户信息,注意这里我们不能使用opsForHash()里的get方法,因为我们要的是一个完整的map,而get只能通过key拿到对应的值:
在这里插入图片描述

所以这里我们使用的是entries方法,它返回的就是一个map;
在这里插入图片描述
我们将返回值进行一个判空,如果为空则进行拦截:

这里不需要进行null的判定,因为entries这个方法如果得到的是个null,则会返回一个空map

		//然后我们再基于token去取redis里面的用户信息
        Map<Object, Object> map = redisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
        //如果map为空就拦截
        if (map.isEmpty()){
            response.setStatus(401);
            //return false就代表拦截  return true代表放行
            return false;
        }

接着我们将拿到的map数据转化为UserDTO类型,这里我们还是使用BeanUtil工具类中的fillBeanWithMap(mapToBean已经弃用):

		UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
        //如果用户存在则存储+放行
        UserHolder.saveUser(userDTO);

最后一步就是刷新token有效期;

//刷新token有效期
redisTemplate.expire(RedisConstants.LOGIN_CODE_KEY+token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);        

接下来我们运行看看效果,会发现登陆异常了,原因是类型转化发生异常:
在这里插入图片描述
在这里插入图片描述
定位到我们的Service层代码中,报错位置如下:
在这里插入图片描述
原因是因为我们的userMap里面id是Long类型的。那么为什么Long不能转String呢?因为我们使用的是StringRedisTemplate,它的特点就是他要求我们不管是key还是value都是String类型的:
在这里插入图片描述
这里我们有两种处理办法:

  • 方法一:不使用BeanUtil.beanToMap,自己手动new一个map,然后一个个往里面填充,不是String的转成string
  • 方法二:还是使用beanToMap这个工具,这个工具是可以自定义的,默认你的值是什么数据类型就用什么数据类型,但是我们可以借助copyOption做一些改变:
    在这里插入图片描述

这里我们采用方法二;

Map<String, Object> userMap = BeanUtil.beanToMap(dto,new HashMap<>(), CopyOptions.create()
                .setIgnoreNullValue(true)
                .setFieldValueEditor((fieldName,fieldValue) -> fieldValue.toString()));

总结:
Redis代替session需要考虑的问题:

  • 选择合适的数据结构
  • 选择合适的key
  • 设置好存储时间
  • 选择合适的存储粒度

登录拦截器的优化

我们原先登录拦截器的逻辑如下:
在这里插入图片描述
那么这样能不能真正的达成用户一直在访问token就不会过期呢?

不行,因为这个拦截器拦截的路径不是一切路径,它拦的是那些需要做登录校验的路径。也就是说如果用户访问了那些没有被拦截的路径,他们的token是不会被刷新的。

那么我们怎么处理这种情况呢?我们可以在原有的拦截器之前新加一个拦截器,让这个拦截器拦截一切路径,用来做刷新token有效期的动作

在这里插入图片描述
改进之后如下:

我们新建一个RefreshTokenInterceptor:

public class RefreshTokenInterceptor implements HandlerInterceptor {

    private StringRedisTemplate redisTemplate;

    public RefreshTokenInterceptor(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String token = request.getHeader("authorization");
        //token为空直接放行,如果不为空则进行token的更新
        if (StrUtil.isBlank(token)){
            return true;
        }
        //然后我们再基于token去取redis里面的用户信息
        Map<Object, Object> map = redisTemplate.opsForHash().entries(RedisConstants.LOGIN_USER_KEY + token);
        //如果map为空直接放行
        if (map.isEmpty()){
            return true;
        }
        UserDTO userDTO = BeanUtil.fillBeanWithMap(map, new UserDTO(), false);
        //如果用户存在则存储在ThreadLocal中+放行
        UserHolder.saveUser(userDTO);

        //刷新token有效期
        redisTemplate.expire(RedisConstants.LOGIN_CODE_KEY+token,RedisConstants.LOGIN_USER_TTL, TimeUnit.MINUTES);


        response.setStatus(200);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        UserHolder.removeUser();
    }
}

这个地方注意如果是直接复制的原来的LoginInterceptor里面的代码,记得把token为空改为直接放行,否则所有的请求都会被拦截!

然后将原来的LoginInterceptor的冗余代码进行删除:

@Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        //直接判断是否需要拦截,判断依据就是ThreadLocal里面是否有我们的用户信息
        if(UserHolder.getUser() == null) {
            response.setStatus(401);
            return false;
        }
        return true;
    }

定义好拦截器之后,我们接下来在MvcConfig中配置拦截器:

@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        InterceptorRegistration interceptorRegistration = registry.addInterceptor(new LoginInterceptor());
        interceptorRegistration.excludePathPatterns(
                "/user/code",
                "/user/login",
                "blog/hot",
                "/upload/**",
                "/shop-type/**",
                "/voucher/**",
                "/shop/**"
        );
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**");
    }
}

完成这个之后我们还要思考一个问题,如何控制拦截器的执行顺序呢?

在我们的添加拦截器的时候不设置order,那么默认都是0:
在这里插入图片描述
我们的拦截器顺序就按照添加的顺序来。

当然我们也可以通过设置order来控制拦截器的执行顺序:

  • order越大,优先级越低
  • order越小,优先级越高
@Configuration
public class MvcConfig implements WebMvcConfigurer {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        //登录拦截器
        InterceptorRegistration interceptorRegistration = registry.addInterceptor(new LoginInterceptor());
        interceptorRegistration.excludePathPatterns(
                "/user/code",
                "/user/login",
                "blog/hot",
                "/upload/**",
                "/shop-type/**",
                "/voucher/**",
                "/shop/**"
        ).order(1);
        //token刷新的拦截器
        registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate)).addPathPatterns("/**").order(0);
    }
}

思维导图总结

在这里插入图片描述

;