1、前言
近期在構建項目腳手架時,關於接口冪等性問題,考慮做成獨立模塊工具放進腳手架中進行通用。
如何保證接口冪等性,換句話說就是如何防止接口重復提交。通常,前后端都需要考慮如何實現相關控制。
- 前端常用的解決方案是“表單提交完成,按鈕置灰、按鈕不可用或者關閉相關頁面”。
- 常見的后端解決方案有“基於JAVA注解+AOP切面實現防止重復提交“。
2、方案
基於JAVA注解+AOP切面方式實現防止重復提交,一般需要自定義JAVA注解,采用AOP切面解析注解,實現接口首次請求提交時,將接口請求標記(由接口簽名、請求token、請求客戶端ip等組成)存儲至redis,並設置超時時間T(T時間之后redis清除接口請求標記),接口每次請求都先檢查redis中接口標記,若存在接口請求標記,則判定為接口重復提交,進行攔截返回處理。
3、實現
本次采用的基礎框架為SpringBoot,涉及的組件模塊有AOP、WEB、Redis、Lombok、Fastjson。詳細代碼與配置如下文。
-
pom依賴
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.28</version>
</dependency>
</dependencies>
-
配置文件
server.port=8888 # Redis數據庫索引(默認為0) spring.redis.database=0 # Redis服務器地址 spring.redis.host=127.0.0.1 # Redis服務器連接端口 spring.redis.port=6379 # Redis服務器連接密碼(默認為空) spring.redis.password= # 連接池最大連接數(使用負值表示沒有限制) spring.redis.pool.max-active=8 # 連接池最大阻塞等待時間(使用負值表示沒有限制) spring.redis.pool.max-wait=-1 # 連接池中的最大空閑連接 spring.redis.pool.max-idle=8 # 連接池中的最小空閑連接 spring.redis.pool.min-idle=0 # 連接超時時間(毫秒) spring.redis.timeout=5000
-
自定義注解
/**
* @author :Gavin
* @see :防止重復操作注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PreventDuplication {
/**
* 防重復操作限時標記數值(存儲redis限時標記數值)
*/
String value() default "value" ;
/**
* 防重復操作過期時間(借助redis實現限時控制)
*/
long expireSeconds() default 10;
}
-
自定義切面(解析注解)
切面用於處理防重復提交注解,通過redis中接口請求限時標記控制接口的提交請求。
/**
* @author :Gavin
* @see :防止重復操作切面(處理切面注解)
*/
@Aspect
@Component
public class PreventDuplicationAspect {
@Autowired
private RedisTemplate redisTemplate;
/**
* 定義切點
*/
@Pointcut("@annotation(com.example.idempotent.idempotent.annotation.PreventDuplication)")
public void preventDuplication() {
}
/**
* 環繞通知 (可以控制目標方法前中后期執行操作,目標方法執行前后分別執行一些代碼)
*
* @param joinPoint
* @return
*/
@Around("preventDuplication()")
public Object before(ProceedingJoinPoint joinPoint) throws Exception {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
Assert.notNull(request, "request cannot be null.");
//獲取執行方法
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
//獲取防重復提交注解
PreventDuplication annotation = method.getAnnotation(PreventDuplication.class);
// 獲取token以及方法標記,生成redisKey和redisValue
String token = request.getHeader(IdempotentConstant.TOKEN);
String redisKey = IdempotentConstant.PREVENT_DUPLICATION_PREFIX
.concat(token)
.concat(getMethodSign(method, joinPoint.getArgs()));
String redisValue = redisKey.concat(annotation.value()).concat("submit duplication");
if (!redisTemplate.hasKey(redisKey)) {
//設置防重復操作限時標記(前置通知)
redisTemplate.opsForValue()
.set(redisKey, redisValue, annotation.expireSeconds(), TimeUnit.SECONDS);
try {
//正常執行方法並返回
//ProceedingJoinPoint類型參數可以決定是否執行目標方法,且環繞通知必須要有返回值,返回值即為目標方法的返回值
return joinPoint.proceed();
} catch (Throwable throwable) {
//確保方法執行異常實時釋放限時標記(異常后置通知)
redisTemplate.delete(redisKey);
throw new RuntimeException(throwable);
}
} else {
throw new RuntimeException("請勿重復提交");
}
}
/**
* 生成方法標記:采用數字簽名算法SHA1對方法簽名字符串加簽
*
* @param method
* @param args
* @return
*/
private String getMethodSign(Method method, Object... args) {
StringBuilder sb = new StringBuilder(method.toString());
for (Object arg : args) {
sb.append(toString(arg));
}
return DigestUtils.sha1DigestAsHex(sb.toString());
}
private String toString(Object arg) {
if (Objects.isNull(arg)) {
return "null";
}
if (arg instanceof Number) {
return arg.toString();
}
return JSONObject.toJSONString(arg);
}
}
public interface IdempotentConstant {
String TOKEN = "token";
String PREVENT_DUPLICATION_PREFIX = "PREVENT_DUPLICATION_PREFIX:";
}
-
controller實現(使用注解)
@Slf4j
@RestController
@RequestMapping("/web")
public class IdempotentController {
@PostMapping("/sayNoDuplication")
@PreventDuplication(expireSeconds = 8)
public String sayNoDuplication(@RequestParam("requestNum") String requestNum) {
log.info("sayNoDuplicatin requestNum:{}", requestNum);
return "sayNoDuplicatin".concat(requestNum);
}
}
4、測試
-
正常請求(首次)
首次請求,接口正常返回處理結果。

-
限定時間內重復請求(上文設置8s)
在限定時間內重復請求,AOP切面攔截處理拋出異常,終止接口處理邏輯,異常返回。

控制台報錯:

5、源代碼
本文代碼已經上傳托管至GitHub以及Gitee,有需要的讀者請自行下載。
- GitHub:https://github.com/gavincoder/idempotent.git
- Gitee:https://gitee.com/gavincoderspace/idempotent.git
Java后端接口防止重復提交
最近在開發的過程中遇到前端沒有對提交按鈕做點擊后變灰處理,必須在后端添加防止重復提交的校驗。網上有很多中方案,我這邊采用的是aop+自定義注解方式實現。
剛開始采用利用自定義注解+aop+redis防止重復提交這篇博客的邏輯去實現,但是后來在測試多線程訪問的時候會出現問題,然后參考網上Redis分布式鎖的邏輯,多線程情況下測試只有一個可以通過。參考了LockManager中關於加鎖的邏輯。具體的代碼邏輯就不占了,只是在上面介紹的資料基礎上做了稍微的改造。
參考資料
https://blog.csdn.net/weixin_37505014/article/details/103461741
https://gitee.com/billion/redisLock/
自定義注解解決API接口冪等設計防止表單重復提交(生成token存放到redis中)
寫在后面
本文重點在於講解如何采用基於JAVA注解+AOP切面快速實現防重復提交功能,該方案實現可以完全勝任非高並發場景下實施應用。但是在高並發場景下仍然有不足之處,存在線程安全問題(可以采用Jemeter復現問題)。那么,如何實現支持高並發場景防重復提交功能?請讀者查看我的博文《基於Redis實現分布式鎖》,這篇博客對本文基於JAVA注解+AOP切面實現進行了優化改造,以便應用於高並發場景。

