背景描述
虽然前端控制了按钮不能连续点击,但是在网络信号弱的情况下,仍然会出现第一次点击,请求A网络信号弱,这个时候前端按钮仍然可以点击,然后用户点击第二次。结果两次请求全部成功,数据库生成了两条除了ID以外一模一样的数据。(业务上不允许这种数据出现)
解决方式
采用AOP,对于不能重复提交的接口在后端加上控制。
第一步 自定义注解
/**
* @Author ztc
* @Description 防止重复提交自定义注解
* @Date 2023/3/16 11:43
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepeatCheck {
}
第二步 写一个HttpServletRequest包装类
原因:对于接口入参有@RequestBody修饰的情况,如果再次获取httpServletRequest中的body参数时,会出现异常,异常描述大意就是已经获取过一次body参数了,不能再获取第二次。因此我们需要将HttpServletRequest包装,通过我们自己写的包装类获取body参数。
/**
* @Author ztc
* @Description HttpServletRequest包装类
* @Date 2023/3/16 12:59
*/
public class MyHttpServletRequestWrapper extends HttpServletRequestWrapper {
public String body;
public MyHttpServletRequestWrapper (HttpServletRequest request) throws IOException {
super(request);
StringBuffer sBuffer = new StringBuffer();
BufferedReader bufferedReader = request.getReader();
String line;
while ((line = bufferedReader.readLine()) != null) {
sBuffer.append(line);
}
body = sBuffer.toString();
}
@Override
public ServletInputStream getInputStream() {
final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
return new ServletInputStream() {
@Override
public int read() {
return byteArrayInputStream.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener listener) {
}
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
}
第三步 有了包装类了那我们还需要写一个过滤器,将HttpServletRequest包装
这里要注意,将有MultipartFile(也就是有文件上传的)接口,要过滤掉。因为他们即使包装了也会抛异常。(还是不能获取第二次的那个异常)
/**
* @Author ztc
* @Description request过滤器
* @Date 2023/3/16 13:01
*/
public class RequestFilter implements Filter {
//配置接口过滤
private final Set<String> ALLOWED_PATHS = Collections.unmodifiableSet(new HashSet<>(
Arrays.asList("/import","/api/upload")));
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
ServletRequest requestWrapper = null;
if(request instanceof HttpServletRequest) {
HttpServletRequest servletRequest = (HttpServletRequest) request;
String requestURI = servletRequest.getRequestURI();
if (ALLOWED_PATHS.contains(requestURI)){
chain.doFilter(servletRequest,response);
}else {
requestWrapper = new MyHttpServletRequestWrapper(servletRequest);
//获取请求中的流,将取出来的字符串,再次转换成流,然后把它放入到新request对象中。
// 在chain.doFiler方法中传递新的request对象
chain.doFilter(requestWrapper, response);
}
}
}
@Override
public void destroy() {
Filter.super.destroy();
}
}
然后加载到配置类中
/**
* @Author ztc
* @Description web配置类
* @Date 2023/3/16 13:03
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public FilterRegistrationBean filterRegist() {
FilterRegistrationBean frBean = new FilterRegistrationBean();
frBean.setFilter(new RequestFilter());
frBean.addUrlPatterns("/*");
return frBean;
}
}
第四步 最后我们只需要再写一个切面就好了
/**
* @Author ztc
* @Description 校验重复注解切面
* @Date 2023/3/16 11:56
*/
@Aspect
@Component
@Slf4j
public class RepeatChekAspect {
//这个是自己写的redis的工具类
@Autowired
private RedisUtils redisUtils;
public final String REPEAT_PARAMS = "repeatParams";
public final String REPEAT_TIME = "repeatTime";
// 令牌自定义标识
@Value("${token.header}")
private String header;
@Before("@annotation(RepeatCheck的依赖路径)")
public void before(JoinPoint point) {
ServletRequestAttributes requestAttributes =
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();
boolean repeatSubmit = isRepeatSubmit(request);
if (repeatSubmit) {
throw new BusinessException("10秒内请勿重复提交");
}
}
/**
* 间隔时间,单位:秒 默认10秒
* <p>
* 两次相同参数的请求,如果间隔时间大于该参数,系统不会认定为重复提交的数据
*/
private final long intervalTime = 10L;
@SuppressWarnings("unchecked")
public boolean isRepeatSubmit(HttpServletRequest request) {
String nowParams = "";
if (request instanceof MyHttpServletRequestWrapper)
{
MyHttpServletRequestWrapper repeatedlyRequest = (MyHttpServletRequestWrapper)request;
nowParams = HttpUtils.read(repeatedlyRequest);
}
// body参数为空,获取Parameter的数据
if (StringUtils.isEmpty(nowParams)) {
nowParams = JSONObject.toJSONString(request.getParameterMap());
}
Map<String, Object> nowDataMap = new HashMap<String, Object>();
nowDataMap.put(REPEAT_PARAMS, nowParams);
nowDataMap.put(REPEAT_TIME, System.currentTimeMillis());
// 请求地址(作为存放cache的key值)
String url = request.getRequestURI();
// 唯一值(没有消息头则使用请求地址)
String submitKey = request.getHeader(header);
if (StringUtils.isEmpty(submitKey)) {
submitKey = url;
}
log.info("submitKey={}",submitKey);
// 唯一标识(指定key + 消息头)
String cacheRepeatKey = RedisCacheEnum.REPEAT_SUBMIT_KEY + submitKey;
Object sessionObj = redisUtils.getCacheObject(cacheRepeatKey);
if (sessionObj != null) {
Map<String, Object> sessionMap = (Map<String, Object>) sessionObj;
if (sessionMap.containsKey(url)) {
Map<String, Object> preDataMap = (Map<String, Object>) sessionMap.get(url);
if (compareParams(nowDataMap, preDataMap) && compareTime(nowDataMap, preDataMap)) {
return true;
}
}
}
Map<String, Object> cacheMap = new HashMap<String, Object>();
cacheMap.put(url, nowDataMap);
redisUtils.setCacheObject(cacheRepeatKey, cacheMap, intervalTime, TimeUnit.SECONDS);
return false;
}
/**
* 判断参数是否相同
*/
private boolean compareParams(Map<String, Object> nowMap, Map<String, Object> preMap) {
String nowParams = (String) nowMap.get(REPEAT_PARAMS);
String preParams = (String) preMap.get(REPEAT_PARAMS);
return nowParams.equals(preParams);
}
/**
* 判断两次间隔时间
*/
private boolean compareTime(Map<String, Object> nowMap, Map<String, Object> preMap) {
long time1 = (Long) nowMap.get(REPEAT_TIME);
long time2 = (Long) preMap.get(REPEAT_TIME);
if ((time1 - time2) < (this.intervalTime * 1000)) {
return true;
}
return false;
}
}
然后我们只需要在防止重复提交的接口上加@RepeatCheck注解就好了
2023-03-27 发现问题
当接口入参为 form-data格式时,仍然会出现
org.springframework.web.multipart.MultipartException: Failed to parse multipart servlet request; nested exception is java.lang.IllegalStateException: getReader() has already been called for this request。
原因在于 FileItemIteratorImpl 这个类的 init方法中的LimitedInputStream
protected void init(FileUploadBase fileUploadBase, RequestContext pRequestContext) throws FileUploadException, IOException {
String contentType = this.ctx.getContentType();
if (null != contentType && contentType.toLowerCase(Locale.ENGLISH).startsWith("multipart/")) {
long requestSize = ((UploadContext)this.ctx).contentLength();
Object input;
if (this.sizeMax >= 0L) {
if (requestSize != -1L && requestSize > this.sizeMax) {
throw new SizeLimitExceededException(String.format("the request was rejected because its size (%s) exceeds the configured maximum (%s)", requestSize, this.sizeMax), requestSize, this.sizeMax);
}
//------------------------这一句报的错----------------------------
input = new LimitedInputStream(this.ctx.getInputStream(), this.sizeMax) {
protected void raiseError(long pSizeMax, long pCount) throws IOException {
FileUploadException ex = new SizeLimitExceededException(String.format("the request was rejected because its size (%s) exceeds the configured maximum (%s)", pCount, pSizeMax), pCount, pSizeMax);
throw new FileUploadIOException(ex);
}
};
} else {
input = this.ctx.getInputStream();
}
String charEncoding = fileUploadBase.getHeaderEncoding();
if (charEncoding == null) {
charEncoding = this.ctx.getCharacterEncoding();
}
this.multiPartBoundary = fileUploadBase.getBoundary(contentType);
if (this.multiPartBoundary == null) {
IOUtils.closeQuietly((Closeable)input);
throw new FileUploadException("the request was rejected because no multipart boundary was found");
} else {
this.progressNotifier = new MultipartStream.ProgressNotifier(fileUploadBase.getProgressListener(), requestSize);
try {
this.multiPartStream = new MultipartStream((InputStream)input, this.multiPartBoundary, this.progressNotifier);
} catch (IllegalArgumentException var9) {
IOUtils.closeQuietly((Closeable)input);
throw new InvalidContentTypeException(String.format("The boundary specified in the %s header is too long", "Content-type"), var9);
}
this.multiPartStream.setHeaderEncoding(charEncoding);
}
} else {
throw new InvalidContentTypeException(String.format("the request doesn't contain a %s or %s stream, content type header is %s", "multipart/form-data", "multipart/mixed", contentType));
}
}
当调用this.ctx.getInputStream()方法时,并不会走到我们自己的包装类MyHttpServletRequestWrapper 中的getInputStream方法,即使我们在过滤器中放行的是我们自己的包装类。
我想这也是第三步中 我们配置接口过滤的原因。遗憾的是我想不出解决办法,所以最后我没有再封装所有的请求流,改成了只对需要防重复提交的接口进行封装。
如下:
/**
* @Author ztc
* @Description request过滤器
* @Date 2023/3/16 13:01
*/
public class RequestFilter implements Filter {
//配置接口过滤 只处理需要防重的接口
private final List<String> ALLOWED_PATHS = Arrays.asList("/initiateAudit");
@Override
public void init(FilterConfig filterConfig) throws ServletException {
Filter.super.init(filterConfig);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
ServletRequest requestWrapper = null;
if(request instanceof HttpServletRequest) {
HttpServletRequest servletRequest = (HttpServletRequest) request;
String requestURI = servletRequest.getRequestURI();
if (contains(requestURI)){
requestWrapper = new MyHttpServletRequestWrapper(servletRequest);
//获取请求中的流如何,将取出来的字符串,再次转换成流,然后把它放入到新request对象中。
// 在chain.doFiler方法中传递新的request对象
chain.doFilter(requestWrapper, response);
}else {
chain.doFilter(servletRequest,response);
}
}
}
@Override
public void destroy() {
Filter.super.destroy();
}
private boolean contains(String requestURI){
return ALLOWED_PATHS.stream().anyMatch(requestURI::endsWith);
}
}
希望有大佬能指点一下,怎么解决form-data数据即使包装了仍然会出现getReader() has already been called for this request的问题。