概述
在应用工程里看到如下被标记为@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强大的全局代码搜索能力(是的,作为一个小型公司的资深开发&技术经理,需要了解公司的全部业务,当然也需要知道前后端开发概况,如代码库),当然需要提前把前后端代码都导入到一个目录下,然后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有交集甚至冗余嫌疑)。