写在前面

前面笔者使用的都是基于token令牌校验这一方式实现接口幂等,这种方式其实比较复杂,今天来介绍另一种比较简单的方式—基于请求参数的校验,这种方式在高并发环境下优势更明显。由于请求只有一次,所以不需要从服务端获取令牌。

原理介绍

基于请求参数校验这一方式原理很简单,如果在某一个时间间隔内,同一个接口接收到的请求参数一样,则说明前后请求是重复的,服务端则拒绝处理后续请求。注意由于前后端通过JOSN格式传递数据,且需要多次重复读取JSON数据,所以前面介绍的文章还是有很大的帮助。

实战

第一步,新建一个名为repeat-submit的SpringBoot项目,然后在POM文件中引入redis、web和aop依赖:

1
2
3
4
5
6
7
8
9
10
11
12
<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>

第二步,在application.yml配置文件中新增redis配置信息及项目运行端口信息:

1
2
3
4
5
6
7
8
spring:
redis:
host: localhost
port: 6379
password: root
database: 4
server:
port: 8888

第三步,新建redis包,并在redis包内新建一个名为RedisCache的工具类,该类用于封装对Redis的操作。里面定义了两个方法,即对字符串进行存入和查看这两个操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component
public class RedisCache {
@Autowired
private RedisTemplate redisTemplate;

public <T> T getCacheObject(final String key){
ValueOperations<String,T> valueOperations = redisTemplate.opsForValue();
return valueOperations.get(key);
}

public <T> void setCacheObject(final String key, final T value, Integer timeout, TimeUnit timeUnit){
redisTemplate.opsForValue().set(key,value,timeout,timeUnit);
}
}

第四步,新建annotation包,并在annotation包内新建一个名为RepeatSubmit的注解,该注解可用在需要实现幂等的接口上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface RepeatSubmit {
/**
* 两个请求之间的间隔时间,单位毫秒
* 小于此间隔认为重复提交
*/
public int interval() default 5000;

/**
* 重复提交时提示信息
* @return
*/
String message() default "请勿重复提交,稍后再试";
}

第五步,新建interceptor包,并在interceptor包内新建一个名为IdempotencyInterceptor的抽象类,这个抽象类通过拦截器来拦截所有被RepeatSubmit注解所修饰的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public abstract class IdempotencyInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(handler instanceof HandlerMethod){
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
if(null != annotation){
//重复提交
if(isRepeatSubmit(request,annotation)){
Map<String,Object> map = new HashMap<>();
map.put("status",500);
map.put("message",annotation.message());
response.setContentType("application/json;charset=utf-8");
response.getWriter().write(new ObjectMapper().writeValueAsString(map));
return false;
}
}
return true;
}
return true;
}

/**
* 判断是否为重复提交,具体判断逻辑由子类确定
*/
public abstract boolean isRepeatSubmit(HttpServletRequest request,RepeatSubmit repeatSubmit);

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

}
}

它重写了preHandle方法的逻辑,首先判断当前handler是否为HandlerMethod的实例,如果是就强转为Method对象并得到方法上的@RepeatSubmit注解。如果该注解存在,然后调用isRepeatSubmit()方法来判断是否为重复提交,如果是则返回既定的重复提交数据提示。注意,这里我们将isRepeatSubmit()方法设置为抽象方法,目的是针对重复提交的实现逻辑可以有很多种,这里我们采用的是根据“URL地址+请求参数”这一方式来判断。后期开发者可以对此有不同的实现,而不用修改此处的重复提交提示信息。

