干翻全岛蛙蛙 发表于 2023-12-20 15:52:50

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


[*]实现思路
[*]在拦截器Interceptor中拦截请求
[*]通过地址+请求uri作为调用者访问接口的区分在Redis中进行计数达到限流目的

[*]简单实现
[*]定义参数
[*]访问周期
[*]最大访问次数
[*]禁用时长
#接口防刷配置,时间单位都是秒.如果second秒内访问次数达到times,就禁用lockTime秒
access:
limit:
    second: 10 #一段时间内
    times: 3#最大访问次数
    lockTime: 5 #禁用时长
[*]代码实现
[*]定义拦截器:实现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;
    }
[*]注册拦截器:配置类实现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);
    }
}
[*]自定义异常,方便错误提示。

/*
* @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 "访问次数过多,请稍候再试";
    }
}
[*]处理逻辑

/** 不使用自定义注解时的逻辑
*获取锁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;
}
[*]目前存在的问题

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


[*]优化一:自定义注解+反射
[*]定义注解

/*
* @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;
}
[*]将注解标注写需要限流的方法上

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

主要修改:通过反射获取到方法注解,判断是否需要进行限流,如果需要就获取注解中的参数进行处理
@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;
}
[*]目前存在的问题

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

[*]优化二:注解作用于类上
[*]添加注解作用范围

@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;
}
[*]修改处理逻辑

/**自定义注解可以作用在类上之后的逻辑
* 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 请求的接口名:类名#方法名[参数个数]
* @returntrue:禁用 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;
}
[*]到此限流方案完善


免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: SpringBoot定义拦截器+自定义注解+Redis实现接口防刷(限流)