对于某些特定的接口,为了防止数据碰撞等问题,可限制接口对同一IP在一段时间内的访问次数。本文使用注解方式:
1.导入需要的依赖
<!-- redis依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- AOP依赖 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <!-- Map依赖 --> <dependency> <groupId>net.jodah</groupId> <artifactId>expiringmap</artifactId> <version>0.5.8</version> </dependency>
2.定义注解
定义注解InterfaceLimit,用于接口拦截
package com.zxh.example.anno; import java.lang.annotation.*; /** * 接口访问频率注解,默认一分钟只能访问5次 */ @Documented @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface InterfaceLimit { long time() default 60000; // 限制时间 单位:毫秒(默认值:一分钟) int value() default 5; // 允许请求的次数(默认值:5次) }
3.在切面做限制
在切面中对接口进行限制。
1)服务为单节点
package com.zxh.example.anno; import lombok.extern.slf4j.Slf4j; import net.jodah.expiringmap.ExpirationPolicy; import net.jodah.expiringmap.ExpiringMap; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; @Aspect @Component @Slf4j public class InterfaceLimitAspect { private static ConcurrentHashMap<String, ExpiringMap<String, Integer>> book = new ConcurrentHashMap<>(); @Autowired private StringRedisTemplate redisTemplate; /** * 层切点 */ @Pointcut("@annotation(interfaceLimit)") public void controllerAspect(InterfaceLimit interfaceLimit) { } @Around("controllerAspect(interfaceLimit)") public Object doAround(ProceedingJoinPoint pjp, InterfaceLimit interfaceLimit) throws Throwable { // 获得request对象 RequestAttributes ra = RequestContextHolder.getRequestAttributes(); ServletRequestAttributes sra = (ServletRequestAttributes) ra; HttpServletRequest request = sra.getRequest(); // 获取Map value对象, 如果没有则返回默认值 // getOrDefault获取参数,获取不到则给默认值 ExpiringMap<String, Integer> uc = book.getOrDefault(request.getRequestURI(), ExpiringMap.builder().variableExpiration().build()); Integer uCount = uc.getOrDefault(request.getRemoteAddr(), 0); if (uCount >= interfaceLimit.value()) { // 超过次数,不执行目标方法 log.error("接口拦截:{} 请求超过限制频率【{}次/{}ms】,IP为{}", request.getRequestURI(), interfaceLimit.value(), interfaceLimit.time(), request.getRemoteAddr()); return "请求过于频繁,请稍后再试"; } else if (uCount == 0) { // 第一次请求时,设置有效时间 uc.put(request.getRemoteAddr(), uCount + 1, ExpirationPolicy.CREATED, interfaceLimit.time(), TimeUnit.MILLISECONDS); } else { // 未超过次数, 记录加一 uc.put(request.getRemoteAddr(), uCount + 1); } book.put(request.getRequestURI(), uc); // result的值就是被拦截方法的返回值 Object result = pjp.proceed(); return result; } }
使用ConcurrentHashMap和ExpiringMap来对请求路径和ip进行限制。其中ConcurrentHashMap可以处理并发情况的 HashMap,ExpiringMap为单个元素设置过期时间。
2)服务为多节点(推荐)
由于ConcurrentHashMap是基于线程的,当服务为多节点时,只能在当前服务有效,那么就会造成实际接口的限制大于规定的限制。
这时可借助redis进行限制。验证的方法修改如下:
@Around("controllerAspect(interfaceLimit)") public Object doAround(ProceedingJoinPoint pjp, InterfaceLimit interfaceLimit) throws Throwable { // 获得request对象 RequestAttributes ra = RequestContextHolder.getRequestAttributes(); ServletRequestAttributes sra = (ServletRequestAttributes) ra; HttpServletRequest request = sra.getRequest(); //redis这里推荐使用hash类型,url为外层key,ip作为内层key,访问次数作为value BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps("test:interfaceLimit:" + request.getRequestURI()); String ipCnt = (String) ops.get(request.getRemoteAddr()); Integer uCount = ipCnt == null ? 0 : "".equals(ipCnt) ? 0 : Integer.parseInt(ipCnt); if (uCount >= interfaceLimit.value()) { // 超过次数,不执行目标方法 log.error("接口拦截:{} 请求超过限制频率【{}次/{}ms】,IP为{}", request.getRequestURI(), interfaceLimit.value(), interfaceLimit.time(), request.getRemoteAddr()); return "请求过于频繁,请稍后再试"; } else { //请求时,设置有效时间, 记录加一 ops.increment(request.getRemoteAddr(), 1); ops.expire(interfaceLimit.time(), TimeUnit.MILLISECONDS); } // result的值就是被拦截方法的返回值 Object result = pjp.proceed(); return result; }
4.在接口中使用注解
package com.zxh.example.controller; import com.zxh.example.anno.InterfaceLimit; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api") @Slf4j public class TestController { @InterfaceLimit @GetMapping("/test") public String test() { return "123"; } @InterfaceLimit(value = 10) @GetMapping("/test2") public String test2() { return "1234"; } }
可使用默认的访问频率,也可自行传值。
本地启动后在1分钟内连续访问/api/test接口(/api/test2同),当未超过5次时,访问正常,超过5次时,就显示请求过于频繁,请稍后再试。控制台打印结果: