正如本文標(biāo)題所言,今天我們來聊一聊在JAVA應(yīng)用系統(tǒng)中如何防止接口重復(fù)提交;簡單地講,這其實(shí)就是“重復(fù)提交”的話題,本文將從以下幾個(gè)部分展開介紹:
1.“重復(fù)提交”簡介與造成的后果
2.“防止接口重復(fù)提交”的實(shí)現(xiàn)思路
3.“防止接口重復(fù)提交”的代碼實(shí)戰(zhàn)
1、“重復(fù)提交”簡介與造成的后果
對于“重復(fù)提交”,想必各位小伙伴都知曉它的意思,簡單的理解,它指的是前端用戶在間隔很短的時(shí)間周期內(nèi)對同一個(gè)請求URL發(fā)起請求,導(dǎo)致前端開發(fā)者在很短的時(shí)間周期內(nèi)將同一份數(shù)據(jù)(請求體)提交到后端相同的接口 多次,最終數(shù)據(jù)庫出現(xiàn)多條主鍵ID不一樣而其他業(yè)務(wù)數(shù)據(jù)幾乎一毛一樣的記錄;
仔細(xì)研究上述整個(gè)過程,會(huì)發(fā)現(xiàn)如果發(fā)起的多次請求的時(shí)間間隔足夠短,即時(shí)間趨向于無窮小 時(shí),其過程可以歸為“多線程并發(fā)導(dǎo)致并發(fā)安全”的問題范疇;而對于“并發(fā)安全”的話題,debug早在此前自己錄制的課程以及之前的文章中介紹過多次了,在此不再贅述;
上述在對“重復(fù)提交”的介紹中隱約也提及它所帶來的的后果:
(1)數(shù)據(jù)庫DB出現(xiàn)多條一毛一樣的數(shù)據(jù)記錄;
(2)如果重復(fù)發(fā)起的請求足夠多、請求體容量足夠大,很可能會(huì)給系統(tǒng)接口帶來極大的壓力,導(dǎo)致其出現(xiàn)“接口不穩(wěn)定”、“DB負(fù)載過高”,嚴(yán)重點(diǎn)甚至可能會(huì)出現(xiàn)“系統(tǒng)宕機(jī)”的情況;
因此,我們需要在一些很可能會(huì)出現(xiàn)“重復(fù)提交”的后端接口中加入一些處理機(jī)制(附注:前端其實(shí)也需要配合一同處理的,其處理方式在本文就不做介紹了~);
2、“防止接口重復(fù)提交”的實(shí)現(xiàn)思路
值得一提的是,絕大部分情況下,只有POST/PUT/DELETE的請求方式才會(huì)出現(xiàn)“重復(fù)提交”的情況,而對于GET請求方式,只要不是出現(xiàn)人為的意外情況,那么它就具有“冪等性”,談不上“重復(fù)提交”現(xiàn)象的出現(xiàn),因此,在實(shí)際項(xiàng)目中,出現(xiàn)“重復(fù)提交”現(xiàn)象比較多的一般是POST請求方式;
而在實(shí)際項(xiàng)目開發(fā)中,“防止接口重復(fù)提交”的實(shí)現(xiàn)方式有兩類,一類是純粹的針對請求鏈接URL的,即防止對同一個(gè)URL發(fā)起多次請求:此種方式明顯粒度過大,容易誤傷友軍;另一類是針對請求鏈接URL + 請求體 的,這種方式可以說是比較人性化而且也是比較合理的,而我們在后面要介紹的實(shí)現(xiàn)方式正是基于此進(jìn)行實(shí)戰(zhàn)的;
為了便于小伙伴理解,接下來我們以“用戶在前端提交注冊信息”為例,介紹“如何防止接口重復(fù)提交”的實(shí)現(xiàn)思路,如下圖所示為整體的實(shí)現(xiàn)思路:
從該圖中可以得知,如果當(dāng)前提交的請求URL已經(jīng)存在于緩存中,且 當(dāng)前提交的請求體 跟 緩存中該URL對應(yīng)的請求體一毛一樣 且 當(dāng)前請求URL的時(shí)間戳跟上次相同請求URL的時(shí)間戳 間隔在8s 內(nèi),即代表當(dāng)前請求屬于 “重復(fù)提交”;如果這其中有一個(gè)條件不成立,則意味著當(dāng)前請求很有可能是第一次請求,或者已經(jīng)過了8s時(shí)間間隔的 第N次請求了,不屬于“重復(fù)提交”了。
3、“防止接口重復(fù)提交”的代碼實(shí)戰(zhàn)
照著這個(gè)思路,接下來我們將采用實(shí)際的代碼進(jìn)行實(shí)戰(zhàn),其中涉及到的技術(shù):Spring Boot2.0 + 自定義注解 + 攔截器 + 本地緩存(也可以分布式緩存);
(1)首先,需要自定義一個(gè)用于加在需要“防止重復(fù)提交”的請求方法上 的注解RepeatSubmit,該注解的定義代碼很簡單,就是一個(gè)常規(guī)的注解定義,如下代碼所示:
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
}
之后,是直接創(chuàng)建一個(gè)新的控制器SubmitController,并在其中創(chuàng)建一請求方法,用于處理前端用戶提交的注冊信息 請求,如下代碼所示:
@RestController
@RequestMApping("submit")
public class SubmitController extends BaseController{
//用戶注冊
@RepeatSubmit
@PostMapping("register")
public BaseResponse register(@RequestBody RegisterDto dto) throws Exception{
BaseResponse response=new BaseResponse(StatusCode.Success);
//log.info("用戶注冊,提交上來的請求信息為:{}",dto);
//將用戶信息插入到db
response.setData(dto);
return response;
}
}
其中,RegisterDto 為自定義的實(shí)體類,代碼定義如下所示:
@Data
public class RegisterDto implements Serializable{
private String userName;
private String nickName;
private Integer age;
}
(2)將注解加上去之后,接下來需要自定義一個(gè)攔截器RepeatSubmitInterceptor,用于攔截并獲取 加了上述這個(gè)注解的所有請求方法的相關(guān)信息,包括其請求URL和請求體數(shù)據(jù),其核心代碼如下所示:
@Component
public abstract class RepeatSubmitInterceptor extends HandlerInterceptorAdapter{
//開始攔截
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod){
HandlerMethod handlerMethod= (HandlerMethod) handler;
Method method=handlerMethod.getMethod();
RepeatSubmit submitAnnotation=method.getAnnotation(RepeatSubmit.class);
if (submitAnnotation!=null){
//如果是重復(fù)提交,則進(jìn)行攔截,拒絕請求
if (this.isRepeatSubmit(request)){
BaseResponse subResponse=new BaseResponse(StatusCode.CanNotRepeatSubmit);
CommonUtil.renderString(response,new Gson().toJson(subResponse));
return false;
}
}
return true;
}else{
return super.preHandle(request, response, handler);
}
}
//自定義方法邏輯-判定是否重復(fù)提交
public abstract boolean isRepeatSubmit(HttpServletRequest request);
}
在這里我們將其定義為抽象類,并自定義一個(gè)抽象方法:“判斷當(dāng)前請求是否為重復(fù)提交isRepeatSubmit()”,之所以這樣做,是因?yàn)?ldquo;判斷是否重復(fù)提交”可以有多種實(shí)現(xiàn)方式,而每種實(shí)現(xiàn)方式可以通過繼承該抽象類 并 實(shí)現(xiàn)該抽象方法 從而將其區(qū)分開來,某種程度降低了耦合性(面向接口/抽象類編程);如下代碼所示為該抽象類的其中一種實(shí)現(xiàn)方式:
/**
* 判斷是否重復(fù)提交,整體的思路:
* 獲取當(dāng)前請求的URL作為鍵Key,暫且標(biāo)記為:A1,其取值為映射Map(Map里面的元素由:請求的鏈接url 和 請求體的數(shù)據(jù)組成) 暫且標(biāo)記為V1;
* 從緩存中(本地緩存或者分布式緩存)查找Key=A1的值V2,如果V2和V1的值一樣,即代表當(dāng)前請求是重復(fù)提交的,拒絕執(zhí)行后續(xù)的請求,否則可以繼續(xù)往后面執(zhí)行
* 其中,設(shè)定重復(fù)提交的請求的間隔有效時(shí)間為8秒
*
* 注意點(diǎn):如果在有效時(shí)間內(nèi),如8秒內(nèi),一直發(fā)起同個(gè)請求url、同個(gè)請求體,那么重復(fù)提交的有效時(shí)間將會(huì)自動(dòng)延長
* @author 修羅debug
* @date 2020/10/21 8:12
* @link 微信:debug0868 QQ:1948831260
* @blog fightjava.com
*/
@Component
public class SameUrlDataRepeatInterceptor extends RepeatSubmitInterceptor{
private static final String REPEAT_PARAMS = "RepeatParams";
private static final String REPEAT_TIME = "RepeatTime";
//防重提交key
public static final String REPEAT_SUBMIT_KEY = "Repeat_Submit:";
private static final int IntervalTime = 8;
//構(gòu)建本地緩存,有效時(shí)間為8秒鐘
private final Cache<String,String> cache= CacheBuilder.newBuilder().expireAfterWrite(IntervalTime, TimeUnit.SECONDS).build();
//真正實(shí)現(xiàn)“是否重復(fù)提交的邏輯”
@Override
public boolean isRepeatSubmit(HttpServletRequest request) {
String currParams=HttpHelper.getBodyString(request);
if (StringUtils.isBlank(currParams)){
currParams=new Gson().toJson(request.getParameterMap());
}
//獲取請求地址,充當(dāng)A1
String url=request.getRequestURI();
//充當(dāng)B1
RepeatSubmitCacheDto currCacheData=new RepeatSubmitCacheDto(currParams,System.currentTimeMillis(),url);
//充當(dāng)鍵A1
String cacheRepeatKey=REPEAT_SUBMIT_KEY+url;
String cacheValue=cache.getIfPresent(cacheRepeatKey);
//從緩存中查找A1對應(yīng)的值,如果存在,說明當(dāng)前請求不是第一次了.
if (StringUtils.isNotBlank(cacheValue)){
//充當(dāng)B2
RepeatSubmitCacheDto preCacheData=new Gson().fromJson(cacheValue,RepeatSubmitCacheDto.class);
if (this.compareParams(currCacheData,preCacheData) && this.compareTime(currCacheData,preCacheData)){
return true;
}
}
//否則,就是第一次請求
Map<String, Object> cacheMap = new HashMap<>();
cacheMap.put(url, currCacheData);
cache.put(cacheRepeatKey,new Gson().toJson(currCacheData));
return false;
}
//比較參數(shù)
private boolean compareParams(RepeatSubmitCacheDto currCacheData, RepeatSubmitCacheDto preCacheData){
Boolean res=currCacheData.getRequestData().equals(preCacheData.getRequestData());
return res;
}
//判斷兩次間隔時(shí)間
private boolean compareTime(RepeatSubmitCacheDto currCacheData, RepeatSubmitCacheDto preCacheData){
Boolean res=( (currCacheData.getCurrTime() - preCacheData.getCurrTime()) < (IntervalTime * 1000) );
return res;
}
}
該代碼雖然看起來有點(diǎn)多,但是仔細(xì)研讀,會(huì)發(fā)現(xiàn)其實(shí)這些代碼 就是筆者在上文中貼出的實(shí)現(xiàn)流程圖 的具體實(shí)現(xiàn),可以說是將理論知識(shí)進(jìn)行真正的落地實(shí)現(xiàn);
在這里再重復(fù)贅述一下,其整體的實(shí)現(xiàn)思路為:獲取當(dāng)前請求的URL作為鍵Key,暫且標(biāo)記為:A1,其取值為映射Map(Map里面的元素由:請求的鏈接url 、 請求體的數(shù)據(jù)、和 請求時(shí)的時(shí)間戳 三部分組成) 暫且標(biāo)記為V1;從緩存中(本地緩存或者分布式緩存)查找Key=A1的值V2,如果V2和V1里的請求體數(shù)據(jù)一樣 且 兩次請求是在8s內(nèi),即代表當(dāng)前請求是重復(fù)提交的,系統(tǒng)將拒絕執(zhí)行后續(xù)的業(yè)務(wù)邏輯;否則可以繼續(xù)往后面執(zhí)行 “將用戶信息插入到數(shù)據(jù)庫中” 的業(yè)務(wù)邏輯;
(3)最后,需要將上述自定義的攔截器加入中系統(tǒng)全局配置中,如下所示:
@Component
public class CustomWebConfig implements WebMvcConfigurer{
@Autowired
private RepeatSubmitInterceptor submitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(submitInterceptor);
}
}
運(yùn)行項(xiàng)目,打開Postman,連續(xù)多番進(jìn)行測試,如下幾張圖所示:
至此,我們已經(jīng)采用實(shí)際的代碼實(shí)戰(zhàn)實(shí)現(xiàn)了“如何防止接口重復(fù)提交”的功能,值得一提的是,上述代碼在實(shí)現(xiàn)過程中,其核心在于緩存組件的搭建;在“重復(fù)提交”這一業(yè)務(wù)場景中,它需要滿足兩個(gè)條件方可發(fā)揮作用:一個(gè)是可以用于緩存信息,即具有Key - Value的特性;另一個(gè)是可以對存儲(chǔ)的數(shù)據(jù)設(shè)置過期時(shí)間;
在這里筆者采用的是google開發(fā)工具類中的CacheBuilder構(gòu)建本地緩存組件的,感興趣的小伙伴可以自行搜索相關(guān)資料;然而這種實(shí)現(xiàn)方式在集群多實(shí)例部署的情況下是有問題的,因?yàn)镃acheBuilder只適用于單一架構(gòu)體系,所以如果是多實(shí)例集群部署的情況,最好用redis。
(1)文中涉及到的代碼已經(jīng)放在gitee上了,訪問鏈接如下所示,別忘了給個(gè)star哦:https://gitee.com/steadyjack/SpringBootTechnologyA。
(2)期間如何有任何問題都可以私信debug。
(3)請繼續(xù)關(guān)注“程序員實(shí)戰(zhàn)基地”,您的關(guān)注和轉(zhuǎn)發(fā) 就是 debug勤勞寫技術(shù)文的動(dòng)力!!!