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 文檔了! - 鄭曉龍 - 博客園