为了防止重复提交,通常做法是:后端生成唯一的提交令牌(uuid),存储在服务端,页面在发起请求时,携带次令牌,后端验证请求后删除令牌,保证请求的唯一性。但是,上诉的做法是需要前后端都需要进行配合,而且不能防止当前请求还没有执行完成,继续点击的场景。
使用spring HandlerInterceptor 拦截+redission 提供的分布式锁来进行控制。
获取当前用户的标识+当前请求地址,作为一个唯一的key,去获取redis分布式锁。 如何获取前用户的标识:sessionId,token,ip 等等多种策略的获取唯一的key,可以采用不同的策略。
看了很多的博客,都是采用AOP去实现,为了防止重复提交一般都是针对web请求,采用拦截器处理足够用了(一般防止重复提交针对url+用户标识,不是特别需要针对body参数进行处理),如果误用到其他的非web 线程的调用,会造成获取 httprequest 异常,而且感觉是有AOP 这种时候不太合适。
如果实践一个 idempotent-spring-boot-starter
分布式锁直接使用 redisson 即可。
- 基本配置
spring.redis.host=127.0.0.1
spring.redis.port=6379
- maven 依赖
根据当前spring 的版本进行选择合适的依赖 redisson-spring-data 可以具体看官方文档。 redisson-spring-data module if necessary to support required Spring Boot version:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.15.3</version>
</dependency>
- 更多配置可以参考链接
https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter
获取前用户的标识,可以通过 sessionId,token,ip 等等多种策略的获取唯一的key,可以采用不同的策略,作为一个工具类可以提供不同的策略,或者自己定制一个分布式key的生成接口,注册到spring bean 即可。
code link 如下所示 根据方法的名称作为一个key
@Component
public class IdempotentCustomKeyGenerator implements LockKeyGenerator {
@Override
public String resolverLockKey(Idempotent idempotent, HttpServletRequest request, HttpServletResponse response, HandlerMethod handlerMethod) {
return handlerMethod.getMethod().getName();
}
}
一般情况下,每个工程项目中都会有定制化 统一的返回信息,默认情况下提供了一个全局的异常处理器,order 比较低,优先级比较低,可以自己定义一个order 等级比较高的spring boot 全局异常处理器,统一去处理。 code link
@ControllerAdvice
@Order(value = Ordered.LOWEST_PRECEDENCE - 100)
@Controller
public static class IdempotentExceptionConfiguration {
private static final Logger logger = LoggerFactory.getLogger(IdempotentExceptionConfiguration.class);
@Autowired
private HttpServletRequest httpServletRequest;
@ExceptionHandler(value = {IdempotentException.class})
@ResponseBody
public ResponseEntity<String> idempotentExceptionHandler(IdempotentException idempotentException) {
logger.info("idempotent requestUrl={} sessionId={}", httpServletRequest.getRequestURI(), httpServletRequest.getSession().getId());
String message = idempotentException.getMessage();
return ResponseEntity.ok(message);
}
}
- 尝试获取锁的等待时间、锁的过期时间。
- 异常错误的提示信息。
- 业务执行完成后 是否解锁。
@Inherited
@Target(ElementType.METHOD)
@Retention(value = RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 有效期 默认:2
*
* @return expireTime
*/
long expireTime() default 2L;
/**
* 获取锁等待的时间
*
* @return
*/
long waitTime() default 0L;
/**
* 时间单位 默认:s
*
* @return TimeUnit
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 提示信息,可自定义
*
* @return String
*/
String info() default "重复请求,请稍后重试";
/**
* 缓存key 前缀
*
* @return
*/
String lockKeyPrefix() default "idempotent";
/**
* 是否解除当前key的锁定,否则过期后才能继续点击
*
* @return
*/
boolean unlockKey() default true;
/**
* 生成锁 key 方式 默认为 sessionId +url
*
* @return
*/
Class<? extends LockKeyGenerator> keyGenerator() default DefaultLockKeyResolver.class;
}
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- redisson-spring-boot-starter 非必须依赖 注意一下boot的版本-->
<!-- 存在 org.redisson.api.RedissonClient bean 即可-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.15.3</version>
</dependency>
<dependency>
<groupId>com.github.WangJi92</groupId>
<artifactId>idempotent-spring-boot-starter</artifactId>
<version>0.0.3</version>
</dependency>
https://github.com/redisson/redisson/tree/master/redisson-spring-boot-starter
简单使用
spring.redis.host=127.0.0.1
spring.redis.port=6379
- 配置统一异常处理器 针对性展示自己想要展示的异常信息格式
@ControllerAdvice
@Order(value = Ordered.HIGHEST_PRECEDENCE + 1)
@Slf4j
public class GlobalExceptionHandler {
@Autowired
private HttpServletRequest httpServletRequest;
/**
* 覆盖里面定义的错误异常 {@link com.wangji92.springboot.idempotent.IdempotentAutoConfiguration.IdempotentExceptionConfiguration#idempotentExceptionHandler(IdempotentException)}
*
* @param idempotentException
* @return
*/
@ExceptionHandler(value = {IdempotentException.class})
@ResponseBody
public ResponseEntity<String> idempotentExceptionHandler(IdempotentException idempotentException) {
log.error("idempotent requestUrl={} sessionId={}", httpServletRequest.getRequestURI(), httpServletRequest.getSession().getId());
String message = idempotentException.getMessage();
message = "覆盖自定义全局异常" + message;
return ResponseEntity.ok(message);
}
}
- 需要设置一下拦截器的顺序,比如需要在登录校验拦截器之后
- 设置拦截的路径
- 设置 cookie or header 获取登录用户的key值
- 设置是否需要自己手动注册 com.wangji92.springboot.idempotent.interceptor.IdempotentInterceptor
# 自动配置(自动将拦截器注册到webconfig) 非手动配置 拦截器
spring.idempotent.manual-setting-idempotent-interceptor=false
# 拦截器的order 位置(比如先要校验登录权限 这个order 设置靠后一点)
spring.idempotent.idempotent-interceptor-order-value=500
# 拦截的url
spring.idempotent.include-urls=/**
# 不进行拦截的url
spring.idempotent.exclude-urls=/wangji,/wangji2
# com.wangji92.springboot.idempotent.keygen.iml.DefaultLockKeyResolver 默认先找header 然后找 cookie 最后sessionId
# 根据配置的key 去查找
# 随便写一个 cookie
spring.idempotent.default-lock-key-cookie-name=SESSION_ID
# 随便找一个 user-agent
spring.idempotent.default-lock-key-http-header-name=user-agent
@GetMapping("/testDefault")
@Idempotent(expireTime = 20L, waitTime = 0L, info = "错误错误", keyGenerator = DefaultLockKeyResolver.class, timeUnit = TimeUnit.SECONDS)
public ResponseEntity<String> testDefault() throws InterruptedException {
logger.info("ok testDefault session={} ip={}", request.getSession().getId(), IpUtils.getIpAddress(request));
Thread.sleep(2000L);
return ResponseEntity.ok("ok");
}
# 先访问一下 获取到sessionId 看日志
curl http://127.0.0.1:8080/testDefault
## Apache Brench 测试
# 把sessionId 替换一下 JSESSIONID=049F083CC7DCCACBA375A416C0A1FE2D
# 查看日志
ab -n 500 -c 50 -C JSESSIONID=049F083CC7DCCACBA375A416C0A1FE2D http://127.0.0.1:8080/testDefault
@GetMapping("/testIp")
@Idempotent(expireTime = 20L, waitTime = 0L, info = "错误错误", keyGenerator = IpLockKeyResolver.class)
public ResponseEntity<String> testIp() throws InterruptedException {
logger.info("ok testIp session={} ip={}", request.getSession().getId(), IpUtils.getIpAddress(request));
Thread.sleep(2000L);
return ResponseEntity.ok("ok");
}
## Apache Brench 测试
ab -n 500 -c 50 http://127.0.0.1:8080/testIp
@GetMapping("/testCustom")
@Idempotent(expireTime = 20L, waitTime = 0L, info = "错误错误", keyGenerator = IdempotentCustomKeyGenerator.class)
public ResponseEntity<String> testCustom() throws InterruptedException {
Thread.sleep(2000L);
logger.info("ok testCustom session={} ip={}", request.getSession().getId(), IpUtils.getIpAddress(request));
return ResponseEntity.ok("ok");
}
## Apache Brench 测试
ab -n 500 -c 50 http://127.0.0.1:8080/testCustom
业务执行完成后不释放锁 unlockKey = false
@GetMapping("/testCustomAndNotUnlockKey")
@Idempotent(expireTime = 20L, waitTime = 0L, info = "错误错误", keyGenerator = IdempotentCustomKeyGenerator.class, unlockKey = false)
public ResponseEntity<String> testCustomAndNotUnlockKey() throws InterruptedException {
Thread.sleep(2000L);
logger.info("ok testCustomAndNotUnlockKey session={} ip={}", request.getSession().getId(), IpUtils.getIpAddress(request));
return ResponseEntity.ok("ok");
}
## Apache Brench 测试
ab -n 500 -c 50 http://127.0.0.1:8080/testCustomAndNotUnlockKey