【Redis实战】Redis实现分布式Session共享

通常对于单体项目,部署到一台服务器去运行时,对于已经登陆的用户信息保存到Session中,来保证多次请求之间的会话,但是如果当访问量增加,单台服务器压力过大时,我们可以考虑使用多台服务器构建集群然后使用负载均衡策略来保证让集群中多个机器都去提供服务。(对于分布式微服务项目更是需要考虑Session共享的问题)

此时,在 TomCat 集群模式下,每个 TomCat 中都有一份属于自己的 Session,假设用户第一次访问第一台 TomCat ,并且把自己的信息存放到第一台服务器的Session中,但是第二次这个用户访问负载均衡到了第二台 TomCat ,那么在第二台服务器上,肯定没有第一台服务器存放的Session,所以此时整个登录拦截功能就会出现问题,我们能如何解决这个问题呢?

早期的方案是Session拷贝,就是说虽然每个 TomCat 上都有不同的Session,但是每当任意一台服务器的Session修改时,都会同步给其他的 TomCat 服务器的Session,这样的话,就可以实现Session的共享了。但是这种方案具有两个大问题:

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

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

所以后来我们可以考虑采用基于 Redis 来完成,我们把 Session 换成 Redis,Redis数据本身就是共享的,就可以避免Session共享的问题。

0.项目准备

数据库设计

数据库设计层面我们需要添加一张用户表,具体SQL语句参考如下:

CREATE TABLE `tb_user` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `phone` varchar(11) NOT NULL COMMENT '手机号码',
  `password` varchar(128) DEFAULT '' COMMENT '密码,加密存储',
  `nick_name` varchar(32) DEFAULT '' COMMENT '昵称,默认是用户id',
  `icon` varchar(255) DEFAULT '' COMMENT '人物头像',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE KEY `uniqe_key_phone` (`phone`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;

后端项目开发

后端项目基于上一篇文章中搭建的基础SpringBoot项目,具体可参考这篇文章的演示项目搭建:0.演示项目搭建

然后使用代码生成器去根据上面新添加的用户表去生成用户相关的三层代码!

前端项目开发

前端项目基于VUE + Element UI组件库搭建的基本注册/登录页面,具体过程参考这篇文章的前端项目搭建:二、前端开发

注意:我们这里模拟使用 手机号 + 验证码 模式进行登录以及注册,后台根据手机号查询用户,如果用户不存在,则为用户创建账号信息,保存到数据库,实现注册的功能。所以就不需要注册页面了,另外登录页面也需要进行改动。

修改前端登录页面:

<template>
  <div class="main">
    <div style="margin: 130px auto; background-color: #fff; width: 400px; height: 380px; padding: 20px; border-radius: 10px">
      <h1 style="text-align: center;">用户登录</h1><br>
      <el-form :model="loginForm" status-icon :rules="rules" ref="loginForm" label-width="80px" class="demo-ruleForm">
        <el-form-item label="手机号" prop="phoneNumber">
          <el-input type="name" v-model="loginForm.phoneNumber" autocomplete="off"></el-input>
        </el-form-item>
        <el-form-item label="验证码" prop="checkCode">
          <el-input type="text" v-model="loginForm.checkCode" autocomplete="off" style="width: 150px; margin-right: 50px"></el-input>
          <el-button type="warning" @click="sendCode(loginForm.phoneNumber)" :disabled="sendCodeStatus">{{sendCodeText}}</el-button>
        </el-form-item>

        <div style="margin-left: 55px">
          <el-form-item class="btn">
            <el-button type="primary" @click="submitForm('loginForm')">提交</el-button>
            <el-button type="success" @click="resetForm('loginForm')">重置</el-button>
          </el-form-item>
        </div>
      </el-form>
    </div>
  </div>
</template>

<script>
export default {
  name: "Login",
  data() {
    return {
      sendCodeStatus: false,
      sendCodeText: "发送验证码",
      loginForm: {
        phoneNumber: '15936788888',
        checkCode: '123456'
      },
      rules: {
        phoneNumber: [{
          required: true,
          message: '请输入账号',
          trigger: 'blur'
        }, {
          min: 11,
          max: 11,
          message: '长度 11 个字符',
          trigger: 'blur'
        }],
        checkCode: [{
          required: true,
          message: '请输入密码',
          trigger: 'blur'
        }, {
          min: 6,
          max: 6,
          message: '长度 6 个字符',
          trigger: 'blur'
        }]
      }
    };
  },
  methods: {
    submitForm(formName) {
      this.$refs[formName].validate((valid) => {
        if (valid) {
          this.$axios.post("/user/login",this.loginForm).then((resp) => {
            if (resp.success){
              localStorage.setItem("token", resp.data);
              this.$router.push('/home')
            }else {
              this.$message.error(resp.msg);
            }
          })
        } else {
          console.log('请正确输入每项数据!');
          return false;
        }
      });
    },
    resetForm(formName) {
      this.$refs[formName].resetFields();
    },
    sendCode(phoneNumber){
      // this.$message.success("发送验证码至" + phoneNumber);
      this.$axios.get("/user/sendCode?phone=" + phoneNumber).then((resp) => {
        if(resp.success){
          this.$message.success("发送验证码成功");
          this.sendCodeStatus = true;
          this.countDown(60);
        } else{
          this.$message.error(resp.errorMsg);
        }
      })
    },
    countDown(time){if(time < 0){
        this.sendCodeStatus = false;
        this.sendCodeText = "发送验证码"
        return;
      }
      this.sendCodeText = time-- + "秒后重发"
      window.setTimeout(()=>{
        this.countDown(time--)
      }, 1000);
      
    }
  }
}
</script>

<style scoped>
.main {
  height: 90vh;
  background-image: linear-gradient(to bottom right, #FC466B, #3F5EFB);
  overflow: hidden;
}
</style>

页面效果:

修改Axios拦截器配置:

import axios from 'axios'; // 导入axios
import {Message} from 'element-ui' // 使用element-ui Message做消息提醒
import router from '../router'  // 导入路由


// 创建Axios 实例
const service = axios.create({
    timeout: 7000, // 超时时间 单位是ms,这里设置了7s的超时时间
    baseURL: 'http://localhost:8889/', // 公共接口
    // headers: {'X-Custom-Header': 'foobar'}  // 可以设置全局请求头
})

service.defaults.withCredentials = true; //允许跨域携带cookie信息

// 添加一个请求拦截器
// 发请求前做的一些处理,数据转化,配置请求头,设置token,设置loading等,根据需求去添加
service.interceptors.request.use(
    config => {
        config.headers.languagetype = 'CN'; // 示例:加上一个公共头部
        config.headers = {
            'Content-Type':'application/json' // 还可以这样配置请求头
        }

        let token = window.localStorage.getItem('token');  // 获取token
        //if(token){
        //config.params = {'token':token} //如果要求携带在参数中
        //config.headers.token= token; //如果要求携带在请求头中
        //}
        token && (config.headers.Authorization = token)  // 添加头部信息token 键名为Authorization
        return config;
    },
    error => {
        console.log(error);
        return Promise.reject(error);
    }
)

// 响应拦截器
// 接收到响应数据并成功后的一些共有的处理,关闭loading等
service.interceptors.response.use(
    response => {
        // 接收后台参数状态
        const res = response.data;
        if(res.success) {
            return res;
        }else {
            let message =  res.errorMsg || '未知错误';
            Message({
                message,
                type: 'error',
                duration: 5 * 1000
            });
            console.log('拦截器打印错误:', res);
            // 这里可以设置后台返回状态码是500或者是其他,然后重定向跳转
            if(res.code === 500) {
               router.push('/')
            }
            return Promise.reject(
                new Error(res.message || (res.error &&res.error.message) || '未知错误')
            );
        }
    },
    error => {
        /***** 接收到异常响应的处理开始 *****/
        console.log('服务器错误信息:', error);
        if (error && error.response) {
            // 1.公共错误处理
            // 2.根据响应码具体处理
            switch (error.response.status) {
                case 400:
                    error.message = '错误请求'
                    break;
                case 401:
                    error.message = '未授权,请重新登录'
                    break;
                case 403:
                    error.message = '拒绝访问'
                    break;
                case 404:
                    error.message = '请求错误,未找到该资源'
                    window.location.href = "/NotFound"
                    break;
                case 405:
                    error.message = '请求方法未允许'
                    break;
                case 408:
                    error.message = '请求超时'
                    break;
                case 500:
                    error.message = '服务器端出错'
                    break;
                case 501:
                    error.message = '网络未实现'
                    break;
                case 502:
                    error.message = '网络错误'
                    break;
                case 503:
                    error.message = '服务不可用'
                    break;
                case 504:
                    error.message = '网络超时'
                    break;
                case 505:
                    error.message = 'http版本不支持该请求'
                    break;
                default:
                    error.message = `连接错误${error.response.status}`
            }
        } else {
            // 超时处理
            if (JSON.stringify(error).includes('timeout')) {
                Message.error('服务器响应超时,请刷新当前页')
            }
            error.message = '连接服务器失败'
        }
        Message.error(error.message)
        /***** 处理结束 *****/
        //如果不需要错误处理,以上的处理过程都可省略
        return Promise.resolve(error.response)
    }
)
export default service;

1.基于Session实现登录

开发验证码接口

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

如果手机号合法,后台此时生成对应的验证码,同时将验证码进行保存到Session,然后再通过短信的方式将验证码发送给用户。

代码实现:

@Override
public Result sendCode(String phone, HttpServletRequest request) {
    // 首先判断手机号格式是否正确  这里使用了自定义的工具类,代码见附录
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 手机号无效
        return Result.fail("手机号格式不正确!");
    }
    // 生成六位数字验证码  这里使用hutool提供的工具类 RandomUtil
    String checkCode = RandomUtil.randomNumbers(6);

    // 验证码保存至session中 
    HttpSession session = request.getSession();
    session.setAttribute("code", checkCode);
    // 设置验证码失效时间,单位是秒,默认30min
    // 如果设置的值为零或负数,则表示会话将永远不会超时。常用于设置当前会话时间。
    session.setMaxInactiveInterval(60);

    // 模拟发送验证码
    log.debug("发送手机验证码至" + phone + "成功,验证码=" + checkCode);
    return Result.ok();
}

经过前台页面测试发送手机验证码接口,发现后端可以正常接收请求并进行响应,但是前端页面却并没有正常接收到响应,这是因为浏览器的同源策略产生的跨域问题,解决方案是在后端服务中加入过滤器,开启允许跨域,具体代码如下。

开启允许跨域

自定义解决跨域问题的过滤器。

@WebFilter(urlPatterns = "/**")
public class CorsFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        response.setHeader("Access-control-Allow-Origin", request.getHeader("Origin"));
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Headers", request.getHeader("Access-Control-Request-Headers"));
        response.addHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT");
        if ("OPTIONS".equals(request.getMethod())) {
            response.setStatus(HttpStatus.NO_CONTENT.value());
            return;
        } else {
            filterChain.doFilter(request, response);
        }
    }
}

