本文介绍: Spring Cloud Gateway转发文件上传接口,并对requestBody进行解析,导致MultipartException: Current request is not a multipart request问题排查过程记录

概述

应用工程里看到如下被标记为@deprecated的代码,这对有代码洁癖的我而言是无法忍受的:
在这里插入图片描述

row.getCell(10).setCellType(Cell.CELL_TYPE_STRING);
String hospital = row.getCell(0).getStringCellValue();

对应的poi版本号?是的,你没猜错,使用次数最多的版本3.17!!

<dependency>
  <groupId>org.apache.poi</groupId>
  <artifactId>poi</artifactId>
  <version>3.17</version>
</dependency>
<dependency>
  <groupId>org.apache.poi</groupId>
  <artifactId>poi-ooxml</artifactId>
  <version>3.17</version>
</dependency>

洁癖

Google一番,终于找到一个靠谱的解决方案-StackOverflow-setcelltypecelltype-string-is-deprecated

You can just call row.setCellValue(String) you don’t have to set the cell type beforehand.

删除此行代码即可

验证

这就完了吗?当然不!作为一个有追求的资深程序员,发现不clean的code,修改只是第一步,接下来还需要验证,此番修改是否会引发新的问题。

一步步找到Controller层接口,好在就一个接口,改动也只会影响这一个接口。具体来说,这是一个Excel文件上传接口解析Excel内容,然后把Excel数据insert or update到MySQL数据库。本地启动应用,Postman模拟请求
在这里插入图片描述
然后去数据库验证一下,一切完美?

问题爆出

等等,这个接口前端在哪里用到呢?通过IDEA强大的全局代码搜索能力(是的,作为一个小型公司的资深开发&amp;技术经理,需要了解公司的全部业务,当然也需要知道前后端开发概况,如代码库),当然需要提前把前后端代码都导入到一个目录下,然后IDEA打开此目录,则会自动把此目录下的全部子目录下的全部工程导入到IDEA的工作空间:
在这里插入图片描述
总之,找到接口调用处,知道具体的业务场景结果不管是测试环境还是生产环境都有问题,报错如下:
在这里插入图片描述

排查

借助于ELK,可以快速找到错误堆栈日志

Current request is not a multipart request
org.springframework.web.multipart.MultipartException: Current request is not a multipart request
at org.springframework.web.servlet.mvc.method.annotation.RequestPartMethodArgumentResolver.resolveArgument(RequestPartMethodArgumentResolver.java:157)
at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:126)
at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:167)
at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:134)
at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:104)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:892)
at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:797)
at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)

对应的后端接口代码:

@PostMapping(value = "/uploadExcel")
    public Response<String> uploadExcel(@RequestPart("excelFile") MultipartFile file, @RequestParam("flag") String flag, @RequestParam("channel") String channel) {
}

上面这段代码将近3年没人改过。并且Postman模拟接口请求文件上传功能是正常的!!!

看了下前端代码,发现有Axios组件升级之类的修改,于是想着让前端去排查。扔过来一张截图,并坚持声称前端代码没有问题,把我噎得够呛:
在这里插入图片描述

初次尝试

Google搜了一圈没有找到靠谱的解决方法。其中包括stackoverflow这篇multipartexception-current-request-is-not-a-multipart-request,在controller层增加配置,改成下面这样:

@PostMapping(value = "/uploadExcel", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})

前文提到过,因为Postman模拟文件上传功能是好的,也就是说本地无法复现问题,当然也就不能验证修改,只能提交代码发布到测试环境:提交->版本->编译构建->滚动发布->测试环境验证。虽然有CI流水线这一套,但是也需要5分钟左右。

测试环境报了另一个错误

Content type 'application/json;charset=UTF-8' not supported
org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/json;charset=UTF-8' not supported
at org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping.handleNoMatch(RequestMappingInfoHandlerMapping.java:214)

无解。。搁置一个周末。。

本地调试环境

既然Postman模拟接口请求没有问题,那咱就启动前端工程(虽然已经将近一年不写前端代码),前端工程直接调用本地后端工程,尝试本地调试复现问题。

前端工程使用React,看到熟悉的package.json文件:
在这里插入图片描述
直接在IDEA的Terminal里执行命令npm install,会在项目根目录下生成node_modules文件夹
在这里插入图片描述
有4种启动方式,如上截图所示,有Run模式和Debug模式。是的,你没看错,前端代码使用IDEA工程也可以Debug。就在上一家公司的上一份工作里,一年里我还提交过四百多次前端代码。

