使用场景
1. 针对前端页面
网络问题导致用户重复点击,发了多次请求引起的重复提交
2. 针对系统间接口调用
因网络超时重试或者程序bug导致的重复请求
3. 通用的业务层面的验证
比如要限制某个接口的一些参数的组合在规定的时间内只允许请求一次
实现方式
通过注解的方式,获取能标识重复请求的唯一的 key 作为 redis key,允许指定超时时间,在这段时间内,这种请求只允许通过一次。
获取唯一标识有三种方式,对应三种处理策略,只允许指定一个。三种方式分别为:
1、调用方自己传过来一个唯一的 id,放到请求的 header 中
2、能获取到登录用户的登录信息的情况下,通过 userId 和请求的 Uri 的组合生成唯一的 key
3、通过指定请求的参数的字段组合,通过这些字段组合生成唯一 key,请求字段通过 spEl
来指定。
使用场景示例
1. 前端表单提交的场景
解决方案:
1)使用策略1,基于唯一id的验证。
在打开一个提交页面的时候,前端可以生成一个全局唯一的 token 值,可自己生成或者调后端接口,请求后端提交接口的时候带上这个 token 值。
这样如果在第一次请求没有相应,用户快速点击的时候,因为在同一个页面,还没有刷新,所以该页面的所有请求携带的 token 值是相同的,这样只有第一次请求被正确处理。
2)使用策略2,基于用户的验证。这种对应前端不需要改动,后端自动获取用户信息和请求的URI。
2. 接口调用超时的场景
如果请求中参数有一个字段是唯一的,则可以使用策略1和3,如果是组合唯一则使用策略3
3. 通用的业务层面的接口参数的验证
使用策略3,通过spEl
在注解中指定要验证的组合字段,举例:
ini
@RepeatCheck(expireTime = 2000,
keyExpression = "#index + '_' + #value + '_'+ #fundDTO.fundType + '_' + #fundDTO.fundChannel")
实战
接下来我们重点讲解业务层通用去重校验,实现思路:
- 注解式:利用 AOP 切面能力
- 业务层自定义唯一 Key:利用 spEL 表达式解析
- redis 存储校验
大致实现如下:
java
@Before("@annotation(annotation)")
public Object repeatCheck(JoinPoint joinPoint, ResubmitCheck annotation) throws Throwable {
Object[] args = joinPoint.getArgs();
String[] conditionExpressions = annotation.conditionExpressions();
if (ExpressionUtils.getConditionValue(args, conditionExpressions) || ArrayUtils.isEmpty(args)) {
this.doCheck(annotation, args);
}
return true;
}
private void doCheck(@NonNull ResubmitCheck annotation, Object[] args) {
if (annotation == null) {
throw new NullPointerException("annotation cannot be null");
}
String keyExpressions = annotation.keyExpression();
String message = annotation.message();
boolean withUserInfoInKey = annotation.withUserInfoInKey();
String methodDesc = this.request.getMethod();
String uri = this.request.getRequestURI();
StringBuilder bizKey = new StringBuilder(64);
Object[] argsKeys = ExpressionUtils.getExpressionValue(args, keyExpressions);
int length = argsKeys.length;
for(int i = 0; i < length; ++i) {
Object obj = argsKeys[i];
bizKey.append(obj.toString());
}
StringBuilder keyBuilder = new StringBuilder();
String userId = Objects.isNull(this.request.getHeader("uerId")) ? "0" : this.request.getHeader("userId");
keyBuilder.append("repeatCheck::").append(withUserInfoInKey ? userId + "::" : "").append(uri).append("::").append(methodDesc).append("::").append(bizKey.toString());
if (this.stringRedisTemplate.opsForValue().get(keyBuilder.toString()) != null) {
throw new ResubmitException(10001, StringUtils.isBlank(message) ? "重复提交" : message);
} else {
this.stringRedisTemplate.opsForValue().set(keyBuilder.toString(), "", annotation.expireTime(), annotation.timeUnit());
}
}
注解定义:
java
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
public @interface RepeatCheck {
String keyExpression();
String message() default "重复提交";
boolean withUserInfoInKey() default false;
long expireTime() default 1L;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
如此,一个通用的防重组件大致完成了...