配置自定义的过滤器。

@Configuration
public class FilterConfig {
    @Bean
    public FilterRegistrationBean replaceTokenFilter(){
        FilterRegistrationBean registration = new FilterRegistrationBean();
        registration.setDispatcherTypes(DispatcherType.REQUEST);
        registration.setFilter( new CorsFilter());
        registration.addUrlPatterns("/*");
        registration.setName("CorsFilter ");
        //设置优先级最高
        registration.setOrder(1);
        return registration;
    }
}

开发登录接口

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

1.定义用于登录的数据传输对象LoginDTO

@Data
public class LoginDTO {
    private String phone;
    private String checkCode;
}

2.开发登录接口

@Override
public Result login(LoginDTO loginDTO, HttpServletRequest request) {
    String phone = loginDTO.getPhoneNumber();
    // 首先判断手机号格式是否正确  这里使用了自定义的工具类,代码见附录
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 手机号无效
        return Result.fail("手机号格式不正确!");
    }

    // 从session中获取验证码
    HttpSession session = request.getSession();
    String cacheCode = (String) session.getAttribute("code");
    // 判断验证码是否为null 或者空值 “”
    if (StrUtil.isBlank(cacheCode)){
        // 是则验证码失效,直接返回
        return Result.fail("验证码失效!");
    }
	// 判断用户输入的验证码和后端存储的验证码是否相同
    if (!cacheCode.equals(loginDTO.getCheckCode())){
        // 不相同直接返回
        return Result.fail("验证码不正确!");
    }

    // 验证码校验通过
    // 先查询数据库看当前手机号用户是否存在
    User user = query().eq("phone", phone).one();

    // 判断用户是否存在
    if(Objects.isNull(user)){
        // 不存在,则创建新用户
        user =  createUserWithPhone(phone);
    }
    // 保存用户信息到session中
    session.setAttribute("user", user);
    return Result.ok();
}

