怎麼在 Java 中限制用戶訪問頻率

怎麼在 Java 中限制用戶訪問頻率

我們當然要限制用戶訪問頻率,因爲用戶可能生氣,並狂點我們的網站或應用。

他也可能很壞,使用一些爬蟲試圖拖垮我們的服務器。

所以怎麼實現呢?

本文使用 springboot,並將用戶的信息和訪問頻率記錄到 redis 中,如果你沒有使用 redis,也不影響,你可以參考着自己實現,比如存儲到內存或數據庫中。

想想這個需求,從第一性原理出發

用戶可能沒有登錄,或者已經登錄了。

如果用戶登錄了,我們就根據用戶名來限制,否則,就根據IP或者其它設備碼來限制,本文假設使用IP。

我們希望它足夠簡單,可以在多個方法上使用,而不需要編寫額外的代碼。所以我們要使用接口切面。

接口

 1public @interface RequestRateLimit {
 2
 3	/**
 4	 * 限流的key,比如限制用戶註冊,限制用戶發送郵件,等等,一般是方法名
 5	 * @return
 6	 */
 7	String key() default "";
 8
 9	/**
10	 * 限流模式,默認單機
11	 * @return
12	 */
13	RateType type() default RateType.PER_CLIENT;
14
15	/**
16	 * 限流速率,1次/分鐘
17	 * @return
18	 */
19	long rate() default 1;
20
21	/**
22	 * 限流速率,每分鐘
23	 * @return
24	 */
25	long rateInterval() default 60 * 1000;
26
27	/**
28	 * 限流速率單位
29	 * @return
30	 */
31	RateIntervalUnit timeUnit() default RateIntervalUnit.MILLISECONDS;
32
33}

切面

你可以直接拷貝這些代碼並測試。

 1public class RequestRateLimitAspect {
 2
 3	private RedissonClient redisson;
 4	private final UserService userService;
 5
 6	/**
 7	 * 根據自定義註解獲取切點
 8	 *
 9	 * @param RequestRateLimit 註解接口
10	 */
11	@Pointcut("@annotation(RequestRateLimit)")
12	public void findAnnotationPointCut(RequestRateLimit RequestRateLimit) {
13	}
14
15	@Around(value = "findAnnotationPointCut(requestRateLimit)", argNames = "joinPoint,requestRateLimit")
16	public Object around(ProceedingJoinPoint joinPoint, RequestRateLimit requestRateLimit) throws Throwable {
17		UserEntity user = userService.getCurrentRequestUser(); // 只是封裝了 SecurityContextHolder.getContext().getAuthentication().getPrincipal();
18		String realIp = "";
19		if (user == null) {
20			RequestAttributes ra = RequestContextHolder.getRequestAttributes();
21			ServletRequestAttributes sra = (ServletRequestAttributes) ra;
22			if (null != sra) {
23				HttpServletRequest request = sra.getRequest();
24				realIp = request.getHeader("His-Real-IP");
25				if (notValidIp(realIp)) {
26					realIp = request.getHeader("His-Real-IP2");
27					if (notValidIp(realIp)) {
28						realIp = request.getRemoteAddr();
29					}
30				}
31			}
32		}
33		if (user == null && notValidIp(realIp)) {
34			return R.failed(EMPTY_USER, "未找到您的任何登錄信息");
35		}
36		// 限流攔截器
37		String key = user == null || StrUtil.isBlank(user.getUserName()) ? realIp : user.getUserName();
38		key = key + "::" + joinPoint.getSignature().getName();
39		RRateLimiter limiter = getRateLimiter(requestRateLimit, key);
40		if (limiter.tryAcquire(1)) {
41			return joinPoint.proceed();
42		} else {
43			log.info("rate-limit: {} {} {}", user == null ? "" : user.getUserName(), realIp, joinPoint.getSignature());
44			return R.failed(REACH_REQUEST_LIMIT, String.format("請求過於頻繁,請於以下時間後重試:%s %s", requestRateLimit.rateInterval(), requestRateLimit.timeUnit().name().toLowerCase()));
45		}
46	}
47
48	private boolean notValidIp(String ip) {
49		return StrUtil.isBlank(ip) || ip.startsWith("172.1"); // docker bridge ip
50	}
51
52	/**
53	 * 獲取限流攔截器
54	 *
55	 * @param limit  在要限流的方法上的配置
56	 * @param defaultKey 在redis中的存儲的key
57	 * @return 限流器
58	 */
59	private RRateLimiter getRateLimiter(RequestRateLimit limit, String defaultKey) {
60		RRateLimiter rRateLimiter = redisson.getRateLimiter(StrUtil.isBlank(limit.key()) ? RATE_LIMITER + "::" + defaultKey : limit.key()); // RATE_LIMITER 隨意起名,比如可以使用你的項目名稱,只是爲了在redis中好區分
61		// 設置限流
62		if (rRateLimiter.isExists()) {
63			RateLimiterConfig existed = rRateLimiter.getConfig();
64			// 判斷配置是否更新,如果更新,重新加載限流器配置
65			if (!Objects.equals(limit.rate(), existed.getRate())
66					|| !Objects.equals(limit.timeUnit().toMillis(limit.rateInterval()), existed.getRateInterval())
67					|| !Objects.equals(limit.type(), existed.getRateType())) {
68				rRateLimiter.delete();
69				rRateLimiter.trySetRate(limit.type(), limit.rate(), limit.rateInterval(), limit.timeUnit());
70				expireByConfig(rRateLimiter, limit);
71			}
72		} else {
73			rRateLimiter.trySetRate(limit.type(), limit.rate(), limit.rateInterval(), limit.timeUnit());
74			expireByConfig(rRateLimiter, limit);
75		}
76
77		return rRateLimiter;
78	}
79
80	private void expireByConfig(RRateLimiter rRateLimiter, RequestRateLimit limit) {
81		// ttl 設置爲 rateLimit 配置時間 + 5s
82		long limitDuration = limit.timeUnit().toMillis(limit.rateInterval()) + 5000;
83		// 設置過期時間,從現在算起 + 以上計算的時間。超時時間到後會刪除一下幾個key
84		// 1) "{rr_limiter::username}:value:***********"
85		// 2) "{rr_limiter::username}:permits:***********"
86		// 3) "rr_limiter::username"
87		rRateLimiter.expire(Instant.now().plusMillis(limitDuration));
88	}
89}

