用户对于同一操作发起的一次请求或者多次请求的结果是一致的。
数据库操作中:SELECT UPDATE DELETE 操作天然就是幂等的,同样的语句执行多次结果都不会产生变化,唯一的就是受影响的行数会变化,但 INSERT 插入操作则不是(在未指定主键或唯一性字段的前提下);所以需要我们在Java层面保证请求为幂等。否则会出现多次下单、数据异常、扣款重复等情况。闲话少说,说时迟那时快,抄起键盘就是干!
/**
* 标识接口需要保证幂等,未登录接口请勿使用
* @author Jiang Jun
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* 间隔时间(ms),小于此时间视为重复提交
*/
int interval() default 5000;
/**
* 提示消息
*/
String message() default "不允许重复提交,请稍候再试";
}
其中一些我系统本身自定义的类可以替换成你自己工程的类,主要的校验逻辑不受影响,校验是否幂等我采用的判断方式是:使用 Redis 的 String 类型存储请求参数,用户 ID+URI 作为 Key 保证接口请求的唯一性,Value 存储的是本次请求参数的 MD5摘要,MD5担心有的小伙伴不懂我解释一下: MD5 即 Message-Digest Algorithm 5(信息-摘要算法5)常用于文件校验。不管文件多大,经过 MD5 后都能生成唯一的 MD5 值。
/**
* 对方法上标注了幂等请求注解进行幂等校验
* @author Jiang Jun
*/
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
@Resource
private RedissonClient redissonClient;
/**
* 防重提交 redis key
*/
public static final String REPEAT_SUBMIT_KEY = "repeat_submit:";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
Idempotent annotation = method.getAnnotation(Idempotent.class);
if (annotation != null) {
// 判断是否为重复提交
if (this.isRepeatSubmit(request, annotation)) {
String responseBody = JSON.toJSONString(ResponseResult.error(ErrorCodeEnum.NO_ERROR, annotation.message()));
response.setStatus(HttpStatus.OK.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.getWriter().print(responseBody);
return false;
}
}
return true;
}
return true;
}
/**
* 判断是否重复提交
* @param request 请求对象
* @param annotation 幂等注解
* @return 重复提交请求返回true
*/
private boolean isRepeatSubmit(HttpServletRequest request, Idempotent annotation) throws IOException, NoSuchAlgorithmException {
// 用户ID+URI为Redis的Key,请求参数md5摘要为Value
String userId = TokenData.takeFromRequest().getUserId();
String uri = request.getRequestURI();
String key = REPEAT_SUBMIT_KEY + userId + uri;
RBucket<String> bucket = redissonClient.getBucket(key);
// 获取请求体参数
String requestBody = getRequestBody(request);
if (StringUtils.isBlank(requestBody)){
requestBody = JSON.toJSONString(request.getParameterMap());
}
// redis查询不为null,并且本次的请求参数md5与val相同则为重复请求
if (StringUtils.isNotBlank(bucket.get())){
return bucket.get().equals(jdkMD5(requestBody));
}
// 如果redis中没有数据,将本次请求参数存入Redis,考虑到并发情况,trySet 如果已经存在则返回false,代表重复请求
return !bucket.trySet(jdkMD5(requestBody), annotation.interval(), TimeUnit.MILLISECONDS);
}
/**
* 读取请求体内容
*/
private String getRequestBody(HttpServletRequest request) throws IOException {
return IOUtils.toString(request.getReader());
}
/**
* MD5摘要并转换为字符串
*/
private static String jdkMD5(String str) throws NoSuchAlgorithmException {
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
byte[] mdBytes = messageDigest.digest(str.getBytes());
return DatatypeConverter.printHexBinary(mdBytes);
}
}
这样校验幂等的工作就完成了,当我感到万事大吉可以潇洒的收起 C V 键帽时。。。又遇到了新的问题:测试的时候 SpringMVC 在解析请求参数转换为我们接收请求参数的实体对象时抛出了一个异常:
java.lang.IllegalStateException: getReader() has already been called for this request
at org.apache.catalina.connector.Request.getInputStream(Request.java:1069)
at org.apache.catalina.connector.RequestFacade.getInputStream(RequestFacade.java:365)
at com.igg.aggregate.server.aspect.LogAspect.before(LogAspect.java:80)
原因分析: HttpServletRequest 的 getInputStream() 和 getReader() 都只能读取一次,由于 Request Body 是流的形式读取,那么流读了一次就没有了,所以只能被调用一次。因为我在拦截器中读取了请求体内容,然后 SpringMVC 的参数转换器读取的时候发现已经被读取过了。
解决办法: 定义一个 RepeatedlyRequestWrapper 类继承 Servlet 自带的 HttpServletRequestWrapper 类,构造方法中先将 Request Body 内容保存在我们重写的 RequestWrapper 的成员属性中,通过覆盖 getReader() 和 getInputStream() 方法,使流从我们自己保存的地方读取。然后使用 Filter 过滤器将原始的 ServletRequest 包装成为 我们自己重写的 RequestWrapper对象。
/**
* 构建可重复读取inputStream的request
* @author Jiang Jun
*/
public class RepeatedlyRequestWrapper extends HttpServletRequestWrapper {
/**
* 存放请求体数据
*/
private final byte[] body;
public RepeatedlyRequestWrapper(HttpServletRequest request, ServletResponse response) throws IOException {
super(request);
request.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
// 首次读取请求体内容从原生request对象中获取,之后读取都从本对象的重写方法中获取请求体内容
body = IOUtils.toString(request.getReader()).getBytes(StandardCharsets.UTF_8);
}
@Override
public BufferedReader getReader() throws IOException {
return new BufferedReader(new InputStreamReader(getInputStream()));
}
@Override
public ServletInputStream getInputStream() throws IOException {
final ByteArrayInputStream bais = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public int read() throws IOException {
return bais.read();
}
@Override
public int available() throws IOException {
return body.length;
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
};
}
}
/**
* 把HttpServletRequest转换为可重复读取的inputStream的request
* @author Jiang Jun
*/
public class RepeatableFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
ServletRequest requestWrapper = null;
if (request instanceof HttpServletRequest
&& StringUtils.startsWithIgnoreCase(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)) {
// 如果是application/json格式的请求体,则将Request转换为可重复读取输入流的形式
requestWrapper = new RepeatedlyRequestWrapper((HttpServletRequest) request, response);
}
if (null == requestWrapper) {
chain.doFilter(request, response);
} else {
chain.doFilter(requestWrapper, response);
}
}
}
/**
* SpringMVC 配置
* @author Jiang Jun
*/
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Bean
public IdempotentInterceptor idempotentInterceptor(){
return new IdempotentInterceptor();
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 添加幂等校验拦截器
registry.addInterceptor(idempotentInterceptor()).addPathPatterns("/**");
}
/**
* 此过滤器作用为把HttpServletRequest转换为自定义可重复读取的inputStream的request
* 否则在拦截器中读取了请求体中的数据,在参数解析器中无法再次读取
*/
@Bean
@SuppressWarnings({ "rawtypes", "unchecked" })
public FilterRegistrationBean someFilterRegistration() {
FilterRegistrationBean registration = new FilterRegistrationBean();
registration.setFilter(new RepeatableFilter());
registration.addUrlPatterns("/*");
registration.setName("repeatableFilter");
registration.setOrder(FilterRegistrationBean.LOWEST_PRECEDENCE);
return registration;
}
}
经过以上步骤的准备就可以保证我们的接口是幂等的了,使用方法就是将幂等注解添加到请求方法上即可,开不开心?意不意外?简不简单?(狗头),不足之处是不支持没有任何参数的请求,当然这种请求大多数情况下也不需要保证幂等,另外就是 key 和用户ID绑定了,如果需要解耦可改为在请求幂等接口前后端生成本次请求的唯一请求编号或 Toekn,前端请求的时候带上,实际效果本人已通过 JMerter 并发测试有效,如果有不清晰或者不正确的地方欢迎大佬们留言多多指正!