Java接口防刷策略(自定义注解实现)
前言
本文一定要看完,前部分為邏輯說明及簡單實現(xiàn),文章最后有最終版解決方案(基于lua腳本),因為前部分是防君子不防小人,無法抵擋for循環(huán)調用。
目的
- 短信發(fā)送及短信驗證碼校驗接口防刷
一方面防止用戶循環(huán)調用刷短信驗證碼
另一方面防止用戶循環(huán)調用測短信驗證碼(一般短信驗證碼為6位純數(shù)字,一秒鐘上百次調用,如果不做限制很快就能試出來了) - 很多接口需要防止前端重復調用
誤操作多次點擊,不屬于攻擊類型,正常用戶經常會觸發(fā)的,例如信息發(fā)布可能前端限制未做好,誤點擊了多次,這種情況實際上應該只記錄第一次的,后續(xù)的不應該繼續(xù)操作數(shù)據(jù)庫。 - 極端的情況
可能很多接口一天或者很長時間只能調用一次(類似簽到?個人想法是盡量不讓數(shù)據(jù)到了數(shù)據(jù)庫層再拋異常)
解決措施
利用Spring AOP理念,自定義注解實現(xiàn)接口級訪問次數(shù)限制
訪問次數(shù)記錄使用Redis存儲,Redis的過期機制很適合當前場景,而且可以在更大程度上提升性能
-
定義注解
package com.cong.core.rate;import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target;@Target({ ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RateLimit {/** 周期,單位是秒 */int cycle() default 5;/** 請求次數(shù) */int number() default 1;/** 默認提示信息 */String msg() default "請勿重復點擊"; }默認是5秒調用一次,現(xiàn)在網上一大堆腳本,貼吧發(fā)帖跟帖自動化,實際上打字點擊發(fā)帖的正常頻率也不會超過2秒一次吧,但是機器很容易就超過這個速度了,在一定程度上也可以限制這種情況的發(fā)生。
接口級限制,所以當前注解只作用在方法上。 -
定義接口訪問頻次限制接口
package com.cong.core.rate;public interface RateLimitService {/*** 接口頻次限制校驗* * @param ip* 客戶端IP* @param uri* 請求接口名* @param rateLimit* 限制頻次信息* @return* @author single-聰* @date 2020年6月1日* @version 1.6.1*/Boolean limit(String ip, String uri, RateLimit rateLimit); }因為Interceptor攔截器最終返回值是true或false,所以當前接口返回值為boolean類型。
關于參數(shù),可以設法獲取設備Mac地址,對于某些明顯是攻擊的IP及設備封禁。 -
RateLimitService接口默認實現(xiàn)類
package com.cong.core.rate;import java.util.concurrent.TimeUnit; import org.springframework.data.redis.core.RedisTemplate; import lombok.extern.slf4j.Slf4j;@Slf4j public class DefaultRateLimitServiceImpl implements RateLimitService {private RedisTemplate<String, Integer> redisTemplate;public void setRedisTemplate(RedisTemplate<String, Integer> redisTemplate) {this.redisTemplate = redisTemplate;}@Overridepublic Boolean limit(String ip, String uri, RateLimit rateLimit) {log.info("默認的實現(xiàn),請自定義實現(xiàn)類覆蓋當前實現(xiàn)");String key = "rate:" + ip + ":" + uri;// 緩存中存在key,在限定訪問周期內已經調用過當前接口if (redisTemplate.hasKey(key)) {// 訪問次數(shù)自增1redisTemplate.opsForValue().increment(key, 1);// 超出訪問次數(shù)限制if (redisTemplate.opsForValue().get(key) > rateLimit.number()) {return false;}// 未超出訪問次數(shù)限制,不進行任何操作,返回true} else {// 第一次設置數(shù)據(jù),過期時間為注解確定的訪問周期redisTemplate.opsForValue().set(key, 1, rateLimit.cycle(), TimeUnit.SECONDS);}return true;} }默認實現(xiàn)類中使用Redis作為存儲策略,加上下面的Bean注入策略你就可以自定義接口實現(xiàn)類使用自己的存儲方式了。
-
Bean配置
package com.cong.core.rate;import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.core.RedisTemplate;@Configuration public class RateLimitBeanConfig {@Autowiredprivate RedisTemplate<String, Integer> redisTemplate;@Bean@ConditionalOnMissingBean(RateLimitService.class)public RateLimitService rateLimitService() {DefaultRateLimitServiceImpl defaultRateLimitServiceImpl = new DefaultRateLimitServiceImpl();defaultRateLimitServiceImpl.setRedisTemplate(redisTemplate);return defaultRateLimitServiceImpl;} }此配置意為讓用戶編寫接口實現(xiàn)類覆蓋默認實現(xiàn)。
-
定義攔截器
package com.cong.core.rate;import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;@Component public class RateLimitInterceptor extends HandlerInterceptorAdapter {private RateLimitService rateLimitService;public void setRateLimitService(RateLimitService rateLimitService) {this.rateLimitService = rateLimitService;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception {// 判斷請求是否屬于方法的請求if (handler instanceof HandlerMethod) {HandlerMethod handlerMethod = (HandlerMethod) handler;// 獲取方法中的注解,看是否有該注解RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);if (rateLimit == null) {return true;}// 請求IP地址String ip = request.getRemoteAddr();// 請求url路徑String uri = request.getRequestURI();return rateLimitService.limit(ip, uri, rateLimit);}return true;} }重點,只對添加了@RateLimit注解的接口進行訪問頻次限制。
-
配置攔截器
package com.cong.config;import com.cong.core.rate.RateLimitInterceptor; import com.cong.core.rate.RateLimitService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;@Configuration public class WebMvcConfig extends WebMvcConfigurationSupport {@Autowiredprivate RateLimitService rateLimitService;@Overrideprotected void addInterceptors(InterceptorRegistry registry) {RateLimitInterceptor rateLimitInterceptor = new RateLimitInterceptor();rateLimitInterceptor.setRateLimitService(rateLimitService);registry.addInterceptor(rateLimitInterceptor);} }文中的很多地方接口使用set方式注入,是為了防止接口注入失敗,報錯空指針異常(應該很多人遇到過)。
使用
-
使用注解
@RestController @RequestMapping("open/public") public class OpenPublicController {@RateLimit(number = 2, cycle = 10)@PostMapping("rate")public void rate() {throw new VersionException();} }
上述注解的作用是10秒內可以請求兩次,其他的請求就不處理了,VersionException是我自定義的異常,用于提示用戶升級新版本,在2次內返回用戶正常提示信息:
{"state": 1000,"msg": "請升級到新版本","data": null }超出限制后無返回信息(RateLimitInterceptor攔截器中返回的是false,直接結束了這次請求,同時未向前端返回任何信息,實際開發(fā)中應該會返回提示信息,補充內容中解決這個問題)
補充
關于攔截器中接口調用超出限制頻次的自定義返回:
package com.cong.core.rate;import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse;import com.cong.core.support.ReturnData; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;import com.fasterxml.jackson.databind.ObjectMapper;@Component public class RateLimitInterceptor extends HandlerInterceptorAdapter {private RateLimitService rateLimitService;public void setRateLimitService(RateLimitService rateLimitService) {this.rateLimitService = rateLimitService;}private ObjectMapper objectMapper;public void setObjectMapper(ObjectMapper objectMapper) {this.objectMapper = objectMapper;}@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception {// 判斷請求是否屬于方法的請求if (handler instanceof HandlerMethod) {HandlerMethod handlerMethod = (HandlerMethod) handler;// 獲取方法中的注解,看是否有該注解RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);if (rateLimit == null) {return true;}// 請求IP地址String ip = request.getRemoteAddr();// 請求url路徑String uri = request.getRequestURI();if (!rateLimitService.limit(ip, uri, rateLimit)) {response.setContentType("application/json;charset=UTF-8");response.getWriter().write(objectMapper.writeValueAsString(new ReturnData(rateLimit.msg())));response.setStatus(HttpStatus.OK.value());return false;}}return true;} }注入ObjectMapper 需要set一下。
ReturnData是封裝的返回值信息,前端可以根據(jù)這個給用戶友好的提示,后端也可以自定義提示信息。
不過建議是自定義失敗處理器,這樣所有的錯誤統(tǒng)一走失敗處理器,更方便以后的代碼維護,這里只是為了實現(xiàn)接口頻次限制,其他的這里就不描述了。
超頻之后返回值:
| open/public/rate | @RateLimit(number = 4, cycle = 10) | { "state": 1000, "msg": "請勿重復點擊","data": null} |
| open/public/rate1 | @RateLimit(number = 4, cycle = 10, msg = “調用頻次過高”) | { "state": 1000, "msg": "調用頻次過高","data": null} |
至此即實現(xiàn)接口訪問頻次限制以及自定義返回提示信息。
我目前的服務端開發(fā)用戶信息是無狀態(tài)的Token,基于JWT,使用的Security框架(前段時間的文章有一組筆記),用戶權限校驗是單獨實現(xiàn)的。
關于性能:
使用了當前注解的接口請求耗時會長一點,我的Redis在一臺學生機上,而且跨省,耗時大概增加了40ms,本地的話大概也就20ms左右,如果對性能還有要求的話建議使用lua腳本。
建議
-
定義IP過濾器
在使用Redis的情況下,可以定義IP過濾器,計算指定IP請求速率,在上文中更多的是防止重復提交,但是對于文章開始所說的超高頻次的調用并沒有處理,建議在過濾器中攔截所有請求,每個IP對于單獨接口在訪問周期內超出限制之后將當前IP限制一段時間(是限制所有請求還是當前請求自行決定) -
基于IP過濾器統(tǒng)計接口訪問次數(shù)
在IP過濾器中借助Redis計算接口訪問次數(shù),每天同步一次,對于后面的服務擴展,接口限流等還是很有好處的。
歡迎留言,共同探討。
lua腳本
自定義接口實現(xiàn)類:
package com.cong.service.impl;import java.util.Collections; import com.cong.core.rate.RateLimit; import com.cong.core.rate.RateLimitService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Service; import lombok.extern.slf4j.Slf4j;@Slf4j @Service public class RateLimitServiceImpl implements RateLimitService {@Autowiredprivate RedisTemplate<String, Integer> redisTemplate;private static final String RATE_LIMIT_LOCK_LUA_SCRIPT = "local limit = tonumber(ARGV[1])"// 限制次數(shù)+ "local expire_time = ARGV[2]"// 過期時間+ "local result = redis.call('SETNX',KEYS[1],1);"// key不存在時設置value為1,返回1、否則返回0+ "if result == 1 then"// 返回值為1,key不存在此時需要設置過期時間+ " redis.call('expire',KEYS[1],expire_time)"// 設置過期時間+ " return 1 "// 返回1+ "else"// key存在+ " if tonumber(redis.call('GET', KEYS[1])) >= limit then"// 判斷數(shù)目比對+ " return 0"// 如果超出限制返回0+ " else" // + " redis.call('incr', KEYS[1])"// key自增+ " return 1 " // 返回1+ " end "// 結束+ "end";// 結束@Overridepublic Boolean limit(String ip, String uri, RateLimit rateLimit) {String key = "custom:rate:" + ip + ":" + uri;// 指定 lua 腳本,并且指定返回值類型DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(RATE_LIMIT_LOCK_LUA_SCRIPT, Long.class);// 參數(shù)一:redisScript,參數(shù)二:key列表,參數(shù)三:arg(可多個)Long result = redisTemplate.execute(redisScript, Collections.singletonList(key), rateLimit.number(),rateLimit.cycle());log.info("lua腳本返回值為:[{}]", result);if (result == 0) {return false;}return true;} }此處使用的是直接編寫lua腳本,當然也可以編寫lua文件。這樣可以確保限制生效,默認的實現(xiàn)在for循環(huán)的調用情況下因為網絡開銷會造成并不能準確限制請求,我的測試中兩次請求間隔50ms沒問題,但是10ms以內限制極易不生效(鎖)。
總結
以上是生活随笔為你收集整理的Java接口防刷策略(自定义注解实现)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: selenium 打开火狐浏览器版本兼容
- 下一篇: 漂亮的网页动态飘花灯笼特效代码