// 基于手机号创建新用户
private User createUserWithPhone(String phone) {
    User user = new User();
    user.setPhone(phone);
    // 设置随机固定前缀的昵称
    user.setNickName("user_" + RandomUtil.randomString(10));
    save(user);
    return user;
}

开发拦截器

经过上面的代码编写,仿佛是实现了用户的注册以及登录功能,但是其实是有不足的,也就是并没有做到登录验证,简单说就是用户如果知道我们后端的一些接口或者是资源,那么他就可以直接发送请求来获取的,显然这是不可以的,那么就需要进行对请求进行拦截,将没有登录的用户对于一些资源的请求进行拦截。

Tomcat 运行原理

当用户发起请求时,会访问我们在 Tomcat 注册的端口,任何程序想要运行,都需要有一个线程对当前端口号进行监听,Tomcat 也不例外,当监听线程知道用户想要和Tomcat 连接时,那会由监听线程创建socket连接,socket都是成对出现的,用户通过socket互相传递数据,当Tomcat 端的socket接受到数据后,此时监听线程会从Tomcat 的线程池中取出一个线程执行用户请求,在我们的服务部署到Tomcat 后,线程会找到用户想要访问的工程,然后用这个线程转发到工程中的controller,service,dao中,并且访问对应的DB,在用户执行完请求后,再统一返回,再找到Tomcat 端的socket,再将数据写回到用户端的socket,完成请求和响应。

现在我们可以得知 每个用户其实对应都是去找Tomcat 线程池中的一个线程来完成工作的, 使用完成后再进行回收,既然每个请求都是独立的,所以在每个用户去访问我们的工程时,我们可以使用threadlocal来做到线程隔离,每个线程操作自己的一份数据。

开发拦截器

1.创建UserDTO

用于将已经登录的用户信息添加到threadlocal中,或者是返回给前端用户信息,但是如果使用User的话,可能会包含敏感信息,所以需要创建一个用户传输对象。

@Data
public class UserDTO {
    private Long id;
    private String nickName;
    private String icon;
}

2.创建ActivityUser

用于将成功登录的用户信息保存到threadlocal中。

public class ActivityUser {
    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();
    }
}

3.开发拦截器

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中的用户
        User user = (User) session.getAttribute("user");
        // 3.判断用户是否存在
        if(Objects.isNull(user)){
            // 4.不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }
        // 5.存在,则表示已登录
        // 保存用户信息到Threadlocal
        UserDTO userDTO = new UserDTO();
        BeanUtil.copyProperties(user, userDTO, true);
        ActivityUser.saveUser(userDTO);
        //6.放行
        return true;
    }
}

4.注册拦截器

@Configuration
public class MvcConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor())
            // 排除拦截与用户相关的请求
            // 比如登录:/user/login
            // 比如发送验证码:/user/sendCode
                .excludePathPatterns("/user/**");
    }
}

测试

在添加上述代码后,当用户登录成功后,页面跳转到后台,并发送查询科目的请求,拦截器将会进行拦截校验当前请求是否为已经登录的用户,如果是则直接放行。否则返回401。

但是当后端服务器一旦重启或者是Session失效后,用户再次发送请求将会被拦截,然后去重新登录。同时如果是在多台服务器下,如果负载均衡到其他机器上时,会因在Session中查找不到该用户则返回401。下面将演示使用基于 Redis 存储Token方式来实现分布式下保证管理请求会话。

2.Redis代替Session实现登录

基于Redis实现短信发送

因为有了Redis,那么我们可以将验证码存储到缓存中,首先是做到了分布式下的共享,其次可以利用Redis的key值TTL定时清除轻松实现验证码的超时校验。

修改sendCode(String phone, HttpServletRequest request)方法:

// 注入StringRedisTemplate 操作redis
@Resource
private StringRedisTemplate stringRedisTemplate;

