{"id":16178826,"url":"https://github.com/wangji92/idempotent-spring-boot-starter","last_synced_at":"2025-03-19T01:30:51.952Z","repository":{"id":107874748,"uuid":"356773520","full_name":"WangJi92/idempotent-spring-boot-starter","owner":"WangJi92","description":"幂等的，防止重复的提交，使用spring HandlerInterceptor 拦截+redission 提供的分布式锁来进行控制","archived":false,"fork":false,"pushed_at":"2021-04-14T15:51:52.000Z","size":83,"stargazers_count":26,"open_issues_count":0,"forks_count":5,"subscribers_count":1,"default_branch":"master","last_synced_at":"2025-03-17T01:35:11.378Z","etag":null,"topics":["idempotent","spring-boot-starter"],"latest_commit_sha":null,"homepage":"","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/WangJi92.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null}},"created_at":"2021-04-11T05:17:35.000Z","updated_at":"2025-02-28T15:51:12.000Z","dependencies_parsed_at":null,"dependency_job_id":"6197255a-7e27-4ca4-b2e9-35469bcfceff","html_url":"https://github.com/WangJi92/idempotent-spring-boot-starter","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/WangJi92%2Fidempotent-spring-boot-starter","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/WangJi92%2Fidempotent-spring-boot-starter/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/WangJi92%2Fidempotent-spring-boot-starter/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/WangJi92%2Fidempotent-spring-boot-starter/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/WangJi92","download_url":"https://codeload.github.com/WangJi92/idempotent-spring-boot-starter/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":244336123,"owners_count":20436772,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["idempotent","spring-boot-starter"],"created_at":"2024-10-10T05:24:38.581Z","updated_at":"2025-03-19T01:30:51.653Z","avatar_url":"https://github.com/WangJi92.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"# idempotent-spring-boot-starter\n\n## 问题\n为了防止重复提交，通常做法是：后端生成唯一的提交令牌（uuid），存储在服务端，页面在发起请求时，携带次令牌，后端验证请求后删除令牌，保证请求的唯一性。但是，上诉的做法是需要前后端都需要进行配合,而且不能防止当前请求还没有执行完成，继续点击的场景。\n\n\n## 思路\n### 基本思路\n使用spring HandlerInterceptor 拦截+redission 提供的分布式锁来进行控制。\n\n\n获取当前用户的标识+当前请求地址，作为一个唯一的key，去获取redis分布式锁。\n如何获取前用户的标识：sessionId,token,ip 等等多种策略的获取唯一的key，可以采用不同的策略。\n\n\n看了很多的博客，都是采用AOP去实现,为了防止重复提交一般都是针对web请求，采用拦截器处理足够用了(一般防止重复提交针对url+用户标识,不是特别需要针对body参数进行处理)，如果误用到其他的非web 线程的调用，会造成获取 httprequest 异常,而且感觉是有AOP 这种时候不太合适。\n\n\n- [SpringBoot利用AOP防止请求重复提交](https://blog.csdn.net/a992795427/article/details/92834286)\n- [spring boot 防止重复提交](https://blog.csdn.net/xiaoqiangyonghu/article/details/108661670)\n- [Spring Boot 如何防止重复提交？](https://www.cnblogs.com/java-stack/p/11952190.html)\n\n\n\n### 动手实践\n如果实践一个  idempotent-spring-boot-starter\n#### 分布式锁问题\n分布式锁直接使用 redisson 即可。\n\n\n- 基本配置\n```java\nspring.redis.host=127.0.0.1\nspring.redis.port=6379\n```\n\n- maven 依赖\n\n根据当前spring 的版本进行选择合适的依赖 redisson-spring-data 可以具体看官方文档。\nredisson-spring-data module if necessary to support required Spring Boot version:\n```xml\n \u003cdependency\u003e\n   \u003cgroupId\u003eorg.redisson\u003c/groupId\u003e\n   \u003cartifactId\u003eredisson-spring-boot-starter\u003c/artifactId\u003e\n   \u003cversion\u003e3.15.3\u003c/version\u003e\n\u003c/dependency\u003e\n```\n\n- 更多配置可以参考链接\n\n[https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter](https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter)\n#### 分布式锁的key\n获取前用户的标识,可以通过 sessionId,token,ip 等等多种策略的获取唯一的key，可以采用不同的策略，作为一个工具类可以提供不同的策略，或者自己定制一个分布式key的生成接口，注册到spring bean 即可。\n\n\n[code link](https://github.com/WangJi92/idempotent-spring-boot-starter/blob/master/idempotent-demo/src/main/java/com/wangji92/idempotent/demo/IdempotentCustomKeyGenerator.java) 如下所示 根据方法的名称作为一个key \n```java\n@Component\npublic class IdempotentCustomKeyGenerator implements LockKeyGenerator {\n    @Override\n    public String resolverLockKey(Idempotent idempotent, HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) {\n\n        return handlerMethod.getMethod().getName();\n    }\n}\n\n```\n#### 异常返回如何处理？\n一般情况下，每个工程项目中都会有定制化 统一的返回信息,默认情况下提供了一个全局的异常处理器，order 比较低,优先级比较低，可以自己定义一个order 等级比较高的spring boot 全局异常处理器，统一去处理。\n[code link ](https://github.com/WangJi92/idempotent-spring-boot-starter/blob/master/src/main/java/com/wangji92/springboot/idempotent/IdempotentAutoConfiguration.java)\n```java\n@ControllerAdvice\n@Order(value = Ordered.LOWEST_PRECEDENCE - 100)\n@Controller\npublic static class IdempotentExceptionConfiguration {\n\n    private static final Logger logger = LoggerFactory.getLogger(IdempotentExceptionConfiguration.class);\n\n    @Autowired\n    private HttpServletRequest httpServletRequest;\n\n\n    @ExceptionHandler(value = {IdempotentException.class})\n    @ResponseBody\n    public ResponseEntity\u003cString\u003e idempotentExceptionHandler(IdempotentException idempotentException) {\n        logger.info(\"idempotent requestUrl={} sessionId={}\", httpServletRequest.getRequestURI(), httpServletRequest.getSession().getId());\n        String message = idempotentException.getMessage();\n        return ResponseEntity.ok(message);\n    }\n}\n```\n#### 其他的细节\n\n- 尝试获取锁的等待时间、锁的过期时间。\n- 异常错误的提示信息。\n- 业务执行完成后 是否解锁。\n\n[code link](https://github.com/WangJi92/idempotent-spring-boot-starter/blob/master/src/main/java/com/wangji92/springboot/idempotent/annotation/Idempotent.java)\n```java\n@Inherited\n@Target(ElementType.METHOD)\n@Retention(value = RetentionPolicy.RUNTIME)\npublic @interface Idempotent {\n\n    /**\n     * 有效期 默认：2\n     *\n     * @return expireTime\n     */\n    long expireTime() default 2L;\n\n    /**\n     * 获取锁等待的时间\n     *\n     * @return\n     */\n    long waitTime() default 0L;\n\n    /**\n     * 时间单位 默认：s\n     *\n     * @return TimeUnit\n     */\n    TimeUnit timeUnit() default TimeUnit.SECONDS;\n\n    /**\n     * 提示信息，可自定义\n     *\n     * @return String\n     */\n    String info() default \"重复请求，请稍后重试\";\n\n    /**\n     * 缓存key 前缀\n     *\n     * @return\n     */\n    String lockKeyPrefix() default \"idempotent\";\n\n    /**\n     * 是否解除当前key的锁定，否则过期后才能继续点击\n     *\n     * @return\n     */\n    boolean unlockKey() default true;\n\n    /**\n     * 生成锁 key 方式 默认为 sessionId +url\n     *\n     * @return\n     */\n    Class\u003c? extends LockKeyGenerator\u003e keyGenerator() default DefaultLockKeyResolver.class;\n}\n```\n\n\n## 使用\n[demo link](https://github.com/WangJi92/idempotent-spring-boot-starter/tree/master/idempotent-demo)\n### maven 依赖\n```xml\n\u003cdependency\u003e\n  \u003cgroupId\u003eorg.springframework.boot\u003c/groupId\u003e\n  \u003cartifactId\u003espring-boot-starter-web\u003c/artifactId\u003e\n\u003c/dependency\u003e\n\n\u003c!-- redisson-spring-boot-starter 非必须依赖 注意一下boot的版本--\u003e\n\u003c!-- 存在 org.redisson.api.RedissonClient bean 即可--\u003e\n\u003cdependency\u003e\n  \u003cgroupId\u003eorg.redisson\u003c/groupId\u003e\n  \u003cartifactId\u003eredisson-spring-boot-starter\u003c/artifactId\u003e\n  \u003cversion\u003e3.15.3\u003c/version\u003e\n\u003c/dependency\u003e\n\u003cdependency\u003e\n  \u003cgroupId\u003ecom.github.WangJi92\u003c/groupId\u003e\n  \u003cartifactId\u003eidempotent-spring-boot-starter\u003c/artifactId\u003e\n  \u003cversion\u003e0.0.3\u003c/version\u003e\n\u003c/dependency\u003e\n```\n### redission 配置\n[https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter](https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter)\n\n简单使用\n```xml\nspring.redis.host=127.0.0.1\nspring.redis.port=6379\n```\n### 统一异常配置\n* 配置统一异常处理器 针对性展示自己想要展示的异常信息格式\n```java\n@ControllerAdvice\n@Order(value = Ordered.HIGHEST_PRECEDENCE + 1)\n@Slf4j\npublic class GlobalExceptionHandler {\n\n    @Autowired\n    private HttpServletRequest httpServletRequest;\n\n    /**\n     * 覆盖里面定义的错误异常 {@link com.wangji92.springboot.idempotent.IdempotentAutoConfiguration.IdempotentExceptionConfiguration#idempotentExceptionHandler(IdempotentException)}\n     *\n     * @param idempotentException\n     * @return\n     */\n    @ExceptionHandler(value = {IdempotentException.class})\n    @ResponseBody\n    public ResponseEntity\u003cString\u003e idempotentExceptionHandler(IdempotentException idempotentException) {\n        log.error(\"idempotent requestUrl={} sessionId={}\", httpServletRequest.getRequestURI(), httpServletRequest.getSession().getId());\n        String message = idempotentException.getMessage();\n        message = \"覆盖自定义全局异常\" + message;\n        return ResponseEntity.ok(message);\n    }\n}\n```\n### 其他的配置\n* 需要设置一下拦截器的顺序，比如需要在登录校验拦截器之后\n* 设置拦截的路径\n* 设置 cookie or header 获取登录用户的key值\n* 设置是否需要自己手动注册 com.wangji92.springboot.idempotent.interceptor.IdempotentInterceptor\n```xml\n# 自动配置(自动将拦截器注册到webconfig) 非手动配置 拦截器\nspring.idempotent.manual-setting-idempotent-interceptor=false\n\n# 拦截器的order 位置(比如先要校验登录权限 这个order 设置靠后一点)\nspring.idempotent.idempotent-interceptor-order-value=500\n\n# 拦截的url\nspring.idempotent.include-urls=/**\n# 不进行拦截的url\nspring.idempotent.exclude-urls=/wangji,/wangji2\n\n\n# com.wangji92.springboot.idempotent.keygen.iml.DefaultLockKeyResolver 默认先找header 然后找 cookie 最后sessionId\n# 根据配置的key 去查找\n# 随便写一个 cookie\nspring.idempotent.default-lock-key-cookie-name=SESSION_ID\n# 随便找一个 user-agent\nspring.idempotent.default-lock-key-http-header-name=user-agent\n```\n### 注解使用\n#### sessionId+uri\n```java\n@GetMapping(\"/testDefault\")\n@Idempotent(expireTime = 20L, waitTime = 0L, info = \"错误错误\", keyGenerator = DefaultLockKeyResolver.class, timeUnit = TimeUnit.SECONDS)\npublic ResponseEntity\u003cString\u003e testDefault() throws InterruptedException {\n    logger.info(\"ok  testDefault session={} ip={}\", request.getSession().getId(), IpUtils.getIpAddress(request));\n    Thread.sleep(2000L);\n    return ResponseEntity.ok(\"ok\");\n }\n```\n\n\n```bash\n# 先访问一下 获取到sessionId 看日志\ncurl  http://127.0.0.1:8080/testDefault\n\n## Apache Brench 测试\n# 把sessionId 替换一下 JSESSIONID=049F083CC7DCCACBA375A416C0A1FE2D\n# 查看日志\nab -n 500 -c 50 -C JSESSIONID=049F083CC7DCCACBA375A416C0A1FE2D  http://127.0.0.1:8080/testDefault\n```\n#### ip+url\n```java\n@GetMapping(\"/testIp\")\n@Idempotent(expireTime = 20L, waitTime = 0L, info = \"错误错误\", keyGenerator = IpLockKeyResolver.class)\npublic ResponseEntity\u003cString\u003e testIp() throws InterruptedException {\n    logger.info(\"ok testIp session={} ip={}\", request.getSession().getId(), IpUtils.getIpAddress(request));\n    Thread.sleep(2000L);\n    return ResponseEntity.ok(\"ok\");\n}\n```\n```bash\n## Apache Brench 测试\nab -n 500 -c 50    http://127.0.0.1:8080/testIp\n```\n#### 自定义key\n```java\n@GetMapping(\"/testCustom\")\n@Idempotent(expireTime = 20L, waitTime = 0L, info = \"错误错误\", keyGenerator = IdempotentCustomKeyGenerator.class)\npublic ResponseEntity\u003cString\u003e testCustom() throws InterruptedException {\n    Thread.sleep(2000L);\n    logger.info(\"ok testCustom session={} ip={}\", request.getSession().getId(), IpUtils.getIpAddress(request));\n    return ResponseEntity.ok(\"ok\");\n}\n```\n```bash\n## Apache Brench 测试\nab -n 500 -c 50    http://127.0.0.1:8080/testCustom\n```\n#### 自定义可以+执行完成不释放锁\n业务执行完成后不释放锁 unlockKey = false\n```java\n@GetMapping(\"/testCustomAndNotUnlockKey\")\n@Idempotent(expireTime = 20L, waitTime = 0L, info = \"错误错误\", keyGenerator = IdempotentCustomKeyGenerator.class, unlockKey = false)\npublic ResponseEntity\u003cString\u003e testCustomAndNotUnlockKey() throws InterruptedException {\n    Thread.sleep(2000L);\n    logger.info(\"ok testCustomAndNotUnlockKey session={} ip={}\", request.getSession().getId(), IpUtils.getIpAddress(request));\n    return ResponseEntity.ok(\"ok\");\n}\n```\n```bash\n## Apache Brench 测试\nab -n 500 -c 50    http://127.0.0.1:8080/testCustomAndNotUnlockKey\n```\n\n\n## 参考文档\n\n- [https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter](https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter)\n- [https://blog.csdn.net/xiaoqiangyonghu/article/details/108661670](https://blog.csdn.net/xiaoqiangyonghu/article/details/108661670)\n- [https://blog.csdn.net/a992795427/article/details/92834286](https://blog.csdn.net/a992795427/article/details/92834286)\n- 并发模拟的三个工具:  [https://www.cnblogs.com/xusp/p/11845750.html](https://www.cnblogs.com/xusp/p/11845750.html)\n\n\n\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwangji92%2Fidempotent-spring-boot-starter","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fwangji92%2Fidempotent-spring-boot-starter","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fwangji92%2Fidempotent-spring-boot-starter/lists"}