來源:juejin.cn/post/7200366809407750181 ## **前言** 本文為描述通過Interceptor以及Redis實現介面訪問防刷Demo 這裡會通過逐步找問題,逐步去完善的形式展示 ## **原理** - 通過ip地址+uri拼接用以作為訪問者訪問介面區分 - 通過 ...
來源:juejin.cn/post/7200366809407750181
前言
本文為描述通過Interceptor以及Redis實現介面訪問防刷Demo
這裡會通過逐步找問題,逐步去完善的形式展示
原理
- 通過ip地址+uri拼接用以作為訪問者訪問介面區分
- 通過在Interceptor中攔截請求,從Redis中統計用戶訪問介面次數從而達到介面防刷目的
如下圖所示
工程
推薦一個開源免費的 Spring Boot 實戰項目:
其中,Interceptor處代碼處理邏輯最為重要
/**
* @author: Zero
* @time: 2023/2/14
* @description: 介面防刷攔截處理
*/
@Slf4j
public class AccessLimintInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 多長時間內
*/
@Value("${interfaceAccess.second}")
private Long second = 10L;
/**
* 訪問次數
*/
@Value("${interfaceAccess.time}")
private Long time = 3L;
/**
* 禁用時長--單位/秒
*/
@Value("${interfaceAccess.lockTime}")
private Long lockTime = 60L;
/**
* 鎖住時的key首碼
*/
public static final String LOCK_PREFIX = "LOCK";
/**
* 統計次數時的key首碼
*/
public static final String COUNT_PREFIX = "COUNT";
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
String ip = request.getRemoteAddr(); // 這裡忽略代理軟體方式訪問,預設直接訪問,也就是獲取得到的就是訪問者真實ip地址
String lockKey = LOCK_PREFIX + ip + uri;
Object isLock = redisTemplate.opsForValue().get(lockKey);
if(Objects.isNull(isLock)){
// 還未被禁用
String countKey = COUNT_PREFIX + ip + uri;
Object count = redisTemplate.opsForValue().get(countKey);
if(Objects.isNull(count)){
// 首次訪問
log.info("首次訪問");
redisTemplate.opsForValue().set(countKey,1,second, TimeUnit.SECONDS);
}else{
// 此用戶前一點時間就訪問過該介面
if((Integer)count < time){
// 放行,訪問次數 + 1
redisTemplate.opsForValue().increment(countKey);
}else{
log.info("{}禁用訪問{}",ip, uri);
// 禁用
redisTemplate.opsForValue().set(lockKey, 1,lockTime, TimeUnit.SECONDS);
// 刪除統計
redisTemplate.delete(countKey);
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
}else{
// 此用戶訪問此介面已被禁用
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
return true;
}
}
在多長時間內訪問介面多少次,以及禁用的時長,則是通過與配置文件配合動態設置
當處於禁用時直接拋異常則是通過在ControllerAdvice處統一處理 (這裡代碼寫的有點醜陋)
下麵是一些測試(可以把項目通過Git還原到“【初始化】”狀態進行測試)
- 正常訪問時
- 訪問次數過於頻繁時
自我提問
上述實現就好像就已經達到了我們的介面防刷目的了
但是,還不夠
為方便後續描述,項目中新增補充Controller
,如下所示
簡單來說就是
PassCotroller
和RefuseController
- 每個Controller分別有對應的get,post,put,delete類型的方法,其映射路徑與方法名稱一致
介面自由
- 對於上述實現,不知道你們有沒有發現一個問題
- 就是現在我們的介面防刷處理,針對是所有的介面(項目案例中我只是寫的介面比較少)
- 而在實際開發中,說對於所有的介面都要做防刷處理,感覺上也不太可能(寫此文時目前大四,實際工作經驗較少,這裡不敢肯定)
- 那麼問題有了,該如何解決呢?目前來說想到兩個解決方案
攔截器映射規則
項目通過Git還原到"【Interceptor設置映射規則實現介面自由】"版本即可得到此案例實現
我們都知道攔截器是可以設置攔截規則的,從而達到攔截處理目的
1.這個AccessInterfaceInterceptor
是專門用來進行防刷處理的,那麼實際上我們可以通過設置它的映射規則去匹配需要進行【介面防刷】的介面即可
2.比如說下麵的映射配置
3.這樣就初步達到了我們的目的,通過映射規則的配置,只針對那些需要進行【介面防刷】的介面才會進行處理
4.至於為啥說是初步呢?下麵我就說說目前我想到的使用這種方式進行【介面防刷】的不足點:
所有要進行防刷處理的介面統一都是配置成了 x 秒內 y 次訪問次數,禁用時長為 z 秒
- 要知道就是要進行防刷處理的介面,其 x, y, z的值也是並不一定會統一的
- 某些防刷介面處理比較消耗性能的,我就把x, y, z設置的緊一點
- 而某些防刷介面處理相對來說比較快,我就把x, y, z 設置的松一點
- 這沒問題吧
- 但是現在呢?x, y, z值全都一致了,這就不行了
- 這就是其中一個不足點
- 當然,其實針對當前這種情況也有解決方案
- 那就是弄多個攔截器
- 每個攔截器的【介面防刷】處理邏輯跟上述一致,並去映射對應要處理的防刷介面
- 唯一不同的就是在每個攔截器內部,去修改對應防刷介面需要的x, y, z值
- 這樣就是感覺會比較麻煩
防刷介面映射路徑修改後維護問題
- 雖然說防刷介面的映射路徑基本上定下來後就不會改變
- 但實際上前後端聯調開發項目時,不會有那麼嚴謹的Api文檔給我們用(這個在實習中倒是碰到過,公司不是很大,開發起來也就不那麼嚴謹,啥都要自己搞,功能能實現就好)
- 也就是說還是會有那種要修改介面的映射路徑需求
- 當防刷介面數量特別多,後面的接手人員就很痛苦了
- 就算是項目是自己從0到1實現的,其實有時候項目開發到後面,自己也會忘記自己前面是如何設計的
- 而使用當前這種方式的話,誰維護誰蛋疼
自定義註解 + 反射
咋說呢
- 就是通過自定義註解中定義 x 秒內 y 次訪問次數,禁用時長為 z 秒
- 自定義註解 + 在需要進行防刷處理的各個介面方法上
- 在攔截器中通過反射獲取到各個介面中的x, y, z值即可達到我們想要的介面自由目的
下麵做個實現
聲明自定義註解
Controlller中方法中使用
Interceptor處邏輯修改(最重要是通過反射判斷此介面是否需要進行防刷處理,以及獲取到x, y, z的值)
/**
* @author: Zero
* @time: 2023/2/14
* @description: 介面防刷攔截處理
*/
@Slf4j
public class AccessLimintInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 鎖住時的key首碼
*/
public static final String LOCK_PREFIX = "LOCK";
/**
* 統計次數時的key首碼
*/
public static final String COUNT_PREFIX = "COUNT";
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 自定義註解 + 反射 實現
// 判斷訪問的是否是介面方法
if(handler instanceof HandlerMethod){
// 訪問的是介面方法,轉化為待訪問的目標方法對象
HandlerMethod targetMethod = (HandlerMethod) handler;
// 取出目標方法中的 AccessLimit 註解
AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
// 判斷此方法介面是否要進行防刷處理(方法上沒有對應註解就代表不需要,不需要的話進行放行)
if(!Objects.isNull(accessLimit)){
// 需要進行防刷處理,接下來是處理邏輯
String ip = request.getRemoteAddr();
String uri = request.getRequestURI();
String lockKey = LOCK_PREFIX + ip + uri;
Object isLock = redisTemplate.opsForValue().get(lockKey);
// 判斷此ip用戶訪問此介面是否已經被禁用
if (Objects.isNull(isLock)) {
// 還未被禁用
String countKey = COUNT_PREFIX + ip + uri;
Object count = redisTemplate.opsForValue().get(countKey);
long second = accessLimit.second();
long maxTime = accessLimit.maxTime();
if (Objects.isNull(count)) {
// 首次訪問
log.info("首次訪問");
redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
} else {
// 此用戶前一點時間就訪問過該介面,且頻率沒超過設置
if ((Integer) count < maxTime) {
redisTemplate.opsForValue().increment(countKey);
} else {
log.info("{}禁用訪問{}", ip, uri);
long forbiddenTime = accessLimit.forbiddenTime();
// 禁用
redisTemplate.opsForValue().set(lockKey, 1, forbiddenTime, TimeUnit.SECONDS);
// 刪除統計--已經禁用了就沒必要存在了
redisTemplate.delete(countKey);
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
} else {
// 此用戶訪問此介面已被禁用
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
}
return true;
}
}
由於不好演示效果,這裡就不貼測試結果圖片了
項目通過Git還原到"【自定義主鍵+反射實現介面自由"版本即可得到此案例實現,後面自己可以針對介面做下測試看看是否如同我所說的那樣實現自定義x, y, z 的效果
嗯,現在看起來,可以針對每個要進行防刷處理的介面進行針對性自定義多長時間內的最大訪問次數,以及禁用時長,哪個介面需要,就直接+在那個介面方法出即可
感覺還不錯的樣子,現在網上挺多資料也都是這樣實現的
但是還是可以有改善的地方
先舉一個例子,以我們的PassController為例,如下是其實現
下圖是其映射路徑關係
同一個Controller的所有介面方法映射路徑的首碼都包含了/pass
我們在類上通過註解@ReqeustMapping
標記映射路徑/pass
,這樣所有的介面方法首碼都包含了/pass
,並且以致於後面要修改映射路徑首碼時只需改這一塊地方即可
這也是我們使用SpringMVC最常見的用法
那麼,我們的自定義註解也可不可以這樣做呢?先無中生有個需求
假設PassController中所有介面都是要進行防刷處理的,並且他們的x, y, z值就一樣
如果我們的自定義註解還是只能載入方法上的話,一個一個介面加,那麼無疑這是一種很呆的做法
要改的話,其實也很簡單,首先是修改自定義註解,讓其可以作用在類上
接著就是修改AccessLimitInterceptor
的處理邏輯
AccessLimitInterceptor
中代碼修改的有點多,主要邏輯如下
與之前實現比較,不同點在於x, y, z的值要首先嘗試在目標類中獲取
其次,一旦類中標有此註解,即代表此類下所有介面方法都要進行防刷處理
如果其介面方法同樣也標有此註解,根據就近優先原則,以介面方法中的註解標明的值為準
/**
* @author: Zero
* @time: 2023/2/14
* @description: 介面防刷攔截處理
*/
@Slf4j
public class AccessLimintInterceptor implements HandlerInterceptor {
@Resource
private RedisTemplate<String, Object> redisTemplate;
/**
* 鎖住時的key首碼
*/
public static final String LOCK_PREFIX = "LOCK";
/**
* 統計次數時的key首碼
*/
public static final String COUNT_PREFIX = "COUNT";
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 自定義註解 + 反射 實現, 版本 2.0
if (handler instanceof HandlerMethod) {
// 訪問的是介面方法,轉化為待訪問的目標方法對象
HandlerMethod targetMethod = (HandlerMethod) handler;
// 獲取目標介面方法所在類的註解@AccessLimit
AccessLimit targetClassAnnotation = targetMethod.getMethod().getDeclaringClass().getAnnotation(AccessLimit.class);
// 特別註意不能採用下麵這條語句來獲取,因為 Spring 採用的代理方式來代理目標方法
// 也就是說targetMethod.getClass()獲得是class org.springframework.web.method.HandlerMethod ,而不知我們真正想要的 Controller
// AccessLimit targetClassAnnotation = targetMethod.getClass().getAnnotation(AccessLimit.class);
// 定義標記位,標記此類是否加了@AccessLimit註解
boolean isBrushForAllInterface = false;
String ip = request.getRemoteAddr();
String uri = request.getRequestURI();
long second = 0L;
long maxTime = 0L;
long forbiddenTime = 0L;
if (!Objects.isNull(targetClassAnnotation)) {
log.info("目標介面方法所在類上有@AccessLimit註解");
isBrushForAllInterface = true;
second = targetClassAnnotation.second();
maxTime = targetClassAnnotation.maxTime();
forbiddenTime = targetClassAnnotation.forbiddenTime();
}
// 取出目標方法中的 AccessLimit 註解
AccessLimit accessLimit = targetMethod.getMethodAnnotation(AccessLimit.class);
// 判斷此方法介面是否要進行防刷處理
if (!Objects.isNull(accessLimit)) {
// 需要進行防刷處理,接下來是處理邏輯
second = accessLimit.second();
maxTime = accessLimit.maxTime();
forbiddenTime = accessLimit.forbiddenTime();
if (isForbindden(second, maxTime, forbiddenTime, ip, uri)) {
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
} else {
// 目標介面方法處無@AccessLimit註解,但還要看看其類上是否加了(類上有加,代表針對此類下所有介面方法都要進行防刷處理)
if (isBrushForAllInterface && isForbindden(second, maxTime, forbiddenTime, ip, uri)) {
throw new CommonException(ResultCode.ACCESS_FREQUENT);
}
}
}
return true;
}
/**
* 判斷某用戶訪問某介面是否已經被禁用/是否需要禁用
*
* @param second 多長時間 單位/秒
* @param maxTime 最大訪問次數
* @param forbiddenTime 禁用時長 單位/秒
* @param ip 訪問者ip地址
* @param uri 訪問的uri
* @return ture為需要禁用
*/
private boolean isForbindden(long second, long maxTime, long forbiddenTime, String ip, String uri) {
String lockKey = LOCK_PREFIX + ip + uri; //如果此ip訪問此uri被禁用時的存在Redis中的 key
Object isLock = redisTemplate.opsForValue().get(lockKey);
// 判斷此ip用戶訪問此介面是否已經被禁用
if (Objects.isNull(isLock)) {
// 還未被禁用
String countKey = COUNT_PREFIX + ip + uri;
Object count = redisTemplate.opsForValue().get(countKey);
if (Objects.isNull(count)) {
// 首次訪問
log.info("首次訪問");
redisTemplate.opsForValue().set(countKey, 1, second, TimeUnit.SECONDS);
} else {
// 此用戶前一點時間就訪問過該介面,且頻率沒超過設置
if ((Integer) count < maxTime) {
redisTemplate.opsForValue().increment(countKey);
} else {
log.info("{}禁用訪問{}", ip, uri);
// 禁用
redisTemplate.opsForValue().set(lockKey, 1, forbiddenTime, TimeUnit.SECONDS);
// 刪除統計--已經禁用了就沒必要存在了
redisTemplate.delete(countKey);
return true;
}
}
} else {
// 此用戶訪問此介面已被禁用
return true;
}
return false;
}
}
好了,這樣就達到我們想要的效果了
項目通過Git還原到"【自定義註解+反射實現介面自由-版本2.0】"版本即可得到此案例實現,自己可以測試萬一下
這是目前來說比較理想的做法,至於其他做法,暫時沒啥瞭解到
時間邏輯漏洞
這是我一開始都有留意到的問題
也是一直搞不懂,就是我們現在的所有做法其實感覺都不是嚴格意義上的x秒內y次訪問次數
特別註意這個x秒,它是連續,任意的(代表這個x秒時間片段其實是可以發生在任意一個時間軸上)
我下麵嘗試表達我的意思,但是我不知道能不能表達清楚
假設我們固定某個介面5秒內只能訪問3次,以下麵例子為例
底下的小圓圈代表此刻請求訪問介面
按照我們之前所有做法的邏輯走
- 第2秒請求到,為首次訪問,Redis中統計次數為1(過期時間為5秒)
- 第7秒,此時有兩個動作,一是請求到,二是剛剛第二秒Redis存的值現在過期
- 我們先假設這一刻,請求處理完後,Redis存的值才過期
- 按照這樣的邏輯走
- 第七秒請求到,Redis存在對應key,且不大於3, 次數+1
- 接著這個key立馬過期
- 再繼續往後走,第8秒又當做新的一個起始,就不往下說了,反正就是不會出現禁用的情況
按照上述邏輯走,實際上也就是說當出現首次訪問時,當做這5秒時間片段的起始
第2秒是,第8秒也是
但是有沒有想過,實際上這個5秒時間片段實際上是可以放置在時間軸上任意區域的
上述情況我們是根據請求的到來情況人為的把它放在【2-7】,【8-13】上
而實際上這5秒時間片段是可以放在任意區域的
那麼,這樣的話,【7-12】也可以放置
而【7-12】這段時間有4次請求,就達到了我們禁用的條件了
是不是感覺怪怪的
想過其他做法,但是好像嚴格意義上真的做不到我所說的那樣(至少目前來說想不到)
之前我們的做法,正常來說也夠用,至少說有達到防刷的作用
後面有機會的話再看看,不知道我是不是鑽牛角尖了
路徑參數問題
假設現在PassController
中有如下介面方法
也就是我們在介面方法中常用的在請求路徑中獲取參數的套路
但是使用路徑參數的話,就會發生問題
那就是同一個ip地址訪問此介面時,我攜帶的參數值不同
按照我們之前那種首碼+ip+uri拼接的形式作為key的話,其實是區分不了的
下圖是訪問此介面,攜帶不同參數值時獲取的uri狀況
這樣的話在我們之前攔截器的處理邏輯中,會認為是此ip用戶訪問的是不同的介面方法,而實際上訪問的是同一個介面方法
也就導致了【介面防刷】失效
接下來就是解決它,目前來說有兩種
- 不要使用路徑參數
這算是比較理想的做法,相當於沒這個問題
但有一定局限性,有時候接手別的項目,或者自己根本沒這個許可權說不能使用路徑參數
- 替換uri
- 我們獲取uri的目的,其實就是為了區別訪問介面
- 而把uri替換成另一種可以區分訪問介面方法的標識即可
- 最容易想到的就是通過反射獲取到介面方法名稱,使用介面方法名稱替換成uri即可
- 當然,其實不同的Controller中,其介面方法名稱也有可能是相同的
- 實際上可以再獲取介面方法所在類類名,使用類名 + 方法名稱替換uri即可
- 實際解決方案有很多,看個人需求吧
真實ip獲取
在之前的代碼中,我們獲取代碼都是通過request.getRemoteAddr()
獲取的
但是後續有瞭解到,如果說通過代理軟體方式訪問的話,這樣是獲取不到來訪者的真實ip的
至於如何獲取,後續我再研究下http再說,這裡先提個醒
總結
說實話,挺有意思的,一開始自己想【介面防刷】的時候,感覺也就是轉化成統計下訪問次數的問題擺了。後面到網上看別人的寫法,又再自己給自己找點問題出來,後面會衍生出來一推東西出來,諸如自定義註解+反射這種實現方式。
以前其實對註解 + 反射其實有點不太懂幹嘛用的,而從之前的數據報表導出,再到基本許可權控制實現,最後到今天的【介面防刷】一點點來進步去補充自己的知識點,而且,感覺寫博客真的是件挺有意義的事情,它會讓你去更深入的瞭解某個點,並且知識是相關聯的,探索的過程中會牽扯到其他別的知識點,就像之前的寫的【單例模式】實現,一開始就瞭解到懶漢式,餓漢式
後面深入的話就知道其實會還有序列化/反序列化,反射調用生成實例,對象克隆這幾種方式回去破壞單例模式,又是如何解決的,這也是一個進步的點,後續為了保證線程安全問題,牽扯到的synchronized,voliate關鍵字,繼而又關聯到JVM,JUC,操作系統的東西。
近期熱文推薦:
1.1,000+ 道 Java面試題及答案整理(2022最新版)
4.別再寫滿屏的爆爆爆炸類了,試試裝飾器模式,這才是優雅的方式!!
覺得不錯,別忘了隨手點贊+轉發哦!