SpringBoot關(guān)于自定義注解實現(xiàn)接口冪等性方式
自定義注解實現(xiàn)接口冪等性方式
近期需要對接口進行冪等性的改造,特此記錄下。
背景
在微服務架構(gòu)中,冪等是一致性方面的一個重要概念。
一個冪等操作的特點是指其多次執(zhí)行所產(chǎn)生的影響均與一次執(zhí)行的影響相同。在業(yè)務中也就是指的,多次調(diào)用方法或者接口不會改變業(yè)務狀態(tài),可以保證重復調(diào)用的結(jié)果和單次調(diào)用的結(jié)果一致。
常見場景
1.用戶重復操作
在產(chǎn)品訂購下單過程中,由于網(wǎng)絡延遲或者用戶誤操作等原因,導致多次提交。這時就會在后臺執(zhí)行多條重復請求,導致臟數(shù)據(jù)或執(zhí)行錯誤等。
2.分布式消息重復消費
消息隊列中由于某種原因消息二次發(fā)送或者被二次消費的時候,導致程序多次執(zhí)行,從而導致數(shù)據(jù)重復,資源沖突等。
3.接口超時重試
由于網(wǎng)絡波動,引起的重復請求,導致數(shù)據(jù)的重復等。
常見解決方案
1.token機制實現(xiàn)
由客戶端發(fā)送請求獲取Token,服務端生成全局唯一的ID作為token,并保存在redis中,同時返回ID給客戶端。
客戶端調(diào)用業(yè)務端的請求的時候需要攜帶token,由服務端進行校驗,校驗成功,則允許執(zhí)行業(yè)務,不成功則表示重復操作,直接返回給客戶端。
2.mysql去重
建立一個去重表,當客戶端請求的時候,將請求信息存入去重表進行判斷。由于去重表帶有唯一索引,如果插入成功則表示可以執(zhí)行。如果失敗則表示已經(jīng)執(zhí)行過當前請求,直接返回。
3.基于redis鎖機制
在redis中,SETNX表示 SET IF NOT EXIST的縮寫,表示只有不存在的時候才可以進行設(shè)置,可以利用它實現(xiàn)鎖的效果。
客戶端請求服務端時,通過計算拿到代表這次業(yè)務請求的唯一字段,將該值存入redis,如果設(shè)置成功表示可以執(zhí)行。失敗則表示已經(jīng)執(zhí)行過當前請求,直接返回。
實現(xiàn)方法
基于種種考慮,本文將基于方法3實現(xiàn)冪等性方法。其中有兩個需要注意的地方:
1.如何實現(xiàn)唯一請求編號進行去重?
本文將采用用戶ID:接口名:請求參數(shù)進行請求參數(shù)的MD5摘要,同時考慮到請求時間參數(shù)的干擾性(同一個請求,除了請求參數(shù)都相同可以認為為同一次請求),排除請求時間參數(shù)進行摘要,可以在短時間內(nèi)保證唯一的請求編號。
2.如何保證最小的代碼侵入性?
本文將采用自定義注解,同時采用切面AOP的方式,最大化的減少代碼的侵入,同時保證了方法的易用性。
代碼實現(xiàn)
1.自定義注解
實現(xiàn)自定義注解,同時設(shè)置超時時間作為重復間隔時間。在需要使用冪等性校驗的方法上面加上注解即可實現(xiàn)冪等性。
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; ? /** ?* @create 2021-01-18 16:40 ?* 實現(xiàn)接口冪等性注解 ?**/ @Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface AutoIdempotent { ? ? ? long expireTime() default 10000; ? }
2.MD5摘要輔助類
通過傳入的參數(shù)進行MD5摘要,同時去除需要排除的干擾參數(shù)生成唯一的請求ID。
import com.google.gson.Gson; import com.hhu.consumerdemo.model.User; import lombok.extern.slf4j.Slf4j; ? import javax.xml.bind.DatatypeConverter; import java.security.MessageDigest; import java.util.*; ? /** ?* @create 2021-01-14 10:12 ?**/ @Slf4j public class ReqDedupHelper { ? ? ? ? private Gson gson = new Gson(); ? ? /** ? ? ?* ? ? ?* @param reqJSON 請求的參數(shù),這里通常是JSON ? ? ?* @param excludeKeys 請求參數(shù)里面要去除哪些字段再求摘要 ? ? ?* @return 去除參數(shù)的MD5摘要 ? ? ?*/ ? ? public String dedupParamMD5(final String reqJSON, String... excludeKeys) { ? ? ? ? String decreptParam = reqJSON; ? ? ? ? ? TreeMap paramTreeMap = gson.fromJson(decreptParam, TreeMap.class); ? ? ? ? if (excludeKeys!=null) { ? ? ? ? ? ? List<String> dedupExcludeKeys = Arrays.asList(excludeKeys); ? ? ? ? ? ? if (!dedupExcludeKeys.isEmpty()) { ? ? ? ? ? ? ? ? for (String dedupExcludeKey : dedupExcludeKeys) { ? ? ? ? ? ? ? ? ? ? paramTreeMap.remove(dedupExcludeKey); ? ? ? ? ? ? ? ? } ? ? ? ? ? ? } ? ? ? ? } ? ? ? ? ? String paramTreeMapJSON = gson.toJson(paramTreeMap); ? ? ? ? String md5deDupParam = jdkMD5(paramTreeMapJSON); ? ? ? ? log.debug("md5deDupParam = {}, excludeKeys = {} {}", md5deDupParam, Arrays.deepToString(excludeKeys), paramTreeMapJSON); ? ? ? ? return md5deDupParam; ? ? } ? ? ? private static String jdkMD5(String src) { ? ? ? ? String res = null; ? ? ? ? try { ? ? ? ? ? ? MessageDigest messageDigest = MessageDigest.getInstance("MD5"); ? ? ? ? ? ? byte[] mdBytes = messageDigest.digest(src.getBytes()); ? ? ? ? ? ? res = DatatypeConverter.printHexBinary(mdBytes); ? ? ? ? } catch (Exception e) { ? ? ? ? ? ? log.error("",e); ? ? ? ? } ? ? ? ? return res; ? ? } ? ? ? //測試方法 ? ? public static void main(String[] args) { ? ? ? ? Gson gson = new Gson(); ? ? ? ? User user1 = new User("1","2",18); ? ? ? ? Object[] objects = new Object[]{"sss",11,user1}; ? ? ? ? ? Map<String, Object> maps = new HashMap<>(); ? ? ? ? maps.put("參數(shù)1",objects[0]); ? ? ? ? maps.put("參數(shù)2",objects[1]); ? ? ? ? maps.put("參數(shù)3",objects[2]); ? ? ? ? String json1 = gson.toJson(maps); ? ? ? ? System.out.println(json1); ? ? ? ? TreeMap paramTreeMap = gson.fromJson(json1, TreeMap.class); ? ? ? ? System.out.println(gson.toJson(paramTreeMap)); ? ? ? }? }
3.redis輔助Service
生成唯一的請求ID作為token存入redis,同時設(shè)置好超時時間,在超時時間內(nèi)的請求參數(shù)將作為重復請求返回,而校驗成功插入redis的請求Token將作為首次請求,進行放通。
本文采用的spring-redis版本為2.0以上,使用2.0以下版本的需要主要沒有setIfAbsent方法,需要自己實現(xiàn)。
import com.xxx.xxx.utils.ReqDedupHelper; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; ? import java.util.concurrent.TimeUnit; ? /** ?* @create 2021-01-18 17:44 ?**/ @Service @Slf4j public class TokenService { ? ? ? private static final String TOKEN_NAME = "request_token"; ? ? ? @Autowired ? ? private StringRedisTemplate stringRedisTemplate;? ? ? ? public boolean checkRequest(String userId, String methodName, long expireTime, String reqJsonParam, String... excludeKeys){ ? ? ? ? final boolean isConsiderDup; ? ? ? ? String dedupMD5 = new ReqDedupHelper().dedupParamMD5(reqJsonParam, excludeKeys); ? ? ? ? String redisKey = "dedup:U="+userId+ "M="+methodName+"P="+dedupMD5; ? ? ? ? log.info("redisKey:{}", redisKey); ? ? ? ? ? long expireAt = System.currentTimeMillis() + expireTime; ? ? ? ? String val = "expireAt@" + expireAt; ? ? ? ? ? // NOTE:直接SETNX不支持帶過期時間,所以設(shè)置+過期不是原子操作,極端情況下可能設(shè)置了就不過期了 ? ? ? ? if (stringRedisTemplate.opsForValue().setIfAbsent(redisKey, val)) { ? ? ? ? ? ? if (stringRedisTemplate.expire(redisKey, expireTime, TimeUnit.MILLISECONDS)) { ? ? ? ? ? ? ? ? isConsiderDup = ?false; ? ? ? ? ? ? } else { ? ? ? ? ? ? ? ? isConsiderDup = ?true; ? ? ? ? ? ? } ? ? ? ? } else { ? ? ? ? ? ? log.info("加鎖失敗 failed??!key:{},value:{}",redisKey,val); ? ? ? ? ? ? return true; ? ? ? ? } ? ? ? ? return isConsiderDup; ? ? } ? }
4.AOP切面輔助類
aop切面,切住所有帶有冪等注解的方法。進行冪等性的操作。
import com.google.gson.Gson; import com.xxx.xxx.annotation.AutoIdempotent; import com.xxx.xxx.service.TokenService; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.*; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; ? import java.util.HashMap; import java.util.Map; ? /** ?* @author:? ?* @date: 2020-04-28 14:20 ?*/ @Aspect @Component @Slf4j public class AutoIdempontentHandler { ? ? ? private Gson gson = new Gson(); ? ? ? private static final String excludeKey = ""; ? ? private static final String methodName = "methodName"; ? ? ? @Autowired ? ? private TokenService tokenService; ? ? ? @Pointcut("@annotation(com.xxx.xxx.annotation.AutoIdempotent)") ? ? public void autoIdempontentHandler() { ? ? } ? ? ? @Before("autoIdempontentHandler()") ? ? public void doBefore() throws Throwable { ? ? ? ? log.info("idempontentHandler..doBefore()"); ? ? } ? ? ? @Around("autoIdempontentHandler()") ? ? public Object doAround(ProceedingJoinPoint joinpoint) throws Throwable { ? ? ? ? ? boolean checkres = this.handleRequest(joinpoint); ? ? ? ? if(checkres){ ? ? ? ? ? ? //重復請求,提示重復 報錯 ? ? ? ? ? ? log.info("重復性請求.."); ? ? ? ? ? ? throw new Exception(); ? ? ? ? } ? ? ? ? return joinpoint.proceed(); ? ? } ? ? ? private Boolean handleRequest(ProceedingJoinPoint joinpoint) { ? ? ? ? Boolean result = false; ? ? ? ? log.info("========判斷是否是重復請求======="); ? ? ? ? MethodSignature methodSignature = (MethodSignature) joinpoint.getSignature(); ? ? ? ? //獲取自定義注解值 ? ? ? ? AutoIdempotent autoIdempotent = methodSignature.getMethod().getDeclaredAnnotation(AutoIdempotent.class); ? ? ? ? long expireTime = autoIdempotent.expireTime(); ? ? ? ? // 獲取參數(shù)名稱 ? ? ? ? String methodsName = methodSignature.getMethod().getName(); ? ? ? ? String[] params = methodSignature.getParameterNames(); ? ? ? ? //獲取參數(shù)值 ? ? ? ? Object[] args = joinpoint.getArgs(); ? ? ? ? Map<String, Object> reqMaps = new HashMap<>(); ? ? ? ? for(int i=0; i<params.length; i++){ ? ? ? ? ? ? reqMaps.put(params[i], args[i]); ? ? ? ? } ? ? ? ? String reqJSON = gson.toJson(reqMaps); ? ? ? ? result = tokenService.checkRequest("user1", methodsName, expireTime, reqJSON, excludeKey); ? ? ? ? return result; ? ? } ? ? ? @AfterReturning(returning = "retVal", pointcut = "autoIdempontentHandler()") ? ? public void doAfter(Object retVal) throws Throwable { ? ? ? ? log.debug("{}", retVal); ? ? } }
5.注解的使用
在需要冪等性的方法上進行注解,同時設(shè)置參數(shù)保證各個接口的超時時間的不一致性??梢钥吹皆?秒內(nèi)是無法再次請求方法1的。
import com.xxx.xxx.annotation.AutoIdempotent; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; ? /** ?* @author? ?* @Date: 2020-01-03 14:16 ?*/ @RestController public class ConsumerController {? ? ? ? @AutoIdempotent(expireTime = 5000) ? ? @GetMapping("/start/{index}") ? ? public String setValue( @PathVariable("index") ?String index){ ? ? ? ? return index + "1"; ? ? } ? ? ? @GetMapping("/start2/{index}") ? ? public String setValue2( @PathVariable("index") ?String index){ ? ? ? ? return index + "2"; ? ? }? }
思考與不足
微服務架構(gòu)中,冪等操作的特點是指任意多次執(zhí)行所產(chǎn)生的影響均與一次執(zhí)行的影響相同。但在實際設(shè)計的時候,卻簡單的進行所有請求進行重復。
然而,重試是降低微服務失敗率的重要手段。因為網(wǎng)絡波動、系統(tǒng)資源的分配不確定等因素會導致部分請求的失敗。而這部分的請求中大部分實際上只需要進行簡單的重試就可以保證成功。這才是冪等性真正需要實現(xiàn)的。暫時我并沒有更好的解決方法,只能通過短時間的禁用,以及人為的決定何種方法進行冪等性校驗來達到目的。歡迎有想法的和我一起探討交流~
SpringBoot接口冪等性設(shè)計
MVC方案
多版本并發(fā)控制,該策略主要使用 update with condition(更新帶條件來防止)來保證多次外部請求調(diào)用對系統(tǒng)的影響是一致的。在系統(tǒng)設(shè)計的過程中,合理的使用樂觀鎖,通過 version 或者 updateTime(timestamp)等其他條件,來做樂觀鎖的判斷條件,這樣保證更新操作即使在并發(fā)的情況下,也不會有太大的問題。
例如
select * from tablename where condition=#condition# // 取出要跟新的對象,帶有版本 versoin update tableName set name=#name#,version=version+1 where version=#version#
在更新的過程中利用 version 來防止,其他操作對對象的并發(fā)更新,導致更新丟失。為了避免失敗,通常需要一定的重試機制。
Token機制,防止頁面重復提交
業(yè)務要求:頁面的數(shù)據(jù)只能被點擊提交一次。
發(fā)生原因:由于重復點擊或者網(wǎng)絡重發(fā),或者 nginx 重發(fā)等情況會導致數(shù)據(jù)被重復提交
解決辦法:
集群環(huán)境:采用 token 加 redis(redis 單線程的,處理需要排隊)
單 JVM 環(huán)境:采用 token 加 redis 或 token 加 jvm 內(nèi)存
處理流程:
數(shù)據(jù)提交前要向服務的申請 token,token 放到 redis 或 jvm 內(nèi)存,token 有效時間
提交后后臺校驗 token,同時刪除 token,生成新的 token 返回
token 特點:要申請,一次有效性,可以限流
基于Token方式防止API接口冪等
客戶端每次在調(diào)用接口的時候,需要在請求頭中,傳遞令牌參數(shù),每次令牌只能用一次。
一旦使用之后,就會被刪除,這樣可以有效防止重復提交。
步驟:
- 生成令牌接口
- 接口中獲取令牌驗證
實戰(zhàn)教程
要用到aop跟Redis , 所以在pom中添加
<!-- Redis-Jedis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- springboot-aop 技術(shù) --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>
這是通過注解實現(xiàn)接口冪等性,先寫Redis邏輯
@Component public class BaseRedisService { @Autowired private StringRedisTemplate stringRedisTemplate; public void setString(String key, Object data, Long timeout) { if (data instanceof String) { String value = (String) data; // 往Redis存值 stringRedisTemplate.opsForValue().set(key, value); } if (timeout != null) { // 帶時間緩存 stringRedisTemplate.expire(key, timeout, TimeUnit.SECONDS); } } /** * 查看是否有值 * @param key 值 * @return */ public Object getString(String key) { return stringRedisTemplate.opsForValue().get(key); } /** * 刪除Redis * @param key 值 */ public void delKey(String key) { stringRedisTemplate.delete(key); } }
然后寫怎么生成token,保證每個token只用一次
@Component public class RedisToken { @Autowired private BaseRedisService baseRedisService; /** 緩存指定時間200秒 */ private static final long TOKENTIMEOUT = 200; /** * 生成Token */ public String getToken(){ String token = UUID.randomUUID().toString(); // 將token放到Redis中,用UUID保證唯一性 baseRedisService.setString(token, token, TOKENTIMEOUT); return token; } public synchronized boolean findToken(String tokenKey) { String tokenValue = (String) baseRedisService.getString(tokenKey); // 如果能夠獲取該(從redis獲取令牌)令牌(將當前令牌刪除掉) 就直接執(zhí)行該訪問的業(yè)務邏輯 if (StringUtils.isEmpty(tokenValue)) { return false; } // 保證每個接口對應的token 只能訪問一次,保證接口冪等性問題,用完直接刪掉 baseRedisService.delKey(tokenValue); return true; } }
寫一個工具類 請求是通過http請求還是from提交過來的,大部分都是form提交來的
public interface ConstantUtils { /** * http 中攜帶的請求 */ static final String EXTAPIHEAD = "head"; /** * from 中提交的請求 */ static final String EXTAPIFROM = "from"; }
寫好了 現(xiàn)在就寫我們的注解了,沒帶參數(shù)的是前后端不分離,直接跳頁面,獲取到token,帶參數(shù)前后端不分離的
- 帶參數(shù)的
@Target(value = ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ExtApiIdempotent { String value(); }
- 不帶參數(shù)的
@Target(value = ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface ExtApiToken { }
寫好這個 要是aop切點,要把注解切入進去
@Aspect @Component public class ExtApiAopIdempotent { @Autowired private RedisToken redisToken; // 1.使用AOP環(huán)繞通知攔截所有訪問(controller) @Pointcut("execution(public * com.yuyi.controller.*.*(..))") public void rlAop() { } /** * 封裝數(shù)據(jù) */ public HttpServletRequest getRequest() { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); HttpServletRequest request = attributes.getRequest(); return request; } /** * 前置通知 */ @Before("rlAop()") public void before(JoinPoint point) { // 獲取被增強的方法相關(guān)信息 - 查看方法上是否有次注解 MethodSignature signature = (MethodSignature) point.getSignature(); ExtApiToken extApiToken = signature.getMethod().getDeclaredAnnotation(ExtApiToken.class); if (extApiToken != null) { // 可以放入到AOP代碼 前置通知 getRequest().setAttribute("token", redisToken.getToken()); } } /** * 環(huán)繞通知 */ @Around("rlAop()") public Object doBefore(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { // 獲取被增強的方法相關(guān)信息 - 查看方法上是否有次注解 MethodSignature methodSignature = (MethodSignature) proceedingJoinPoint.getSignature(); ExtApiIdempotent declaredAnnotation = methodSignature.getMethod().getDeclaredAnnotation(ExtApiIdempotent.class); if (declaredAnnotation != null) { String values = declaredAnnotation.value(); String token = null; HttpServletRequest request = getRequest(); if (values.equals(ConstantUtils.EXTAPIHEAD)) { token = request.getHeader("token"); } else { token = request.getParameter("token"); } // 獲取不到token if (StringUtils.isEmpty(token)) { return ResultTool.error(ExceptionNume.PARAMETER_ERROR); } // 接口獲取對應的令牌,如果能夠獲取該(從redis獲取令牌)令牌(將當前令牌刪除掉) 就直接執(zhí)行該訪問的業(yè)務邏輯 boolean isToken = redisToken.findToken(token); // 接口獲取對應的令牌,如果獲取不到該令牌 直接返回請勿重復提交 if (!isToken) { return ResultTool.error(ExceptionNume.REPEATED_SUBMISSION); } } Object proceed = proceedingJoinPoint.proceed(); return proceed; } }
controller層 大家可以測一下
@Autowired private OrderInfoDAO infoDAO; @Autowired private RedisToken token; // @Autowired // private RedisTokenUtils redisTokenUtils; // // 從redis中獲取Token @RequestMapping("/redisToken") public String getRedisToken() { return token.getToken(); } @RequestMapping("/addOrderExtApiIdempotent") @ExtApiIdempotent(ConstantUtils.EXTAPIFROM) public ResultBO<?> addOrderExtApiIdempotent( @RequestParam String orderName, @RequestParam String orderDes ) { int result = infoDAO.addOrderInfo(orderName, orderDes); return ResultTool.success(result); }
保證了只能請求一次。前后端沒有分離的,@ExtApiToken帶上注解會自動吧token攜帶過去
以上為個人經(jīng)驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關(guān)文章
Java中documentHelper解析xml獲取想要的數(shù)據(jù)
本文主要介紹了Java中documentHelper解析xml獲取想要的數(shù)據(jù),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2023-02-02spring cloud 使用Hystrix 實現(xiàn)斷路器進行服務容錯保護的方法
本篇文章主要介紹了spring cloud 使用Hystrix 實現(xiàn)斷路器進行服務容錯保護的方法,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-05-05MyBatis insert語句返回主鍵和selectKey標簽方式
這篇文章主要介紹了MyBatis insert語句返回主鍵和selectKey標簽方式,具有很好的參考價值,希望對大家有所幫助。如有錯誤或未考慮完全的地方,望不吝賜教2021-09-092020新版idea創(chuàng)建項目沒有javaEE 沒有Web選項的完美解決方法
這篇文章主要介紹了2020新版idea創(chuàng)建項目沒有javaEE 沒有Web選項的完美解決方法,本文給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2020-09-09基于Spring Data的AuditorAware審計功能的示例代碼
這篇文章主要介紹了基于Spring Data的AuditorAware審計功能的示例代碼,小編覺得挺不錯的,現(xiàn)在分享給大家,也給大家做個參考。一起跟隨小編過來看看吧2018-03-03Java中的位運算符號解讀(&、|、^、~、<<、>>、>>>)
這篇文章主要介紹了Java中的位運算符號(&、|、^、~、<<、>>、>>>),具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-08-08