SpringBoot定义拦截器+自定义注解+Redis实现接口防刷(限流)

发布时间 2023-11-14 11:52:42作者: 程长新
  1. 实现思路

    1. 在拦截器Interceptor中拦截请求
    2. 通过地址+请求uri作为调用者访问接口的区分在Redis中进行计数达到限流目的
  2. 简单实现

    1. 定义参数

      1. 访问周期
      2. 最大访问次数
      3. 禁用时长
      #接口防刷配置,时间单位都是秒.  如果second秒内访问次数达到times,就禁用lockTime秒
      access:
        limit:
          second: 10 #一段时间内
          times: 3  #最大访问次数
          lockTime: 5 #禁用时长
      
    2. 代码实现

      1. 定义拦截器:实现HandlerInterceptor接口,重写preHandle()方法
        @Slf4j
        @Component
        public class AccessLimintInterceptor implements HandlerInterceptor {
            
            @Resource
            private RedisTemplate redisTemplate;
        
            //锁住时的key前缀
            private static final String LOCK_PREFIX = "LOCK";
            //统计次数的key前缀
            private static final String COUNT_PREFIX = "COUNT";
        
            //访问周期
            @Value("${access.limit.second}")
            private long second;
            //访问周期内最大访问次数
            @Value("${access.limit.times}")
            private int times;
            //禁用时长
            @Value("${access.limit.lockTime}")
            private long lockTime;
            
            @Override
            public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
                return true;
            }
        
      2. 注册拦截器:配置类实现WebMvcConfigurer接口,重写addInterceptors()方法
        @Configuration
        public class WebConfig implements WebMvcConfigurer {
            @Resource
            private AccessLimintInterceptor accessLimintInterceptor;
        
            //在这个方法中注册拦截器
            @Override
            public void addInterceptors(InterceptorRegistry registry) {
                //注册拦截器
                InterceptorRegistration interceptorRegistration = registry.addInterceptor(accessLimintInterceptor);
                //配置要拦截的路径。优化为实现自定义注解,那就拦截所有路径,在拦截器中判断是否使用了注解,没使用就放行
        //        interceptorRegistration.addPathPatterns("/search/**");
                interceptorRegistration.addPathPatterns("/**");
                WebMvcConfigurer.super.addInterceptors(registry);
            }
        }
        
      3. 自定义异常,方便错误提示。
        /*
         * @Description TODO (自定义访问限制异常,防刷)
         * 创建人: 程长新
         * 创建时间:2023/11/12 8:46
         **/
        public class AccessLimitException extends RuntimeException{
            public AccessLimitException() {
            }
        
            public AccessLimitException(Throwable e) {
                super(e.getMessage(),e);
            }
        
            public AccessLimitException(String message) {
                super(message);
            }
        }
        

        添加全局异常捕捉

        /*
         * @Description TODO (全局异常处理)
         * 创建人: 程长新
         * 创建时间:2023/11/7 9:54
         **/
        @RestControllerAdvice
        public class AdviceController {
            @ExceptionHandler(Exception.class)
            public String exceptionHandler(HttpServletRequest request,
                                           HttpServletResponse response,
                                           Exception e){
                return e.getMessage();
            }
        
            @ExceptionHandler(AccessLimitException.class)
            public String exceptionHandler(AccessLimitException e){
                return "访问次数过多,请稍候再试";
            }
        }
        
      4. 处理逻辑
        /** 不使用自定义注解时的逻辑
        *获取锁key
        *  1 锁key为空,未被禁用,进入处理逻辑
        *      获取计数key
        *          1)计数key为空,说明首次访问,设置计数key为1,放行
        *          2)计数key不为空,判断是否达到最大访问次数
        *              (1)达到:返回错误提示
        *              (2)未达到:计数值+1
        *  2 锁key不为空,已被禁用,直接返回提示
        */
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
            log.info("进入拦截器");
            //获取访问的url和访问者ip
            String requestURI = request.getRequestURI();
            String remoteAddr = request.getRemoteAddr();
            String lockKey = LOCK_PREFIX + requestURI + remoteAddr;
            Object o = redisTemplate.opsForValue().get(lockKey);
            if (Objects.isNull(o)){
                //还未被禁用
                //查看当前访问次数
                String countKey = COUNT_PREFIX + requestURI + remoteAddr;
                Integer count = (Integer)redisTemplate.opsForValue().get(countKey);
                if (Objects.isNull(count)){
                    //首次访问
                    log.info("{}用户首次访问接口{}",remoteAddr,requestURI);
                    redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
                    log.info("访问次数写入redis");
                }else {
                    log.info("{}用户第{}次访问接口{}", remoteAddr, count + 1, requestURI);
                    //此用户在设置的一段时间内已经访问过该接口
                    //判断次数+1是否超过最大限制
                    if (count++ >= times){
                        //超过最大限制,禁用该用户对此接口的访问
                        log.info("{}用户访问接口{}已达到最大限制,禁用",remoteAddr,requestURI);
                        redisTemplate.opsForValue().set(lockKey, 1, lockTime, TimeUnit.SECONDS);
                        //返回提示
                        //                    throw new RuntimeException("服务器繁忙,请稍候再试");
                        throw new AccessLimitException();
                    }else {
                        //访问次数+1
                        ValueOperations valueOperations = redisTemplate.opsForValue();
                        valueOperations.set(countKey, count, second, TimeUnit.SECONDS);
                    }
                }
            }else {
                //已被禁用,返回提示
                throw new AccessLimitException();
            }
            return true;
        }
        
      5. 目前存在的问题

        此时已经简单实现了限流功能,但是上边配置拦截路径直接写了/**,是为了方便测试,但是如果正常开发应该不会写全部,应该单个配置,那么就要为每个接口添加配置,比较繁琐。并且现在对所有接口的限制都是一样的规则,时间都是一样的,如果想要有不同的时间规则,那么就需要设置多个过滤器,明显是不合适的,所以需要优化。

  3. 优化一:自定义注解+反射

    1. 定义注解

      /*
       * @Description TODO (自定义接口防刷注解)
       * 创建人: 程长新
       * 创建时间:2023/11/12 9:03
       **/
      @Target({ElementType.METHOD})//注解可以作用在方法上
      @Retention(RetentionPolicy.RUNTIME)
      @Documented
      public @interface AccessLimit {
          /**
           * 时间周期
           */
          long second() default 5L;
      
          /**
           * 最大访问次数
           */
          int times() default 3;
      
          /**
           * 禁用时长
           */
          long lockTime() default 3L;
      }
      
    2. 将注解标注写需要限流的方法上

      @AccessLimit(second = 10L, times = 5, lockTime = 2L)
      @GetMapping("/search")
      public String search(){
          return "进来了";
      }
      
    3. 修改处理逻辑

      主要修改:通过反射获取到方法注解,判断是否需要进行限流,如果需要就获取注解中的参数进行处理

      @Override
      public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
          log.info("进入拦截器");
          //判断拦截的是否为接口方法
          if (handler instanceof HandlerMethod){
              log.info("开始处理");
              //转化为目标方法对象
              HandlerMethod targetMethod = (HandlerMethod) handler;
              //获取对象的AccessLimit注解
              AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
              //如果获取到注解再进行处理,否则直接放行
              if(Objects.nonNull(accessLimit)){
                  //防刷处理逻辑
                  //获取访问的接口的访问者IP
                  String remoteAddr = request.getRemoteAddr();
                  String requestURI = request.getRequestURI();
                  //拼接锁key和计数key
                  String lockKey = LOCK_PREFIX + requestURI + remoteAddr;
                  String countKey = COUNT_PREFIX + requestURI + remoteAddr;
                  //从redis中获取锁值
                  Object o = redisTemplate.opsForValue().get(lockKey);
                  if (Objects.nonNull(o)){
                      log.info("用户{},访问{}接口,被禁用",remoteAddr,requestURI);
                      //获取锁值不为空说明已经禁用,直接返回
                      throw new AccessLimitException();
                  }else {
                      //未被禁用
                      //获取注解中设置的x,y,z时间值
                      long second1 = accessLimit.second();
                      int times1 = accessLimit.times();
                      long lockTime1 = accessLimit.lockTime();
                      //获取访问次数
                      Integer o1 = (Integer) redisTemplate.opsForValue().get(countKey);
                      if (Objects.isNull(o1)){
                          log.info("用户{},访问{}接口,首次访问",remoteAddr,requestURI);
                          //首次访问,保存访问次数为1
                          redisTemplate.opsForValue().set(countKey,1,second1,TimeUnit.SECONDS);
                      }else {
                          //判断访问次数
                          if (o1 == times1){
                              log.info("用户{},访问{}接口,达到次数限制被禁用",remoteAddr,requestURI);
                              //已经达到限制,禁用,返回
                              redisTemplate.opsForValue().set(lockKey,1,lockTime1,TimeUnit.SECONDS);
                              //删除计数key,已经禁用,这个也就没必要了
                              redisTemplate.delete(countKey);
                              throw new AccessLimitException();
                          }else {
                              log.info("用户{},访问{}接口,现在第{}次访问",remoteAddr,requestURI,(o1 + 1));
                              //次数加1
                              redisTemplate.opsForValue().set(countKey,++o1,second1,TimeUnit.SECONDS);
                          }
                      }
                  }
              }
          }
          return true;
      }
      
    4. 目前存在的问题

      对需要进行限流的每个方法得挨个添加注解,那么如果一个controller中的所以接口都需要限流处理的话,每个接口挨个添加注解的做法属实不怎么样。应该做到如果在一个controller上添加了注解,那么这个controller中的所以接口都进行限流,如果某个接口上也添加了注解,那么就采用就近原则使用接口上注解的参数。仍然需要优化

  4. 优化二:注解作用于类上

    1. 添加注解作用范围

      @Target({ElementType.METHOD, ElementType.TYPE})//添加ElementType.TYPE范围
      @Retention(RetentionPolicy.RUNTIME)
      @Documented
      public @interface AccessLimit {
          /**
           * 时间周期
           */
          long second() default 5L;
      
          /**
           * 最大访问次数
           */
          int times() default 3;
      
          /**
           * 禁用时长
           */
          long lockTime() default 3L;
      }
      
    2. 修改处理逻辑

      /**自定义注解可以作用在类上之后的逻辑
      * 1 获取类上的注解
      * 2 获取方法上的注解
      * 3 判断类是是否有注解
      *   1)类上没有
      *     判断方法上是否存在注解
      *       不存在:说明该接口不需要防刷,放行就可以
      *       存在:获取注解中的值,进行处理
      *   2)类上存在注解
      *     判断方法上是否存在注解
      *       不存在:说明该方法使用类上的统一配置
      *       存在:采用就近原则,使用方法上注解的值进行处理
      */
      @Override
      public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
          //判断拦截的是否为接口方法
          if (handler instanceof HandlerMethod){
              //转化为目标方法
              HandlerMethod targetMethod = (HandlerMethod) handler;
              //获取目标类上的注解
              //不可以直接使用targetMethod.getClass(),这样获取到的是HandlerMethod,不是真正想要的controller类
              //            Class<? extends HandlerMethod> aClass = targetMethod.getClass();
              Class<?> targetClass = targetMethod.getMethod().getDeclaringClass();
              AccessLimit classAccessLimit = targetClass.getAnnotation(AccessLimit.class);
              //获取目标方法上的注解,
              AccessLimit methodAccessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
              //类名#方法名[参数个数]
              String shortLogMessage = targetMethod.getShortLogMessage();
              long second = 0L;//一段时间内
              int times = 0;//最大访问次数
              long lockTime = 0L;//禁用时长
              if (Objects.nonNull(classAccessLimit)){
                  //类上存在注解
                  if (Objects.nonNull(methodAccessLimit)){
                      //方法上存在注解,就近原则,使用方法上注解的参数
                      second = methodAccessLimit.second();
                      times = methodAccessLimit.times();
                      lockTime = methodAccessLimit.lockTime();
                  }else {
                      second = classAccessLimit.second();
                      times = classAccessLimit.times();
                      lockTime = classAccessLimit.lockTime();
                  }
                  //只传uri的话,如果请求中含有路径参数,那么请求同一个接口但传递不同参数也会记录为不同的key,就会导致防刷失效,所以将uri改为类名+方法名
                  if(isLimit(second, times, lockTime, request.getRemoteAddr(), shortLogMessage)){
                      throw new AccessLimitException();
                  }
              }else {
                  //类上不存在注解
                  //判断方法上是否存在
                  if (Objects.nonNull(methodAccessLimit)){
                      //方法上存在注解
                      second = methodAccessLimit.second();
                      times = methodAccessLimit.times();
                      lockTime = methodAccessLimit.lockTime();
                      if(isLimit(second, times, lockTime, request.getRemoteAddr(), shortLogMessage)){
                          throw new AccessLimitException();
                      }
                  }
                  //方法上不存在,不用分支了,直接到最后return true
              }
          }
          return true;
      }
      
      /**
      * 判断该ip访问此uri是否已经被限制
      * @param second
      * @param times
      * @param lockTime
      * @param ip
      * @param uri 请求的接口名:类名#方法名[参数个数]
      * @return  true:禁用 false:未禁用
      */
      public boolean isLimit(long second, int times, long lockTime, String ip, String uri){
          String lockKey = LOCK_PREFIX + ip + uri;
          String countKey = COUNT_PREFIX + ip + uri;
          Object o = redisTemplate.opsForValue().get(lockKey);
          if (Objects.nonNull(o)){
              log.info("用户{},访问{}接口,被禁用",ip,uri);
              //获取锁值不为空说明已经禁用,直接返回
              return true;
          }else {
              //未被禁用
              //获取访问次数
              Integer o1 = (Integer) redisTemplate.opsForValue().get(countKey);
              if (Objects.isNull(o1)){
                  log.info("用户{},访问{}接口,首次访问",ip,uri);
                  //首次访问,保存访问次数为1
                  redisTemplate.opsForValue().set(countKey,1,second,TimeUnit.SECONDS);
              }else {
                  //判断访问次数
                  if (o1 == times){
                      log.info("用户{},访问{}接口,达到次数限制被禁用",ip,uri);
                      //已经达到限制,禁用,返回
                      redisTemplate.opsForValue().set(lockKey,1,lockTime,TimeUnit.SECONDS);
                      //删除计数key,已经禁用,这个也就没必要了
                      redisTemplate.delete(countKey);
                      return true;
                  }else {
                      log.info("用户{},访问{}接口,现在第{}次访问",ip,uri,(o1 + 1));
                      //次数加1
                      //                    redisTemplate.opsForValue().set(countKey,++o1,second,TimeUnit.SECONDS);
                      Long increment = redisTemplate.opsForValue().increment(countKey);
                  }
              }
          }
          return false;
      }
      
    3. 到此限流方案完善