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切面实现进行了优化改造,以便应用于高并发场景。


评论关闭
IT干货网

微信公众号号:IT虾米 (左侧二维码扫一扫)欢迎添加!

如何画出优秀的软件架构图