優雅的接口防刷處理方案


前言

本文爲描述通過 Interceptor 以及 Redis 實現接口訪問防刷 Demo

這裏會通過逐步找問題,逐步去完善的形式展示

原理

如下圖所示

工程

項目地址:

https://github.com/Tonciy/interface-brush-protection

其中,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,如下所示

簡單來說就是

接口自由

攔截器映射規則

項目通過 Git 還原到 "【Interceptor 設置映射規則實現接口自由】" 版本即可得到此案例實現

我們都知道攔截器是可以設置攔截規則的,從而達到攔截處理目的

  1. 這個AccessInterfaceInterceptor是專門用來進行防刷處理的,那麼實際上我們可以通過設置它的映射規則去匹配需要進行【接口防刷】的接口即可

  2. 比如說下面的映射配置

  1. 這樣就初步達到了我們的目的,通過映射規則的配置,只針對那些需要進行【接口防刷】的接口才會進行處理

  2. 至於爲啥說是初步呢?下面我就說說目前我想到的使用這種方式進行【接口防刷】的不足點:

所有要進行防刷處理的接口統一都是配置成了 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 次,以下面例子爲例

底下的小圓圈代表此刻請求訪問接口

按照我們之前所有做法的邏輯走

  1. 第 2 秒請求到,爲首次訪問,Redis 中統計次數爲 1(過期時間爲 5 秒)

  2. 第 7 秒,此時有兩個動作,一是請求到,二是剛剛第二秒 Redis 存的值現在過期

  3. 我們先假設這一刻,請求處理完後,Redis 存的值才過期

  4. 按照這樣的邏輯走

  5. 第七秒請求到,Redis 存在對應 key,且不大於 3, 次數 + 1

  6. 接着這個 key 立馬過期

  7. 再繼續往後走,第 8 秒又當做新的一個起始,就不往下說了,反正就是不會出現禁用的情況

按照上述邏輯走,實際上也就是說當出現首次訪問時,當做這 5 秒時間片段的起始

第 2 秒是,第 8 秒也是

但是有沒有想過,實際上這個 5 秒時間片段實際上是可以放置在時間軸上任意區域的

上述情況我們是根據請求的到來情況人爲的把它放在【2-7】,【8-13】上

而實際上這 5 秒時間片段是可以放在任意區域的

那麼,這樣的話,【7-12】也可以放置

而【7-12】這段時間有 4 次請求,就達到了我們禁用的條件了

是不是感覺怪怪的

想過其他做法,但是好像嚴格意義上真的做不到我所說的那樣(至少目前來說想不到)

之前我們的做法,正常來說也夠用,至少說有達到防刷的作用

後面有機會的話再看看,不知道我是不是鑽牛角尖了

路徑參數問題

假設現在PassController中有如下接口方法

也就是我們在接口方法中常用的在請求路徑中獲取參數的套路

但是使用路徑參數的話,就會發生問題

那就是同一個 ip 地址訪問此接口時,我攜帶的參數值不同

按照我們之前那種前綴 + ip+uri 拼接的形式作爲 key 的話,其實是區分不了的

下圖是訪問此接口,攜帶不同參數值時獲取的 uri 狀況

這樣的話在我們之前攔截器的處理邏輯中,會認爲是此 ip 用戶訪問的是不同的接口方法, 而實際上訪問的是同一個接口方法

也就導致了【接口防刷】失效

接下來就是解決它,目前來說有兩種

  1. 不要使用路徑參數

這算是比較理想的做法,相當於沒這個問題

但有一定侷限性,有時候接手別的項目,或者自己根本沒這個權限說不能使用路徑參數

  1. 替換 uri

真實 ip 獲取

在之前的代碼中,我們獲取代碼都是通過request.getRemoteAddr()獲取的

但是後續有了解到,如果說通過代理軟件方式訪問的話,這樣是獲取不到來訪者的真實 ip 的

至於如何獲取,後續我再研究下 http 再說,這裏先提個醒

總結

說實話,挺有意思的,一開始自己想【接口防刷】的時候,感覺也就是轉化成統計下訪問次數的問題擺了。後面到網上看別人的寫法,又再自己給自己找點問題出來,後面會衍生出來一推東西出來,諸如自定義註解 + 反射這種實現方式。

以前其實對註解 + 反射其實有點不太懂幹嘛用的,而從之前的數據報表導出,再到基本權限控制實現,最後到今天的【接口防刷】一點點來進步去補充自己的知識點,而且,感覺寫博客真的是件挺有意義的事情,它會讓你去更深入的瞭解某個點,並且知識是相關聯的,探索的過程中會牽扯到其他別的知識點,就像之前的寫的【單例模式】實現,一開始就瞭解到懶漢式,餓漢式

後面深入的話就知道其實會還有序列化 / 反序列化,反射調用生成實例,對象克隆這幾種方式回去破壞單例模式,又是如何解決的,這也是一個進步的點,後續爲了保證線程安全問題,牽扯到的 synchronized,voliate 關鍵字,繼而又關聯到 JVM,JUC,操作系統的東西。

來源:juejin.cn/post/7200366809407750181

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qMLEJXoBB6hM8DpobKkc6A