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 请求的接口名:类名#方法名[参数个数] * @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; }
-
到此限流方案完善
-