Commit 98a67cce5ff2e24474fb4c518fa929f1784439b4
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 |
... | ... |