aop解决 防重复提交

背景描述

虽然前端控制了按钮不能连续点击,但是在网络信号弱的情况下,仍然会出现第一次点击,请求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的问题。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
自定义注解AOP重复提交是一种通过在代码中添加自定义注解的方式来实现防止重复提交的功能。这种方法可以有效地避免代码耦合性过强,提高代码的可读性和可维护性。 具体实现的方案可以有多种,以下是几种常见的方案: 1. Token验证:在每次请求中添加一个唯一的Token标识,服务端接收到请求后将Token保存在缓存中,然后进行重复提交的验证。如果同一个Token已经存在于缓存中,则表示该请求已经被处理过,可以拒绝重复提交。 2. 请求参数验证:通过对请求参数进行校验,判断是否已经存在相同的请求参数,如果存在则表示重复提交。可以使用缓存或者数据库来存储已经处理过的请求参数,通过查询来进行重复提交的验证。 3. 时间窗口验证:通过设置一个时间窗口,限制在该时间窗口内只接受一次请求。可以使用缓存或者数据库记录请求的时间戳,每次接收到请求时与最近一次的时间戳进行比对,如果在时间窗口内已经存在过请求,则拒绝重复提交。 以上方案都可以使用Redis作为缓存来进行存储和验证操作。可以通过引入相关的依赖来使用Spring Boot集成的Redis组件和Jedis依赖。 通过使用自定义注解AOP来实现重复提交可以有效地提高代码的可读性和可维护性,同时也能够减轻服务器的负载,避免因为重复提交而导致的服务器宕机等问题。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值