Commit 98a67cce5ff2e24474fb4c518fa929f1784439b4

Authored by shaofan
1 parent bf898edf

添加README.md文件

新增了一个详细的README.md文件,介绍了idempotent-spring-boot-starter项目的背景、基本思路、配置方式和使用示例。文件还包含了如何使用分布式锁来防止重复提交请求的相关内容,文档化了注解用法以及统一异常处理配置。
Showing 1 changed file with 300 additions and 0 deletions
README.md 0 → 100644
  1 +# idempotent-spring-boot-starter
  2 +
  3 +## 问题
  4 +为了防止重复提交,通常做法是:后端生成唯一的提交令牌(uuid),存储在服务端,页面在发起请求时,携带次令牌,后端验证请求后删除令牌,保证请求的唯一性。但是,上诉的做法是需要前后端都需要进行配合,而且不能防止当前请求还没有执行完成,继续点击的场景。
  5 +
  6 +
  7 +## 思路
  8 +### 基本思路
  9 +使用spring HandlerInterceptor 拦截+redission 提供的分布式锁来进行控制。
  10 +
  11 +
  12 +获取当前用户的标识+当前请求地址,作为一个唯一的key,去获取redis分布式锁。
  13 +如何获取前用户的标识:sessionId,token,ip 等等多种策略的获取唯一的key,可以采用不同的策略。
  14 +
  15 +
  16 +看了很多的博客,都是采用AOP去实现,为了防止重复提交一般都是针对web请求,采用拦截器处理足够用了(一般防止重复提交针对url+用户标识,不是特别需要针对body参数进行处理),如果误用到其他的非web 线程的调用,会造成获取 httprequest 异常,而且感觉是有AOP 这种时候不太合适。
  17 +
  18 +
  19 +
  20 +#### 分布式锁问题
  21 +分布式锁直接使用 redisson 即可。
  22 +
  23 +
  24 +- 基本配置
  25 +```java
  26 +spring.redis.host=127.0.0.1
  27 +spring.redis.port=6379
  28 +```
  29 +
  30 +- maven 依赖
  31 +
  32 +根据当前spring 的版本进行选择合适的依赖 redisson-spring-data 可以具体看官方文档。
  33 +redisson-spring-data module if necessary to support required Spring Boot version:
  34 +```xml
  35 + <dependency>
  36 + <groupId>org.redisson</groupId>
  37 + <artifactId>redisson-spring-boot-starter</artifactId>
  38 + <version>3.15.3</version>
  39 +</dependency>
  40 +```
  41 +
  42 +- 更多配置可以参考链接
  43 +
  44 +[https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter](https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter)
  45 +#### 分布式锁的key
  46 +获取前用户的标识,可以通过 sessionId,token,ip 等等多种策略的获取唯一的key,可以采用不同的策略,作为一个工具类可以提供不同的策略,或者自己定制一个分布式key的生成接口,注册到spring bean 即可。
  47 +
  48 +
  49 +如下所示 根据方法的名称作为一个key
  50 +```java
  51 +@Component
  52 +public class IdempotentCustomKeyGenerator implements LockKeyGenerator {
  53 + @Override
  54 + public String resolverLockKey(Idempotent idempotent, HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) {
  55 +
  56 + return handlerMethod.getMethod().getName();
  57 + }
  58 +}
  59 +
  60 +```
  61 +#### 异常返回如何处理?
  62 +一般情况下,每个工程项目中都会有定制化 统一的返回信息,默认情况下提供了一个全局的异常处理器,order 比较低,优先级比较低,可以自己定义一个order 等级比较高的spring boot 全局异常处理器,统一去处理。
  63 +```java
  64 +@ControllerAdvice
  65 +@Order(value = Ordered.LOWEST_PRECEDENCE - 100)
  66 +@Controller
  67 +public static class IdempotentExceptionConfiguration {
  68 +
  69 + private static final Logger logger = LoggerFactory.getLogger(IdempotentExceptionConfiguration.class);
  70 +
  71 + @Autowired
  72 + private HttpServletRequest httpServletRequest;
  73 +
  74 +
  75 + @ExceptionHandler(value = {IdempotentException.class})
  76 + @ResponseBody
  77 + public ResponseEntity<String> idempotentExceptionHandler(IdempotentException idempotentException) {
  78 + logger.info("idempotent requestUrl={} sessionId={}", httpServletRequest.getRequestURI(), httpServletRequest.getSession().getId());
  79 + String message = idempotentException.getMessage();
  80 + return ResponseEntity.ok(message);
  81 + }
  82 +}
  83 +```
  84 +#### 其他的细节
  85 +
  86 +- 尝试获取锁的等待时间、锁的过期时间。
  87 +- 异常错误的提示信息。
  88 +- 业务执行完成后 是否解锁。
  89 +
  90 +```java
  91 +@Inherited
  92 +@Target(ElementType.METHOD)
  93 +@Retention(value = RetentionPolicy.RUNTIME)
  94 +public @interface Idempotent {
  95 +
  96 + /**
  97 + * 有效期 默认:2
  98 + *
  99 + * @return expireTime
  100 + */
  101 + long expireTime() default 2L;
  102 +
  103 + /**
  104 + * 获取锁等待的时间
  105 + *
  106 + * @return
  107 + */
  108 + long waitTime() default 0L;
  109 +
  110 + /**
  111 + * 时间单位 默认:s
  112 + *
  113 + * @return TimeUnit
  114 + */
  115 + TimeUnit timeUnit() default TimeUnit.SECONDS;
  116 +
  117 + /**
  118 + * 提示信息,可自定义
  119 + *
  120 + * @return String
  121 + */
  122 + String info() default "重复请求,请稍后重试";
  123 +
  124 + /**
  125 + * 缓存key 前缀
  126 + *
  127 + * @return
  128 + */
  129 + String lockKeyPrefix() default "idempotent";
  130 +
  131 + /**
  132 + * 是否解除当前key的锁定,否则过期后才能继续点击
  133 + *
  134 + * @return
  135 + */
  136 + boolean unlockKey() default true;
  137 +
  138 + /**
  139 + * 生成锁 key 方式 默认为 sessionId +url
  140 + *
  141 + * @return
  142 + */
  143 + Class<? extends LockKeyGenerator> keyGenerator() default DefaultLockKeyResolver.class;
  144 +}
  145 +```
  146 +
  147 +
  148 +## 使用
  149 +
  150 +### maven 依赖
  151 +
  152 +| 版本 | 支持 |
  153 +|----------------|--|
  154 +| 1.0.0-SNAPSHOT | 适配 SpringBoot3.x |
  155 +| 2.0.0-SNAPSHOT | 适配 SpringBoot2.x |
  156 +
  157 +```xml
  158 +<dependency>
  159 + <groupId>org.springframework.boot</groupId>
  160 + <artifactId>spring-boot-starter-web</artifactId>
  161 +</dependency>
  162 +
  163 +<!-- redisson-spring-boot-starter 非必须依赖 注意一下boot的版本-->
  164 +<!-- 存在 org.redisson.api.RedissonClient bean 即可-->
  165 +<dependency>
  166 + <groupId>org.redisson</groupId>
  167 + <artifactId>redisson-spring-boot-starter</artifactId>
  168 + <version>3.15.3</version>
  169 +</dependency>
  170 +<dependency>
  171 + <groupId>com.diligrp</groupId>
  172 + <artifactId>idempotent-spring-boot-starter</artifactId>
  173 + <version>2.0.0-SNAPSHOT</version>
  174 +</dependency>
  175 +```
  176 +### redission 配置
  177 +[https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter](https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter)
  178 +
  179 +简单使用
  180 +```xml
  181 +spring.redis.host=127.0.0.1
  182 +spring.redis.port=6379
  183 +```
  184 +### 统一异常配置
  185 +* 配置统一异常处理器 针对性展示自己想要展示的异常信息格式
  186 +```java
  187 +@ControllerAdvice
  188 +@Order(value = Ordered.HIGHEST_PRECEDENCE + 1)
  189 +@Slf4j
  190 +public class GlobalExceptionHandler {
  191 +
  192 + @Autowired
  193 + private HttpServletRequest httpServletRequest;
  194 +
  195 + /**
  196 + * 覆盖里面定义的错误异常 {@link com.diligrp.idempotent.spring.boot.IdempotentAutoConfiguration.IdempotentExceptionConfiguration#idempotentExceptionHandler(IdempotentException)}
  197 + *
  198 + * @param idempotentException
  199 + * @return
  200 + */
  201 + @ExceptionHandler(value = {IdempotentException.class})
  202 + @ResponseBody
  203 + public ResponseEntity<String> idempotentExceptionHandler(IdempotentException idempotentException) {
  204 + log.error("idempotent requestUrl={} sessionId={}", httpServletRequest.getRequestURI(), httpServletRequest.getSession().getId());
  205 + String message = idempotentException.getMessage();
  206 + message = "覆盖自定义全局异常" + message;
  207 + return ResponseEntity.ok(message);
  208 + }
  209 +}
  210 +```
  211 +### 其他的配置
  212 +* 需要设置一下拦截器的顺序,比如需要在登录校验拦截器之后
  213 +* 设置拦截的路径
  214 +* 设置 cookie or header 获取登录用户的key值
  215 +* 设置是否需要自己手动注册 com.diligrp.idempotent.spring.boot.interceptor.IdempotentInterceptor
  216 +```xml
  217 +# 自动配置(自动将拦截器注册到webconfig) 非手动配置 拦截器
  218 +spring.idempotent.manual-setting-idempotent-interceptor=false
  219 +
  220 +# 拦截器的order 位置(比如先要校验登录权限 这个order 设置靠后一点)
  221 +spring.idempotent.idempotent-interceptor-order-value=500
  222 +
  223 +# 拦截的url
  224 +spring.idempotent.include-urls=/**
  225 +# 不进行拦截的url
  226 +spring.idempotent.exclude-urls=/login,/logout
  227 +
  228 +
  229 +# com.diligrp.idempotent.spring.boot.keygen.iml.DefaultLockKeyResolver 默认先找header 然后找 cookie 最后sessionId
  230 +# 根据配置的key 去查找
  231 +# 随便写一个 cookie
  232 +spring.idempotent.default-lock-key-cookie-name=SESSION_ID
  233 +# 随便找一个 user-agent
  234 +spring.idempotent.default-lock-key-http-header-name=user-agent
  235 +```
  236 +### 注解使用
  237 +#### sessionId+uri
  238 +```java
  239 +@GetMapping("/testDefault")
  240 +@Idempotent(expireTime = 20L, waitTime = 0L, info = "错误错误", keyGenerator = DefaultLockKeyResolver.class, timeUnit = TimeUnit.SECONDS)
  241 +public ResponseEntity<String> testDefault() throws InterruptedException {
  242 + logger.info("ok testDefault session={} ip={}", request.getSession().getId(), IpUtils.getIpAddress(request));
  243 + Thread.sleep(2000L);
  244 + return ResponseEntity.ok("ok");
  245 + }
  246 +```
  247 +
  248 +
  249 +```bash
  250 +# 先访问一下 获取到sessionId 看日志
  251 +curl http://127.0.0.1:8080/testDefault
  252 +
  253 +## Apache Brench 测试
  254 +# 把sessionId 替换一下 JSESSIONID=049F083CC7DCCACBA375A416C0A1FE2D
  255 +# 查看日志
  256 +ab -n 500 -c 50 -C JSESSIONID=049F083CC7DCCACBA375A416C0A1FE2D http://127.0.0.1:8080/testDefault
  257 +```
  258 +#### ip+url
  259 +```java
  260 +@GetMapping("/testIp")
  261 +@Idempotent(expireTime = 20L, waitTime = 0L, info = "错误错误", keyGenerator = IpLockKeyResolver.class)
  262 +public ResponseEntity<String> testIp() throws InterruptedException {
  263 + logger.info("ok testIp session={} ip={}", request.getSession().getId(), IpUtils.getIpAddress(request));
  264 + Thread.sleep(2000L);
  265 + return ResponseEntity.ok("ok");
  266 +}
  267 +```
  268 +```bash
  269 +## Apache Brench 测试
  270 +ab -n 500 -c 50 http://127.0.0.1:8080/testIp
  271 +```
  272 +#### 自定义key
  273 +```java
  274 +@GetMapping("/testCustom")
  275 +@Idempotent(expireTime = 20L, waitTime = 0L, info = "错误错误", keyGenerator = IdempotentCustomKeyGenerator.class)
  276 +public ResponseEntity<String> testCustom() throws InterruptedException {
  277 + Thread.sleep(2000L);
  278 + logger.info("ok testCustom session={} ip={}", request.getSession().getId(), IpUtils.getIpAddress(request));
  279 + return ResponseEntity.ok("ok");
  280 +}
  281 +```
  282 +```bash
  283 +## Apache Brench 测试
  284 +ab -n 500 -c 50 http://127.0.0.1:8080/testCustom
  285 +```
  286 +#### 自定义可以+执行完成不释放锁
  287 +业务执行完成后不释放锁 unlockKey = false
  288 +```java
  289 +@GetMapping("/testCustomAndNotUnlockKey")
  290 +@Idempotent(expireTime = 20L, waitTime = 0L, info = "错误错误", keyGenerator = IdempotentCustomKeyGenerator.class, unlockKey = false)
  291 +public ResponseEntity<String> testCustomAndNotUnlockKey() throws InterruptedException {
  292 + Thread.sleep(2000L);
  293 + logger.info("ok testCustomAndNotUnlockKey session={} ip={}", request.getSession().getId(), IpUtils.getIpAddress(request));
  294 + return ResponseEntity.ok("ok");
  295 +}
  296 +```
  297 +```bash
  298 +## Apache Brench 测试
  299 +ab -n 500 -c 50 http://127.0.0.1:8080/testCustomAndNotUnlockKey
  300 +```
0 301 \ No newline at end of file
... ...