SpringBoot自定義註解 + AOP 防止重複提交(建議收藏)

語言: CN / TW / HK

大家好,我是飄渺。今天我們來看看如何通過AOP來防止重複提交

哪些因素會引起重複提交?

開發的專案中可能會出現下面這些情況:

  1. 前端下單按鈕重複點選導致訂單建立多次     
  2. 網速等原因造成頁面卡頓,使用者重複重新整理提交請求
  3. 黑客或惡意使用者使用postman等http工具重複惡意提交表單

重複提交會帶來哪些問題?

重複提交帶來的問題

  1. 會導致表單重複提交,造成資料重複或者錯亂
  2. 核心介面的請求增加,消耗伺服器負載,嚴重甚至會造成伺服器宕機

訂單的防重複提交你能想到幾種方案?

核心介面需要做防重提交,你應該可以想到以下幾種方案:

方式一:前端JS控制點選次數,遮蔽點選按鈕無法點選 前端可以被繞過,前端有限制,後端也需要有限制

方式二:資料庫或者其他儲存增加唯一索引約束 需要想出滿足業務需求的唯一索引約束,比如註冊的手機號唯一。但是有些業務是沒有唯一性限制的,且重複提交也會導致資料錯亂,比如你在電商平臺可以買一部手機,也可以買兩部手機

方式三:服務端token令牌方式 下單前先獲取令牌-儲存redis,下單時一併把token提交併檢驗和刪除-lua指令碼

分散式情況下,採用Lua指令碼進行操作(保障原子性)

其中方式三 是大家採用的最多的,那有沒更加優雅的方式呢?

假如系統中不止一個地方,需要用到這種防重複提交,每一次都要寫這種lua指令碼,程式碼耦合性太強,這種又不屬於業務邏輯,所以不推薦耦合進service中,可讀性較低。

本文采用自定義註解+AOP的方式,優雅的實現防止重複提交功能。

自定義註解

Java核心知識-自定義註解(先了解下什麼是自定義註解)

Annotation(註解)

從JDK 1.5開始, Java增加了對元資料(MetaData)的支援,也就是 Annotation(註解)。 註解其實就是程式碼裡的特殊標記,它用於替代配置檔案,常見的很多,有 @Override、@Deprecated等

什麼是元註解

元註解是註解的註解,比如當我們需要自定義註解時會需要一些元註解(meta-annotation),如@Target和@Retention

image-20220710232205734

java內建4種元註解

@Target 表示該註解用於什麼地方

  • ElementType.CONSTRUCTOR 用在構造器
  • ElementType.FIELD 用於描述域-屬性上
  • ElementType.METHOD 用在方法上
  • ElementType.TYPE 用在類或介面上
  • ElementType.PACKAGE 用於描述包

image-20220710232348168

@Retention 表示在什麼級別儲存該註解資訊

  • RetentionPolicy.SOURCE 保留到原始碼上
  • RetentionPolicy.CLASS 保留到位元組碼上
  • RetentionPolicy.RUNTIME 保留到虛擬機器執行時(最多,可通過反射獲取)

image-20220710232439451

@Documented 將此註解包含在 javadoc 中

  • @Inherited 是否允許子類繼承父類中的註解
  • @interface 用來宣告一個註解,可以通過default來宣告引數的預設值

自定義註解時,自動繼承了java.lang.annotation.Annotation介面,可以通過反射可以獲取自定義註解

AOP+自定義註解介面防重提交多場景設計

防重提交方式

  • token令牌方式
  • ip+類+方法方式(方法引數)

利用AOP來實現

  • Aspect Oriented Program 面向切面程式設計, 在不改變原有邏輯上增加額外的功能
  • AOP思想把功能分兩個部分,分離系統中的各種關注點

好處

  • 減少程式碼侵入,解耦
  • 可以統一處理橫切邏輯,方便新增和刪除橫切邏輯

業務流程:

image-20220710233137091

程式碼實戰防重提交自定義註解之Token令牌/引數方式

自定義註解token令牌方式

第一步 自定義註解

