SpringBoot自定義註解 + AOP 防止重複提交(建議收藏)
大家好,我是飄渺。今天我們來看看如何通過AOP來防止重複提交
哪些因素會引起重複提交?
開發的項目中可能會出現下面這些情況:
- 前端下單按鈕重複點擊導致訂單創建多次
- 網速等原因造成頁面卡頓,用户重複刷新提交請求
- 黑客或惡意用户使用postman等http工具重複惡意提交表單
重複提交會帶來哪些問題?
重複提交帶來的問題
- 會導致表單重複提交,造成數據重複或者錯亂
- 核心接口的請求增加,消耗服務器負載,嚴重甚至會造成服務器宕機
訂單的防重複提交你能想到幾種方案?
核心接口需要做防重提交,你應該可以想到以下幾種方案:
方式一:前端JS控制點擊次數,屏蔽點擊按鈕無法點擊 前端可以被繞過,前端有限制,後端也需要有限制
方式二:數據庫或者其他存儲增加唯一索引約束 需要想出滿足業務需求的唯一索引約束,比如註冊的手機號唯一。但是有些業務是沒有唯一性限制的,且重複提交也會導致數據錯亂,比如你在電商平台可以買一部手機,也可以買兩部手機
方式三:服務端token令牌方式 下單前先獲取令牌-存儲redis,下單時一併把token提交併檢驗和刪除-lua腳本
分佈式情況下,採用Lua腳本進行操作(保障原子性)
其中方式三 是大家採用的最多的,那有沒更加優雅的方式呢?
假如系統中不止一個地方,需要用到這種防重複提交,每一次都要寫這種lua腳本,代碼耦合性太強,這種又不屬於業務邏輯,所以不推薦耦合進service中,可讀性較低。
本文采用自定義註解+AOP的方式,優雅的實現防止重複提交功能。
自定義註解
Java核心知識-自定義註解(先了解下什麼是自定義註解)
Annotation(註解)
從JDK 1.5開始, Java增加了對元數據(MetaData)的支持,也就是 Annotation(註解)。 註解其實就是代碼裏的特殊標記,它用於替代配置文件,常見的很多,有 @Override、@Deprecated等
什麼是元註解
元註解是註解的註解,比如當我們需要自定義註解時會需要一些元註解(meta-annotation),如@Target和@Retention
java內置4種元註解
@Target 表示該註解用於什麼地方
- ElementType.CONSTRUCTOR 用在構造器
- ElementType.FIELD 用於描述域-屬性上
- ElementType.METHOD 用在方法上
- ElementType.TYPE 用在類或接口上
- ElementType.PACKAGE 用於描述包
@Retention 表示在什麼級別保存該註解信息
- RetentionPolicy.SOURCE 保留到源碼上
- RetentionPolicy.CLASS 保留到字節碼上
- RetentionPolicy.RUNTIME 保留到虛擬機運行時(最多,可通過反射獲取)
@Documented 將此註解包含在 javadoc 中
- @Inherited 是否允許子類繼承父類中的註解
- @interface 用來聲明一個註解,可以通過default來聲明參數的默認值
自定義註解時,自動繼承了java.lang.annotation.Annotation接口,可以通過反射可以獲取自定義註解
AOP+自定義註解接口防重提交多場景設計
防重提交方式
- token令牌方式
- ip+類+方法方式(方法參數)
利用AOP來實現
- Aspect Oriented Program 面向切面編程, 在不改變原有邏輯上增加額外的功能
- AOP思想把功能分兩個部分,分離系統中的各種關注點
好處
- 減少代碼侵入,解耦
- 可以統一處理橫切邏輯,方便添加和刪除橫切邏輯
業務流程:
代碼實戰防重提交自定義註解之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;
}
} ```
驗證結果
第一次請求後,執行正常查詢篩選邏輯
再次請求同一個接口:
這樣就完成了通過AOP token的防止重複提交
再看下參數的防重方式
參數式防重複的核心就是IP地址+類+方法+賬號的方式,增加到redis中做為key。第一次加鎖成功返回true,第二次返回false,通過這種來做到的防重複。
先介紹下Redission: Redission是一個在Redis的基礎上實現的Java駐內存數據網格,支持多樣Redis配置支持、豐富連接方式、分佈式對象、分佈式集合、分佈式鎖、分佈式服務、多種序列化方式、三方框架整合。 Redisson底層採用的是Netty 框架 官方文檔:http://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。
典型的用法:
這種用法可以保證在獲得了鎖的情況下解鎖,在沒有獲得鎖的情況下不嘗試解鎖。
第五步 使用
依然是在分頁這塊做個驗證 看起來比較清晰
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請求接口進行驗證:
第一次請求後,redis的key中存在的,TTL 5秒
5秒內重複點擊接口 因為已經存在的這個key,所以當再次增加key的時候,就會返回flase:
這樣就完成了通過AOP 參數的防止重複提交
兩種防重提交,應用場景不一樣,也可以更多方式進行防重,根據實際業務進行選擇即可~
我正在參與掘金技術社區創作者簽約計劃招募活動,點擊鏈接報名投稿。
- 數據權限就該這麼實現(設計篇)
- 數據權限就該這麼實現(實現篇)
- 給你一段SQL,你會如何優化?
- 當我把ChatGPT機器人拉到微信羣裏,羣友都玩瘋了!!
- SpringBoot 如何保證接口安全?老鳥們都是這麼玩的!
- 掌握系統思維,你就可以既勤奮努力又輕鬆愉快。
- SpringBoot自定義註解 AOP 防止重複提交(建議收藏)
- 面試官:應用上線後Cpu使用率飆升如何排查?
- SpringBoot中實現業務校驗,這種方式才叫優雅!
- SpringCloud Gateway 收集輸入輸出日誌
- 震驚,Spring官方推薦的@Transational還能導致生產事故?
- 為什麼要在MVC三層架構上再加一層Manager層?
- SpringBoot 如何生成接口文檔,老鳥們都這麼玩的!
- SpringBoot 如何進行限流?老鳥們都這麼玩的!
- SpringBoot 生成接口文檔,我用smart-doc,一款比Swagger更nice的工具!
- SpringBoot 如何進行對象複製,老鳥們都這麼玩的!
- 3天,我把MySQL索引、鎖、事務、分庫分表擼乾淨了!
- 字節全面對外開放中台能力!中台,又靈了?
- 基於 Kubernetes 的微服務項目設計與實現
- 老闆要我開發一個簡單的工作流引擎