请求重复提交的危害
- 数据重复:例如用户重复提交表单,造成数据重复。
- 资源浪费:多次重复请求提交将会浪费服务器的处理资源。但这个相比数据重复的危害性较小。
- 不一致性:假设我们触发请求增加用户的积分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();
}