现在这份工作9个月多,第一次启动前端工程。这里我也习惯性使用Debug模式结果并不行。Anyway最终以Run模式执行dev启动成功。

再来看看上面截图dev命令提到的server.js文件:

const devProxy = {
  '/api': {
    target: 'http://10.18.65.51:8848',
    // pathRewrite: { '^/api': '' },
    // target: 'https://stg-open.aaaaa.com',
    pathRewrite: { '^/api': '/api' },
    changeOrigin: true,
  },
}

主要有两个配置

  • target:指向的后端服务。如果指向的是域名,则需要在nginx配置域名对应的后端服务,如果指向的是IP,则是联调时的后端服务所在的机器IP。此处前后端工程是同一个机器启动的。IP当然需要修改为本机IP,使用命令查看ifconfig en0即可
  • pathRewrite:发送请求时,请求路径重写。就是多少一个/api的差别。这也太眼熟了吧,和Spring Cloud Gateway网关路由配置差不多意思。

浏览器打开http://localhost:3001,看到熟悉的界面,后端服务接口设置断点,界面操作。ok,本地联调环境已具备。

复现成功

一开始后端只启动一个merchant工程,也就是上面Postman截图里的merchant/open/uploadExcel接口,修改server.js为:

target: 'http://10.18.65.51:8849', // merchant服务占用端口
pathRewrite: { '^/api': '' }, // 直接请求merchant服务

在这里插入图片描述
上传成功,本地没有复现。

那测试环境为啥有问题呢?

看到pathRewrite,前面也提到Spring Cloud Gateway网关路由配置

对了,测试和生产环境里所有的服务请求都是走Gateway网关。

ok,再启动一个Gateway服务,同时需要修改server.js为:

target: 'http://10.18.65.51:8848', // gateway服务占用端口
pathRewrite: { '^/api': '/api' }, // 直接请求gateway服务,后端gateway服务再负责转发请求到merchant服务

问题复现!!所以,问题出现在Gateway网关服务。

看到曙光

我们再来看看Gateway服务的Apollo配置
在这里插入图片描述
这里有个RequestLogFilter!!

打断点,再来一次。前端页面点击文件上传,请求进入到gateway服务,断点进入RequestLogFilter!!

来看看源码

import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.factory.rewrite.RewriteFunction;
import org.springframework.cloud.gateway.support.BodyInserterContext;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.BodyInserter;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.HandlerStrategies;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.util.List;

/**
 * 所有请求的日志过滤器
 **/
@Slf4j
@Component
public class RequestLogFilter extends AbstractGatewayFilterFactory<Config> {

    private final List<HttpMessageReader<?>> messageReaders;

    public RequestLogFilter() {
        super(Config.class);
        this.messageReaders = HandlerStrategies.withDefaults().messageReaders();
    }

    private RewriteFunction<String, String> rewriteFunction() {
        return (exchange, body) -> {
            String url = exchange.getRequest().getURI().getPath();
            log.info("*****请求信息日志拦截*****,请求的路径:{},请求的入参数据:{}", url, body);
            return Mono.just(body);
        };
    }

    @Override
    public GatewayFilter apply(Config config) {
        config.setRewriteFunction(String.class, String.class, rewriteFunction());
        return (exchange, chain) -> {
            Class inClass = config.getInClass();
            ServerRequest serverRequest = ServerRequest.create(exchange, this.messageReaders);
            Mono<?> responseBody = serverRequest.bodyToMono(inClass).flatMap((o) -> config.getRewriteFunction().apply(exchange, o));
            BodyInserter bodyInserter = BodyInserters.fromPublisher(responseBody, config.getOutClass());
            HttpHeaders headers = new HttpHeaders();
            headers.putAll(exchange.getRequest().getHeaders());
            headers.remove("Content-Length");
            if (config.getContentType() != null) {
                headers.set("Content-Type", config.getContentType());
            }
            CachedBody outputMessage = new CachedBody(exchange, headers);
            return bodyInserter.insert(outputMessage, new BodyInserterContext()).then(Mono.defer(() -> {
                ServerHttpRequest decorator = this.decorate(exchange, headers, outputMessage);
                return chain.filter(exchange.mutate().request(decorator).build());
            }));
        };
    }