@Override
public Result sendCode(String phone) {  // 不再需要HttpServletRequest
    // 首先判断手机号格式是否正确  这里使用了自定义的工具类,代码见附录
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 手机号无效
        return Result.fail("手机号格式不正确!");
    }
    // 生成六位数字验证码
    String checkCode = RandomUtil.randomNumbers(6);

    // 验证码保存至session中
    // HttpSession session = request.getSession();
    //session.setAttribute("code", checkCode);
    // 设置验证码失效时间,单位是秒,默认30min
    // 如果设置的值为零或负数,则表示会话将永远不会超时。常用于设置当前会话时间。
    // session.setMaxInactiveInterval(60);


    // 验证码保存至Redis中
    String key = CODE_LOGIN_KEY + phone;
    stringRedisTemplate.opsForValue().set(key, checkCode, CODE_LOGIN_TTL, TimeUnit.SECONDS);

    // 模拟发送验证码
    log.debug("发送手机验证码至" + phone + "成功,验证码=" + checkCode);
    return Result.ok();
}

基于Token缓存到Redis实现登录

当我们使用 Redis 时,需要注意的一点就是关于Key的合理设计,在设计这个key的时候,至少需要满足两点

1、key要具有唯一性

2、key要方便携带

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

当用户去登录时后端会去校验用户提交的手机号和验证码是否一致,如果一致,则根据手机号查询用户信息,若不存在则新建,最后将用户数据保存到 Redis,并且生成一个 token 作为 Redis 的 key,返回给客户端浏览器。当用户每次从前端发送请求时,每次都会携带这个token,后端在拦截器会去取出token,然后去redis中查询token对应的value,判断是否存在这个用户数据,如果没有则拦截,如果存在则将其保存到threadLocal中,并且放行。

实现代码如下:

@Override
public Result login(LoginDTO loginDTO) {
    String phone = loginDTO.getPhoneNumber();
    // 首先判断手机号格式是否正确  这里使用了自定义的工具类,代码见附录
    if (RegexUtils.isPhoneInvalid(phone)) {
        // 手机号无效
        return Result.fail("手机号格式不正确!");
    }
    /*
     // 从session中获取验证码
     HttpSession session = request.getSession();
     String cacheCode = (String) session.getAttribute("code");
    */
    
    // 从Redis中获取验证码
    String key = CODE_LOGIN_KEY + phone;
    String cacheCode = stringRedisTemplate.opsForValue().get(key);
    if (StrUtil.isBlank(cacheCode)){
        // 验证码失效
        return Result.fail("验证码失效!");
    }

    if (!cacheCode.equals(loginDTO.getCheckCode())){
        return Result.fail("验证码不正确!");
    }

    // 查询数据库
    User user = query().eq("phone", phone).one();

    // 判断用户是否存在
    if(Objects.isNull(user)){
        // 不存在,则创建新用户
        user =  createUserWithPhone(phone);
    }
    // 保存用户信息到session中
    // session.setAttribute("user", user);

    // 随机生成token,作为登录令牌
    String token = UUID.randomUUID().toString(true);
    // 将User对象转为HashMap存储
    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()));
    // 存储
    String tokenKey = LOGIN_USER_KEY + token;
    stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
    // 设置token有效期
    stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.MINUTES);

    // 返回token
    return Result.ok(token);
}

修改拦截器

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.获取session
        // HttpSession session = request.getSession();
        //2.获取session中的用户
        // User user = (User) session.getAttribute("user");

        // 1.获取token
        String token = request.getHeader("Authorization");
        if (StrUtil.isBlank(token)){
            // token不存在,拦截,返回401状态码
            response.setStatus(401);
            return false;
        }

        // 2.从Redis中获取User
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
        if (userMap.isEmpty()){
            // 用户不存在
            response.setStatus(401);
            return false;
        }

        // 3.存在,保存用户信息到Threadlocal
        // 将查询到的hash数据转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 4.保存用户信息到 ThreadLocal
        ActivityUser.saveUser(userDTO);
        // 5.放行
        return true;
    }
}

 

优化刷新登录存活时间

目前可以实现使用对应路径的拦截,同时刷新登录token令牌的存活时间,但是现在这个拦截器他只是拦截需要被拦截的路径,假设当前用户访问了一些不需要拦截的路径,那么这个拦截器就不会生效,所以此时令牌刷新的动作实际上就不会执行,所以这个方案他是存在问题的。

