接口防刷!利用Redisson快速實(shí)現(xiàn)自定義限流注解
問(wèn)題:
在日常開(kāi)發(fā)中,一些重要的對(duì)外接口,需要添加訪問(wèn)頻率限制,以免造成資產(chǎn)損失。
如登錄接口,當(dāng)用戶使用手機(jī)號(hào)+驗(yàn)證碼登錄時(shí),一般我們會(huì)生成6位數(shù)的隨機(jī)驗(yàn)證碼,并將驗(yàn)證碼有效期設(shè)置為1-3分鐘,如果對(duì)登錄接口不加以限制,理論上,通過(guò)技術(shù)手段,快速重試100000次,即可將驗(yàn)證碼窮舉出來(lái)。
解決思路:
對(duì)登錄接口加上限流操作,如限制一分鐘內(nèi)最多登錄5次,登錄次數(shù)過(guò)多,就返回失敗提示,或者將賬號(hào)鎖定一段時(shí)間。
實(shí)現(xiàn)手段:
利用redis的有序集合即Sorted Set數(shù)據(jù)結(jié)構(gòu),構(gòu)造一個(gè)令牌桶來(lái)實(shí)施限流。而redisson已經(jīng)幫我們封裝成了RRateLimiter,通過(guò)redisson,即可快速實(shí)現(xiàn)我們的目標(biāo)。
1. 定義一個(gè)限流注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface GlobalRateLimiter {
String key();
long rate();
long rateInterval() default 1L;
RateIntervalUnit rateIntervalUnit() default RateIntervalUnit.SECONDS;
}
2. 利用aop進(jìn)行切面
@Aspect
@Component
@Slf4j
public class GlobalRateLimiterAspect {
@Resource
private Redisson redisson;
@Value("${spring.application.name}")
private String applicationName;
private final DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
@Pointcut(value = "@annotation(com.zj.demoshow.annotion.GlobalRateLimiter)")
public void cut() {
}
@Around(value = "cut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
String className = method.getDeclaringClass().getName();
String methodName = method.getName();
GlobalRateLimiter globalRateLimiter = method.getDeclaredAnnotation(GlobalRateLimiter.class);
Object[] params = joinPoint.getArgs();
long rate = globalRateLimiter.rate();
String key = globalRateLimiter.key();
long rateInterval = globalRateLimiter.rateInterval();
RateIntervalUnit rateIntervalUnit = globalRateLimiter.rateIntervalUnit();
if (key.contains("#")) {
ExpressionParser parser = new SpelExpressionParser();
StandardEvaluationContext ctx = new StandardEvaluationContext();
String[] parameterNames = discoverer.getParameterNames(method);
if (parameterNames != null) {
for (int i = 0; i < parameterNames.length; i++) {
ctx.setVariable(parameterNames[i], params[i]);
}
}
Expression expression = parser.parseExpression(key);
Object value = expression.getValue(ctx);
if (value == null) {
throw new RuntimeException("key無(wú)效");
}
key = value.toString();
}
key = applicationName + "_" + className + "_" + methodName + "_" + key;
log.info("設(shè)置限流鎖key={}", key);
RRateLimiter rateLimiter = this.redisson.getRateLimiter(key);
if (!rateLimiter.isExists()) {
log.info("設(shè)置流量,rate={},rateInterval={},rateIntervalUnit={}", rate, rateInterval, rateIntervalUnit);
rateLimiter.trySetRate(RateType.OVERALL, rate, rateInterval, rateIntervalUnit);
//設(shè)置一個(gè)過(guò)期時(shí)間,避免key一直存在浪費(fèi)內(nèi)存,這里設(shè)置為延長(zhǎng)5分鐘
long millis = rateIntervalUnit.toMillis(rateInterval);
this.redisson.getBucket(key).expire(Long.sum(5 * 1000 * 60, millis), TimeUnit.MILLISECONDS);
}
boolean acquire = rateLimiter.tryAcquire(1);
if (!acquire) {
//這里直接拋出了異常 也可以拋出自定義異常,通過(guò)全局異常處理器攔截進(jìn)行一些其他邏輯的處理
throw new RuntimeException("請(qǐng)求頻率過(guò)高,此操作已被限制");
}
return joinPoint.proceed();
}
}
ok,通過(guò)以上兩步,即可完成我們的限流注解了,下面通過(guò)一個(gè)接口驗(yàn)證下效果。
新建一個(gè)controller,寫一個(gè)模擬登錄的方法。
@RestController
@RequestMapping(value = "/user")
public class UserController {
@PostMapping(value = "/testForLogin")
//以account為鎖的key,限制每分鐘最多登錄5次
@GlobalRateLimiter(key = "#params.account", rate = 5, rateInterval = 60)
R<Object> testForLogin(@RequestBody @Validated LoginParams params) {
//登錄邏輯
return R.success("登錄成功");
}
}
啟動(dòng)服務(wù),通過(guò)postman訪問(wèn)此接口進(jìn)行驗(yàn)證。
可以看到,在第6次訪問(wèn)接口的時(shí)候,拋出了請(qǐng)求限制的異常。
注意點(diǎn):
設(shè)置key的時(shí)候,一定要注意唯一性,比如登錄接口,可以將登錄賬號(hào)作為唯一性,查詢某個(gè)人的訂單記錄時(shí),將用戶id作為唯一性,要避免無(wú)意義的key,以免誤造成全局接口的限流。
設(shè)置rateLimiter的rate時(shí),RateType有兩種模式:全局 or 客戶端,可以根據(jù)需求自主設(shè)置,一般都使用全局。