1、前言

  近期在构建项目脚手架时,关于接口幂等性问题,考虑做成独立模块工具放进脚手架中进行通用。
  如何保证接口幂等性,换句话说就是如何防止接口重复提交。通常,前后端都需要考虑如何实现相关控制。

  • 前端常用的解决方案是“表单提交完成,按钮置灰、按钮不可用或者关闭相关页面”。
  • 常见的后端解决方案有“基于JAVA注解+AOP切面实现防止重复提交“。

 

2、方案

  基于JAVA注解+AOP切面方式实现防止重复提交,一般需要自定义JAVA注解,采用AOP切面解析注解,实现接口首次请求提交时,将接口请求标记(由接口签名、请求token、请求客户端ip等组成)存储至redis,并设置超时时间T(T时间之后redis清除接口请求标记),接口每次请求都先检查redis中接口标记,若存在接口请求标记,则判定为接口重复提交,进行拦截返回处理。

 

3、实现

     本次采用的基础框架为SpringBoot,涉及的组件模块有AOP、WEB、Redis、Lombok、Fastjson。详细代码与配置如下文。

  • pom依赖

防止重复提交解决方案-(基于JAVA注解+AOP切面)_redis
<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>
防止重复提交解决方案-(基于JAVA注解+AOP切面)_redis

 

  • 配置文件

防止重复提交解决方案-(基于JAVA注解+AOP切面)_redis
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
防止重复提交解决方案-(基于JAVA注解+AOP切面)_redis

 

  • 自定义注解

防止重复提交解决方案-(基于JAVA注解+AOP切面)_redis
/**
 * @author :Gavin
 * @see :防止重复操作注解
 */

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface PreventDuplication {
    /**
     * 防重复操作限时标记数值(存储redis限时标记数值)
     */
    String value() default "value" ;

    /**
     * 防重复操作过期时间(借助redis实现限时控制)
     */
    long expireSeconds() default 10;
}
防止重复提交解决方案-(基于JAVA注解+AOP切面)_redis

 

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

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

防止重复提交解决方案-(基于JAVA注解+AOP切面)_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);
    }
}
防止重复提交解决方案-(基于JAVA注解+AOP切面)_redis
防止重复提交解决方案-(基于JAVA注解+AOP切面)_redis
public interface IdempotentConstant {

    String TOKEN = "token";

    String PREVENT_DUPLICATION_PREFIX = "PREVENT_DUPLICATION_PREFIX:";
}
防止重复提交解决方案-(基于JAVA注解+AOP切面)_redis

 

  • controller实现(使用注解)

防止重复提交解决方案-(基于JAVA注解+AOP切面)_redis
@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);
    }

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

 

4、测试

  • 正常请求(首次)

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

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

 

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

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

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

 控制台报错:

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

 

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/

自定义注解解决API接口幂等设计防止表单重复提交(生成token存放到redis中)

写在后面

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