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://gitee.com/billion/redisLock/
写在后面
本文重点在于讲解如何采用基于JAVA注解+AOP切面快速实现防重复提交功能,该方案实现可以完全胜任非高并发场景下实施应用。但是在高并发场景下仍然有不足之处,存在线程安全问题(可以采用Jemeter复现问题)。那么,如何实现支持高并发场景防重复提交功能?请读者查看我的博文《基于Redis实现分布式锁》,这篇博客对本文基于JAVA注解+AOP切面实现进行了优化改造,以便应用于高并发场景。