赞
踩
在软件开发过程中,我们都会遇到一个问题,那就是接口的重复提交,特别是对于新增操作,这种需要控制幂等的接口,如果不处理好,系统可能会产生大量重复的垃圾数据。
对于防重复提交,分为前端和后端处理,前端处理方法通常为,当用户点击按钮后,禁用按钮,再发出接口请求,直到得到接口的响应,进行后续逻辑处理后,才允许再次点击按钮,前端防重提交,可靠性不是那么的高,如果想要实现高可靠的接口防重,还是需要在后端处理,下文,我们将一步步实现自定义注解的接口幂等校验。
对于最简单的防重方式,就是通过用户提交的参数,判断在一定时间段内,是否重复提交,如果在规定时间内,同一接口发生了参数相同的多次请求,可视为重复,由于接口参数往往是比较多的,我们会将(接口全路径 + 参数)进行哈希计算,得到一个较短的 key
,添加自动过期时间存入 redis
中用于判重,我们可以通过Spring
提供的Aop
操作,对接口进行前置的幂等校验处理。
上述的实现方式,粗略看来没啥问题,但是遇到某些特定场景的时候,就不行了,比如有一个接口,提供签到操作,接口只提供了一个签到时间的参数,如果通过上述方式防重,那么,对于并发很大的时候,大部分的请求无法成功,因为并发访问,大部分的签到时间是相同的,就会被误判为接口被重复请求了,但其实是不同的用户。
为了解决上述问题,我们可以在注解中添加一个属性,可以让用户针对不同的接口,自定义生成幂等校验的key
,比如上述的签到接口,应该要加上用户ID来防重,这里我们可以通过 Spring EL
表达式来实现用户的自定义规则。
此外,可能还会遇到另一种情况,使用实体来接收参数,但是实体有一些字段有默认值(随机的),这种情况下,用实体的键值对来生成幂等校验key
就会没啥效果,因为实体中有随机字段,即使用户重复提交,也会出现不同,因此,我们可能需要在注解中添加一个属性,用来排除不参与生成幂等key
的字段。
对于设计规范的接口,他们的功能是分离开的,比如新增和编辑,是两个不同的接口,但是某些系统,新增和编辑会是同一个接口,通过参数来判断是新增还是编辑,这样的情况下,我们就不能对这个接口的所有请求都做幂等校验,而是只对新增操作的时候才校验,如同自定义幂等key
的实现方式,我们可以通过Spring EL
表达式来定义需要校验的条件,当满足这个条件,才对接口进行幂等校验,否则不处理。
我们的目的是实现一个可扩展、能满足更多复杂情况的注解,因此,对于幂等
key
和启用条件,我们会通过定义接口的方式,来满足更多情况的扩展性。
首先,我们需要定义一个注解,用于添加到接口方法上,做幂等校验的参数获取,代码如下:
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;
/**
* 接口幂等(防重提交)注解
*
* @author zhufeihong
* @since 2023/10/13 10:31
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 幂等的超时时间,默认为 3 秒
* <p>
* 注意,如果执行时间超过它,请求还是会进来
*/
int timeout() default 3;
/**
* 时间单位,默认为 SECONDS 秒
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 重复请求的提示
*/
String message() default "重复请求,请稍后重试";
/**
* 使用的 Key 解析器
*/
Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
/**
* 使用的 Key 参数
* <p>
* 通过 Spring EL {@link ExpressionIdempotentKeyResolver}表达式生成幂等 key 示例:
* 1. 根据登录用户 id 控制 test 接口访问
* keyArg = "T(cn.hutool.crypto.SecureUtil).md5('cn.springcoder.api.test.test' + T(cn.springcoder.modules.sys.utils.UserUtils).getUser().getId())"
* 2. 根据登录用户 id + 参数 param.code 控制接口访问
* keyArg = "T(cn.hutool.crypto.SecureUtil).md5(#param.getId() + T(cn.springcoder.modules.sys.utils.UserUtils).getUser().getId())"
* </p>
*/
String keyArg() default "";
/**
* 生成幂等 key 忽略的参数字段名
*/
String keyFilterFields() default "tempId, page";
/**
* 动态判断是否启用幂等校验解析器
* 如同一个接口,新增需要校验,修改不校验
*/
Class<? extends IdempotentEnabledResolver> enabledResolver() default DefaultIdempotentEnabledResolver.class;
/**
* 启用幂等校验的表达式
* <p>
* 使用 Spring EL {@link ExpressionIdempotentEnabledResolver}表达式示例:
* 1. 当参数 param.id 为空时启用幂等校验
* enabledArg = "T(cn.hutool.core.util.StrUtil).isBlank(#param.id)"
* 2. 当参数对象 param.getIsNewRecord() == true 时启用幂等校验
* enabledArg = "#param.getIsNewRecord()"
* </p>
*/
String enabledArg() default "";
}
注解中各个属性解释:
timeout
本次幂等的超时时间,请求后,需要间隔多久才允许再次的重复请求,需要注意控制业务处理时间的大小;
timeUnit
定义超时时间的单位,默认为秒;
message
重复请求时接口返回的提示信息;
keyResolver
生成幂等key
的解析器,通过实现接口的方式,增加扩展性,满足更多场景下key
的生成规则;
keyArg
生成幂等key
规则中需要用到的参数,例如,使用Spring EL
解析器生成幂等key
,这里填写的就是Spring EL
表达式内容;
keyFilterFields
通过参数来生成幂等key
时,忽略的字段,多字段英文逗号分割;
enabledResolver
是否启用幂等校验的解析器,通过实现接口的方式,增加扩展性,满足更多场景下是否启用幂等校验的规则定义;
enabledArg
判断是否启用幂等校验中需要用到的参数,例如,使用Spring EL
解析器做条件判断,这里填写的就是Spring EL
表达式内容。
接口中,我们定义三个方法,
import cn.hutool.core.util.StrUtil;
import cn.springcoder.common.annotation.Idempotent;
import org.aspectj.lang.JoinPoint;
/**
* 幂等 Key 解析器接口
*
* @author zhufeihong
* @since 2023/10/13 10:36
*/
public interface IdempotentKeyResolver {
/**
* 解析一个 Key
*
* @param idempotent 幂等注解
* @param joinPoint AOP 切面
* @return Key
*/
String resolver(JoinPoint joinPoint, Idempotent idempotent);
/**
* 默认的 key 解析表达式
*
* @return java.lang.String
*/
default String defKeyArg() {
return "";
}
/**
* 获取 key 解析表达式
*
* @param idempotent 注解
* @return java.lang.String
*/
default String getKeyArg(Idempotent idempotent) {
return StrUtil.isBlank(idempotent.keyArg()) ? defKeyArg() : idempotent.keyArg();
}
}
接口中的方法解释:
resolver()
通过传入的参数,按照实现类定义的处理逻辑,生成一个幂等校验的key
字符串,建议用哈希算法处理得到一个较短的key
;
defKeyArg()
定义当前解析器的默认参数;
getKeyArg()
获取解析规则需要用到的参数,如果用户没在注解中自定义,就用当前解析器的默认值。
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;
/**
* 默认幂等 Key 解析器,通过 MD5(方法名 + 方法参数),组装成一个 Key
*
* @author zhufeihong
* @since 2023/10/13 10:37
*/
@Component
public class DefaultIdempotentKeyResolver implements IdempotentKeyResolver {
/**
* 生成幂等 key 忽略的字段
*/
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
字符串。
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;
/**
* 幂等 Key 解析器,基于 Spring EL 表达式生成 key
*
* @author zhufeihong
* @since 2023/10/19 17:24
*/
@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);
// 准备 Spring EL 表达式解析的上下文
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);
}
}
}
import cn.hutool.core.util.StrUtil;
import cn.springcoder.common.annotation.Idempotent;
import org.aspectj.lang.JoinPoint;
/**
* 幂等校验是否启用解析器接口
*
* @author zhufeihong
* @since 2023/10/13 11:39
*/
public interface IdempotentEnabledResolver {
/**
* 解析 当前是否进行幂等校验
* true:本次进行幂等校验
* false:本次忽略幂等校验
*
* @param idempotent 幂等注解
* @param joinPoint AOP 切面
* @return 是否启用
*/
boolean enabled(JoinPoint joinPoint, Idempotent idempotent);
/**
* 默认 enabled 解析表达式
*
* @return String
*/
String defEnabledArg();
/**
* 获取 enabledArg 解析表达式
*
* @param idempotent 注解
* @return String
*/
default String getEnabledArg(Idempotent idempotent) {
return StrUtil.isBlank(idempotent.enabledArg()) ? defEnabledArg() : idempotent.enabledArg();
}
}
接口中的方法解释:
enabled()
定义判断逻辑,本次请求是否启用幂等校验,如果接口所有请求都要做校验,直接定义一个布尔解析器,返回true
即可;
defEnabledArg()
定义当前解析器的默认参数;
getEnabledArg()
获取解析规则需要用到的参数,如果用户没在注解中自定义,就用当前解析器的默认值。
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;
/**
* 默认幂等校验 enabled 解析器,基于布尔值
*
* @author zhufeihong
* @since 2023/10/19 10:48
*/
@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";
}
}
对于功能分离清晰的接口,往往是对某个接口的所有请求都需要做幂等校验的,比如新增接口,我们就可以通过基于布尔的实现方式,定义对当前接口的每次请求都做幂等校验。
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;
/**
* 幂等校验 enabled 解析器,基于 Spring EL 表达式
*
* @author zhufeihong
* @since 2023/10/13 11:42
*/
@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);
// 准备 Spring EL 表达式解析的上下文
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() {
// 参数名称不是 entity 的需要在注解中进行自定义覆盖
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
则是新增,需要做幂等校验。这样,我们就可以通过定义各种表达式,来满足对一个接口的幂等校验控制,而不是加了注解,就对这个接口的所有请求都做幂等校验。大大提升了适用性,满足更多更复杂的情况。
/**
* 接口重复提交异常
*
* @author zhufeihong
* @since 2023/10/13 11:05
*/
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);
}
}
我们定义一个异常类,用于在幂等校验中抛出指定异常,方便其他业务捕捉和处理。
有了上述准备,我们比较核心的代码来了,通过切面,拦截添加了注解@Idempotent
的请求,在请求进入接口方法体之前,处理幂等校验逻辑。
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;
/**
* 拦截声明了 {@link Idempotent} 注解的方法,实现接口幂等
*
* @author zhufeihong
* @since 2023/10/13 10:32
*/
@Aspect
@Configuration
public class IdempotentAspect {
protected Logger logger = LoggerFactory.getLogger(getClass());
/**
* IdempotentKeyResolver 集合
*/
private final Map<Class<? extends IdempotentKeyResolver>, IdempotentKeyResolver> keyResolvers;
/**
* IdempotentEnabledResolver 集合
*/
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
IdempotentEnabledResolver enabledResolver = enabledResolvers.get(idempotent.enabledResolver());
Assert.notNull(enabledResolver, "找不到对应的 IdempotentEnabledResolver");
// 判断当前条件是否启用幂等校验
if (!enabledResolver.enabled(joinPoint, idempotent)) {
return;
}
// 获得 IdempotentKeyResolver
IdempotentKeyResolver keyResolver = keyResolvers.get(idempotent.keyResolver());
Assert.notNull(keyResolver, "找不到对应的 IdempotentKeyResolver");
// 解析 Key
String key = "idempotent:" + keyResolver.resolver(joinPoint, idempotent);
// 锁定 Key。
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分钟内只能请求一次,也可通过注解控制。下面,我们通过使用案例,来看看它的强大性与实用性。
业务逻辑:
有一个新增接口,需要根据提交参数做幂等校验,只要提交参数相同,就认为是重复提交,5秒内不得再次提交。
@RequestMapping("test")
@Idempotent(timeout = 5)
public String test(@RequestBody InsertParam param) {
return "SUCCESS";
}
业务逻辑:
有一个新增接口,需要根据提交参数做幂等校验,只要提交参数相同(参数实体中,有一个字段含随机默认值,譬如
private String tempId = IdUtil.uuid();
),就认为是重复提交,5秒内不得再次提交。
@RequestMapping("test")
@Idempotent(timeout = 5, keyFilterFields = "tempId")
public String test(@RequestBody InsertParam param) {
return "SUCCESS";
}
业务逻辑:
有一个签到接口,无任何参数,通过获取登录用户id来进行签到,要求同一个账号,一分钟内不能重复请求。
@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";
}
业务逻辑:
有一个发送验证码接口,需要控制对同一个号码,一分钟内只能发送一次。
@RequestMapping("test")
@Idempotent(timeout = 60)
public String test(@RequestParam String phone) {
return "SUCCESS";
}
业务逻辑:
有一个保存接口,可以操作新增和修改,需要对新增操作做幂等控制,根据参数中字段
id
是否为空来判断是不是新增,为空是新增,5秒内不得重复提交。
@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";
}
业务逻辑:
有一个保存接口,可以操作新增和修改,需要对新增操作做幂等控制,参数实体中方法
isAdd() == true
是新增,5秒内不得重复提交。
@RequestMapping("test")
@Idempotent(timeout = 5,
enabledResolver = ExpressionIdempotentEnabledResolver.class,
enabledArg = "#param.isAdd()"
)
public String test(@RequestBody InsertParam param) {
return "SUCCESS";
}
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。