自定义注解防止接口重复提交
幂等性
什么是接口幂等性
幂等性是指用户对一个接口发起一次或者多次请求的结果是一致的,不会因为多次请求而对系统数据产生其他的副作用
请求方式对幂等性的影响
GET
一般只是做查询资源,多次查询的结果会一致,所以一般不会出现幂等性的问题- select * from where table
幂等
- select * from where table
PUT
一般用作修改资源,多次执行会出现幂等性的情况- update table set money=money+10 where id=1
不幂等
- update table set money=10 where id=1
幂等
- update table set money=money+10 where id=1
POST
一般做创建资源,多次执行会出现幂等性问题- insert into table values(1,'zhangsan',1000)
不幂等
- insert into table values(1,'zhangsan',1000)
DELETE
一般做删除资源操作,在不考虑返回结果的情况下,认为此操作也是幂等性的- delete from table where id=1
幂等
- delete from table where id=1
出现幂等性问题的几种情况
- 前端重复提交:
- 用户下单一次,因为其网络导致,导致没有反应,再次点击下单,导致生成了多个订单的情况。
- 用户提交表单,因为网络问题,页面没有及时响应,用户多次点击,导致重复提交。
实现幂等性的几种解决方案
Token机制
实现思路:
- 首先客户端再请求业务接口之前先去服务端获取一个token,服务端生成token后返回给客户的。该token具有唯一性,token指定超时时间,并存入redis中。
- 客户端拿到token后再去服务端执行业务请求
- 服务端收到请求有以下几种情况:
- 如果token存在,代表在时间周期内第一次请求,执行删除token,然后再处理业务
- 如果token不存在,则表明token已被删除(第一次处理业务请求之前会删除token)或者过期(网络问题导致获取token和执行业务间隔过长),则表示业务已经执行过了,所以此时不会执行业务
优缺点:
- 优点:实现简单
- 缺点:因为有一次获取token的额外过程,所以性能较低。
锁机制
乐观锁
实现思路:
给要操作的数据表加版本号字段version,当第一次要操作的时候以此版本号作为条件进行更新,并对对版本号加1操作
UPDATE my_table SET price = price+50 ,version = version + 1 WHERE id = 1 AND version = 5
第二次为重复提交时,还是会以版本号version=5作为条件去操作表,但是第一次提交操作已经将version改为5了,所以该次找不到此条记录,操作失败。
**缺点:**多一次数据库交互,效率较低
悲观锁
实现思路:
给要操作的业务等sql直接加x锁
start; select * from table where id=1 for update #其他业务操作 #..... #..... commit;
**缺点:**会产生行锁,高并发情况下效率非常低
Token+请求参数⭐
这里采用 token+请求参数 的方式接口幂等的处理。
即某个用户在在指定的周期内,多次请求的相同的接口并且请求参数是相同的,那么第二次以及后续请求是不允许的
实现思路:
- 首先通过拦截器拿到客户端请求头的Authroization的token和请求参数,将客户端请求的「固定前缀+请求地址+token」作为key,「请求的当前时间」和「当前的请求参数」作为value存入redis,并指定超时时间
- 当第二次为重复提交请求时,再将当前「请求地址+token」作为key去redis中获取
- 若key不存在(超时),则直接放行,允许执行,然后存入redis
- 若key存在,则拿到此value解析后,若「当前的请求参数和value中的请求参数相等」,并且「当前时间-value中的时间<设置的周期时间(redis超时时间)」则认为重复提交,则不允许进行后续操作,否则覆盖此key即可
**优点:**效率较高,避免多一次的请求token请求操作。前提是前端需要携带唯一token将各个用户进行区分
Token+请求参数 案例
pom依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</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-aop</artifactId>
</dependency>
自定义重复提交注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RepeatSubmit {
/**
* 默认请求周期间隔时间为5s
* @return
*/
int interval() default 5000;
/**
* 提示消息
* @return
*/
String message() default "请勿重复提交,请稍后再试";
}
Request再封装保证多次读流
当拦截器通过request.getReader().readLine()
读取请求体Body
中的内容后,那么后面控制器方法就无法读取Request流中的内容,导致报错: getReader() has already been called for this request
。
所以就需要对Request进行包装,将流放入包装中的字节数组中,后面每次取的时候都是从该包装Request中读取。
public class RepeatableReadRequestWrapper extends HttpServletRequestWrapper {
/**
* 将请求的流放入该数组中保存
*/
private final byte[] bytes;
public RepeatableReadRequestWrapper(HttpServletRequest request, HttpServletResponse response) throws IOException {
super(request);
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
bytes = request.getReader().readLine().getBytes();
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream bais = new ByteArrayInputStream(bytes);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() throws IOException {
return bais.read();
}
};
}
}
过滤器
拦截application/json
类型的请求,并对请求进行包装
public class RepeatableRequestFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request= (HttpServletRequest) servletRequest;
//如果请求头为 application/json 形式的那么,那么对请求进行包装
if (StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)) {
request = new RepeatableReadRequestWrapper(request, (HttpServletResponse) servletResponse);
filterChain.doFilter(request, servletResponse);
return;
}
filterChain.doFilter(servletRequest,servletResponse);
}
}
Redis序列化配置
@Configuration
public class RedisConfig {
/**
* 重写RedisTemplate 对默认的存储值进行序列化
* @param redisConnectionFactory
* @return
*/
@Bean
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<Object,Object> redisTemplate=new RedisTemplate<>();
redisTemplate.setConnectionFactory(redisConnectionFactory);
Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);
redisTemplate.setKeySerializer(serializer);
redisTemplate.setValueSerializer(serializer);
redisTemplate.setHashKeySerializer(serializer);
redisTemplate.setHashValueSerializer(serializer);
return redisTemplate;
}
}
Redis工具类配置
@Component
public class RedisCache {
@Autowired
private RedisTemplate redisTemplate;
/**
* 缓存基本的对象,Integer、String、实体类等
* @param key 缓存的键值
* @param value 缓存的值
* @param timeout 时间
* @param timeUnit 时间颗粒度
*/
public <T> void setCacheObject(final String key, final T value, long timeout, final TimeUnit timeUnit) {
redisTemplate.opsForValue().set(key, value, timeout, timeUnit);
}
/**
* 获得缓存的基本对象。
* @param key 缓存键值
* @return 缓存键值对应的数据
*/
public <T> T getCacheObject(final String key) {
ValueOperations<String, T> operation = redisTemplate.opsForValue();
return operation.get(key);
}
}
拦截器
@Component
public abstract class RepeatSubmitInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//被拦截下来的接口方法会封装为HandlerMethod类,里面保存着接口方法的所有信息
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
//获取方法上的RepeatSubmit注解
RepeatSubmit repeatSubmit = method.getAnnotation(RepeatSubmit.class);
if (repeatSubmit != null) {
//是否为重复提交 是:直接返回提示消息,不允许访问
if (isRepeatSubmit(request, repeatSubmit)) {
Map<String, Object> map = new HashMap<>();
map.put("status", 500);
map.put("message", repeatSubmit.message());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(map));
return false;
}
}
}
//因为流只能读取一次,若在拦截器中对request进行读取后,那么后续接口方法将不能获取到request中的流,所以需要对流进行封装,每次从封装过滤器中读取流数据
// System.out.println("RepeatSubmitInterceptor preHandle:"+request.getReader().readLine());
return true;
}
/**
* 验证是否重复提交由子类实现具体的防重复提交的规则
* @param request
* @param repeatSubmit
* @return
*/
public abstract boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit repeatSubmit);
}
@Component
public class SameUrlAndParamsInterceptorImpl extends RepeatSubmitInterceptor {
//value 参数key
private static final String REPEAT_PARAMS = "repeat_params";
//value 时间key
private static final String REPEAT_TIME = "repeat_time";
//redis中的key固定前缀
private static final String REPEAT_REPEAT_KEY = "repeat_submit_key";
//请求头的认证的key
private static final String HEADER = "Authorization";
@Autowired
private RedisCache redisCache;
@Override
public boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit repeatSubmit) {
//获取请求参数
//请求参数
String nowParams = "";
//如果为包装后的request对象,代表该请求为json格式的
if (request instanceof RepeatableReadRequestWrapper) {
try {
//直接从包装的request中读取到流数据
nowParams = request.getReader().readLine();
} catch (IOException e) {
e.printStackTrace();
}
}
//如果为空,则代表为 key-value 格式的请求
if (StringUtils.isEmpty(nowParams)) {
try {
//直接获取key-value参数
nowParams = new ObjectMapper().writeValueAsString(request.getParameterMap());
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
//构建value
Map<String, Object> nowDataMap = new HashMap<>();
//设置获取的请求参数
nowDataMap.put(REPEAT_PARAMS, nowParams);
//设置当前时间
nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
String requestURI = request.getRequestURI();
//获取请求头中的Authorization的唯一token (不可缺少,因为这样才能区分是不同客户端提交的)
String header = request.getHeader(HEADER);
//构建key 【固定前缀repeat_submit_key + 请求uri + 请求唯一token】 即可表明当前请求的唯一性。这里如果为jwt则可以去掉Bearer
String cacheKey = REPEAT_REPEAT_KEY + requestURI + header.replace("Bearer ", "");
Object cacheObject = redisCache.getCacheObject(cacheKey);
//如果从redis中拿到key不为空
if (cacheObject != null) {
//将redis中拿到的数据转为map
Map<String, Object> objectMap = (Map<String, Object>) cacheObject;
//判断value参数是否相同并且在指定的时间范围内是否有重复提交
if (compareParams(nowDataMap, objectMap) && compareTime(nowDataMap, objectMap, repeatSubmit.interval())) {
return true;
}
}
//表明第一次提交,直接设置到redis中
redisCache.setCacheObject(cacheKey, nowDataMap, repeatSubmit.interval(), TimeUnit.MILLISECONDS);
return false;
}
/**
* 判断周期内的时间是否重复提交
*
* @param nowDataMap
* @param objectMap
* @param interval
* @return
*/
private boolean compareTime(Map<String, Object> nowDataMap, Map<String, Object> objectMap, int interval) {
Long nowTime = (Long) nowDataMap.get(REPEAT_TIME);
Long objectTime = (Long) objectMap.get(REPEAT_TIME);
//如果 【当前时间】-【redis中的value时间】小于指定的周期直接,那么代表周期时间内为第二次请求, 则返回true,代表为重复提交
return nowTime - objectTime < interval;
}
/**
* 判断请求参数是否相同
*
* @param nowDataMap
* @param objectMap
* @return
*/
private boolean compareParams(Map<String, Object> nowDataMap, Map<String, Object> objectMap) {
String nowDataParams = (String) nowDataMap.get(REPEAT_PARAMS);
String objectParams = (String) objectMap.get(REPEAT_PARAMS);
return nowDataParams.equals(objectParams);
}
}
过滤器拦截器注册
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private SameUrlAndParamsInterceptorImpl sameUrlAndParamsInterceptor;
/**
* 注册拦截器
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(sameUrlAndParamsInterceptor).addPathPatterns("/**");
}
/**
* 注册拦截器
* @return
*/
@Bean
public FilterRegistrationBean<RepeatableRequestFilter> repeatableRequestFilterFilterRegistrationBean() {
FilterRegistrationBean<RepeatableRequestFilter> bean=new FilterRegistrationBean<>();
bean.setFilter(new RepeatableRequestFilter());
bean.addUrlPatterns("/*");
return bean;
}
}
控制器接口
10s
内不允许重复提交
@RestController
public class HelloController {
@PostMapping("/hello")
@RepeatSubmit(interval = 10000)
public String hello(@RequestBody String str) {
return str;
}
}
properties配置
# 应用名称
spring.application.name=repeat_submit
# 应用服务 WEB 访问端口
server.port=8080
spring.redis.host=xxx
spring.redis.password=xxx
最终测试:
第一次提交:
请求体:
hello
请求头
Authorization
: dh21312j3h1j3123结果:hello
第二次提交:
请求体:
hello
请求头
Authorization
: dh21312j3h1j3123结果:
{ "message": "请勿重复提交,请稍后再试", "status": 500 }
Redis中数据:
key: repeat_submit_key/hellodh21312j3h1j3123
value :
{ "repeat_params": "hello", "repeat_time": 1668608150712 }