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切面快速實現防重復提交功能,該方案實現可以完全勝任非高並發場景下實施應用。但是在高並發場景下仍然有不足之處,存在線程安全問題(可以采用Jemeter復現問題)。那么,如何實現支持高並發場景防重復提交功能?請讀者查看我的博文《基於Redis實現分布式鎖》,這篇博客對本文基於JAVA注解+AOP切面實現進行了優化改造,以便應用於高並發場景。