使用

1    @GetMapping("/info")
2	@RequestRateLimit(rate = 2, rateInterval = 1, timeUnit = RateIntervalUnit.MINUTES) // 1 分鐘允許請求 2 次
3	public R getInfo() {
4        // ...
5    }

當用戶請求 /info 接口的時候,redis 中就會存儲一個 RATE_LIMITER::his_user_name::com.package.getInfo 這樣的 key。當該用戶在1分鐘內請求該接口超過2次,那麼他將會收到報錯,並且 getInfo 方法並不會執行。

注意該註解無法作用於 @Cacheable 註釋的方法上。

更多

我們可以實現一個自定義的頻率限制,可以限制任意的方法,比如發送給運維人員的緊急郵件,如果同一主題發送過了,在5分鐘內不要再次發送。

  1public @interface CustomRateLimit {
  2    /**
  3     * key 的前綴,用於一組相同功能限流的標記
  4     * @return
  5     */
  6    String prefix() default "";
  7
  8    /**
  9     * 限流的 key,要求不爲空,支持從參數中讀取
 10     * @return
 11     */
 12    String key() default "#key";
 13
 14    /**
 15     * 限流模式,默認單機
 16     * @return
 17     */
 18    RateType type() default RateType.PER_CLIENT;
 19
 20    /**
 21     * 限流速率,1次/分鐘
 22     * @return
 23     */
 24    long rate() default 1;
 25
 26    /**
 27     * 限流速率,每分鐘
 28     * @return
 29     */
 30    long rateInterval() default 60 * 1000;
 31
 32    /**
 33     * 限流速率單位
 34     * @return
 35     */
 36    RateIntervalUnit timeUnit() default RateIntervalUnit.MILLISECONDS;
 37
 38}
 39
 40public class CustomRateLimitAspect {
 41
 42	private final RedissonClient redisson;
 43	/**
 44	 * 根據自定義註解獲取切點
 45	 *
 46	 * @param CustomRateLimit 註解接口
 47	 */
 48	@Pointcut("@annotation(CustomRateLimit)")
 49	public void findAnnotationPointCut(CustomRateLimit CustomRateLimit) {
 50	}
 51
 52	@Around(value = "findAnnotationPointCut(customRateLimit)", argNames = "joinPoint,customRateLimit")
 53	public Object around(ProceedingJoinPoint joinPoint, CustomRateLimit customRateLimit) throws Throwable {
 54		// 限流攔截器
 55		String key = getKey(joinPoint, customRateLimit);
 56		RRateLimiter limiter = getRateLimiter(customRateLimit, key);
 57		if (limiter.tryAcquire(1)) {
 58			return joinPoint.proceed();
 59		} else {
 60			log.info("skip method cause violate rate limit, key is {}", key);
 61			return R.failed(REACH_REQUEST_LIMIT, String.format("請求過於頻繁,請於以下時間後重試:%s %s", customRateLimit.rateInterval(), customRateLimit.timeUnit().name().toLowerCase()));
 62		}
 63	}
 64
 65	/**
 66	 * 獲取限流攔截器
 67	 *
 68	 * @param limit  在要限流的方法上的配置
 69	 * @return 限流器
 70	 */
 71	private RRateLimiter getRateLimiter(CustomRateLimit limit, String key) {
 72		RRateLimiter rRateLimiter = redisson.getRateLimiter(CUSTOM_RATE_LIMITER_PREFIX + "::" + limit.prefix() + "::" + key);
 73		// 設置限流
 74		if (rRateLimiter.isExists()) {
 75			RateLimiterConfig existed = rRateLimiter.getConfig();
 76			// 判斷配置是否更新,如果更新,重新加載限流器配置
 77			if (!Objects.equals(limit.rate(), existed.getRate())
 78					|| !Objects.equals(limit.timeUnit().toMillis(limit.rateInterval()), existed.getRateInterval())
 79					|| !Objects.equals(limit.type(), existed.getRateType())) {
 80				rRateLimiter.delete();
 81				rRateLimiter.trySetRate(limit.type(), limit.rate(), limit.rateInterval(), limit.timeUnit());
 82				expireByConfig(rRateLimiter, limit);
 83			}
 84		} else {
 85			rRateLimiter.trySetRate(limit.type(), limit.rate(), limit.rateInterval(), limit.timeUnit());
 86			expireByConfig(rRateLimiter, limit);
 87		}
 88
 89		return rRateLimiter;
 90	}
 91
 92	private void expireByConfig(RRateLimiter rRateLimiter, CustomRateLimit limit) {
 93		long limitDuration = limit.timeUnit().toMillis(limit.rateInterval()) + 5000;
 94		rRateLimiter.expire(Instant.now().plusMillis(limitDuration));
 95	}
 96
 97	// el表達式支持
 98	private String getKey(JoinPoint joinPoint, CustomRateLimit customRateLimit) {
 99		ExpressionParser expressionParser = new SpelExpressionParser();
100		Expression expression = expressionParser.parseExpression(customRateLimit.key());
101		CodeSignature methodSignature = (CodeSignature) joinPoint.getSignature();
102		String[] sigParamNames = methodSignature.getParameterNames();
103		EvaluationContext context = new StandardEvaluationContext();
104		Object[] args = joinPoint.getArgs();
105		for (int i = 0; i < sigParamNames.length; i++) {
106			context.setVariable(sigParamNames[i], args[i]);
107		}
108		return (String) expression.getValue(context);
109	}
110}

使用

1	@Override
2	@CustomRateLimit(prefix = Constants.Cache.EMAIL_RATE_LIMITER, rateInterval = 5, timeUnit = RateIntervalUnit.MINUTES) // 5分鐘最多一次
3	public void sendToMaintainersWithFrequencyLimit(String key, String subject, String... content) {
4		sendToMaintainers("[緊急通知]",  subject, content);
5	}