系统从Spring5升级到Spring6, 除了要进行把javax的api迁移到Jakarta、升级Servlet容器到支持你所选的Jakarta的版本、升级Spring Security对应的API等这些常规操作,还可能遇到一些trick的问题。这里聊一下因为老系统没有限制客户端发送 multipart/related 这种请求而在升级后造成的问题及解决,虽然感觉这种场景 99.999% 的项目都不会遇到。
以流水帐的方式过一下。
升级前Spring5 Jetty9, 升级后Spring6 Jetty11
1)系统上线几天后,有客户说upload csv文件不成功。
2)最后从SumoLogic日志中发现原因是ContentType格式不对,又进一步确认是Spring5的系统支持 multipart/related 而Spring6 不支持造成的。
代码语言:log复制Caused by: jakarta.servlet.ServletException: Unsupported Content-Type [Multipart/Related; boundary=AAABBB; type="text/xml"; start="root-part--123"], expected [multipart/form-data]
at org.eclipse.jetty.server.Request.getParts(Request.java:2324) ~[jetty-server-11.0.19.jar!/:11.0.19]
3)Spring5 是使用Spring自带的MultipartParser,在解析后传给 servlet controller。系统使用Jetty9作为servlet容器。
4)Spring6 之后之前的 CommonsMultipartResolver 被替换为 StandardServletMultipartResolver。而StandardServletMultipartResolver会依赖容器来对Multipart请求做解析。(容器的实现必然有差别)
代码语言:log复制Several outdated Servlet-based integrations have been dropped: e.g. Apache Commons FileUpload (org.springframework.web.multipart.commons.CommonsMultipartResolver), and Apache Tiles as well as FreeMarker JSP support in the corresponding org.springframework.web.servlet.view subpackages. We recommend org.springframework.web.multipart.support.StandardServletMultipartResolver
5)其实不管Jetty 9还是Jetty 11其实都是不支持multipart/related的,之前没有问题是因为CommonsMultipartResolver支持。这样controller直接收到Multipart file这个数据。
6)搭建环境重现、Debug问题。
这里推荐IntelliJ插件jump-to-line
还有个调试技巧是利用IntelliJ的 条件断点及 Evaluate and Log 进行一些变量值的动态修改。
7)修改 org.eclipse.jetty.server.Request,如下
代码语言:java复制 public Collection<Part> getParts()
// if (contentType == null || !MimeTypes.Type.MULTIPART_FORM_DATA.is(HttpField.valueParameters(contentType, null)))
if (contentType == null)
private MultiParts newMultiParts(MultipartConfigElement config, int maxParts) throws IOException
{
// MultiPartFormDataCompliance compliance = getHttpChannel().getHttpConfiguration().getMultipartFormDataCompliance();
MultiPartFormDataCompliance compliance = MultiPartFormDataCompliance.LEGACY;
修改 org.eclipse.jetty.server.MultiPartInputStreamParser 中如下
代码语言:java复制 protected void parse()
// if (_contentType == null || !_contentType.startsWith("multipart/form-data"))
if (_contentType == null)
return;
绕过条件限制后,发现对普通csv文件通过http Multipart/related上传是可以处理了,controller 可以接收到 MultipartFile 类型的 file 参数了。
其实这个蛮侥幸的,如果Jetty代码压根不支持,估计就得再用其它办法了。
8)后来发现zip格式不支持。Debug后发现是Jetty自己在内部处理时,必须要求临时文件的目录要存在,所以有加了对应逻辑。如下:
代码语言:java复制public void write(String fileName) throws IOException
{
if (_file == null)
{
_temporary = false;
// Make sure the file/directory _tmpDir.getAbsolutePath() fileName existed.
touchTmpFileForJetty(_tmpDir.getAbsolutePath(), fileName);
这样修改之后通过了QA的测试。
9)为了测试 multipart/related 请求,也颇费周折。
通过curl命令实现了发送 multipart/related 请求。
代码语言:shell复制boundary="upload_boundary"
body=$(cat <<EOF
--$boundary
Content-Disposition: form-data; name="file"; filename="myuploaded.csv"
Content-Type: text/xml; charset=UTF-8
Content-Transfer-Encoding: binary
$(cat /my-path-to-file/my.csv)
--$boundary--
EOF
)
curl -v -X 'POST'
-H 'accept: application/json'
-H 'Authorization: YOUR-BASE64-USERIDPWD'
-H "Content-Type: multipart/related; boundary=$boundary"
-d "$body"
'https://Your-Server:Port/service-path'
10) 通过这 curl 命令向spring6 发送没问题。但是向 spring5系统发送后却得到500响应。但是通过java程序发送的multipart/releated请求确没问题。???
后台错误日志:
代码语言:log复制Caused by: org.apache.commons.fileupload.MultipartStream$MalformedStreamException: Stream ended unexpectedly
11) 为了搞清原因,在本地把 Mitmproxy 跑起来抓包。
从界面上怎么也看不出root cause,直到把请求通过 mitmproxy 导出成curl命令,才发现是换行表示的不同造成的。
通过Java程序发送的能被Spring5处理的请求是rn作为换行。
代码语言:log复制-d '--upload_boundaryx0dx0aContent-Disposition: form-data; name="file"; filename="myuploaded.csv"x0dx0aContent-Type: text;
而curl发送的就是n。
代码语言:log复制-d '--upload_boundaryx0aContent-Disposition: form-data; name="file"; filename="myuploaded.csv"x0aContent-Type: text/xml;
12)为了证实确实是换行符造成的, 把/n转为 /r/n后通过curl命令发送后 Spring5也能处理了。
这个规范 rf7230 上也说有的请求接受者做得更“健壮”可以接受LF结尾的请求。
实际上也就是这些“健壮”破坏了规范。(另外,如果从window系统上用curl命令,应该默认就是CRLF的吧?)
代码语言:shell复制echo $body > body.txt
cat -e body.txt
unix2dos body.txt
cat -e body.txt
RNBody=$(cat body.txt)
echo $RNBody | cat -e
curl -v -X 'POST'
-H 'accept: application/json'
-H 'Authorization: YOUR-BASE64-USERIDPWD'
-H "Content-Type: multipart/related; boundary=$boundary"
-d "$RNBody"
'https://Your-Server:Port/service-path'
下面代码演示如何发送zip这样的二进制格式文件。
代码语言:shell复制boundary="upload_boundary"
# 这里使用系统默认回撤换行。
{
echo "--$boundary"
echo "Content-Disposition: form-data; name="file"; filename="example.zip""
echo "Content-Type: text; charset=UTF-8"
echo "Content-Transfer-Encoding: binary"
echo ""
cat /your/path/to/zipfile
echo ""
echo "--$boundary--"
} > zip_body.txt
curl -X POST --proxy http://127.0.0.1:7070 -k
-H "Content-Type: multipart/related; boundary=$boundary"
-H "Authorization: Basic XXXXXX"
--data-binary @zip_body.txt
https://Your-Server:Port/service-path
# 这里明确使用 rn
{
echo -ne "--$boundaryrn"
echo -ne "Content-Disposition: form-data; name="file"; filename="example.zip"rn"
echo -ne "Content-Type: text; charset=UTF-8rn"
echo -ne "Content-Transfer-Encoding: binaryrn"
echo -ne "rn"
cat /your/path/to/zipfile
echo -ne "rn"
echo -ne "--$boundary--rn"
} > zip_body_inCRCL.txt
curl -X POST --proxy http://127.0.0.1:7070 -k
-H "Content-Type: multipart/related; boundary=$boundary"
-H "Authorization: Basic XXXXXX"
--data-binary @zip_body_inCRCL.txt
https://Your-Server:Port/service-path
13)中间也尝试通过filter在中间使用 Commons FileUpload 2
但是遇到 Stream ended unexpectedly 的问题。另外,在Tomcat做容器的POC中,也是遇到类似的问题。当时debug时发现似乎是跟回车换行有关。当时也都是通过curl命令验证的。但因为自定义Jetty的方案已经可以work,所以就没再继续看。现在回头看很当时遇到的问题很可能跟Spring5遇到的一样。也许发送前对回撤换行处理一下,或许也可以解决。
14)如果通过 Commons FileUpload 2 Filer 的方式可以解决,那这个方案就是最好的。最不好的方法其实就是这种定制Jetty代码,对以后的升级维护都是潜在的极大风险。
15)想起那句话,重要的是系统要限制能做什么。 为了这个patch前后花费的人天挺多的。。。新版本还delay了好久。
References:
- The MIME Multipart/Related Content-type
- Form-based File Upload in HTML
- Hypertext Transfer Protocol (HTTP/1.1): Message Syntax and Routing
- commons-fileupload2
- Servlet Spec and Tomcat version
- https://github.com/spring-projects/spring-framework/wiki/Upgrading-to-Spring-Framework-6.x
- jetty-examples