1、前言
近期在构建项目脚手架时,关于接口幂等性问题,考虑做成独立模块工具放进脚手架中进行通用。
如何保证接口幂等性,换句话说就是如何防止接口重复提交。通常,前后端都需要考虑如何实现相关控制。
- 前端常用的解决方案是“表单提交完成,按钮置灰、按钮不可用或者关闭相关页面”。
- 常见的后端解决方案有“基于JAVA注解+AOP切面实现防止重复提交“。
2、方案
基于JAVA注解+AOP切面方式实现防止重复提交,一般需要自定义JAVA注解,采用AOP切面解析注解,实现接口首次请求提交时,将接口请求标记(由接口签名、请求token、请求客户端ip等组成)存储至redis,并设置超时时间T(T时间之后redis清除接口请求标记),接口每次请求都先检查redis中接口标记,若存在接口请求标记,则判定为接口重复提交,进行拦截返回处理。
3、实现
本次采用的基础框架为SpringBoot,涉及的组件模块有AOP、WEB、Redis、Lombok、Fastjson。详细代码与配置如下文。
-
pom依赖
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.28</version>
</dependency>
</dependencies>
-
配置文件
server.port=8888 # Redis数据库索引(默认为0) spring.redis.database=0 # Redis服务器地址 spring.redis.host=127.0.0.1 # Redis服务器连接端口 spring.redis.port=6379 # Redis服务器连接密码(默认为空) spring.redis.password= # 连接池最大连接数(使用负值表示没有限制) spring.redis.pool.max-active=8 # 连接池最大阻塞等待时间(使用负值表示没有限制) spring.redis.pool.max-wait=-1 # 连接池中的最大空闲连接 spring.redis.pool.max-idle=8 # 连接池中的最小空闲连接 spring.redis.pool.min-idle=0 # 连接超时时间(毫秒) spring.redis.timeout=5000
-
自定义注解
/**
* @author :Gavin
* @see :防止重复操作注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PreventDuplication {
/**
* 防重复操作限时标记数值(存储redis限时标记数值)
*/
String value() default "value" ;
/**
* 防重复操作过期时间(借助redis实现限时控制)
*/
long expireSeconds() default 10;
}
-
自定义切面(解析注解)
切面用于处理防重复提交注解,通过redis中接口请求限时标记控制接口的提交请求。
/**
* @author :Gavin
* @see :防止重复操作切面(处理切面注解)
*/
@Aspect
@Component
public class PreventDuplicationAspect {
@Autowired
private RedisTemplate redisTemplate;
/**
* 定义切点
*/
@Pointcut("@annotation(com.example.idempotent.idempotent.annotation.PreventDuplication)")
public void preventDuplication() {
}
/**
* 环绕通知 (可以控制目标方法前中后期执行操作,目标方法执行前后分别执行一些代码)
*
* @param joinPoint
* @return
*/
@Around("preventDuplication()")
public Object before(ProceedingJoinPoint joinPoint) throws Exception {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder
.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
Assert.notNull(request, "request cannot be null.");
//获取执行方法
Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
//获取防重复提交注解
PreventDuplication annotation = method.getAnnotation(PreventDuplication.class);
// 获取token以及方法标记,生成redisKey和redisValue
String token = request.getHeader(IdempotentConstant.TOKEN);
String redisKey = IdempotentConstant.PREVENT_DUPLICATION_PREFIX
.concat(token)
.concat(getMethodSign(method, joinPoint.getArgs()));
String redisValue = redisKey.concat(annotation.value()).concat("submit duplication");
if (!redisTemplate.hasKey(redisKey)) {
//设置防重复操作限时标记(前置通知)
redisTemplate.opsForValue()
.set(redisKey, redisValue, annotation.expireSeconds(), TimeUnit.SECONDS);
try {
//正常执行方法并返回
//ProceedingJoinPoint类型参数可以决定是否执行目标方法,且环绕通知必须要有返回值,返回值即为目标方法的返回值
return joinPoint.proceed();
} catch (Throwable throwable) {
//确保方法执行异常实时释放限时标记(异常后置通知)
redisTemplate.delete(redisKey);
throw new RuntimeException(throwable);
}
} else {
throw new RuntimeException("请勿重复提交");
}
}
/**
* 生成方法标记:采用数字签名算法SHA1对方法签名字符串加签
*
* @param method
* @param args
* @return
*/
private String getMethodSign(Method method, Object... args) {
StringBuilder sb = new StringBuilder(method.toString());
for (Object arg : args) {
sb.append(toString(arg));
}
return DigestUtils.sha1DigestAsHex(sb.toString());
}
private String toString(Object arg) {
if (Objects.isNull(arg)) {
return "null";
}
if (arg instanceof Number) {
return arg.toString();
}
return JSONObject.toJSONString(arg);
}
}
public interface IdempotentConstant {
String TOKEN = "token";
String PREVENT_DUPLICATION_PREFIX = "PREVENT_DUPLICATION_PREFIX:";
}
-
controller实现(使用注解)
@Slf4j
@RestController
@RequestMapping("/web")
public class IdempotentController {
@PostMapping("/sayNoDuplication")
@PreventDuplication(expireSeconds = 8)
public String sayNoDuplication(@RequestParam("requestNum") String requestNum) {
log.info("sayNoDuplicatin requestNum:{}", requestNum);
return "sayNoDuplicatin".concat(requestNum);
}
}
4、测试
-
正常请求(首次)
首次请求,接口正常返回处理结果。

-
限定时间内重复请求(上文设置8s)
在限定时间内重复请求,AOP切面拦截处理抛出异常,终止接口处理逻辑,异常返回。

控制台报错:

5、源代码
本文代码已经上传托管至GitHub以及Gitee,有需要的读者请自行下载。
- GitHub:https://github.com/gavincoder/idempotent.git
- Gitee:https://gitee.com/gavincoderspace/idempotent.git
Java后端接口防止重复提交
最近在开发的过程中遇到前端没有对提交按钮做点击后变灰处理,必须在后端添加防止重复提交的校验。网上有很多中方案,我这边采用的是aop+自定义注解方式实现。
刚开始采用利用自定义注解+aop+redis防止重复提交这篇博客的逻辑去实现,但是后来在测试多线程访问的时候会出现问题,然后参考网上Redis分布式锁的逻辑,多线程情况下测试只有一个可以通过。参考了LockManager中关于加锁的逻辑。具体的代码逻辑就不占了,只是在上面介绍的资料基础上做了稍微的改造。
参考资料
https://blog.csdn.net/weixin_37505014/article/details/103461741
https://gitee.com/billion/redisLock/
自定义注解解决API接口幂等设计防止表单重复提交(生成token存放到redis中)
写在后面
本文重点在于讲解如何采用基于JAVA注解+AOP切面快速实现防重复提交功能,该方案实现可以完全胜任非高并发场景下实施应用。但是在高并发场景下仍然有不足之处,存在线程安全问题(可以采用Jemeter复现问题)。那么,如何实现支持高并发场景防重复提交功能?请读者查看我的博文《基于Redis实现分布式锁》,这篇博客对本文基于JAVA注解+AOP切面实现进行了优化改造,以便应用于高并发场景。