java import java.lang.annotation.*; /** * 自定義防重提交 */ @Documented @Target(ElementType.METHOD)//可以用在方法上 @Retention(RetentionPolicy.RUNTIME)//保留到虛擬機器執行時,可通過反射獲取 public @interface RepeatSubmit { /** * 防重提交,支援兩種,一個是方法引數,一個是令牌 */ enum Type { PARAM, TOKEN } /** * 預設防重提交,是方法引數 * @return */ Type limitType() default Type.PARAM; /** * 加鎖過期時間,預設是5秒 * @return */ long lockTime() default 5; }

第二步 引入redis

```properties

-------redis連線配置-------

spring.redis.client-type=jedis spring.redis.host=120.79.xxx.xxx spring.redis.password=123456 spring.redis.port=6379 spring.redis.jedis.pool.max-active=100 spring.redis.jedis.pool.max-idle=100 spring.redis.jedis.pool.min-idle=100 spring.redis.jedis.pool.max-wait=60000 ```

第三步 下單前獲取令牌用於防重提交

```java @Autowired private StringRedisTemplate redisTemplate; /* * 提交訂單令牌的快取key / public static final String SUBMIT_ORDER_TOKEN_KEY = "order:submit:%s:%s";

/* * 下單前獲取令牌用於防重提交 * @return / @GetMapping("token") public JsonData getOrderToken(){ //獲取登入賬戶 long accountNo = LoginInterceptor.threadLocal.get().getAccountNo(); //隨機獲取32位的數字+字母作為token String token = CommonUtil.getStringNumRandom(32); //key的組成 String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY,accountNo,token); //令牌有效時間是30分鐘 redisTemplate.opsForValue().set(key, String.valueOf(Thread.currentThread().getId()),30,TimeUnit.MINUTES);

return JsonData.buildSuccess(token); }

/* * 獲取隨機長度的串 * * @param length * @return / private static final String ALL_CHAR_NUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";

public static String getStringNumRandom(int length) { //生成隨機數字和字母, Random random = new Random(); StringBuilder saltString = new StringBuilder(length); for (int i = 1; i <= length; ++i) {

saltString.append(ALL_CHAR_NUM.charAt(random.nextInt(ALL_CHAR_NUM.length())));

} return saltString.toString(); } ```

第四步 定義切面類-開發解析器

根據type區分是使用token方式 還是引數方式

先看下token的方式

```java / * 定義一個切面類 / @Aspect @Component @Slf4j public class RepeatSubmitAspect { @Autowired private StringRedisTemplate redisTemplate;

/**
 * 定義 @Pointcut註解表示式, 通過特定的規則來篩選連線點, 就是Pointcut,選中那幾個你想要的方法
 * 在程式中主要體現為書寫切入點表示式(通過通配、正則表示式)過濾出特定的一組 JointPoint連線點
 * 方式一:@annotation:當執行的方法上擁有指定的註解時生效(本部落格採用這)
 * 方式二:execution:一般用於指定方法的執行
 */
@Pointcut("@annotation(repeatSubmit)")
public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {

}

/**
 * 環繞通知, 圍繞著方法執行
 * @param joinPoint
 * @param repeatSubmit
 * @return
 * @throws Throwable
 * @Around 可以用來在呼叫一個具體方法前和呼叫後來完成一些具體的任務。
 * <p>
 * 方式一:單用 @Around("execution(* net.wnn.controller.*.*(..))")可以
 * 方式二:用@Pointcut和@Around聯合註解也可以(本部落格採用這個)
 * <p>
 * <p>
 * 兩種方式
 * 方式一:加鎖 固定時間內不能重複提交
 * <p>
 * 方式二:先請求獲取token,這邊再刪除token,刪除成功則是第一次提交
 */
@Around("pointCutNoRepeatSubmit(repeatSubmit)")
public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
    HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
    long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
    //用於記錄成功或者失敗
    boolean res = false;
    //防重提交型別
    String type = repeatSubmit.limitType().name();
    if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
        //方式一,引數形式防重提交
       } else {
        //方式二,令牌形式防重提交
        String requestToken = request.getHeader("request-token");
        if (StringUtils.isBlank(requestToken)) {
            throw new BizException(BizCodeEnum.ORDER_CONFIRM_TOKEN_EQUAL_FAIL);
        }
        String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY, accountNo, requestToken);
        /**
         * 提交表單的token key
         * 方式一:不用lua指令碼獲取再判斷,之前是因為 key組成是 order:submit:accountNo, value是對應的token,所以需要先獲取值,再判斷
         * 方式二:可以直接key是 order:submit:accountNo:token,然後直接刪除成功則完成
         */
        res = redisTemplate.delete(key);
    }
    if (!res) {
        log.error("請求重複提交");
        log.info("環繞通知中");
        return null;
    }
    log.info("環繞通知執行前");
    Object obj = joinPoint.proceed();
    log.info("環繞通知執行後");
    return obj;
}

} ```

