請求重複送出的危害
- 資料重複:例如使用者重複送出表單,造成資料重複。
- 資源浪費:多次重複請求送出将會浪費伺服器的處理資源。但這個相比資料重複的危害性較小。
- 不一緻性:假設我們觸發請求增加使用者的積分500,如果多次觸發這個請求,積分是累加的。這個危害性比重複的資料更大。
- 安全性:例如我們在登入頁面觸發手機驗證碼的發送請求。頻繁觸發這個請求将會耗費我們的驗證碼成本。
防請求重複送出的方案
前端
- 在使用者第一次點選按鈕後,即禁用送出按鈕。
- 限制使用者送出請求間隔,在一定的時間間隔内隻允許使用者發起某個請求一次。
- 在表單送出前,檢查前一次請求是否送出成功,已成功的話則提示使用者無需再重複送出。
後端
- 嚴謹的做法 Token機制,在每一個請求中都添加一個Token。Token由服務端生成并發放給前端。服務端接收到請求時,根據Token進行校驗。看這個Token是否已被使用。(一般基于緩存) 唯一标志,比如在建立訂單的時候,即生成一個唯一的訂單号,并将其作為訂單的唯一辨別。在後續的請求中攜帶該訂單号。當收到訂單建立請求時,檢查訂單号是否已經存在。(一般基于資料庫)
- 非嚴謹的做法 後端攔截請求,檢查請求的使用者和參數是否和上次請求相同,相同的話即為重複請求。
這種防請求重複送出的實作有基于Filter的實作,也有基于HandlerInterceptor的實作。最後考量下筆者認為利用RequestBodyAdviceAdapter類來實作代碼實作更加簡潔,配置更加簡單。
在此筆者提供一個注解+RequestBodyAdviceAdapter配合使用的防重複送出的實作。 但是這個方案有個小弊端。僅生效于有RequestBody注解的參數,因為使用RequestBodyAdvice來實作。但是大部分我們需要做請求防重複送出的接口一般都是POST請求,且有requestBody。
完整實作在開源項目中:github.com/valarchie/A…
實作
聲明注解
/**
* 自定義注解防止表單重複送出
* 僅生效于有RequestBody注解的參數 因為使用RequestBodyAdvice來實作
* @author valarchie
*/
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Resubmit {
/**
* 間隔時間(s),小于此時間視為重複送出
*/
int interval() default 5;
}
繼承RequestBodyAdviceAdapter實作ResubmitInterceptor
大緻的實作是。
- 覆寫了supports方法,指明我們僅處理擁有Resubmit注解的方法。
- 生成每一個請求的簽名作為Key。key的生成由generateResubmitRedisKey方法實作。格式如下:resubmit:{}:{}:{}。比如使用者是userA。我們請求的類是UserService。方法名是addUser。則這個key為resubmit:userA:UserService:addUser。
- 将Key和請求的參數作為值存到redis當中去
- 每一次請求過來時,我們檢查緩存中這個請求的簽名對應的參數是否相同,相同的話即為重複請求。
/**
* 重複送出攔截器 如果涉及前後端加解密的話 也可以通過繼承RequestBodyAdvice來實作
*
* @author valarchie
*/
@ControllerAdvice(basePackages = "com.agileboot")
@Slf4j
@RequiredArgsConstructor
public class ResubmitInterceptor extends RequestBodyAdviceAdapter {
public static final String NO_LOGIN = "Anonymous";
public static final String RESUBMIT_REDIS_KEY = "resubmit:{}:{}:{}";
@NonNull
private RedisUtil redisUtil;
@Override
public boolean supports(MethodParameter methodParameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
return methodParameter.hasMethodAnnotation(Resubmit.class);
}
/**
* @param body 僅擷取有RequestBody注解的參數
*/
@NotNull
@Override
public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType,
Class<? extends HttpMessageConverter<?>> converterType) {
// 僅擷取有RequestBody注解的參數
String currentRequest = JSONUtil.toJsonStr(body);
Resubmit resubmitAnno = parameter.getMethodAnnotation(Resubmit.class);
if (resubmitAnno != null) {
String redisKey = generateResubmitRedisKey(parameter.getMethod());
log.info("請求重複送出攔截,目前key:{}, 目前參數:{}", redisKey, currentRequest);
String preRequest = redisUtil.getCacheObject(redisKey);
if (preRequest != null) {
boolean isSameRequest = Objects.equals(currentRequest, preRequest);
if (isSameRequest) {
throw new ApiException(ErrorCode.Client.COMMON_REQUEST_RESUBMIT);
}
}
redisUtil.setCacheObject(redisKey, currentRequest, resubmitAnno.interval(), TimeUnit.SECONDS);
}
return body;
}
public String generateResubmitRedisKey(Method method) {
String username;
try {
LoginUser loginUser = AuthenticationUtils.getLoginUser();
username = loginUser.getUsername();
} catch (Exception e) {
username = NO_LOGIN;
}
return StrUtil.format(RESUBMIT_REDIS_KEY,
method.getDeclaringClass().getName(),
method.getName(),
username);
}
}
使用
通過在Controller上打上Resubmit注解即可,interval即多久的間隔内相同參數視為重複請求。
/**
* 新增通知公告
*/
@Resubmit(interval = 60)
@PostMapping
public ResponseDTO<Void> add(@RequestBody NoticeAddCommand addCommand) {
noticeApplicationService.addNotice(addCommand);
return ResponseDTO.ok();
}
這是筆者關于中小型項目防請求重複送出的實作,如有不足歡迎大家評論指正。