    ServerHttpRequestDecorator decorate(ServerWebExchange exchange, HttpHeaders headers, CachedBody outputMessage) {
        return new ServerHttpRequestDecorator(exchange.getRequest()) {
            @NotNull
            @Override
            public HttpHeaders getHeaders() {
                long contentLength = headers.getContentLength();
                HttpHeaders httpHeaders = new HttpHeaders();
                httpHeaders.putAll(super.getHeaders());
                httpHeaders.setContentType(MediaType.APPLICATION_JSON_UTF8);
                if (contentLength > 0L) {
                    httpHeaders.setContentLength(contentLength);
                } else {
                    httpHeaders.set(OpenConstants.TRANSFER_ENCODING, OpenConstants.CHUNKED);
                }
                return httpHeaders;
            }

            @NotNull
            @Override
            public Flux<DataBuffer> getBody() {
                return outputMessage.getBody();
            }
        };
    }
}

大致瞟一眼就看到这里有对request进行解析的代码,也有设置HTTP headers的代码。问题大概率就出现在这里

好在测试环境也有问题,那就删除spring.cloud.gateway.routes[23].filters[0] = RequestLogFilter这条配置项。再来一次。

!!!问题消失!!!

分析原因

RequestLogFilter是一个Filter(废话),用于在请求转发到对应的后端其他服务前,解析requestBody,并打印出来,类似于AOP日志记录。

去掉httpHeaders.setContentType(MediaType.APPLICATION_JSON_UTF8);这行代码,莫名其妙报了另一个错误:

ERROR | com.aba.open.merchant.service.impl.MerchantOpenServiceImpl | uploadExcel | 93 | - 
java.io.IOException: ZIP entry size is too large or invalid
    at org.apache.poi.openxml4j.util.ZipArchiveFakeEntry.<init>(ZipArchiveFakeEntry.java:43)
    at org.apache.poi.openxml4j.util.ZipInputStreamZipEntrySource.<init>(ZipInputStreamZipEntrySource.java:53)
    at org.apache.poi.openxml4j.opc.ZipPackage.<init>(ZipPackage.java:106)
    at org.apache.poi.openxml4j.opc.OPCPackage.open(OPCPackage.java:307)
    at org.apache.poi.ooxml.util.PackageHelper.open(PackageHelper.java:47)
    at org.apache.poi.xssf.usermodel.XSSFWorkbook.<init>(XSSFWorkbook.java:309)
    at com.aba.open.merchant.service.impl.MerchantOpenServiceImpl.uploadExcel(MerchantOpenServiceImpl.java:68)

难搞。

附录

AOP

@Aspect
@Component
@Slf4j
public class ControllerLogAop {
    @Pointcut("execution(public * com.aaaaa.dialog.controller..*.*(..))")
    public void webLog() {
    }

    /**
     * 在切点之前织入
     */
    @Before("webLog()")
    public void doBefore(JoinPoint joinPoint) {
        try {
            log.info("======= Start ===========");
            // 打印请求入参
            Object[] args = joinPoint.getArgs();
            Object[] arguments = new Object[args.length];
            for (int i = 0; i < args.length; i++) {
                if (args[i] instanceof ServletRequest || args[i] instanceof ServletResponse || args[i] instanceof MultipartFile) {
                    continue;
                }
                arguments[i] = args[i];
            }
            log.info("类{}方法{},请求参数= {}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(), JSON.toJSONString(arguments));
        } catch (Exception e) {
            log.error("日志切面异常", e);
        }
    }

    /**
     * 在切点之后织入
     */
    @After("webLog()")
    public void doAfter() {
        log.info("=========== End =========");
    }

    /**
     * 环绕
     */
    @Around("webLog()")
    public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        Object result = null;
        try {
            long startTime = System.currentTimeMillis();
            result = proceedingJoinPoint.proceed();
            // 打印出参
            log.info("返回参数= {}", JSON.toJSONString(result));
            // 执行耗时
            log.info("耗时{} ms", System.currentTimeMillis() - startTime);
            return result;
        } catch (Exception e) {
            log.error("日志切面异常", e);
        }
        return result;
    }
}

ControllerLogAop这种配置类由于涉及到controller包路径的切入,即@Pointcut,可能需要在每个服务里都写一份(事实上我们目前也是这样做的,功能定位和RequestLogFilter有交集甚至冗余嫌疑)。

参考

原文地址:https://blog.csdn.net/lonelymanontheway/article/details/86505024

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。

如若转载,请注明出处:http://www.7code.cn/show_1112.html

如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱:suwngjj01@126.com进行投诉反馈,一经查实,立即删除

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注