驗證結果

image-20220710233747314

image-20220710233811955

image-20220710233828948

第一次請求後,執行正常查詢篩選邏輯

image-20220710234016017

再次請求同一個介面:

image-20220710234044144

這樣就完成了通過AOP token的防止重複提交

再看下引數的防重方式

引數式防重複的核心就是IP地址+類+方法+賬號的方式,增加到redis中做為key。第一次加鎖成功返回true,第二次返回false,通過這種來做到的防重複。

先介紹下Redission: Redission是一個在Redis的基礎上實現的Java駐記憶體資料網格,支援多樣Redis配置支援、豐富連線方式、分散式物件、分散式集合、分散式鎖、分散式服務、多種序列化方式、三方框架整合。 Redisson底層採用的是Netty 框架 官方文件:https://github.com/redisson/redisson

第一步 引入依賴pom.xml:

xml <dependency> <groupId>org.redisson</groupId> <artifactId>redisson</artifactId> <version>3.10.1</version> </dependency>

第二步 增加配置:

```pro

-------redis連線配置-------

spring.redis.client-type=jedis spring.redis.host=120.79.xxx.xxx spring.redis.password=123456 spring.redis.port=6379 spring.redis.jedis.pool.max-active=100 spring.redis.jedis.pool.max-idle=100 spring.redis.jedis.pool.min-idle=100 spring.redis.jedis.pool.max-wait=60000 ```

第三步 獲取redissonClient:

```java

import org.redisson.Redisson; import org.redisson.api.RedissonClient; import org.redisson.config.Config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration;

@Configuration public class RedissionConfiguration { @Value("${spring.redis.host}") private String redisHost; @Value("${spring.redis.port}") private String redisPort; @Value("${spring.redis.password}") private String redisPwd; /* * 配置分散式鎖的redisson * @return / @Bean public RedissonClient redissonClient(){ Config config = new Config(); //單機方式 config.useSingleServer().setPassword(redisPwd).setAddress("redis://"+redisHost+":"+redisPort); //叢集 //config.useClusterServers().addNodeAddress("redis://192.31.21.1:6379","redis://192.31.21.2:6379") RedissonClient redissonClient = Redisson.create(config); return redissonClient; }

/**
 * 叢集模式
 * 備註:可以用"rediss://"來啟用SSL連線
 */
/*@Bean
public RedissonClient redissonClusterClient() {
    Config config = new Config();
    config.useClusterServers().setScanInterval(2000) // 叢集狀態掃描間隔時間,單位是毫秒
          .addNodeAddress("redis://127.0.0.1:7000")
          .addNodeAddress("redis://127.0.0.1:7002");
    RedissonClient redisson = Redisson.create(config);
    return redisson;
}*/

} ```

第四步切面引數防重邏輯:

