背景
最近在小組同學卷的受不了的情況下,我決定換一個方向卷去,在算法上還是認命吧,跟他們差距太大了, 在最近一段時間偶然看到網上關于接口冪等性校驗的文章,在我一番思索下,發現他們的實現原理各有不同而且每種實現原理各有不同,加之最近恰好在學設計模式,我就在想怎樣利用設計模式讓我們可以隨意選擇不同的實現方式。在此聲明一下,筆者僅僅是一個學生,對于正式的業務流程開發并不太懂,只是利用自己現有的知識儲備,打造一個讓自己使用起來更方便的小demo, 如果有大佬覺得哪兒有問題,歡迎指出。
什么是冪等性
在數學領域中對于冪等性的解釋是 f(f(x)) = f(x) 即冪等元素x在函數f的多次作用下,其效果和在f的一次作用下相同。在編程上可以理解為,如果某個函數(方法)或接口被調用多次其行為結果和被調用一次相同,則這種函數或接口就具有冪等性。簡單舉個例子,==天然冪等性==:
假設對象Person中有個name屬性,有個
setName(Stringname){ this.name=name }
的方法,那這個方法就是天然冪等的哦,你輸入相同的“小明”參數,不論你重復調用多少次都是將名字設置為“小明”,其對對象Person的影響都是一樣的。這就是天然冪等性。
==非冪等性==:還是拿對象Person舉例子,假設對象中有個age屬性,有個
increaseAge(){ this.age++; }
方法,我們按正常的步驟一次一次調用是不會有問題的,如果調用者沒有控制好邏輯,一次流程重復調用好幾次,這時候影響效果和一次是有非常大區別,代碼編寫者以為它只會調用一次,結果出現了意外調用了很多次,恰好方法不具有冪等性,于是就會出現和預期不一樣的效果。這個方法本身是不具備冪等性的,我們可以修改這個方法,讓其傳入一個標識符,每一次重復的請求會有相同的標識符,方法內部可以根據標識符查數據庫是不是已經處理過,如果處理過就不重復處理。這樣方法就具備了冪等性。
更通俗一點就是:當在進行轉賬的時候,我們分了兩個系統來處理這個轉賬的流程:
①系統A負責收集轉賬人和接收人還有金額的信息然后傳給系統B進行轉賬,將控制邏輯留在系統A。
②系統B讀取系統A傳過來的信息,負責更改數據庫的金額。如果操作成功,就回復系統A成功,如果失敗就回復系統A失敗。
③系統A可以接受系統B操作成功或失敗的回復,但是我們知道,系統A這個交易流程是有等待時間的,如果等待超時,它不確認是否是轉賬成功或失敗,于是系統A會重試調用直到得到一個明確的回復。
這是系統大致的交易流程。這個流程是有問題的,系統B提供的操作接口不是冪等性的,因為A會重復調用接口,導致出現一個接口被同一個數據源發送相同數據切想要達到請求一次接口的效果的現象。
常見請求方式的冪等性
√ 滿足冪等
x 不滿足冪等
可能滿足也可能不滿足冪等,根據實際業務邏輯有關
方法類型 | 是否冪等 | 描述 |
---|---|---|
Get | √ | Get 方法用于獲取資源。其一般不會也不應當對系統資源進行改變,所以是冪等的。 |
Post | x | Post 方法一般用于創建新的資源。其每次執行都會新增數據,所以不是冪等的。 |
Put | _ | Put 方法一般用于修改資源。該操作則分情況來判斷是不是滿足冪等,更新操作中直接根據某個值進行更新,也能保持冪等。不過執行累加操作的更新是非冪等。 |
Delete | _ | Delete 方法一般用于刪除資源。該操作則分情況來判斷是不是滿足冪等,當根據唯一值進行刪除時,刪除同一個數據多次執行效果一樣。不過需要注意,帶查詢條件的刪除則就不一定滿足冪等了。例如在根據條件刪除一批數據后,這時候新增加了一條數據也滿足條件,然后又執行了一次刪除,那么將會導致新增加的這條滿足條件數據也被刪除。 |
為什么要實現冪等性校驗
在接口調用時一般情況下都能正常返回信息不會重復提交,不過在遇見以下情況時可以就會出現問題,如:
前端重復提交表單:在填寫一些表格時候,用戶填寫完成提交,很多時候會因網絡波動沒有及時對用戶做出提交成功響應,致使用戶認為沒有成功提交,然后一直點提交按鈕,這時就會發生重復提交表單請求。
用戶惡意進行刷單:例如在實現用戶投票這種功能時,如果用戶針對一個用戶進行重復提交投票,這樣會導致接口接收到用戶重復提交的投票信息,這樣會使投票結果與事實嚴重不符。
接口超時重復提交:很多時候 HTTP 客戶端工具都默認開啟超時重試的機制,尤其是第三方調用接口時候,為了防止網絡波動超時等造成的請求失敗,都會添加重試機制,導致一個請求提交多次。
消息進行重復消費:當使用 MQ 消息中間件時候,如果發生消息中間件出現錯誤未及時提交消費信息,導致發生重復消費。
使用冪等性最大的優勢在于使接口保證任何冪等性操作,免去因重試等造成系統產生的未知的問題。
如何實現接口的冪等性校驗
網上流傳最多的應該是四種方式去實現接口的冪等性校驗,接下來我們來一個個盤點。
數據庫唯一主鍵
「方案描述」 數據庫唯一主鍵的實現主要是利用數據庫中主鍵唯一約束的特性,一般來說唯一主鍵比較適用于“插入”時的冪等性,其能保證一張表中只能存在一條帶該唯一主鍵的記錄。使用數據庫唯一主鍵完成冪等性時需要注意的是,該主鍵一般來說并不是使用數據庫中自增主鍵,而是使用分布式 ID 充當主鍵(或者使用其他算法生成的全局唯一的id),這樣才能能保證在分布式環境下 ID 的全局唯一性。
「適用操作:」 插入操作 刪除操作
「使用限制:」 需要生成全局唯一主鍵 ID;
「主要流程:」 ① 客戶端執行創建請求,調用服務端接口。② 服務端執行業務邏輯,生成一個分布式 ID,將該 ID 充當待插入數據的主鍵,然后執數據插入操作,運行對應的 SQL 語句。③ 服務端將該條數據插入數據庫中,如果插入成功則表示沒有重復調用接口。如果拋出主鍵重復異常,則表示數據庫中已經存在該條記錄,返回錯誤信息到客戶端。
數據庫樂觀鎖
「方案描述:」 數據庫樂觀鎖方案一般只能適用于執行“更新操作”的過程,我們可以提前在對應的數據表中多添加一個字段,充當當前數據的版本標識。這樣每次對該數據庫該表的這條數據執行更新時,都會將該版本標識作為一個條件,值為上次待更新數據中的版本標識的值。「適用操作:」 更新操作
「使用限制:」 需要數據庫對應業務表中添加額外字段;
防重 Token 令牌
「方案描述:」 針對客戶端連續點擊或者調用方的超時重試等情況,例如提交訂單,此種操作就可以用 Token 的機制實現防止重復提交。簡單的說就是調用方在調用接口的時候先向后端請求一個全局 ID(Token),請求的時候攜帶這個全局 ID 一起請求(Token 最好將其放到 Headers 中),后端需要對這個 Token 作為 Key,用戶信息作為 Value 到 Redis 中進行鍵值內容校驗,如果 Key 存在且 Value 匹配就執行刪除命令,然后正常執行后面的業務邏輯。如果不存在對應的 Key 或 Value 不匹配就返回重復執行的錯誤信息,這樣來保證冪等操作。
「適用操作:」 插入操作 更新操作 刪除操作
「使用限制:」 需要生成全局唯一 Token 串;需要使用第三方組件 Redis 進行數據效驗;
redis
「方案描述:」
第四種是我覺著用著挺方便的,但是實用性應該不大,而且和第三種類似,我們可以把接口名加請求參數通過算法生成一個全局唯一的id,然后 存到redis中,如果在一定時間請求多次,我們就直接拒絕。
「適用操作:」 插入操作 更新操作 刪除操作
「使用限制:」 需要使用第三方組件 Redis 進行數據效驗;
如何將這幾種方式都組裝到一起
我使用了Java自帶的注解以及設計模式中的策略模式,我們可以在注解中直接指定冪等性校驗的方式,當然也可以在配置文件中指定,但是直接在注解中指定更加靈活。
但是,由于最近時間比較忙,天天被某些人卷,很少有時間去完善,目前只是實現了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);//根據方法參數等生成唯一的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(); //獲取方法參數 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; }
「模式實現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("請求次數過多"); }else{ redisCache.setCacheObject(key,"1",time,TimeUnit.MINUTES); } } }
「模式實現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("未授權的token"); } //根據key和token執行冪等性校驗 booleanisDuplicateRequest=performTokenValidation(key,token); if(!isDuplicateRequest){ thrownewRequestManyValidationException("多次請求"); } } privatebooleanperformTokenValidation(Stringkey,Stringtoken){ //執行根據Token進行冪等性校驗的邏輯 //這里可以使用你選擇的合適的方法,比如將Token存儲到數據庫或緩存中,然后檢查是否已存在 StringstoredToken=redisCache.getCacheObject(key); //比較存儲的Token和當前請求的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); } /** *設置有效時間 * *@paramkeyRedis鍵 *@paramtimeout超時時間 *@returntrue=設置成功;false=設置失敗 */ publicbooleanexpire(finalStringkey,finallongtimeout) { returnexpire(key,timeout,TimeUnit.SECONDS); } publicbooleanhasKey(finalStringkey) { returnBoolean.TRUE.equals(redisTemplate.hasKey(key)); } /** *設置有效時間 * *@paramkeyRedis鍵 *@paramtimeout超時時間 *@paramunit時間單位 *@returntrue=設置成功;false=設置失敗 */ publicbooleanexpire(finalStringkey,finallongtimeout,finalTimeUnitunit) { returnredisTemplate.expire(key,timeout,unit); } /** *獲得緩存的基本對象。 * *@paramkey緩存鍵值 *@return緩存鍵值對應的數據 */ public TgetCacheObject(finalStringkey) { ValueOperations operation=redisTemplate.opsForValue(); returnoperation.get(key); } /** *刪除單個對象 * *@paramkey */ publicbooleandeleteObject(finalStringkey) { returnredisTemplate.delete(key); } /** *刪除集合對象 * *@paramcollection多個對象 *@return */ publiclongdeleteObject(finalCollectioncollection) { returnredisTemplate.delete(collection); } /** *緩存List數據 * *@paramkey緩存的鍵值 *@paramdataList待緩存的List數據 *@return緩存的對象 */ public longsetCacheList(finalStringkey,finalList dataList) { Longcount=redisTemplate.opsForList().rightPushAll(key,dataList); returncount==null?0:count; } /** *獲得緩存的list對象 * *@paramkey緩存的鍵值 *@return緩存鍵值對應的數據 */ public List getCacheList(finalStringkey) { returnredisTemplate.opsForList().range(key,0,-1); } /** *緩存Set * *@paramkey緩存鍵值 *@paramdataSet緩存的數據 *@return緩存數據的對象 */ 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中存入數據 * *@paramkeyRedis鍵 *@paramhKeyHash鍵 *@paramvalue值 */ public voidsetCacheMapValue(finalStringkey,finalStringhKey,finalTvalue) { redisTemplate.opsForHash().put(key,hKey,value); } /** *獲取Hash中的數據 * *@paramkeyRedis鍵 *@paramhKeyHash鍵 *@returnHash中的對象 */ public TgetCacheMapValue(finalStringkey,finalStringhKey) { HashOperations opsForHash=redisTemplate.opsForHash(); returnopsForHash.get(key,hKey); } /** *刪除Hash中的數據 * *@paramkey *@paramhkey */ publicvoiddelCacheMapValue(finalStringkey,finalStringhkey) { HashOperationshashOperations=redisTemplate.opsForHash(); hashOperations.delete(key,hkey); } /** *獲取多個Hash中的數據 * *@paramkeyRedis鍵 *@paramhKeysHash鍵集合 *@returnHash對象集合 */ public List getMultiCacheMapValue(finalStringkey,finalCollection
「配置文件」
如果要實現其他方式的話只需要實現下RequestManyStrategy模板方法,然后編寫自己的校驗邏輯就可以。
以上代碼已經上傳到github :https://github.com/Lumos-i/tools-and-frameworks
結語
大學過的可真快,轉眼就大三了,自己的技術還是不行,跟別人的差距還有很大距離,希望自己能在有限的時間里學到更多有用的知識,同時也希望在明年的這個時候可以坐在辦公室里敲代碼。突然想到高中時中二的一句話“聽聞少年二字,應與平庸相斥”,誰不希望這樣呢,奈何身邊大佬太多,現在只能追趕別人的腳步。。。
審核編輯:黃飛
-
接口
+關注
關注
33文章
8586瀏覽量
151083 -
數據庫
+關注
關注
7文章
3798瀏覽量
64371 -
Redis
+關注
關注
0文章
374瀏覽量
10872
原文標題:策略模式實現接口的冪等性校驗
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論