既然之前的拦截器无法对不需要拦截的路径生效,那么我们可以添加一个拦截器,在第一个拦截器中拦截所有的路径,把第二个拦截器做的事情放入到第一个拦截器中,同时刷新令牌,因为第一个拦截器有了threadLocal的数据,所以此时第二个拦截器只需要判断拦截器中的user对象是否存在即可,完成整体刷新功能。

新增RefreshTokenInterceptor拦截器:

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)){
            // token不存在,表示未登录用户,直接放行到下一个拦截器
            return true;
        }

        // 2.基于TOKEN获取redis中的用户
        Map<Object, Object> userMap = stringRedisTemplate.opsForHash().entries(LOGIN_USER_KEY + token);
        if (userMap.isEmpty()){
            // 用户不存在 表示未登录用户或登录已过期,直接放行到下一个拦截器
            return false;
        }
        // 3.存在,保存用户信息到Threadlocal
        // 将查询到的hash数据转为UserDTO
        UserDTO userDTO = BeanUtil.fillBeanWithMap(userMap, new UserDTO(), false);
        // 4.保存用户信息到 ThreadLocal
        ActivityUser.saveUser(userDTO);
        // 5.刷新token有效期
        stringRedisTemplate.expire(LOGIN_USER_KEY + token, LOGIN_USER_TTL, TimeUnit.MINUTES);
        // 6.放行
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 移除用户
        ActivityUser.removeUser();
    }
}

修改LoginInterceptor:

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1.判断是否需要拦截(ThreadLocal中是否有用户)
        if (ActivityUser.getUser() == null) {
            // 没有,需要拦截,设置状态码
            response.setStatus(401);
            // 拦截
            return false;
        }
        // 有用户,则放行
        return true;
    }
}

注册拦截器:

 @Configuration
 public class MvcConfig implements WebMvcConfigurer {
     @Resource
     private StringRedisTemplate stringRedisTemplate;
 ​
     @Override
     public void addInterceptors(InterceptorRegistry registry) {
         registry.addInterceptor(new LoginInterceptor())
                .excludePathPatterns("/user/**")
                .order(1);
 ​
         registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
                .addPathPatterns("/**")
                .order(0);
    }
 }

至此,基于Redis 实现分布式Session共享就结束了。

3.附录

正则校验工具类RegexUtils:

public class RegexUtils {
    /**
     * 是否是无效手机格式
     * @param phone 要校验的手机号
     * @return true:符合,false:不符合
     */
    public static boolean isPhoneInvalid(String phone){
        return mismatch(phone, RegexPatterns.PHONE_REGEX);
    }
    /**
     * 是否是无效邮箱格式
     * @param email 要校验的邮箱
     * @return true:符合,false:不符合
     */
    public static boolean isEmailInvalid(String email){
        return mismatch(email, RegexPatterns.EMAIL_REGEX);
    }

    /**
     * 是否是无效验证码格式
     * @param code 要校验的验证码
     * @return true:符合,false:不符合
     */
    public static boolean isCodeInvalid(String code){
        return mismatch(code, RegexPatterns.VERIFY_CODE_REGEX);
    }

    // 校验是否不符合正则格式
    private static boolean mismatch(String str, String regex){
        if (StrUtil.isBlank(str)) {
            return true;
        }
        return !str.matches(regex);
    }
}
public abstract class RegexPatterns {
    /**
     * 手机号正则
     */
    public static final String PHONE_REGEX = "^1([38][0-9]|4[579]|5[0-3,5-9]|6[6]|7[0135678]|9[89])\\d{8}$";
    /**
     * 邮箱正则
     */
    public static final String EMAIL_REGEX = "^[a-zA-Z0-9_-]+@[a-zA-Z0-9_-]+(\\.[a-zA-Z0-9_-]+)+$";
    /**
     * 密码正则。4~32位的字母、数字、下划线
     */
    public static final String PASSWORD_REGEX = "^\\w{4,32}$";
    /**
     * 验证码正则, 6位数字或字母
     */
    public static final String VERIFY_CODE_REGEX = "^[a-zA-Z\\d]{6}$";

}

微信关注

编程那点事儿

编程那点事儿

阅读剩余
THE END