```java / * 定義一個切面類 / @Aspect @Component @Slf4j public class RepeatSubmitAspect { @Autowired private StringRedisTemplate redisTemplate; @Autowired private RedissonClient redissonClient; /* * 定義 @Pointcut註解表示式, 通過特定的規則來篩選連線點, 就是Pointcut,選中那幾個你想要的方法 * 在程式中主要體現為書寫切入點表示式(通過通配、正則表示式)過濾出特定的一組 JointPoint連線點 * 方式一:@annotation:當執行的方法上擁有指定的註解時生效(本部落格採用這) * 方式二:execution:一般用於指定方法的執行 / @Pointcut("@annotation(repeatSubmit)") public void pointCutNoRepeatSubmit(RepeatSubmit repeatSubmit) {

}

/**
 * 環繞通知, 圍繞著方法執行
 * @param joinPoint
 * @param repeatSubmit
 * @return
 * @throws Throwable
 * @Around 可以用來在呼叫一個具體方法前和呼叫後來完成一些具體的任務。
 * <p>
 * 方式一:單用 @Around("execution(* net.wnn.controller.*.*(..))")可以
 * 方式二:用@Pointcut和@Around聯合註解也可以(本部落格採用這個)
 * <p>
 * <p>
 * 兩種方式
 * 方式一:加鎖 固定時間內不能重複提交
 * <p>
 * 方式二:先請求獲取token,這邊再刪除token,刪除成功則是第一次提交
 */
@Around("pointCutNoRepeatSubmit(repeatSubmit)")
public Object around(ProceedingJoinPoint joinPoint, RepeatSubmit repeatSubmit) throws Throwable {
    HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
    long accountNo = LoginInterceptor.threadLocal.get().getAccountNo();
    //用於記錄成功或者失敗
    boolean res = false;
    //防重提交型別
    String type = repeatSubmit.limitType().name();
    if (type.equalsIgnoreCase(RepeatSubmit.Type.PARAM.name())) {
        //方式一,引數形式防重提交
        long lockTime = repeatSubmit.lockTime();
        String ipAddr = CommonUtil.getIpAddr(request);
        MethodSignature methodSignature = (MethodSignature)joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        String className = method.getDeclaringClass().getName();
        String key = "order-server:repeat_submit:"+CommonUtil.MD5(String.format("%s-%s-%s-%s",ipAddr,className,method,accountNo));
        //加鎖
        // 這種也可以 本部落格也介紹下redisson的使用
        // res  = redisTemplate.opsForValue().setIfAbsent(key, "1", lockTime, TimeUnit.SECONDS);
        RLock lock = redissonClient.getLock(key);
        // 嘗試加鎖,最多等待0秒,上鎖以後5秒自動解鎖 [lockTime預設為5s, 可以自定義]
        res = lock.tryLock(0,lockTime,TimeUnit.SECONDS);
    } else {
        //方式二,令牌形式防重提交
        String requestToken = request.getHeader("request-token");
        if (StringUtils.isBlank(requestToken)) {
            throw new BizException(BizCodeEnum.ORDER_CONFIRM_TOKEN_EQUAL_FAIL);
        }
        String key = String.format(RedisKey.SUBMIT_ORDER_TOKEN_KEY, accountNo, requestToken);
        /**
         * 提交表單的token key
         * 方式一:不用lua指令碼獲取再判斷,之前是因為 key組成是 order:submit:accountNo, value是對應的token,所以需要先獲取值,再判斷
         * 方式二:可以直接key是 order:submit:accountNo:token,然後直接刪除成功則完成
         */
        res = redisTemplate.delete(key);
    }
    if (!res) {
        log.error("請求重複提交");
        log.info("環繞通知中");
        return null;
    }
    log.info("環繞通知執行前");
    Object obj = joinPoint.proceed();
    log.info("環繞通知執行後");
    return obj;
}

} ```

其中lock.tryLock解釋下:

// 嘗試加鎖,最多等待0秒,上鎖以後5秒自動解鎖 [lockTime預設為5s, 可以自定義] res = lock.tryLock(0,lockTime,TimeUnit.SECONDS);

tryLock只有在呼叫時空閒的情況下,才會獲得該鎖。如果鎖可用,則獲取該鎖,並立即返回值為true;如果鎖不可用,那麼這個方法將立即返回值為false。

典型的用法:

image-20220710234543837

這種用法可以保證在獲得了鎖的情況下解鎖,在沒有獲得鎖的情況下不嘗試解鎖。

第五步 使用

依然是在分頁這塊做個驗證 看起來比較清晰

type改成RepeatSubmit.Type.PARAM

java /** * 分頁介面 * * @return */ @PostMapping("page") @RepeatSubmit(limitType = RepeatSubmit.Type.PARAM) public JsonData page(@RequestBody ProductOrderPageRequest orderPageRequest) { Map<String, Object> pageResult = productOrderService.page(orderPageRequest); return JsonData.buildSuccess(pageResult); } postman請求介面進行驗證:

image-20220710234624313

第一次請求後,redis的key中存在的,TTL 5秒

image-20220710234639887

image-20220710234707809

5秒內重複點選介面 因為已經存在的這個key,所以當再次增加key的時候,就會返回flase:

image-20220710234729328

這樣就完成了通過AOP 引數的防止重複提交

兩種防重提交,應用場景不一樣,也可以更多方式進行防重,根據實際業務進行選擇即可~

我正在參與掘金技術社群創作者簽約計劃招募活動,點選連結報名投稿