SpringBoot使用AOP實(shí)現(xiàn)防重復(fù)提交功能
防重冪等的概念
防重冪等指的是我們的業(yè)務(wù)需要防止兩條相同的數(shù)據(jù)重復(fù)提交導(dǎo)致臟數(shù)據(jù)或業(yè)務(wù)錯(cuò)亂。需要注意的是,重復(fù)提交屬于小概率事件,這和并發(fā)壓測(cè)不是同一個(gè)概念。
我們的目標(biāo)是通過(guò)防重冪等的設(shè)計(jì),讓系統(tǒng)支持業(yè)務(wù)失敗或異??焖籴尫畔拗?。業(yè)務(wù)處理成功后,會(huì)在指定時(shí)間限定內(nèi)限制同一條數(shù)據(jù)的提交。本文將介紹如何在SpringBoot開(kāi)發(fā)中,使用AOP+Redis實(shí)現(xiàn)一個(gè)防重冪等功能。
防重冪等設(shè)計(jì)思路
目標(biāo):防止同一個(gè)用戶在同一個(gè)業(yè)務(wù)下提交同一個(gè)數(shù)據(jù)。
策略:將用戶路徑+請(qǐng)求參數(shù)+Token生成唯一ID,存入Redis。具體流程如下:
- 用戶從前端發(fā)送請(qǐng)求,我們通過(guò)切面攔截,拿到請(qǐng)求地址、請(qǐng)求參數(shù)和token,生成一個(gè)唯一ID。
- 判斷Redis中是否已存在數(shù)據(jù)以及數(shù)據(jù)是否有效
- 如果不存在:正常執(zhí)行業(yè)務(wù)。如果存在:拋出異常,提示重復(fù)提交。
- 通過(guò)AOP攔截方法執(zhí)行結(jié)果,如果結(jié)果正常,就放行;否則就刪掉存入Redis的Key,說(shuō)明本次業(yè)務(wù)異常,下次提交可以放行。
自定義注解@RepeatSubmit
首先我們定義一個(gè)注解@RepeatSubmit
,作用于方法上,設(shè)置如下參數(shù),用于設(shè)置AOP切點(diǎn)。
- interval:間隔時(shí)間
- timeUnit:時(shí)間單位,ms
- message:支持國(guó)際化的提示消息
@Inherited @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RepeatSubmit { /** * 間隔時(shí)間(ms),小于此時(shí)間視為重復(fù)提交 */ int interval() default 5000; TimeUnit timeUnit() default TimeUnit.MILLISECONDS; /** * 提示消息 支持國(guó)際化 格式為 [code] */ String message() default "{repeat.submit.message}"; }
自定義切面@RepeatSubmitAspect
定義一個(gè)切面@RepeatSubmitAspect
,作為防重冪等的模塊化,用于橫切標(biāo)記上@RepeatSubmit
注解的方法。
我們需要定義三個(gè)通知:前置通知、后置通知、拋出異常時(shí)的通知,他們執(zhí)行的業(yè)務(wù)如下,基本上是按照上述防重冪等設(shè)計(jì)的策略來(lái)寫(xiě)的。
doBefore:使用@Before("@annotation(repeatSubmit)")
定義前置通知,切點(diǎn)是加了注解的方法。
- 從注解拿到間隔時(shí)間
- 從切點(diǎn)拿到請(qǐng)求參數(shù),從ServletRequest拿到請(qǐng)求地址和請(qǐng)求頭的用戶token。
- 拼接SubmitKey:對(duì)
token:請(qǐng)求參數(shù)
做MD5加密。 - 拼接CacheKey(存到ThreadLocal里面):將緩存常量SUBMIT_KEY,拼接URL,SubmitKey三者拼接作為Cachekey。(這意味著,如果請(qǐng)求的地址相同、參數(shù)相同、token相同,就認(rèn)為是相同提交。)
- 判斷Redis中是否已存在Key,如果存在,拋出異常。否則將CacheKey設(shè)置到Redis。
doAfterReturning:使用@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult")
定義后置通知并拿到j(luò)sonResult。
- 拿到j(luò)sonResult,轉(zhuǎn)為R類(lèi)型
- 判斷code是否為成功,如果是就返回,如果不是就代表業(yè)務(wù)失敗,于是我們就刪掉CacheKey,因?yàn)檫@次業(yè)務(wù)并未處理成功,下一次請(qǐng)求是可以接納的。
- 刪除ThreadLocal本地變量
doAfterThrowing:
刪除key,移除ThreadLocal本地變量
PS:這里用到了ThreadLocal,ThreadLocal
是一個(gè) Java 類(lèi),可以用來(lái)定義只由創(chuàng)建它們的線程訪問(wèn)的變量,常用于我們需要存儲(chǔ)不在線程之間共享的數(shù)據(jù)。
@Aspect @Component public class RepeatSubmitAspect { private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>(); @Before("@annotation(repeatSubmit)") public void doBefore(JoinPoint point, RepeatSubmit repeatSubmit) throws Throwable { // 如果注解不為0 則使用注解數(shù)值 long interval = repeatSubmit.timeUnit().toMillis(repeatSubmit.interval()); if (interval < 1000) { throw new ServiceException("重復(fù)提交間隔時(shí)間不能小于'1'秒"); } HttpServletRequest request = ServletUtils.getRequest(); String nowParams = argsArrayToString(point.getArgs()); // 請(qǐng)求地址(作為存放cache的key值) String url = request.getRequestURI(); // 唯一值(沒(méi)有消息頭則使用請(qǐng)求地址) String submitKey = StringUtils.trimToEmpty(request.getHeader(SaManager.getConfig().getTokenName())); submitKey = SecureUtil.md5(submitKey + ":" + nowParams); // 唯一標(biāo)識(shí)(指定key + url + 消息頭) String cacheRepeatKey = CacheConstants.REPEAT_SUBMIT_KEY + url + submitKey; if (RedisUtils.setObjectIfAbsent(cacheRepeatKey, "", Duration.ofMillis(interval))) { KEY_CACHE.set(cacheRepeatKey); } else { String message = repeatSubmit.message(); if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) { message = MessageUtils.message(StringUtils.substring(message, 1, message.length() - 1)); } throw new ServiceException(message); } } /** * 處理完請(qǐng)求后執(zhí)行 * * @param joinPoint 切點(diǎn) */ @AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult") public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object jsonResult) { if (jsonResult instanceof R) { try { R<?> r = (R<?>) jsonResult; // 成功則不刪除redis數(shù)據(jù) 保證在有效時(shí)間內(nèi)無(wú)法重復(fù)提交 if (r.getCode() == R.SUCCESS) { return; } RedisUtils.deleteObject(KEY_CACHE.get()); } finally { KEY_CACHE.remove(); } } } /** * 攔截異常操作 * * @param joinPoint 切點(diǎn) * @param e 異常 */ @AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e") public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception e) { RedisUtils.deleteObject(KEY_CACHE.get()); KEY_CACHE.remove(); } /** * 參數(shù)拼裝 */ private String argsArrayToString(Object[] paramsArray) { StringJoiner params = new StringJoiner(" "); if (ArrayUtil.isEmpty(paramsArray)) { return params.toString(); } for (Object o : paramsArray) { if (ObjectUtil.isNotNull(o) && !isFilterObject(o)) { params.add(JsonUtils.toJsonString(o)); } } return params.toString(); } /** * 判斷是否需要過(guò)濾的對(duì)象。 * * @param o 對(duì)象信息。 * @return 如果是需要過(guò)濾的對(duì)象,則返回true;否則返回false。 */ @SuppressWarnings("rawtypes") public boolean isFilterObject(final Object o) { Class<?> clazz = o.getClass(); if (clazz.isArray()) { return clazz.getComponentType().isAssignableFrom(MultipartFile.class); } else if (Collection.class.isAssignableFrom(clazz)) { Collection collection = (Collection) o; for (Object value : collection) { return value instanceof MultipartFile; } } else if (Map.class.isAssignableFrom(clazz)) { Map map = (Map) o; for (Object value : map.values()) { return value instanceof MultipartFile; } } return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse || o instanceof BindingResult; } }
簡(jiǎn)單測(cè)試
我們創(chuàng)建一個(gè)接口用于測(cè)試防重冪等,我的系統(tǒng)中使用Sa-Token
權(quán)限框架,為了方便,我們通過(guò)@SaIngore
放行接口。
/** * @author AjaxZhan */@RestController @RequestMapping("/repeat") @Slf4j @SaIgnore public class RepeatController { @PostMapping @RepeatSubmit(interval = 2000) public R<Void> repeat1(String info){ log.info("請(qǐng)求成功,信息" + info); return R.ok("請(qǐng)求成功"); } }
使用Apifox測(cè)試結(jié)果如下:
當(dāng)我們?cè)?s內(nèi)連續(xù)提交就會(huì)觸發(fā)異常:
至此,我們就成功地使用AOP+Redis的方式設(shè)計(jì)了一個(gè)防重冪等功能。
到此這篇關(guān)于SpringBoot使用AOP實(shí)現(xiàn)防重復(fù)提交功能的文章就介紹到這了,更多相關(guān)SpringBoot AOP防重復(fù)提交內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Java中?springcloud.openfeign應(yīng)用案例解析
使用OpenFeign能讓編寫(xiě)Web?Service客戶端更加簡(jiǎn)單,使用時(shí)只需定義服務(wù)接口,然后在上面添加注解,OpenFeign也支持可拔插式的編碼和解碼器,這篇文章主要介紹了Java中?springcloud.openfeign應(yīng)用案例解析,需要的朋友可以參考下2024-06-06解決springboot文件配置端口不起作用(默認(rèn)8080)
這篇文章主要介紹了解決springboot文件配置端口不起作用(默認(rèn)8080),具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2021-08-08Spring Boot集成MyBatis實(shí)現(xiàn)通用Mapper的配置及使用
關(guān)于MyBatis,大部分人都很熟悉。MyBatis 是一款優(yōu)秀的持久層框架,它支持定制化 SQL、存儲(chǔ)過(guò)程以及高級(jí)映射。這篇文章主要介紹了Spring Boot集成MyBatis實(shí)現(xiàn)通用Mapper,需要的朋友可以參考下2018-08-08Java和MySQL數(shù)據(jù)庫(kù)中關(guān)于小數(shù)的保存問(wèn)題詳析
在Java和MySQL中小數(shù)的精度可能會(huì)受到限制,如float類(lèi)型的小數(shù)只能精確到6-7位,double類(lèi)型也只能精確到15-16位,這篇文章主要給大家介紹了關(guān)于Java和MySQL數(shù)據(jù)庫(kù)中關(guān)于小數(shù)的保存問(wèn)題,需要的朋友可以參考下2024-01-01Java中jakarta.validation數(shù)據(jù)校驗(yàn)幾個(gè)主要依賴包講解
在Java開(kāi)發(fā)中,BeanValidationAPI提供了一套標(biāo)準(zhǔn)的數(shù)據(jù)驗(yàn)證機(jī)制,尤其是通過(guò)JakartaBeanValidation(原HibernateValidator)實(shí)現(xiàn),文中通過(guò)代碼介紹的非常詳細(xì),需要的朋友可以參考下2024-09-09