1.防重幂等概念
有时因为网络问题导致用户多次提交表单,后端会出现重复脏数据,所以做表单防重复提交很有必要。
接口幂等性跟并发请求是两个概念,接口幂等性是针对自身,而并发请求是代表不同人。
目标:前端通过防抖, 后端通过Spring AOP + Redis实现防重幂等功能
2.接口幂等设计
目标:在指定窗口时间内,限制同一个用户对同一种业务提交相同数据。
实现思路:
- 通过AOP环绕通知,在进入方法前记录当前请求唯一性标识(方法+参数+用户唯一性标识)存入Redis。
- 判断当前请求标识能否在redis中找到,如果找到则代表还在窗口内,不允许提交。
- 当前方法执行完从Redis移除请求标识。
代码实现
- 防重复注解
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AvoidRepeatSubmit {
/**
* 有效期内,相同请求将会拒绝掉, 默认为1s
* @return
*/
long explainTime() default 1;
}
2.实现切面
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
@Order(1)
public class RequestSubmitAspect {
private final RedisUtil redisUtil;
@Around("@annotation(validate)")
public Object around(ProceedingJoinPoint joinPoint, AvoidRepeatSubmit validate) throws Throwable {
String userName = UserContext.get() == null ? null : UserContext.get().getUsername();
// 1.提取请求参数
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName = signature.getMethod().getName();
String url = getRequestUrl();
String paramStr = getRequestParams();
log.debug("请求地址:{}", url);
log.debug("请求方法:{}", methodName);
log.debug("当前用户:{}", userName);
// 2.拼接业务重复请求Key
String repeatSubmitKey = RedisKeyUtil.repeatSubmitKey(userName, url, requestParamMD5(paramStr));
// 3. 设置时间内,不可再次请求
long expireTime = validate.explainTime();
long expireAt = System.currentTimeMillis() + expireTime * 1000;;
boolean isRepeatRequest = redisUtil.setnx(repeatSubmitKey, expireAt, expireTime);
if (!isRepeatRequest) {
throw new BusinessException("请求频繁,稍后重试");
}
// 4.执行目标方法
return joinPoint.proceed();
}
/**
* 方法执行完移除key
* @param joinPoint
* @param validate
*/
@After("@annotation(validate)")
public void after(JoinPoint joinPoint, AvoidRepeatSubmit validate) {
String userName = UserContext.get() == null ? null : UserContext.get().getUsername();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String url = getRequestUrl();
String paramStr;
try {
paramStr = getRequestParams();
} catch (IOException e) {
log.error("获取请求操作异常, {}", e.getMessage());
paramStr = "";
}
String repeatSubmitKey = RedisKeyUtil.repeatSubmitKey(userName, url, requestParamMD5(paramStr));
redisUtil.del(repeatSubmitKey);
}
/**
* 获取请求Url
* @return
*/
private String getRequestUrl() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String url = "";
if (attributes != null) {
url = attributes.getRequest().getRequestURI();
}
return url;
}
/**
* 获取请求参数
* @return
* @throws IOException
*/
private String getRequestParams() throws IOException {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
// 1. init请求参数,默认空字符串
String paramStr = "";
// 2. 获取请求参数
if (attributes != null) {
HttpServletRequest request = attributes.getRequest();
Map params = request.getParameterMap();
if (!MyUtils.isEmpty(params)) {
paramStr = JacksonUtils.toJson(request.getParameterMap());
} else {
paramStr = IOUtils.toString(request.getInputStream(), StandardCharsets.UTF_8).replaceAll("\\s+", " ");
}
}
log.info("请求参数:{}", paramStr);
return paramStr;
}
/**
* @param reqJson 请求的参数,这里通常是JSON
* @param excludeKeys 请求参数里面要去除哪些字段再求摘要
* @return 去除参数的MD5摘要
*/
private String requestParamMD5(final String reqJson, String... excludeKeys) {
if (StrUtil.isEmpty(reqJson)) {
return "";
}
TreeMap paramTreeMap = JSONUtil.toBean(reqJson, TreeMap.class);
if (excludeKeys != null) {
List<String> dedupExcludeKeys = Arrays.asList(excludeKeys);
if (!dedupExcludeKeys.isEmpty()) {
for (String dedupExcludeKey : dedupExcludeKeys) {
paramTreeMap.remove(dedupExcludeKey);
}
}
}
String requestJson = JSON.toJSONString(paramTreeMap);
String md5Str = DigestUtil.md5Hex(requestJson);
log.debug("md5deDupParam = {}, excludeKeys = {} {}", md5Str, Arrays.deepToString(excludeKeys), requestJson);
return md5Str;
}
}