防止重复提交解决方案-(基于JAVA注解+AOP切面)

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>
<span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">dependencies</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
    <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>org.springframework.boot<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>spring-boot-starter-aop<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
    <span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>

    <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>org.springframework.boot<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>spring-boot-starter-web<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
    <span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>

    <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>org.springframework.boot<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>spring-boot-starter-data-redis<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
    <span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>

    <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>org.springframework.boot<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>spring-boot-starter-test<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">scope</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>test<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">scope</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
    <span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>

    <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>org.projectlombok<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>lombok<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
    <span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>

    <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>com.alibaba<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">groupId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>fastjson<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">artifactId</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
        <span style="color: rgba(0, 0, 255, 1)">&lt;</span><span style="color: rgba(128, 0, 0, 1)">version</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>1.2.28<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">version</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>
    <span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">dependency</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span>

<span style="color: rgba(0, 0, 255, 1)">&lt;/</span><span style="color: rgba(128, 0, 0, 1)">dependencies</span><span style="color: rgba(0, 0, 255, 1)">&gt;</span></pre>

 

  • 配置文件

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" ;

</span><span style="color: rgba(0, 128, 0, 1)">/**</span><span style="color: rgba(0, 128, 0, 1)">
 * 防重复操作过期时间(借助redis实现限时控制)
 </span><span style="color: rgba(0, 128, 0, 1)">*/</span>
<span style="color: rgba(0, 0, 255, 1)">long</span> expireSeconds() <span style="color: rgba(0, 0, 255, 1)">default</span> 10<span style="color: rgba(0, 0, 0, 1)">;

}

 

  • 自定义切面(解析注解)

    切面用于处理防重复提交注解,通过 redis 中接口请求限时标记控制接口的提交请求。

/**
 * @author :Gavin
 * @see : 防止重复操作切面(处理切面注解)
 */

@Aspect
@Component
public class PreventDuplicationAspect {

@Autowired
</span><span style="color: rgba(0, 0, 255, 1)">private</span><span style="color: rgba(0, 0, 0, 1)"> RedisTemplate redisTemplate;

</span><span style="color: rgba(0, 128, 0, 1)">/**</span><span style="color: rgba(0, 128, 0, 1)">
 * 定义切点
 </span><span style="color: rgba(0, 128, 0, 1)">*/</span><span style="color: rgba(0, 0, 0, 1)">
@Pointcut(</span>"@annotation(com.example.idempotent.idempotent.annotation.PreventDuplication)"<span style="color: rgba(0, 0, 0, 1)">)
</span><span style="color: rgba(0, 0, 255, 1)">public</span> <span style="color: rgba(0, 0, 255, 1)">void</span><span style="color: rgba(0, 0, 0, 1)"> preventDuplication() {
}

</span><span style="color: rgba(0, 128, 0, 1)">/**</span><span style="color: rgba(0, 128, 0, 1)">
 * 环绕通知 (可以控制目标方法前中后期执行操作,目标方法执行前后分别执行一些代码)
 *
 * </span><span style="color: rgba(128, 128, 128, 1)">@param</span><span style="color: rgba(0, 128, 0, 1)"> joinPoint
 * </span><span style="color: rgba(128, 128, 128, 1)">@return</span>
 <span style="color: rgba(0, 128, 0, 1)">*/</span><span style="color: rgba(0, 0, 0, 1)">
@Around(</span>"preventDuplication()"<span style="color: rgba(0, 0, 0, 1)">)
</span><span style="color: rgba(0, 0, 255, 1)">public</span> Object before(ProceedingJoinPoint joinPoint) <span style="color: rgba(0, 0, 255, 1)">throws</span><span style="color: rgba(0, 0, 0, 1)"> Exception {
    ServletRequestAttributes attributes </span>=<span style="color: rgba(0, 0, 0, 1)"> (ServletRequestAttributes) RequestContextHolder
            .getRequestAttributes();
    HttpServletRequest request </span>=<span style="color: rgba(0, 0, 0, 1)"> attributes.getRequest();
    Assert.notNull(request, </span>"request cannot be null."<span style="color: rgba(0, 0, 0, 1)">);

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">获取执行方法</span>
    Method method =<span style="color: rgba(0, 0, 0, 1)"> ((MethodSignature) joinPoint.getSignature()).getMethod();
    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">获取防重复提交注解</span>
    PreventDuplication annotation = method.getAnnotation(PreventDuplication.<span style="color: rgba(0, 0, 255, 1)">class</span><span style="color: rgba(0, 0, 0, 1)">);

    </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)"> 获取token以及方法标记,生成redisKey和redisValue</span>
    String token =<span style="color: rgba(0, 0, 0, 1)"> request.getHeader(IdempotentConstant.TOKEN);
    String redisKey </span>=<span style="color: rgba(0, 0, 0, 1)"> IdempotentConstant.PREVENT_DUPLICATION_PREFIX
            .concat(token)
            .concat(getMethodSign(method, joinPoint.getArgs()));
    String redisValue </span>= redisKey.concat(annotation.value()).concat("submit duplication"<span style="color: rgba(0, 0, 0, 1)">);

    </span><span style="color: rgba(0, 0, 255, 1)">if</span> (!<span style="color: rgba(0, 0, 0, 1)">redisTemplate.hasKey(redisKey)) {
        </span><span style="color: rgba(0, 128, 0, 1)">//</span><span style="color: rgba(0, 128, 0, 1)">设置防重复操作限时标记(前置通知)</span>

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("请勿重复提交");
}
}

</span><span style="color: rgba(0, 128, 0, 1)">/**</span><span style="color: rgba(0, 128, 0, 1)">
 * 生成方法标记:采用数字签名算法SHA1对方法签名字符串加签
 *
 * </span><span style="color: rgba(128, 128, 128, 1)">@param</span><span style="color: rgba(0, 128, 0, 1)"> method
 * </span><span style="color: rgba(128, 128, 128, 1)">@param</span><span style="color: rgba(0, 128, 0, 1)"> args
 * </span><span style="color: rgba(128, 128, 128, 1)">@return</span>
 <span style="color: rgba(0, 128, 0, 1)">*/</span>
<span style="color: rgba(0, 0, 255, 1)">private</span><span style="color: rgba(0, 0, 0, 1)"> String getMethodSign(Method method, Object... args) {
    StringBuilder sb </span>= <span style="color: rgba(0, 0, 255, 1)">new</span><span style="color: rgba(0, 0, 0, 1)"> StringBuilder(method.toString());
    </span><span style="color: rgba(0, 0, 255, 1)">for</span><span style="color: rgba(0, 0, 0, 1)"> (Object arg : args) {
        sb.append(toString(arg));
    }
    </span><span style="color: rgba(0, 0, 255, 1)">return</span><span style="color: rgba(0, 0, 0, 1)"> DigestUtils.sha1DigestAsHex(sb.toString());
}

</span><span style="color: rgba(0, 0, 255, 1)">private</span><span style="color: rgba(0, 0, 0, 1)"> String toString(Object arg) {
    </span><span style="color: rgba(0, 0, 255, 1)">if</span><span style="color: rgba(0, 0, 0, 1)"> (Objects.isNull(arg)) {
        </span><span style="color: rgba(0, 0, 255, 1)">return</span> "null"<span style="color: rgba(0, 0, 0, 1)">;
    }
    </span><span style="color: rgba(0, 0, 255, 1)">if</span> (arg <span style="color: rgba(0, 0, 255, 1)">instanceof</span><span style="color: rgba(0, 0, 0, 1)"> Number) {
        </span><span style="color: rgba(0, 0, 255, 1)">return</span><span style="color: rgba(0, 0, 0, 1)"> arg.toString();
    }
    </span><span style="color: rgba(0, 0, 255, 1)">return</span><span style="color: rgba(0, 0, 0, 1)"> JSONObject.toJSONString(arg);
}

}

public interface IdempotentConstant {
String TOKEN </span>= "token"<span style="color: rgba(0, 0, 0, 1)">;

String PREVENT_DUPLICATION_PREFIX </span>= "PREVENT_DUPLICATION_PREFIX:"<span style="color: rgba(0, 0, 0, 1)">;

}

 

  • controller 实现(使用注解)

@Slf4j
@RestController
@RequestMapping("/web")
public class IdempotentController {
@PostMapping(</span>"/sayNoDuplication"<span style="color: rgba(0, 0, 0, 1)">)
@PreventDuplication(expireSeconds </span>= 8<span style="color: rgba(0, 0, 0, 1)">)
</span><span style="color: rgba(0, 0, 255, 1)">public</span> String sayNoDuplication(@RequestParam("requestNum"<span style="color: rgba(0, 0, 0, 1)">) String requestNum) {
    log.info(</span>"sayNoDuplicatin requestNum:{}"<span style="color: rgba(0, 0, 0, 1)">, requestNum);
    </span><span style="color: rgba(0, 0, 255, 1)">return</span> "sayNoDuplicatin"<span style="color: rgba(0, 0, 0, 1)">.concat(requestNum);
}

}

 

4、测试

  • 正常请求(首次)

     首次请求,接口正常返回处理结果。

 

  •  限定时间内重复请求(上文设置 8s)

   在限定时间内重复请求,AOP 切面拦截处理抛出异常,终止接口处理逻辑,异常返回。

 控制台报错:

 

 

5、源代码

  本文代码已经上传托管至 GitHub 以及 Gitee,有需要的读者请自行下载。

  • GitHub:https://github.com/gavincoder/idempotent.git
  • Gitee:https://gitee.com/gavincoderspace/idempotent.git

 

写在后面

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