背景
在软件开发过程中,我们都会遇到一个问题,那就是接口的重复提交,特别是对于新增操作,这种需要控制幂等的接口,如果不处理好,系统可能会产生大量重复的垃圾数据。
对于防重复提交,分为前端和后端处理,前端处理方法通常为,当用户点击按钮后,禁用按钮,再发出接口请求,直到得到接口的响应,进行后续逻辑处理后,才允许再次点击按钮,前端防重提交,可靠性不是那么的高,如果想要实现高可靠的接口防重,还是需要在后端处理,下文,我们将一步步实现自定义注解的接口幂等校验。
设计思路
幂等key的生成
对于最简单的防重方式,就是通过用户提交的参数,判断在一定时间段内,是否重复提交,如果在规定时间内,同一接口发生了参数相同的多次请求,可视为重复,由于接口参数往往是比较多的,我们会将(接口全路径 + 参数)进行哈希计算,得到一个较短的 key
,添加自动过期时间存入 redis
中用于判重,我们可以通过Spring
提供的Aop
操作,对接口进行前置的幂等校验处理。
上述的实现方式,粗略看来没啥问题,但是遇到某些特定场景的时候,就不行了,比如有一个接口,提供签到操作,接口只提供了一个签到时间的参数,如果通过上述方式防重,那么,对于并发很大的时候,大部分的请求无法成功,因为并发访问,大部分的签到时间是相同的,就会被误判为接口被重复请求了,但其实是不同的用户。
为了解决上述问题,我们可以在注解中添加一个属性,可以让用户针对不同的接口,自定义生成幂等校验的key
,比如上述的签到接口,应该要加上用户ID来防重,这里我们可以通过 Spring EL
表达式来实现用户的自定义规则。
此外,可能还会遇到另一种情况,使用实体来接收参数,但是实体有一些字段有默认值(随机的),这种情况下,用实体的键值对来生成幂等校验key
就会没啥效果,因为实体中有随机字段,即使用户重复提交,也会出现不同,因此,我们可能需要在注解中添加一个属性,用来排除不参与生成幂等key
的字段。
幂等校验的条件
对于设计规范的接口,他们的功能是分离开的,比如新增和编辑,是两个不同的接口,但是某些系统,新增和编辑会是同一个接口,通过参数来判断是新增还是编辑,这样的情况下,我们就不能对这个接口的所有请求都做幂等校验,而是只对新增操作的时候才校验,如同自定义幂等key
的实现方式,我们可以通过Spring EL
表达式来定义需要校验的条件,当满足这个条件,才对接口进行幂等校验,否则不处理。
我们的目的是实现一个可扩展、能满足更多复杂情况的注解,因此,对于幂等key
和启用条件,我们会通过定义接口的方式,来满足更多情况的扩展性。
实现代码
注解
首先,我们需要定义一个注解,用于添加到接口方法上,做幂等校验的参数获取,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80
| import cn.springcoder.common.aop.resolver.IdempotentEnabledResolver; import cn.springcoder.common.aop.resolver.IdempotentKeyResolver; import cn.springcoder.common.aop.resolver.impl.DefaultIdempotentEnabledResolver; import cn.springcoder.common.aop.resolver.impl.DefaultIdempotentKeyResolver; import cn.springcoder.common.aop.resolver.impl.ExpressionIdempotentEnabledResolver; import cn.springcoder.common.aop.resolver.impl.ExpressionIdempotentKeyResolver;
import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.concurrent.TimeUnit;
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent {
int timeout() default 3;
TimeUnit timeUnit() default TimeUnit.SECONDS;
String message() default "重复请求,请稍后重试";
Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
String keyArg() default "";
String keyFilterFields() default "tempId, page";
Class<? extends IdempotentEnabledResolver> enabledResolver() default DefaultIdempotentEnabledResolver.class;
String enabledArg() default ""; }
|
注解中各个属性解释:
timeout
本次幂等的超时时间,请求后,需要间隔多久才允许再次的重复请求,需要注意控制业务处理时间的大小;
timeUnit
定义超时时间的单位,默认为秒;
message
重复请求时接口返回的提示信息;
keyResolver
生成幂等key
的解析器,通过实现接口的方式,增加扩展性,满足更多场景下key
的生成规则;
keyArg
生成幂等key
规则中需要用到的参数,例如,使用Spring EL
解析器生成幂等key
,这里填写的就是Spring EL
表达式内容;
keyFilterFields
通过参数来生成幂等key
时,忽略的字段,多字段英文逗号分割;
enabledResolver
是否启用幂等校验的解析器,通过实现接口的方式,增加扩展性,满足更多场景下是否启用幂等校验的规则定义;
enabledArg
判断是否启用幂等校验中需要用到的参数,例如,使用Spring EL
解析器做条件判断,这里填写的就是Spring EL
表达式内容。
幂等key解析器接口
接口中,我们定义三个方法,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| import cn.hutool.core.util.StrUtil; import cn.springcoder.common.annotation.Idempotent; import org.aspectj.lang.JoinPoint;
public interface IdempotentKeyResolver {
String resolver(JoinPoint joinPoint, Idempotent idempotent);
default String defKeyArg() { return ""; }
default String getKeyArg(Idempotent idempotent) { return StrUtil.isBlank(idempotent.keyArg()) ? defKeyArg() : idempotent.keyArg(); } }
|
接口中的方法解释:
resolver()
通过传入的参数,按照实现类定义的处理逻辑,生成一个幂等校验的key
字符串,建议用哈希算法处理得到一个较短的key
;
defKeyArg()
定义当前解析器的默认参数;
getKeyArg()
获取解析规则需要用到的参数,如果用户没在注解中自定义,就用当前解析器的默认值。
基于参数生成key的解析器实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45
| import cn.hutool.core.util.StrUtil; import cn.hutool.crypto.SecureUtil; import com.alibaba.fastjson.JSONObject; import com.alibaba.fastjson.serializer.SimplePropertyPreFilter; import cn.springcoder.common.annotation.Idempotent; import cn.springcoder.common.aop.resolver.IdempotentKeyResolver; import org.aspectj.lang.JoinPoint; import org.springframework.stereotype.Component;
@Component public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {
public SimplePropertyPreFilter getKeyFilter(String excludeFields) { excludeFields = StrUtil.replace(excludeFields, " ", ""); SimplePropertyPreFilter filter = new SimplePropertyPreFilter(); if (StrUtil.isBlank(excludeFields)) { return filter; }
for (String field : StrUtil.split(excludeFields, ",")) { filter.getExcludes().add(field); } return filter; }
@Override public String resolver(JoinPoint joinPoint, Idempotent idempotent) { String methodName = joinPoint.getSignature().toString(); Object[] args = joinPoint.getArgs(); StringBuilder argsStr = new StringBuilder(); for (Object arg : args) { argsStr.append(JSONObject.toJSONString(arg, getKeyFilter(idempotent.keyFilterFields()))); } return SecureUtil.md5(methodName + argsStr); } }
|
这里我们定义了一个默认的幂等key
解析器,基于参数的方式来生成,同时可以支持排除某些字段,比如tempId
字段的值,不作为判断是否重复提交的条件,我们将接口的多个参数,转为json
字符串后拼接到一起,再加上接口方法的完整路径,进行一次简单的MD5
哈希运算,得到一个长度固定的幂等校验key
字符串。
基于SpringEL表达式生成key的解析器实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| import cn.hutool.core.util.ArrayUtil; import cn.springcoder.common.annotation.Idempotent; import cn.springcoder.common.aop.IdempotentException; import cn.springcoder.common.aop.resolver.IdempotentKeyResolver; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.core.LocalVariableTableParameterNameDiscoverer; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.expression.Expression; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
@Component public class ExpressionIdempotentKeyResolver implements IdempotentKeyResolver {
private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer(); private final ExpressionParser expressionParser = new SpelExpressionParser();
@Override public String resolver(JoinPoint joinPoint, Idempotent idempotent) { Method method = getMethod(joinPoint); Object[] args = joinPoint.getArgs(); String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method); StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); if (ArrayUtil.isNotEmpty(parameterNames)) { for (int i = 0; i < parameterNames.length; i++) { evaluationContext.setVariable(parameterNames[i], args[i]); } }
Expression expression = expressionParser.parseExpression(getKeyArg(idempotent)); return expression.getValue(evaluationContext, String.class); }
private static Method getMethod(JoinPoint point) { MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); if (!method.getDeclaringClass().isInterface()) { return method; }
try { return point.getTarget().getClass().getDeclaredMethod( point.getSignature().getName(), method.getParameterTypes()); } catch (NoSuchMethodException e) { throw new IdempotentException(e); } } }
|
幂等校验启用条件解析器接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| import cn.hutool.core.util.StrUtil; import cn.springcoder.common.annotation.Idempotent; import org.aspectj.lang.JoinPoint;
public interface IdempotentEnabledResolver {
boolean enabled(JoinPoint joinPoint, Idempotent idempotent);
String defEnabledArg();
default String getEnabledArg(Idempotent idempotent) { return StrUtil.isBlank(idempotent.enabledArg()) ? defEnabledArg() : idempotent.enabledArg(); } }
|
接口中的方法解释:
enabled()
定义判断逻辑,本次请求是否启用幂等校验,如果接口所有请求都要做校验,直接定义一个布尔解析器,返回true
即可;
defEnabledArg()
定义当前解析器的默认参数;
getEnabledArg()
获取解析规则需要用到的参数,如果用户没在注解中自定义,就用当前解析器的默认值。
基于布尔的启用条件解析器实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| import cn.hutool.core.convert.Convert; import cn.springcoder.common.annotation.Idempotent; import cn.springcoder.common.aop.resolver.IdempotentEnabledResolver; import org.aspectj.lang.JoinPoint; import org.springframework.stereotype.Component;
@Component public class DefaultIdempotentEnabledResolver implements IdempotentEnabledResolver {
@Override public boolean enabled(JoinPoint joinPoint, Idempotent idempotent) { return Convert.toBool(getEnabledArg(idempotent), true); }
@Override public String defEnabledArg() { return "true"; } }
|
对于功能分离清晰的接口,往往是对某个接口的所有请求都需要做幂等校验的,比如新增接口,我们就可以通过基于布尔的实现方式,定义对当前接口的每次请求都做幂等校验。
基于SpringEL表达式的启用条件解析器实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69
| import cn.hutool.core.util.ArrayUtil; import cn.springcoder.common.annotation.Idempotent; import cn.springcoder.common.aop.resolver.IdempotentEnabledResolver; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.core.LocalVariableTableParameterNameDiscoverer; import org.springframework.core.ParameterNameDiscoverer; import org.springframework.expression.Expression; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
@Component public class ExpressionIdempotentEnabledResolver implements IdempotentEnabledResolver {
private final ParameterNameDiscoverer parameterNameDiscoverer = new LocalVariableTableParameterNameDiscoverer(); private final ExpressionParser expressionParser = new SpelExpressionParser();
@Override public boolean enabled(JoinPoint joinPoint, Idempotent idempotent) { Method method = getMethod(joinPoint); Object[] args = joinPoint.getArgs(); String[] parameterNames = this.parameterNameDiscoverer.getParameterNames(method); StandardEvaluationContext evaluationContext = new StandardEvaluationContext(); if (ArrayUtil.isNotEmpty(parameterNames)) { for (int i = 0; i < parameterNames.length; i++) { evaluationContext.setVariable(parameterNames[i], args[i]); } }
Expression expression = expressionParser.parseExpression(getEnabledArg(idempotent)); return Boolean.TRUE.equals(expression.getValue(evaluationContext, Boolean.class)); }
@Override public String defEnabledArg() { return "#entity.isAdd()"; }
private static Method getMethod(JoinPoint point) { MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); if (!method.getDeclaringClass().isInterface()) { return method; }
try { return point.getTarget().getClass().getDeclaredMethod( point.getSignature().getName(), method.getParameterTypes()); } catch (NoSuchMethodException e) { throw new RuntimeException(e); } } }
|
对于新增和保存使用的是同一个接口的情况,我们只需要对新增操作做幂等校验,此时我们就可以通过基于Spring EL
表达式的方式来实现解析器,比如判断参数中id
字段是否为空,为空则是新增,就做幂等校验,或者调用接收参数的实体的方法isAdd()
,为true
则是新增,需要做幂等校验。这样,我们就可以通过定义各种表达式,来满足对一个接口的幂等校验控制,而不是加了注解,就对这个接口的所有请求都做幂等校验。大大提升了适用性,满足更多更复杂的情况。
幂等校验异常类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
|
public class IdempotentException extends RuntimeException {
public IdempotentException() { super(); }
public IdempotentException(String message) { super(message); }
public IdempotentException(String message, Throwable cause) { super(message, cause); }
public IdempotentException(Throwable cause) { super(cause); }
protected IdempotentException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { super(message, cause, enableSuppression, writableStackTrace); } }
|
我们定义一个异常类,用于在幂等校验中抛出指定异常,方便其他业务捕捉和处理。
Aop切面实现类
有了上述准备,我们比较核心的代码来了,通过切面,拦截添加了注解@Idempotent
的请求,在请求进入接口方法体之前,处理幂等校验逻辑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
| import cn.springcoder.common.annotation.Idempotent; import cn.springcoder.common.aop.resolver.IdempotentEnabledResolver; import cn.springcoder.common.aop.resolver.IdempotentKeyResolver; import cn.springcoder.common.utils.RedisUtils; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Configuration; import org.springframework.util.Assert;
import java.util.HashMap; import java.util.List; import java.util.Map;
@Aspect @Configuration public class IdempotentAspect {
protected Logger logger = LoggerFactory.getLogger(getClass());
private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;
private final Map<Class<? extends IdempotentEnabledResolver>, IdempotentEnabledResolver> enabledResolvers;
public IdempotentAspect(List<IdempotentKeyResolver> keyResolvers, List<IdempotentEnabledResolver> enabledResolvers) { this.keyResolvers = new HashMap<>(); this.enabledResolvers = new HashMap<>(); keyResolvers.forEach(v -> this.keyResolvers.put(v.getClass(), v)); enabledResolvers.forEach(v -> this.enabledResolvers.put(v.getClass(), v)); }
@Before("@annotation(idempotent)") public void beforePointCut(JoinPoint joinPoint, Idempotent idempotent) { IdempotentEnabledResolver enabledResolver = enabledResolvers.get(idempotent.enabledResolver()); Assert.notNull(enabledResolver, "找不到对应的 IdempotentEnabledResolver"); if (!enabledResolver.enabled(joinPoint, idempotent)) { return; }
IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver()); Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver"); String key = "idempotent:" + keyResolver.resolver(joinPoint, idempotent);
boolean success = RedisUtils.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit().toSeconds(idempotent.timeout())); if (!success) { logger.info("[beforePointCut][方法({}) 参数({}) 存在重复请求]", joinPoint.getSignature().toString(), joinPoint.getArgs()); throw new IdempotentException(idempotent.message()); } } }
|
到此,我们已经通过代码,完成了所有的逻辑处理,改注解,不仅能实现接口幂等控制,还能做接口访问频率限制,例如,验证码发送接口,一个用户1分钟内只能请求一次,也可通过注解控制。下面,我们通过使用案例,来看看它的强大性与实用性。
使用案例
案列1
业务逻辑:
有一个新增接口,需要根据提交参数做幂等校验,只要提交参数相同,就认为是重复提交,5秒内不得再次提交。
1 2 3 4 5
| @RequestMapping("test") @Idempotent(timeout = 5) public String test(@RequestBody InsertParam param) { return "SUCCESS"; }
|
案例2
业务逻辑:
有一个新增接口,需要根据提交参数做幂等校验,只要提交参数相同(参数实体中,有一个字段含随机默认值,譬如private String tempId = IdUtil.uuid();
),就认为是重复提交,5秒内不得再次提交。
1 2 3 4 5
| @RequestMapping("test") @Idempotent(timeout = 5, keyFilterFields = "tempId") public String test(@RequestBody InsertParam param) { return "SUCCESS"; }
|
案例3
业务逻辑:
有一个签到接口,无任何参数,通过获取登录用户id来进行签到,要求同一个账号,一分钟内不能重复请求。
1 2 3 4 5 6 7 8
| @RequestMapping("test") @Idempotent(timeout = 5, keyResolver = ExpressionIdempotentKeyResolver.class, keyArg = "T(cn.hutool.crypto.SecureUtil).md5('cn.springcoder.api.Test.test' + T(cn.springcoder.utils.UserUtils).getUser().getId())" ) public String test() { return "SUCCESS"; }
|
案例4
业务逻辑:
有一个发送验证码接口,需要控制对同一个号码,一分钟内只能发送一次。
1 2 3 4 5
| @RequestMapping("test") @Idempotent(timeout = 60) public String test(@RequestParam String phone) { return "SUCCESS"; }
|
案例5
业务逻辑:
有一个保存接口,可以操作新增和修改,需要对新增操作做幂等控制,根据参数中字段id
是否为空来判断是不是新增,为空是新增,5秒内不得重复提交。
1 2 3 4 5 6 7 8
| @RequestMapping("test") @Idempotent(timeout = 5, enabledResolver = ExpressionIdempotentEnabledResolver.class, enabledArg = "T(cn.hutool.core.util.StrUtil).isBlank(#param.id)" ) public String test(@RequestBody InsertParam param) { return "SUCCESS"; }
|
案例6
业务逻辑:
有一个保存接口,可以操作新增和修改,需要对新增操作做幂等控制,参数实体中方法isAdd() == true
是新增,5秒内不得重复提交。
1 2 3 4 5 6 7 8
| @RequestMapping("test") @Idempotent(timeout = 5, enabledResolver = ExpressionIdempotentEnabledResolver.class, enabledArg = "#param.isAdd()" ) public String test(@RequestBody InsertParam param) { return "SUCCESS"; }
|