背景
最近在小組同學(xué)卷的受不了的情況下,我決定換一個方向卷去,在算法上還是認(rèn)命吧,跟他們差距太大了, 在最近一段時間偶然看到網(wǎng)上關(guān)于接口冪等性校驗的文章,在我一番思索下,發(fā)現(xiàn)他們的實現(xiàn)原理各有不同而且每種實現(xiàn)原理各有不同,加之最近恰好在學(xué)設(shè)計模式,我就在想怎樣利用設(shè)計模式讓我們可以隨意選擇不同的實現(xiàn)方式。在此聲明一下,筆者僅僅是一個學(xué)生,對于正式的業(yè)務(wù)流程開發(fā)并不太懂,只是利用自己現(xiàn)有的知識儲備,打造一個讓自己使用起來更方便的小demo, 如果有大佬覺得哪兒有問題,歡迎指出。
什么是冪等性
在數(shù)學(xué)領(lǐng)域中對于冪等性的解釋是 f(f(x)) = f(x) 即冪等元素x在函數(shù)f的多次作用下,其效果和在f的一次作用下相同。在編程上可以理解為,如果某個函數(shù)(方法)或接口被調(diào)用多次其行為結(jié)果和被調(diào)用一次相同,則這種函數(shù)或接口就具有冪等性。簡單舉個例子,==天然冪等性==:
假設(shè)對象Person中有個name屬性,有個
setName(Stringname){ this.name=name }
的方法,那這個方法就是天然冪等的哦,你輸入相同的“小明”參數(shù),不論你重復(fù)調(diào)用多少次都是將名字設(shè)置為“小明”,其對對象Person的影響都是一樣的。這就是天然冪等性。
==非冪等性==:還是拿對象Person舉例子,假設(shè)對象中有個age屬性,有個
increaseAge(){ this.age++; }
方法,我們按正常的步驟一次一次調(diào)用是不會有問題的,如果調(diào)用者沒有控制好邏輯,一次流程重復(fù)調(diào)用好幾次,這時候影響效果和一次是有非常大區(qū)別,代碼編寫者以為它只會調(diào)用一次,結(jié)果出現(xiàn)了意外調(diào)用了很多次,恰好方法不具有冪等性,于是就會出現(xiàn)和預(yù)期不一樣的效果。這個方法本身是不具備冪等性的,我們可以修改這個方法,讓其傳入一個標(biāo)識符,每一次重復(fù)的請求會有相同的標(biāo)識符,方法內(nèi)部可以根據(jù)標(biāo)識符查數(shù)據(jù)庫是不是已經(jīng)處理過,如果處理過就不重復(fù)處理。這樣方法就具備了冪等性。
更通俗一點就是:當(dāng)在進(jìn)行轉(zhuǎn)賬的時候,我們分了兩個系統(tǒng)來處理這個轉(zhuǎn)賬的流程:
①系統(tǒng)A負(fù)責(zé)收集轉(zhuǎn)賬人和接收人還有金額的信息然后傳給系統(tǒng)B進(jìn)行轉(zhuǎn)賬,將控制邏輯留在系統(tǒng)A。
②系統(tǒng)B讀取系統(tǒng)A傳過來的信息,負(fù)責(zé)更改數(shù)據(jù)庫的金額。如果操作成功,就回復(fù)系統(tǒng)A成功,如果失敗就回復(fù)系統(tǒng)A失敗。
③系統(tǒng)A可以接受系統(tǒng)B操作成功或失敗的回復(fù),但是我們知道,系統(tǒng)A這個交易流程是有等待時間的,如果等待超時,它不確認(rèn)是否是轉(zhuǎn)賬成功或失敗,于是系統(tǒng)A會重試調(diào)用直到得到一個明確的回復(fù)。
這是系統(tǒng)大致的交易流程。這個流程是有問題的,系統(tǒng)B提供的操作接口不是冪等性的,因為A會重復(fù)調(diào)用接口,導(dǎo)致出現(xiàn)一個接口被同一個數(shù)據(jù)源發(fā)送相同數(shù)據(jù)切想要達(dá)到請求一次接口的效果的現(xiàn)象。
常見請求方式的冪等性
√ 滿足冪等
x 不滿足冪等
可能滿足也可能不滿足冪等,根據(jù)實際業(yè)務(wù)邏輯有關(guān)
方法類型 | 是否冪等 | 描述 |
---|---|---|
Get | √ | Get 方法用于獲取資源。其一般不會也不應(yīng)當(dāng)對系統(tǒng)資源進(jìn)行改變,所以是冪等的。 |
Post | x | Post 方法一般用于創(chuàng)建新的資源。其每次執(zhí)行都會新增數(shù)據(jù),所以不是冪等的。 |
Put | _ | Put 方法一般用于修改資源。該操作則分情況來判斷是不是滿足冪等,更新操作中直接根據(jù)某個值進(jìn)行更新,也能保持冪等。不過執(zhí)行累加操作的更新是非冪等。 |
Delete | _ | Delete 方法一般用于刪除資源。該操作則分情況來判斷是不是滿足冪等,當(dāng)根據(jù)唯一值進(jìn)行刪除時,刪除同一個數(shù)據(jù)多次執(zhí)行效果一樣。不過需要注意,帶查詢條件的刪除則就不一定滿足冪等了。例如在根據(jù)條件刪除一批數(shù)據(jù)后,這時候新增加了一條數(shù)據(jù)也滿足條件,然后又執(zhí)行了一次刪除,那么將會導(dǎo)致新增加的這條滿足條件數(shù)據(jù)也被刪除。 |
為什么要實現(xiàn)冪等性校驗
在接口調(diào)用時一般情況下都能正常返回信息不會重復(fù)提交,不過在遇見以下情況時可以就會出現(xiàn)問題,如:
前端重復(fù)提交表單:在填寫一些表格時候,用戶填寫完成提交,很多時候會因網(wǎng)絡(luò)波動沒有及時對用戶做出提交成功響應(yīng),致使用戶認(rèn)為沒有成功提交,然后一直點提交按鈕,這時就會發(fā)生重復(fù)提交表單請求。
用戶惡意進(jìn)行刷單:例如在實現(xiàn)用戶投票這種功能時,如果用戶針對一個用戶進(jìn)行重復(fù)提交投票,這樣會導(dǎo)致接口接收到用戶重復(fù)提交的投票信息,這樣會使投票結(jié)果與事實嚴(yán)重不符。
接口超時重復(fù)提交:很多時候 HTTP 客戶端工具都默認(rèn)開啟超時重試的機(jī)制,尤其是第三方調(diào)用接口時候,為了防止網(wǎng)絡(luò)波動超時等造成的請求失敗,都會添加重試機(jī)制,導(dǎo)致一個請求提交多次。
消息進(jìn)行重復(fù)消費:當(dāng)使用 MQ 消息中間件時候,如果發(fā)生消息中間件出現(xiàn)錯誤未及時提交消費信息,導(dǎo)致發(fā)生重復(fù)消費。
使用冪等性最大的優(yōu)勢在于使接口保證任何冪等性操作,免去因重試等造成系統(tǒng)產(chǎn)生的未知的問題。
如何實現(xiàn)接口的冪等性校驗
網(wǎng)上流傳最多的應(yīng)該是四種方式去實現(xiàn)接口的冪等性校驗,接下來我們來一個個盤點。
數(shù)據(jù)庫唯一主鍵
「方案描述」 數(shù)據(jù)庫唯一主鍵的實現(xiàn)主要是利用數(shù)據(jù)庫中主鍵唯一約束的特性,一般來說唯一主鍵比較適用于“插入”時的冪等性,其能保證一張表中只能存在一條帶該唯一主鍵的記錄。使用數(shù)據(jù)庫唯一主鍵完成冪等性時需要注意的是,該主鍵一般來說并不是使用數(shù)據(jù)庫中自增主鍵,而是使用分布式 ID 充當(dāng)主鍵(或者使用其他算法生成的全局唯一的id),這樣才能能保證在分布式環(huán)境下 ID 的全局唯一性。
「適用操作:」 插入操作 刪除操作
「使用限制:」 需要生成全局唯一主鍵 ID;
「主要流程:」 ① 客戶端執(zhí)行創(chuàng)建請求,調(diào)用服務(wù)端接口。② 服務(wù)端執(zhí)行業(yè)務(wù)邏輯,生成一個分布式 ID,將該 ID 充當(dāng)待插入數(shù)據(jù)的主鍵,然后執(zhí)數(shù)據(jù)插入操作,運行對應(yīng)的 SQL 語句。③ 服務(wù)端將該條數(shù)據(jù)插入數(shù)據(jù)庫中,如果插入成功則表示沒有重復(fù)調(diào)用接口。如果拋出主鍵重復(fù)異常,則表示數(shù)據(jù)庫中已經(jīng)存在該條記錄,返回錯誤信息到客戶端。
數(shù)據(jù)庫樂觀鎖
「方案描述:」 數(shù)據(jù)庫樂觀鎖方案一般只能適用于執(zhí)行“更新操作”的過程,我們可以提前在對應(yīng)的數(shù)據(jù)表中多添加一個字段,充當(dāng)當(dāng)前數(shù)據(jù)的版本標(biāo)識。這樣每次對該數(shù)據(jù)庫該表的這條數(shù)據(jù)執(zhí)行更新時,都會將該版本標(biāo)識作為一個條件,值為上次待更新數(shù)據(jù)中的版本標(biāo)識的值。「適用操作:」 更新操作
「使用限制:」 需要數(shù)據(jù)庫對應(yīng)業(yè)務(wù)表中添加額外字段;
防重 Token 令牌
「方案描述:」 針對客戶端連續(xù)點擊或者調(diào)用方的超時重試等情況,例如提交訂單,此種操作就可以用 Token 的機(jī)制實現(xiàn)防止重復(fù)提交。簡單的說就是調(diào)用方在調(diào)用接口的時候先向后端請求一個全局 ID(Token),請求的時候攜帶這個全局 ID 一起請求(Token 最好將其放到 Headers 中),后端需要對這個 Token 作為 Key,用戶信息作為 Value 到 Redis 中進(jìn)行鍵值內(nèi)容校驗,如果 Key 存在且 Value 匹配就執(zhí)行刪除命令,然后正常執(zhí)行后面的業(yè)務(wù)邏輯。如果不存在對應(yīng)的 Key 或 Value 不匹配就返回重復(fù)執(zhí)行的錯誤信息,這樣來保證冪等操作。
「適用操作:」 插入操作 更新操作 刪除操作
「使用限制:」 需要生成全局唯一 Token 串;需要使用第三方組件 Redis 進(jìn)行數(shù)據(jù)效驗;
redis
「方案描述:」
第四種是我覺著用著挺方便的,但是實用性應(yīng)該不大,而且和第三種類似,我們可以把接口名加請求參數(shù)通過算法生成一個全局唯一的id,然后 存到redis中,如果在一定時間請求多次,我們就直接拒絕。
「適用操作:」 插入操作 更新操作 刪除操作
「使用限制:」 需要使用第三方組件 Redis 進(jìn)行數(shù)據(jù)效驗;
如何將這幾種方式都組裝到一起
我使用了Java自帶的注解以及設(shè)計模式中的策略模式,我們可以在注解中直接指定冪等性校驗的方式,當(dāng)然也可以在配置文件中指定,但是直接在注解中指定更加靈活。
但是,由于最近時間比較忙,天天被某些人卷,很少有時間去完善,目前只是實現(xiàn)了redis和防重 Token 令牌兩種方式的。以下是部分代碼
「自定義注解」
packageorg.example.annotation; importjava.lang.annotation.*; /** *@authorzrq */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @Documented public@interfaceRequestMany{ /** *策略 *@return */ Stringvalue()default""; /** *過期時間 *@return */ longexpireTime()default0; }
「定義切面」
packageorg.example.aop; importorg.aspectj.lang.ProceedingJoinPoint; importorg.aspectj.lang.annotation.Around; importorg.aspectj.lang.annotation.Aspect; importorg.aspectj.lang.reflect.MethodSignature; importorg.example.annotation.RequestMany; importorg.example.factory.RequestManyStrategy; importorg.springframework.beans.factory.annotation.Autowired; importorg.springframework.stereotype.Component; importorg.springframework.util.DigestUtils; importorg.springframework.web.context.request.RequestContextHolder; importorg.springframework.web.context.request.ServletRequestAttributes; importjavax.servlet.http.HttpServletRequest; importjava.lang.reflect.Method; importjava.util.Arrays; importjava.util.Map; importjava.util.stream.Collectors; /** *@authorzrq *@ClassNameRequestManyValidationAspect *@date2023/11/229:14 *@DescriptionTODO */ @Aspect @Component publicclassRequestManyValidationAspect{ @Autowired privateMapidempotentStrategies; @Around("@annotation(org.example.annotation.RequestMany)") publicObjectvalidateIdempotent(ProceedingJoinPointjoinPoint)throwsThrowable{ MethodSignaturemethodSignature=(MethodSignature)joinPoint.getSignature(); Methodmethod=methodSignature.getMethod(); RequestManyrequestMany=method.getAnnotation(RequestMany.class); Stringstrategy=requestMany.value();//獲取注解中配置的策略名稱 Integertime=(int)requestMany.expireTime();//獲取注解中配置的策略名稱 if(!idempotentStrategies.containsKey(strategy)){ thrownewIllegalArgumentException("Invalididempotentstrategy:"+strategy); } Stringkey=generateKey(joinPoint);//根據(jù)方法參數(shù)等生成唯一的key RequestManyStrategyidempotentStrategy=idempotentStrategies.get(strategy); idempotentStrategy.validate(key,time); returnjoinPoint.proceed(); } privateStringgenerateKey(ProceedingJoinPointjoinPoint){ //獲取類名 StringclassName=joinPoint.getTarget().getClass().getSimpleName(); //獲取方法名 MethodSignaturemethodSignature=(MethodSignature)joinPoint.getSignature(); StringmethodName=methodSignature.getMethod().getName(); //獲取方法參數(shù) Object[]args=joinPoint.getArgs(); StringargString=Arrays.stream(args) .map(Object::toString) .collect(Collectors.joining(",")); //獲取請求攜帶的Token HttpServletRequestrequest=((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest(); Stringtoken=request.getHeader("token"); //生成唯一的key Stringkey=className+":"+methodName+":"+argString+":"+token; Stringmd5Password=DigestUtils.md5DigestAsHex(key.getBytes()); returnmd5Password; } }
「處理異常」
packageorg.example.exception; /** *運行時異常 *@authorbinbin.hou *@since0.0.1 */ publicclassRequestManyValidationExceptionextendsRuntimeException{ publicRequestManyValidationException(){ } publicRequestManyValidationException(Stringmessage){ super(message); } publicRequestManyValidationException(Stringmessage,Throwablecause){ super(message,cause); } publicRequestManyValidationException(Throwablecause){ super(cause); } publicRequestManyValidationException(Stringmessage,Throwablecause,booleanenableSuppression,booleanwritableStackTrace){ super(message,cause,enableSuppression,writableStackTrace); } }
「模式工廠」
packageorg.example.factory; importorg.example.exception.RequestManyValidationException; /** *@authorzrq *@ClassNameRequestManyStrategy *@date2023/11/229:04 *@DescriptionTODO */ publicinterfaceRequestManyStrategy{ voidvalidate(Stringkey,Integertime)throwsRequestManyValidationException; }
「模式實現(xiàn)01」
packageorg.example.factory.impl; importorg.example.exception.RequestManyValidationException; importorg.example.factory.RequestManyStrategy; importorg.example.utils.RedisCache; importorg.springframework.stereotype.Component; importjavax.annotation.Resource; importjava.util.concurrent.TimeUnit; /** *@authorzrq *@ClassNameRedisIdempotentStrategy *@date2023/11/229:07 *@DescriptionTODO */ @Component publicclassRedisIdempotentStrategyimplementsRequestManyStrategy{ @Resource privateRedisCacheredisCache; @Override publicvoidvalidate(Stringkey,Integertime)throwsRequestManyValidationException{ if(redisCache.hasKey(key)){ thrownewRequestManyValidationException("請求次數(shù)過多"); }else{ redisCache.setCacheObject(key,"1",time,TimeUnit.MINUTES); } } }
「模式實現(xiàn)02」
packageorg.example.factory.impl; importorg.example.exception.RequestManyValidationException; importorg.example.factory.RequestManyStrategy; importorg.example.utils.RedisCache; importorg.springframework.data.redis.connection.RedisConnectionFactory; importorg.springframework.data.redis.connection.jedis.JedisConnectionFactory; importorg.springframework.data.redis.core.RedisTemplate; importorg.springframework.data.redis.serializer.StringRedisSerializer; importorg.springframework.stereotype.Component; importorg.springframework.web.context.request.RequestContextHolder; importorg.springframework.web.context.request.ServletRequestAttributes; importjavax.annotation.Resource; importjavax.servlet.http.HttpServletRequest; /** *@authorzrq *@ClassNameTokenIdempotentStrategy *@date2023/11/229:13 *@DescriptionTODO */ @Component publicclassTokenIdempotentStrategyimplementsRequestManyStrategy{ @Resource privateRedisCacheredisCache; @Override publicvoidvalidate(Stringkey,Integertime)throwsRequestManyValidationException{ HttpServletRequestrequest=((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest(); Stringtoken=request.getHeader("token"); if(token==null||token.isEmpty()){ thrownewRequestManyValidationException("未授權(quán)的token"); } //根據(jù)key和token執(zhí)行冪等性校驗 booleanisDuplicateRequest=performTokenValidation(key,token); if(!isDuplicateRequest){ thrownewRequestManyValidationException("多次請求"); } } privatebooleanperformTokenValidation(Stringkey,Stringtoken){ //執(zhí)行根據(jù)Token進(jìn)行冪等性校驗的邏輯 //這里可以使用你選擇的合適的方法,比如將Token存儲到數(shù)據(jù)庫或緩存中,然后檢查是否已存在 StringstoredToken=redisCache.getCacheObject(key); //比較存儲的Token和當(dāng)前請求的Token是否一致 returntoken.equals(storedToken); } }
「redisutil類」
packageorg.example.utils; importlombok.extern.slf4j.Slf4j; importorg.springframework.beans.factory.annotation.Autowired; importorg.springframework.data.redis.connection.BitFieldSubCommands; importorg.springframework.data.redis.core.*; importorg.springframework.stereotype.Component; importjava.util.*; importjava.util.concurrent.TimeUnit; @SuppressWarnings(value={"unchecked","rawtypes"}) @Component @Slf4j publicclassRedisCache { @Autowired publicRedisTemplateredisTemplate; @Autowired privateStringRedisTemplatestringRedisTemplate; /** *緩存基本的對象,Integer、String、實體類等 * *@paramkey緩存的鍵值 *@paramvalue緩存的值 */ publicvoidsetCacheObject(finalStringkey,finalTvalue) { redisTemplate.opsForValue().set(key,value); } /** *緩存基本的對象,Integer、String、實體類等 * *@paramkey緩存的鍵值 *@paramvalue緩存的值 *@paramtimeout時間 *@paramtimeUnit時間顆粒度 */ public voidsetCacheObject(finalStringkey,finalTvalue,finalIntegertimeout,finalTimeUnittimeUnit) { redisTemplate.opsForValue().set(key,value,timeout,timeUnit); } /** *設(shè)置有效時間 * *@paramkeyRedis鍵 *@paramtimeout超時時間 *@returntrue=設(shè)置成功;false=設(shè)置失敗 */ publicbooleanexpire(finalStringkey,finallongtimeout) { returnexpire(key,timeout,TimeUnit.SECONDS); } publicbooleanhasKey(finalStringkey) { returnBoolean.TRUE.equals(redisTemplate.hasKey(key)); } /** *設(shè)置有效時間 * *@paramkeyRedis鍵 *@paramtimeout超時時間 *@paramunit時間單位 *@returntrue=設(shè)置成功;false=設(shè)置失敗 */ publicbooleanexpire(finalStringkey,finallongtimeout,finalTimeUnitunit) { returnredisTemplate.expire(key,timeout,unit); } /** *獲得緩存的基本對象。 * *@paramkey緩存鍵值 *@return緩存鍵值對應(yīng)的數(shù)據(jù) */ public TgetCacheObject(finalStringkey) { ValueOperations operation=redisTemplate.opsForValue(); returnoperation.get(key); } /** *刪除單個對象 * *@paramkey */ publicbooleandeleteObject(finalStringkey) { returnredisTemplate.delete(key); } /** *刪除集合對象 * *@paramcollection多個對象 *@return */ publiclongdeleteObject(finalCollectioncollection) { returnredisTemplate.delete(collection); } /** *緩存List數(shù)據(jù) * *@paramkey緩存的鍵值 *@paramdataList待緩存的List數(shù)據(jù) *@return緩存的對象 */ public longsetCacheList(finalStringkey,finalList dataList) { Longcount=redisTemplate.opsForList().rightPushAll(key,dataList); returncount==null?0:count; } /** *獲得緩存的list對象 * *@paramkey緩存的鍵值 *@return緩存鍵值對應(yīng)的數(shù)據(jù) */ public List getCacheList(finalStringkey) { returnredisTemplate.opsForList().range(key,0,-1); } /** *緩存Set * *@paramkey緩存鍵值 *@paramdataSet緩存的數(shù)據(jù) *@return緩存數(shù)據(jù)的對象 */ public BoundSetOperations setCacheSet(finalStringkey,finalSet dataSet) { BoundSetOperations setOperation=redisTemplate.boundSetOps(key); Iterator it=dataSet.iterator(); while(it.hasNext()) { setOperation.add(it.next()); } returnsetOperation; } /** *獲得緩存的set * *@paramkey *@return */ public Set getCacheSet(finalStringkey) { returnredisTemplate.opsForSet().members(key); } /** *緩存Map * *@paramkey *@paramdataMap */ public voidsetCacheMap(finalStringkey,finalMap dataMap) { if(dataMap!=null){ redisTemplate.opsForHash().putAll(key,dataMap); } } /** *獲得緩存的Map * *@paramkey *@return */ public Map getCacheMap(finalStringkey) { returnredisTemplate.opsForHash().entries(key); } /** *往Hash中存入數(shù)據(jù) * *@paramkeyRedis鍵 *@paramhKeyHash鍵 *@paramvalue值 */ public voidsetCacheMapValue(finalStringkey,finalStringhKey,finalTvalue) { redisTemplate.opsForHash().put(key,hKey,value); } /** *獲取Hash中的數(shù)據(jù) * *@paramkeyRedis鍵 *@paramhKeyHash鍵 *@returnHash中的對象 */ public TgetCacheMapValue(finalStringkey,finalStringhKey) { HashOperations opsForHash=redisTemplate.opsForHash(); returnopsForHash.get(key,hKey); } /** *刪除Hash中的數(shù)據(jù) * *@paramkey *@paramhkey */ publicvoiddelCacheMapValue(finalStringkey,finalStringhkey) { HashOperationshashOperations=redisTemplate.opsForHash(); hashOperations.delete(key,hkey); } /** *獲取多個Hash中的數(shù)據(jù) * *@paramkeyRedis鍵 *@paramhKeysHash鍵集合 *@returnHash對象集合 */ public List getMultiCacheMapValue(finalStringkey,finalCollection
「配置文件」
如果要實現(xiàn)其他方式的話只需要實現(xiàn)下RequestManyStrategy模板方法,然后編寫自己的校驗邏輯就可以。
以上代碼已經(jīng)上傳到github :https://github.com/Lumos-i/tools-and-frameworks
結(jié)語
大學(xué)過的可真快,轉(zhuǎn)眼就大三了,自己的技術(shù)還是不行,跟別人的差距還有很大距離,希望自己能在有限的時間里學(xué)到更多有用的知識,同時也希望在明年的這個時候可以坐在辦公室里敲代碼。突然想到高中時中二的一句話“聽聞少年二字,應(yīng)與平庸相斥”,誰不希望這樣呢,奈何身邊大佬太多,現(xiàn)在只能追趕別人的腳步。。。
審核編輯:黃飛
-
接口
+關(guān)注
關(guān)注
33文章
8691瀏覽量
151826 -
數(shù)據(jù)庫
+關(guān)注
關(guān)注
7文章
3845瀏覽量
64665 -
Redis
+關(guān)注
關(guān)注
0文章
378瀏覽量
10926
原文標(biāo)題:策略模式實現(xiàn)接口的冪等性校驗
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關(guān)注!文章轉(zhuǎn)載請注明出處。
發(fā)布評論請先 登錄
相關(guān)推薦
評論