各位小伙伴们大家好!!,在平常的编写接口的过程中,一般都会遇到一个问题就是说关于我们接口限速的,如

  1. 同一用户恶意调用同一接口,导致接口压力过大
  2. 用户频繁提交的问题,有些操作是不必要的,所以我们需要对同一用户进行接口限速!!!

当然在我们整合第三方服务,如高德地图,微信小程序等等一些服务时,别人对接口的调用也做了限制如每日的调用次数,或者说QPS意思是接口每秒的响应效率等 因为设计到成本的问题,毕竟有钱就能使你变强,马爸爸说的,可不关我的事,当然实现方法有很多种,看具体的业务场景,此博文只提供一种思路或者一种实现方式!

java Controller限速传输 java限速器_aop

项目实际需求点

项目是一个基于微信小程序,为一个音乐节的前期宣传推广而开发的,存在用户给选手投票,但是每个用户的投票次数都是有次数限制的,大家也知道平常我们点赞的时候会连续点击,有些时候我们没有票数了,用户还在点击(可以通过前端限制解决),或者说用户直接拿到了我们接口的URL地址直接进行访问刷新会导致很多问题!!!!

需要掌握的知识点

springBoot,Redis,AOP,注解和反射,异常
不会也没关系,注释很详细可以当入门看

封装思路

  1. 通过注解标识我们的接口,对于同一用户的次数限制,限制时长区间是多少,快速使用接口的限速,实现快速可拔插的效果,特别方便
  2. 有了注解,我们肯定需要Handle去处理,标识过的此注解的接口
  3. 如果用户的访问次数超过我们的限制,抛出了异常需要手动捕获吗?当然这样是很麻烦的!我们可以使用自定义异常,和Spring提供的AOP和注解来实现异常中心捕获我们的自定义异常!

上代码

1.首先定义我们的注解,用于标注在我们的接口之上,代表需要增强的接口

/**
 * 创建
 * ip拦截器 注解
 * @author cc
 * @date 2020-06-26-9:36
 */
//元注解--->注解信息保留到class文件运行仍可获取
@Retention(RetentionPolicy.RUNTIME)
//元注解--->只能使用在我们的方法之上
@Target(ElementType.METHOD)
//元注解--->JavaDoc会生成此枚举的文档
@Documented
public @interface IpInterceptor {
    /**
     * @Description: 限制某时间段内可以访问的次数  (默认设置100)
     * @return
     */
    long requestCounts() default 100;

    /**
     * @Description: 限制访问的某一个时间段,单位为秒 (默认值1分钟即可)
     * @return
     */
    int expiresTimeSecond() default 60;

    /**
     * @Description:
     * 接口是否是restful风格 (默认false)--->因为Restful风格请求参数是直接跟在请求地址后,由于每次传递的参数不同也会导致,也就无法判断真正的请求地址,由此需要标注
     *
     * @return
     */
    boolean isRestful() default false;

    /**
     * @Description: restful风格参数个数 (默认0),用于restful风格请求地址的解析
     * @return
     */
    int restfulParamCounts() default 0;
}

2.注解的处理器 —>注解功能的具体实现

/**
 * 创建
 * IpInterceptor ip拦截器具体实现
 * @author cc
 * @date 2021-02-26-10:46
 */
//自定义类 切面
@Aspect
//注入Spring
@Component
@Slf4j
public class IpInterceptorImpl {

    @Autowired
    private RedisUtil redisUtil;

    @Autowired
    @Qualifier("myRedisTemplate")
    private RedisTemplate<String, Object> redisTemplate;


    //方法前置增强--->增强标注过IpInterceptor注解的方法
    @Before("@annotation(com.cc.util.IpInterceptor)")
    public void requestLimit(JoinPoint joinPoint) {

            long incr=0;

            // 1.获取HttpRequest
            ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
            HttpServletRequest request = attributes.getRequest();

            // 2.判断request不能为空  存在异常将被GlobalExceptionHandler统一捕获
            if (SuperUtil.isNullOrEmpty(request)) throw new IpExcetion(IpExcetionEnum.HTTP_ERROR);

            //3.获取切入方法上标识的注解信息
            IpInterceptor info = this.getAnnotation(joinPoint);

            //4.判断解析出来的注解对象不能为空 存在异常将被GlobalExceptionHandler统一捕获
            if(SuperUtil.isNullOrEmpty(info))throw new IpExcetion(IpExcetionEnum.GET_ANNOTATION_FAIL);

            //5.获取请求者的ip地址
            String requestIp = request.getRemoteAddr();

            //6.存储请求者请求的地址
            StringBuilder requestUrl = new StringBuilder();

            //7.对地址进行解析判断是否是,restful风格 如果是restful风格需要进行单独的解析,因为参数是拼接到我们的URL上的
            if(info.isRestful()){

                //根据/ 对请求的地址进行分割
                String[] urls = request.getRequestURI().split("/");
                //循环解析出请求地址  根据restfulParamCounts属性,忽略掉restful风格导致的参数,注意点--->循环要从1开始,因为url地址一开始就会有一个/
                for (int i = 1; i < urls.length-info.restfulParamCounts(); i++) {
                    requestUrl.append("/").append(urls[i]);
                }

            }else{
                requestUrl.append(request.getRequestURI());
            }


            //6.生成redisKey
            StringBuffer redisKey=new StringBuffer("REQUEST_LIMIT:").append(requestUrl).append("-").append(requestIp) ;

            //如果元素不为空 代表此key已设置
            if(redisUtil.hasKey(redisKey.toString())){

                //7.原子加 此key
                incr= redisUtil.incr(redisKey.toString(), 1);
                //8.如果计数大于我们的 限制次数 直接抛出异常由GlobalExceptionHandler统一捕获 403拒绝访问
                if(incr>info.requestCounts()){
                   throw  new IpExcetion(IpExcetionEnum.ACCESS_FREQUENCY_IS_TOO_FAST);
                }


            //9.如果元素为空 代表此key未设置 将递增操作和设置过期时间的操作放在了一个事务中,从而保证了两个操作的原子性
            //不这样做  比如客户端在执行INCR之后,没有成功设置EXPIRE时间。这个ip的key 会造成内存泄漏
            }else {
                //开启事务支持,在同一个 Connection 中执行命令 redisKey.toString()
                redisUtil.enableTransactionSupport();
                //开启事务
                redisUtil.multi();
                //原子加
                redisTemplate.opsForValue().increment(redisKey.toString());
                //设置过期时间
                redisTemplate.expire(redisKey.toString(), info.expiresTimeSecond(), TimeUnit.SECONDS);
                redisUtil.exec();//执行

            }

            log.debug("IP限速地址KEY--->"+redisKey+"---次数--->"+incr);
    }