第六步,在interceptor包内新建一个名为isRepeatSubmit的类,这个类需要继承前面的IdempotencyInterceptor类并重写其中的isRepeatSubmit方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
/**
* 基于请求地址URL+请求参数来判断是否重复提交
*/
@Component
public class RepeatSubmitInterceptor extends IdempotencyInterceptor{

public final String REPEAT_PARAMS = "repeatParams";
public final String REPEAT_TIME = "repeatTime";
public static final String REPEAT_SUBMIT_KEY = "REPEAT_SUBMIT_KEY";

private String HEADER = "Authorization";

@Autowired
private RedisCache redisCache;

/**
* 判断是否为重复提交,true则表示重复提交
* @param request
* @param repeatSubmit
* @return
*/
@SuppressWarnings("unchcked")
@Override
public boolean isRepeatSubmit(HttpServletRequest request, RepeatSubmit repeatSubmit) {
String nowParams = "";
if(request instanceof RepeatableReadRequestWrapper){
RepeatableReadRequestWrapper requestWrapper = (RepeatableReadRequestWrapper) request;
try{
nowParams = requestWrapper.getReader().readLine();
}catch (IOException e){
e.printStackTrace();
}
}
//body参数为空的话,获取Parameter中的参数
if(ObjectUtils.isEmpty(nowParams)){
try{
nowParams = new ObjectMapper().writeValueAsString(request.getParameterMap());
}catch (JsonProcessingException e){
e.printStackTrace();
}
}
//构建一个存于缓存的nowDataMap对象
Map<String,Object> nowDataMap = new HashMap<>();
nowDataMap.put(REPEAT_PARAMS,nowParams);
nowDataMap.put(REPEAT_TIME,System.currentTimeMillis());

//获取请求地址,作为缓存中的key的一部分
String url = request.getRequestURI();
//获取消息头,注意这个值唯一,值不存在则使用请求地址
String header = request.getHeader(HEADER);
//构建缓存中的key
String cacheRepeatKey = REPEAT_SUBMIT_KEY + url + header.replace("Bearer ","");

//查询缓存中是否存在对应的数据
Object cacheObject = redisCache.getCacheObject(cacheRepeatKey);
if(null != cacheObject){
Map<String, Object> cacheMap = (Map<String, Object>) cacheObject;
//参数一致且时间小于设定的间隔,则说明此为重复提交
if(compareParams(nowDataMap,cacheMap) && compareTime(nowDataMap,cacheMap,repeatSubmit.interval())){
return true;
}
}
//缓存中没有数据,说明不是重复提交
redisCache.setCacheObject(cacheRepeatKey,nowDataMap,repeatSubmit.interval(), TimeUnit.MILLISECONDS);
return false;
}

/**
* 判断缓存中前后两次请求的参数是否一致
* @param nowDataMap 现在的参数值
* @param preDataMap 之前的参数值
* @return
*/
private boolean compareParams(Map<String,Object> nowDataMap,Map<String,Object> preDataMap){
String nowParams = (String)nowDataMap.get(REPEAT_PARAMS);
String preParams = (String)preDataMap.get(REPEAT_PARAMS);
return nowParams.equals(preParams);
}

/**
* 判断缓存中前后两次请求的时间间隔是否小于RepeatSubmit注解中指定的值
* @param nowDataMap 现在时间
* @param preDataMap 之前时间
* @param interval RepeatSubmit注解中指定的时间间隔
* @return
*/
private boolean compareTime(Map<String,Object> nowDataMap,Map<String,Object> preDataMap,int interval){
long nowTime = (Long)nowDataMap.get(REPEAT_TIME);
long preTime = (Long)preDataMap.get(REPEAT_TIME);
if((nowTime - preTime)< interval){
return true;
}
return false;
}
}

简单解释一下上述代码的含义:
(1)首先判断当前请求是否为RepeatableReadRequestWrapper的实例,如果是则说明当前请求参数格式为JSON,此时就会通过解析IO流来读取数据,关于这一部分在之前的《SpringBoot实现JSON数据重复读取》一文中进行了介绍;
(2)如果当前请求不是RepeatableReadRequestWrapper的实例,说明不是JSON格式,那么可以从请求参数中获取,即以key-value方式读取数据,并使用objectMapper.writeValueAsString()方法来将其转换成字符串;
(3)接下来我们构造一个用于存入Redis的对象,注意这里我们使用Redis的String类型,Value为Map对象,然后Map对象中存有从请求中读取的参数和当前时间;
(4)然后我们构造Redis的Key,这个Key的格式为“固定前缀+请求地址URL+请求头的令牌”,其中固定前缀值为REPEAT_SUBMIT_KEY,请求头的令牌需要去除其中的Bearer 字符串。请注意,请求令牌此处必须添加,这样可以区分用户;
(5)根据Key去Redis中查询是否存在对应的缓存数据,如果存在则去判断参数是否相同以及两次请求的时间间隔是否小于既定时间间隔,如果两者同时满足,则说明前后两次请求为重复请求,并返回true;
(6)如果不是(5)中的结果,那么说明请求是第一次过来或者说已经过了既定的时间窗口,服务器都接受并处理请求,此时将得到的请求信息重新添加到Redis中,并返回false。

