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切面實現進行了優化改造,以便應用於高併發場景。