Java面試題~Java如何防止接口重復提交
作者:
修羅debug
版權聲明:本文為博主原創(chuàng)文章,遵循 CC 4.0 by-sa 版權協(xié)議,轉載請附上原文出處鏈接和本聲明。
1.“重復提交”簡介與造成的后果
2.“防止接口重復提交”的實現(xiàn)思路
3.“防止接口重復提交”的代碼實戰(zhàn)
一、“重復提交”簡介與造成的后果
對于“重復提交”,想必各位小伙伴都知曉它的意思,簡單的理解,它指的是前端用戶在間隔很短的時間周期內(nèi)對同一個請求URL發(fā)起請求,導致前端開發(fā)者在很短的時間周期內(nèi)將同一份數(shù)據(jù)(請求體)提交到后端相同的接口
多次,最終數(shù)據(jù)庫出現(xiàn)多條主鍵ID不一樣而其他業(yè)務數(shù)據(jù)幾乎一毛一樣的記錄;
仔細研究上述整個過程,會發(fā)現(xiàn)如果發(fā)起的多次請求的時間間隔足夠短,即時間趨向于無窮小 時,其過程可以歸為“多線程并發(fā)導致并發(fā)安全”的問題范疇;而對于“并發(fā)安全”的話題,debug早在此前自己錄制的課程以及之前的文章中介紹過多次了,在此不再贅述;
上述在對“重復提交”的介紹中隱約也提及它所帶來的的后果:
(1)數(shù)據(jù)庫DB出現(xiàn)多條一毛一樣的數(shù)據(jù)記錄;
(2)如果重復發(fā)起的請求足夠多、請求體容量足夠大,很可能會給系統(tǒng)接口帶來極大的壓力,導致其出現(xiàn)“接口不穩(wěn)定”、“DB負載過高”,嚴重點甚至可能會出現(xiàn)“系統(tǒng)宕機”的情況;
因此,我們需要在一些很可能會出現(xiàn)“重復提交”的后端接口中加入一些處理機制(附注:前端其實也需要配合一同處理的,其處理方式在本文就不做介紹了~);
二、“防止接口重復提交”的實現(xiàn)思路
對于“重復提交”,想必各位小伙伴都知曉它的意思,簡單的理解,它指的是前端用戶在間隔很短的時間周期內(nèi)對同一個請求URL發(fā)起請求,導致前端開發(fā)者在很短的時間周期內(nèi)將同一份數(shù)據(jù)(請求體)提交到后端相同的接口
多次,最終數(shù)據(jù)庫出現(xiàn)多條主鍵ID不一樣而其他業(yè)務數(shù)據(jù)幾乎一毛一樣的記錄;
仔細研究上述整個過程,會發(fā)現(xiàn)如果發(fā)起的多次請求的時間間隔足夠短,即時間趨向于無窮小 時,其過程可以歸為“多線程并發(fā)導致并發(fā)安全”的問題范疇;而對于“并發(fā)安全”的話題,debug早在此前自己錄制的課程以及之前的文章中介紹過多次了,在此不再贅述;
上述在對“重復提交”的介紹中隱約也提及它所帶來的的后果:
(1)數(shù)據(jù)庫DB出現(xiàn)多條一毛一樣的數(shù)據(jù)記錄;
(2)如果重復發(fā)起的請求足夠多、請求體容量足夠大,很可能會給系統(tǒng)接口帶來極大的壓力,導致其出現(xiàn)“接口不穩(wěn)定”、“DB負載過高”,嚴重點甚至可能會出現(xiàn)“系統(tǒng)宕機”的情況;
因此,我們需要在一些很可能會出現(xiàn)“重復提交”的后端接口中加入一些處理機制(附注:前端其實也需要配合一同處理的,其處理方式在本文就不做介紹了~);
值得一提的是,絕大部分情況下,只有POST/PUT/DELETE的請求方式才會出現(xiàn)“重復提交”的情況,而對于GET請求方式,只要不是出現(xiàn)人為的意外情況,那么它就具有“冪等性”,談不上“重復提交”現(xiàn)象的出現(xiàn),因此,在實際項目中,出現(xiàn)“重復提交”現(xiàn)象比較多的一般是POST請求方式;
而在實際項目開發(fā)中,“防止接口重復提交”的實現(xiàn)方式有兩類,一類是純粹的針對請求鏈接URL的,即防止對同一個URL發(fā)起多次請求:此種方式明顯粒度過大,容易誤傷友軍;另一類是針對請求鏈接URL
+ 請求體 的,這種方式可以說是比較人性化而且也是比較合理的,而我們在后面要介紹的實現(xiàn)方式正是基于此進行實戰(zhàn)的;
為了便于小伙伴理解,接下來我們以“用戶在前端提交注冊信息”為例,介紹“如何防止接口重復提交”的實現(xiàn)思路,如下圖所示為整體的實現(xiàn)思路:
從該圖中可以得知,如果當前提交的請求URL已經(jīng)存在于緩存中,且 當前提交的請求體 跟
緩存中該URL對應的請求體一毛一樣 且 當前請求URL的時間戳跟上次相同請求URL的時間戳 間隔在8s 內(nèi),即代表當前請求屬于
“重復提交”;如果這其中有一個條件不成立,則意味著當前請求很有可能是第一次請求,或者已經(jīng)過了8s時間間隔的 第N次請求了,不屬于“重復提交”了。
三、“防止接口重復提交”的實現(xiàn)思路
照著這個思路,接下來我們將采用實際的代碼進行實戰(zhàn),其中涉及到的技術:Spring Boot2.0 + 自定義注解 + 攔截器 + 本地緩存(也可以分布式緩存);
(1)首先,需要自定義一個用于加在需要“防止重復提交”的請求方法上 的注解RepeatSubmit,該注解的定義代碼很簡單,就是一個常規(guī)的注解定義,如下代碼所示:
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {
}
之后,是直接創(chuàng)建一個新的控制器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 為自定義的實體類,代碼定義如下所示:
@Data
public class RegisterDto implements Serializable{
private String userName;
private String nickName;
private Integer age;
}
(2)將注解加上去之后,接下來需要自定義一個攔截器RepeatSubmitInterceptor,用于攔截并獲取 加了上述這個注解的所有請求方法的相關信息,包括其請求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){
//如果是重復提交,則進行攔截,拒絕請求
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);
}
}
//自定義方法邏輯-判定是否重復提交
public abstract boolean isRepeatSubmit(HttpServletRequest request);
}
在這里我們將其定義為抽象類,并自定義一個抽象方法:“判斷當前請求是否為重復提交isRepeatSubmit()”,之所以這樣做,是因為“判斷是否重復提交”可以有多種實現(xiàn)方式,而每種實現(xiàn)方式可以通過繼承該抽象類
并 實現(xiàn)該抽象方法 從而將其區(qū)分開來,某種程度降低了耦合性(面向接口/抽象類編程);如下代碼所示為該抽象類的其中一種實現(xiàn)方式:
/**
* 判斷是否重復提交,整體的思路:
* 獲取當前請求的URL作為鍵Key,暫且標記為:A1,其取值為映射Map(Map里面的元素由:請求的鏈接url 和 請求體的數(shù)據(jù)組成) 暫且標記為V1;
* 從緩存中(本地緩存或者分布式緩存)查找Key=A1的值V2,如果V2和V1的值一樣,即代表當前請求是重復提交的,拒絕執(zhí)行后續(xù)的請求,否則可以繼續(xù)往后面執(zhí)行
* 其中,設定重復提交的請求的間隔有效時間為8秒
*
* 注意點:如果在有效時間內(nèi),如8秒內(nèi),一直發(fā)起同個請求url、同個請求體,那么重復提交的有效時間將會自動延長
* @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;
//構建本地緩存,有效時間為8秒鐘
private final Cache<String,String> cache= CacheBuilder.newBuilder().expireAfterWrite(IntervalTime, TimeUnit.SECONDS).build();
//真正實現(xiàn)“是否重復提交的邏輯”
@Override
public boolean isRepeatSubmit(HttpServletRequest request) {
String currParams=HttpHelper.getBodyString(request);
if (StringUtils.isBlank(currParams)){
currParams=new Gson().toJson(request.getParameterMap());
}
//獲取請求地址,充當A1
String url=request.getRequestURI();
//充當B1
RepeatSubmitCacheDto currCacheData=new RepeatSubmitCacheDto(currParams,System.currentTimeMillis(),url);
//充當鍵A1
String cacheRepeatKey=REPEAT_SUBMIT_KEY+url;
String cacheValue=cache.getIfPresent(cacheRepeatKey);
//從緩存中查找A1對應的值,如果存在,說明當前請求不是第一次了.
if (StringUtils.isNotBlank(cacheValue)){
//充當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;
}
//判斷兩次間隔時間
private boolean compareTime(RepeatSubmitCacheDto currCacheData, RepeatSubmitCacheDto preCacheData){
Boolean res=( (currCacheData.getCurrTime() - preCacheData.getCurrTime()) < (IntervalTime * 1000) );
return res;
}
}
該代碼雖然看起來有點多,但是仔細研讀,會發(fā)現(xiàn)其實這些代碼 就是筆者在上文中貼出的實現(xiàn)流程圖 的具體實現(xiàn),可以說是將理論知識進行真正的落地實現(xiàn);
在這里再重復贅述一下,其整體的實現(xiàn)思路為:獲取當前請求的URL作為鍵Key,暫且標記為:A1,其取值為映射Map(Map里面的元素由:請求的鏈接url
、 請求體的數(shù)據(jù)、和 請求時的時間戳 三部分組成)
暫且標記為V1;從緩存中(本地緩存或者分布式緩存)查找Key=A1的值V2,如果V2和V1里的請求體數(shù)據(jù)一樣 且
兩次請求是在8s內(nèi),即代表當前請求是重復提交的,系統(tǒng)將拒絕執(zhí)行后續(xù)的業(yè)務邏輯;否則可以繼續(xù)往后面執(zhí)行 “將用戶信息插入到數(shù)據(jù)庫中” 的業(yè)務邏輯;
(3)最后,需要將上述自定義的攔截器加入中系統(tǒng)全局配置中,如下所示:
@Component
public class CustomWebConfig implements WebMvcConfigurer{
@Autowired
private RepeatSubmitInterceptor submitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(submitInterceptor);
}
}
運行項目,打開Postman,連續(xù)多番進行測試,如下幾張圖所示:
至此,我們已經(jīng)采用實際的代碼實戰(zhàn)實現(xiàn)了“如何防止接口重復提交”的功能,值得一提的是,上述代碼在實現(xiàn)過程中,其核心在于緩存組件的搭建;在“重復提交”這一業(yè)務場景中,它需要滿足兩個條件方可發(fā)揮作用:一個是可以用于緩存信息,即具有Key
- Value的特性;另一個是可以對存儲的數(shù)據(jù)設置過期時間;
在這里筆者采用的是google開發(fā)工具類中的CacheBuilder構建本地緩存組件的,感興趣的小伙伴可以自行搜索相關資料;然而這種實現(xiàn)方式在集群多實例部署的情況下是有問題的,因為CacheBuilder只適用于單一架構體系,所以如果是多實例集群部署的情況,最好用Redis。
精致的結尾
(1)文中涉及到的代碼已經(jīng)放在gitee上了,訪問鏈接如下所示,別忘了給個star哦:https://gitee.com/steadyjack/SpringBootTechnologyA。
(2)期間如何有任何問題都可以加debug的微信進行交流:debug0868
(3) 關注Debug的技術公眾號,學習更多的干貨實戰(zhàn)技術~~~