第七步,新建config包,并在config包内新建一个名为MyWebMvcConfig的配置类,这个配置类需要实现WebMvcConfigurer接口,并重写其中的addInterceptors方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class MyWebMvcConfig implements WebMvcConfigurer {
@Autowired
private RepeatSubmitInterceptor repeatSubmitInterceptor;

/**
* 手动将自定义的RepeatSubmitInterceptor拦截器注册到Spring容器中
* @param registry
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(repeatSubmitInterceptor).addPathPatterns("/**");
}
}

第八步,新建request包,并在request包内新建一个名为RepeatableReadRequestWrapper的类,
这个类需要继承HttpServletRequestWrapper类并重写其中的getInputStream和getReader方法,同时重载一下父类ServletRequestWrapper中有HttpServletRequest和HttpServletResponse对象的构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* 自定义请求包装类
*/
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(StandardCharsets.UTF_8);
}

@Override
public ServletInputStream getInputStream() throws IOException {
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();
}

@Override
public int available() throws IOException {
return bytes.length;
}
};
}

@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
}

这其实就是自定义了一个新的HttpServletRequest类,并重载了一个包含HttpServletRequest和HttpServletResponse对象的构造方法,目的就是修改请求和响应的字符编码格式以及从IO流出读取数据,然后存入一个字节数组中,并通过重写getInputStream和getReader方法分别从字节数组中获取数据并构造IO流进行返回,这样就实现了IO流的多次读取。

第九步,新建filter包,并在filter包内新建一个名为RepeatRequestFilter的类,这个类需要实现Filter接口并重写其中的doFilter方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 请求拦截器,只有JSON数据才会使用自定义的RequestWrapper
*/
public class RepeatRequestFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
if(StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)){
RepeatableReadRequestWrapper requestWrapper = new RepeatableReadRequestWrapper(request,(HttpServletResponse) servletResponse);
filterChain.doFilter(requestWrapper,servletResponse);
return;
}
filterChain.doFilter(request,servletResponse);
}
}

可以看到这里我们重写了doFilter方法,目的就是判断请求的类型,如果请求是HttpServletRequest且请求数据类型为JSON格式才会调用自定义的RepeatableReadRequestWrapper,即将HttpServletRequest替换为RepeatableReadRequestWrapper,走IO流可以多次读取的逻辑,之后让过滤器继续往下执行。

请注意,过滤器最好不要使用@Component注解交由Spring容器来管理,这样会导致每个接口都会被进行过滤,最好是开发者自己手动注册,并且配置过滤的接口。

第十步,在之前定义的MyWebMvcConfig类中将这个自定义的RepeatRequestFilter过滤器注册进去:

1
2
3
4
5
6
7
8
9
10
11
/**
* 手动将自定义的RepeatRequestFilter过滤器注册到Spring容器中
* @return
*/
@Bean
FilterRegistrationBean<RepeatRequestFilter> repeatRequestFilterFilterRegistrationBean(){
FilterRegistrationBean<RepeatRequestFilter> bean = new FilterRegistrationBean();
bean.setFilter(new RepeatRequestFilter());
bean.addUrlPatterns("/*");
return bean;
}

第十一步,新建controller包,并在controller包内新建一个名为RepeatSubmitController的类,我们在该类中提供一个名为/repeat的接口:

1
2
3
4
5
6
7
8
9
10
11
@RestController
public class RepeatSubmitController {
private static final Logger logger = LoggerFactory.getLogger(RepeatSubmitController.class);

@PostMapping("/repeat")
@RepeatSubmit(interval = 2000)
public String repeat(@RequestBody String message){
logger.info("message is: {}",message);
return message;
}
}

注意由于需要提交参数因此必须使用POST请求,同时这里设置了重复提交的时间间隔为2秒,即两秒内如有来自同一用户对同一接口多次请求相同参数时,可认为前后请求是重复提交的。

第十二步,启动项目进行测试。用户构造http://localhost:8888/repeat链接并以JSON形式传递message时,页面第一次会返回获取的message信息:

如果两秒内多次请求则会抛出此为重复请求,请稍后重试的提示信息:

小结

本篇通过判断请求数据格式是否为JOSN形式,如果是则调用增强的HttpServletRequest并从请求中获取请求参数,然后构建Key并从Redis中查询缓存信息,如果缓存中存在则通过请求参数和时间间隔来判断是否为重复提交,如果是则给出相应提示信息并返回false;否则返回认为是非重复提交并返回true,继续后续流程。