    /**
     *
     * @Description: 获取切入方法标识的注解信息
     * @param joinPoint
     * @return
     * @author cc
     */
    private IpInterceptor getAnnotation(JoinPoint joinPoint) {

        //1.连接点处的签名
        Signature signature = joinPoint.getSignature();
        //2.强制转换成方法签名
        MethodSignature methodSignature = (MethodSignature) signature;
        //3.获取反射方法对象
        Method method = methodSignature.getMethod();
        //4.如果对象 不为空
        if(method!=null){
            //返回方法上标注的注解
            return method.getAnnotation(IpInterceptor.class);
        }
        return null;
    }



}

3.自定义异常类,用于规范统一,抛出异常

/**
 * 创建
 * ip自定义异常
 * @author cc
 * @date 2021-02-26-11:48
 */
@Data
//继承RuntimeException是不需要显示捕获异常的
public class IpExcetion extends RuntimeException {
    /**
     * 异常状态码
     */
    private int errorCode;


    public IpExcetion(IpExcetionEnum ipExcetionEnum) {
        //从枚举中获取需要的信息
        super(ipExcetionEnum.getMessage());
        this.errorCode = ipExcetionEnum.getCode();
    }

    public IpExcetion() {

    }



}

4.自定义异常枚举—>规范统一的抛出异常

/**
 * 创建
 * 异常枚举
 * @author cc
 * @date 2021-02-26-10:46
 */
enum IpExcetionEnum {

    HTTP_ERROR("HttpServletRequest有误",400),
    GET_ANNOTATION_FAIL("注解信息获取失败",500),
    ACCESS_FREQUENCY_IS_TOO_FAST("访问频率太快",403);

    /**
     * 异常消息
     */
    private String message;

    /**
     * 异常code
     */
    private int code;

    /**
     * 枚举的构造方法,是私有的哦!初始化枚举
     * @param message
     * @param code
     */
    private IpExcetionEnum(String message,int code){
        this.message=message;
        this.code=code;
    }

    public String getMessage() {
        return message;
    }

    public int getCode() {
        return code;
    }
}

5.异常处理中心—>负责异常统一处理的
注:Result是结果统一返回的对象

/**
 * 创建
 * 全局异常处理
 * @author cc
 * @date 2021-02-26-12:00
 */
@RestControllerAdvice   //捕捉正常返回json的Controller异常
//@ControllerAdvice //捕捉正常Controller的异常
public class GlobalExceptionHandler {

    /**
     * 捕获异常
     * @param e 异常类型为 IpExcetion 
     * @return
     */
    @ExceptionHandler(value = IpExcetion.class)
    public Result error(IpExcetion e) {
        return Result.handelLose(e.getMessage(),e.getErrorCode());
    }

}

6.实际使用

/**
 * 创建
 * 通用访问接口
 * @author cc
 * @date 2021-01-31-12:32
 */
@RestController
@RequestMapping("/common")
public class CommonController {


    @Autowired
    private UserDataService userDataService;


    @GetMapping( value = "/test/{id}",produces = MediaType.APPLICATION_JSON_VALUE)
    //限制接口对于同一用户,一秒钟可以访问3次,是一个restFul风格的接口,有一个参数不需要被解析
    @IpInterceptor(requestCounts =3,expiresTimeSecond = 1,isRestful = true,restfulParamCounts = 1)
    public Result videoUpload(@PathVariable("id")String id) {
            return Result.handelSuccess("处理成功--->"+id);
    }
}

7.使用效果

java Controller限速传输 java限速器_java Controller限速传输_02

可能这个时候就有人开喷了!!!

  • 要是多个用户在同一局域网呢,你怎么能拿得到用户真实的IP地址呢?
    1.可以做数据埋点啊!!! 我这是微信小程序,用户都是用手机访问的!!!
    2.这里一定要通过用户的IP地址吗???也可以通过用户的Token类似于访问凭证的这些东西!!!各有各的实现方式!!!但是思路都是加一层解决!