Spring 转发 form-data 文件上传请求时中文文件名乱码
Spring 转发 form-data 文件上传请求时中文文件名乱码
- 复现问题
- 找原因
- 解决问题
- 参考
复现问题
后端有两个接口:
/upload
是文件上传的接口。
/forward
是转发文件上传请求的接口。
@RequestMapping
@RestController
public class FileUploadController {/*** 直接调用-文件上传*/@PostMapping("/upload")public String upload(HttpServletRequest request, @RequestParam("title") String title,@RequestParam("file") MultipartFile file) {return """title: %s <br/>filename: %s <br/>contentType: %s""".formatted(title, file.getOriginalFilename(), file.getContentType());}/*** 转发请求*/@PostMapping("/forward")public String forward(HttpServletRequest request) throws ServletException, IOException {try (CloseableHttpClient httpclient = HttpClients.createDefault()) {ClassicRequestBuilder requestBuilder = ClassicRequestBuilder.post("http://localhost:8080/upload");MultipartEntityBuilder entityBuilder = MultipartEntityBuilder.create();for (Part part : request.getParts()) {MultipartPartBuilder partBuilder = MultipartPartBuilder.create();for (String headerName : part.getHeaderNames()) {partBuilder.addHeader(headerName, part.getHeader(headerName));}InputStreamBody body = new InputStreamBody(part.getInputStream(), part.getSubmittedFileName());partBuilder.setBody(body);entityBuilder.addPart(partBuilder.build());}ClassicHttpRequest forwardRequest = requestBuilder.setEntity(entityBuilder.build()).build();return httpclient.execute(forwardRequest, response -> {final HttpEntity responseEntity = response.getEntity();String res = EntityUtils.toString(responseEntity);EntityUtils.consume(responseEntity);return res;});}}
}
前端使用 form 进行文件上传
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>test</title>
</head>
<body><h2>直接调用</h2><form action="/upload" method="post" enctype="multipart/form-data"><label>标题:</label><input type="text" name="title" value="直接调用" required/><label>文件:</label><input type="file" name="file" required/><input type="submit" value="提交"></form><h2>转发请求</h2><form action="/forward" method="post" enctype="multipart/form-data"><label>标题:</label><input type="text" name="title" value="转发请求" required/><label>文件:</label><input type="file" name="file" required/><input type="submit" value="转发"></form>
</body>
</html>
测试发现直接调用时,文件名正常显示,但转发请求时却乱码了。
找原因
通过 Apifox 调用 /forward
发现不会乱码。
使用 fiddler 抓包,浏览器和 Apifox 请求 /forward
的数据包。发现 Apifox 在 multipart body 的 Content-Disposition
header 中多了一个 filename*
的属性。
Content-Disposition 的文档中这样写到:
filename
后面是要传送的文件的初始名称的字符串。这个参数总是可选的,而且不能盲目使用:路径信息必须舍掉,同时要进行一定的转换以符合服务器文件系统规则。这个参数主要用来提供展示性信息。当与
Content-Disposition: attachment
一同使用的时候,它被用作"保存为"对话框中呈现给用户的默认文件名。
filename\*
filename
和filename*
两个参数的唯一区别在于,filename*
采用了 RFC 5987 中规定的编码方式。当filename
和filename*
同时出现的时候,应该优先采用filename*
,假如二者都支持的话。
filename*
的优先级高于 filename
。
当使用
multipart/form-data
格式提交表单数据时,每个子部分(例如每个表单字段和任何与字段数据相关的文件)都需要提供一个Content-Disposition
标头,以提供相关信息。标头的第一个指令始终为form-data
,并且还必须包含一个name
参数来标识相关字段。额外的指令不区分大小写,并使用带引号的字符串语法在=
号后面指定参数。多个参数之间使用分号(;
)分隔。Content-Disposition: form-data; name="fieldName" Content-Disposition: form-data; name="fieldName"; filename="filename.jpg"
上述 Content-Disposition 的文档中也指出了,要添加多个参数,需要用 ;
分隔。
下一步就是要弄明白 filename*
的值应该怎么构造。以下是 RFC 5987 中关于参数名、参数值的语法,其中又提到了 RFC 2231、RFC 3986, 比较晦涩难懂:
parameter = reg-parameter / ext-parameterreg-parameter = parmname LWSP "=" LWSP valueext-parameter = parmname "*" LWSP "=" LWSP ext-valueparmname = 1*attr-charext-value = charset "'" [ language ] "'" value-chars; like RFC 2231's <extended-initial-value>; (see [RFC2231], Section 7)charset = "UTF-8" / "ISO-8859-1" / mime-charsetmime-charset = 1*mime-charsetcmime-charsetc = ALPHA / DIGIT/ "!" / "#" / "$" / "%" / "&"/ "+" / "-" / "^" / "_" / "`"/ "{" / "}" / "~"; as <mime-charset> in Section 2.3 of [RFC2978]; except that the single quote is not included; SHOULD be registered in the IANA charset registrylanguage = <Language-Tag, defined in [RFC5646], Section 2.1>value-chars = *( pct-encoded / attr-char )pct-encoded = "%" HEXDIG HEXDIG; see [RFC3986], Section 2.1attr-char = ALPHA / DIGIT/ "!" / "#" / "$" / "&" / "+" / "-" / "."/ "^" / "_" / "`" / "|" / "~"; token except ( "*" / "'" / "%" )
总结后就是:
// charset 字符集,比如 UTF-8、ISO-8859-1 // language 语言,比如 en,可以省略,language 的前后使用单引号分隔 // percentEncoding 百分号编码,% 加上两个 16 进制,比如中的编码为 %E4%B8%AD,我们常见的空格的编码为 %20 // rawStr 原始的字符串,比如 中文 abc 123.png parmname + "*" + "=" + charset + "'" + language + "'" + percentEncoding(rawStr)
js 相关
注意 encodeURIComponent
并未将 百分号编码 | MDN 中描述的所有特殊字符进行编码,在 encodeURIComponent() - JavaScript | MDN 的描述中提到它不会编码 ! * ' ( )
这五个字符,所以需要特殊处理。
百分号编码 | MDN 还提到了以下内容,这对后续 java 后端添加 filename*
很重要。
根据上下文,空白符
' '
将会转换为'+'
(如使用百分号编码的application/x-www-form-urlencoded
消息),或者将会转换为'%20'
(如 URL 中)。
翻译成 js 代码是:
// 例如 filename*=UTF-8''%E4%B8%AD%E6%96%87%20abc%20123.png
parmname = "filename"
charset = "UTF-8"
language = ""
rawStr = "中文 abc 123.png"function percentEncoding(rawStr) {var res = encodeURIComponent(rawStr)// 特殊处理 ! * ' ( )res = res.replace(/\*/g, '%2A')res = res.replace(/!/g, '%21')res = res.replace(/\(/g, '%28')res = res.replace(/\)/g, '%29')res = res.replace(/'/g, '%27')return res
}
// 重点
kv = `${parmname}*=${charset}'${language}'${percentEncoding(rawStr)}`
解决问题
解决问题最直接的解决办法就是前端上传时在 Content-Disposition
中添加 ; filename*=UTF-8''%E4%B8%AD%E6%96%87%20abc%20123.png
这一段。但查阅资料后发现 <form>
标签不支持直接控制 Content-Disposition
的值。
最后采用后端转发时添加 filename*
的方式。
/*** 转发请求*/
@PostMapping("/forward")
public String forward(HttpServletRequest request) throws ServletException, IOException {try (CloseableHttpClient httpclient = HttpClients.createDefault()) {ClassicRequestBuilder requestBuilder = ClassicRequestBuilder.post("http://localhost:8080/upload");MultipartEntityBuilder entityBuilder = MultipartEntityBuilder.create();for (Part part : request.getParts()) {MultipartPartBuilder partBuilder = MultipartPartBuilder.create();for (String headerName : part.getHeaderNames()) {String headerValue = part.getHeader(headerName);// 如果是文件上传,则为 Content-Disposition 添加 filename*if ("Content-Disposition".equalsIgnoreCase(headerName)&& headerValue.contains("filename")&& !headerValue.contains("filename*")) {headerValue += "; filename*=UTF-8''%s".formatted(percentEncoding(part.getSubmittedFileName()));}partBuilder.addHeader(headerName, headerValue);}InputStreamBody body = new InputStreamBody(part.getInputStream(), part.getSubmittedFileName());partBuilder.setBody(body);entityBuilder.addPart(partBuilder.build());}ClassicHttpRequest forwardRequest = requestBuilder.setEntity(entityBuilder.build()).build();return httpclient.execute(forwardRequest, response -> {final HttpEntity responseEntity = response.getEntity();String res = EntityUtils.toString(responseEntity);EntityUtils.consume(responseEntity);return res;});}
}private static String percentEncoding(String raw) {// 在 encode 方法的注释中能看出,encode 方法遵循 application/x-www-form-urlencoded 的规范,// 会将空格替换为 +,所以需要再次将 + 替换为 %20return URLEncoder.encode(raw, StandardCharsets.UTF_8).replace("+", "%20");
}
乱码问题完美解决:
参考
- Content-Disposition - HTTP | MDN | en-US
- Content-Disposition - HTTP | MDN | zh-CN
- RFC 5987 - Character Set and Language Encoding for Hypertext Transfer Protocol (HTTP) Header Field Parameters
- RFC 2231 - MIME Parameter Value and Encoded Word Extensions: Character Sets, Languages, and Continuations
- 百分号编码 - MDN Web 文档术语表:Web 相关术语的定义 | MDN
- encodeURIComponent() - JavaScript | MDN
- RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
- 【编码篇】看破字符 %20 之谜,百分号编码以及其背后前言 提到这个 %20,想必大家都见过,熟悉一点编码的人,还会知道 - 掘金
- 下载的附件名总乱码?你该去读一下 RFC 文档了! - 郑晓龙 - 博客园