下載的附件名總亂碼?你該去讀一下 RFC 文件了!

Java課代表發表於2020-08-17
紙上得來終覺淺,絕知此事要躬行

Web 開發過程中,相信大家都遇到過附件下載的場景,其中,各瀏覽器下載後的檔名中文亂碼問題或許一度讓你苦惱不已。

網上搜尋一下,大部分都是通過Request Headers中的UserAgent欄位來判斷瀏覽器型別,根據不同的瀏覽器做不同的處理,類似下面的程式碼:

// MicroSoft Browser
if (agent.contains("msie") || agent.contains("trident") || agent.contains("edge")) {
  // filename 特殊處理
}
// firefox
else if (agent.contains("firefox")) {
  // filename 特殊處理
}
// safari
else if (agent.contains("safari")) {
  // filename 特殊處理
}
// Chrome
else if (agent.contains("chrome")) {
  // filename 特殊處理
}
// 其他
else{
 // filename 特殊處理
}
//最後把特殊處理後的檔名放到head裡
response.setHeader("Content-Disposition",
                    "attachment;fileName=" + filename);

不過,這樣的程式碼看起來很魔幻,為什麼每個瀏覽器的處理方式都不一樣?難道每次新出一個瀏覽器都要做相容嗎?就沒有一個統一標準來約束一下這幫瀏覽器嗎?

帶著這個疑惑,我翻閱了 RFC 文件,最終得出了一個優雅的解決方案:

// percentEncodedFileName 為百分號編碼後的檔名
response.setHeader("Content-disposition",
        "attachment;filename=" + percentEncodedFileName +
        ";filename*=utf-8''" + percentEncodedFileName);

經過測試,這段響應頭可以相容市面上所有主流瀏覽器,由於是 HTTP 協議範疇,所以語言無關。只要按這個規則設定響應頭,就能一勞永逸地解決惱人的附件名中文亂碼問題。

接下來課代表帶大家抽絲剝繭,通過閱讀 RFC 文件,還原一下這個響應頭的產出過程。

1. Content-Disposition

一切要從 RFC 6266 開始,在這份文件中,介紹了Content-Disposition響應頭,其實它並不屬於HTTP標準,但是因為使用廣泛,所以在該文件中進行了約束。它的語法格式如下:

content-disposition = "Content-Disposition" ":"
                            disposition-type *( ";" disposition-parm )

     disposition-type    = "inline" | "attachment" | disp-ext-type
                         ; case-insensitive
     disp-ext-type       = token

     disposition-parm    = filename-parm | disp-ext-parm

     filename-parm       = "filename" "=" value
                         | "filename*" "=" ext-value

其中的disposition-type有兩種:

  • inline 代表預設處理,一般會在頁面展示
  • attachment 代表應該被儲存到本地,需要配合設定filenamefilename*

注意到disposition-parm中的filenamefilename*,文件規定:這裡的資訊可以用於儲存的檔名。

它倆的區別在於,filename 的 value 不進行編碼,而filename*遵從 RFC 5987中定義的編碼規則:

Producers MUST use either the "UTF-8" ([RFC3629]) or the "ISO-8859-1" ([ISO-8859-1]) character set.

由於filename*是後來才定義的,許多老的瀏覽器並不支援,所以文件規定,當二者同時出現在頭欄位中時,需要採用filename*,忽略filename

至此,響應頭的骨架已經呼之欲出了,摘錄 [RFC 6266] 中的示例如下:

 Content-Disposition: attachment;
                      filename="EURO rates";
                      filename*=utf-8''%e2%82%ac%20rates

這裡對filename*=utf-8''%e2%82%ac%20rates做一下說明,這個寫法乍一看可能會覺得很奇怪,它其實是用單引號作為分隔符,將等號右邊分成了三部分:第一部分是字符集(utf-8),中間部分是語言(未填寫),最後的%e2%82%ac%20rates代表了實際值。對於這部分的組成,在RFC 2231.section 4 中有詳細說明:

 A single quote is used to
   separate the character set, language, and actual value information in
   the parameter value string, and an percent sign is used to flag
   octets encoded in hexadecimal.

2.PercentEncode

PercentEncode 又叫 Percent-encoding 或 URL encoding.

正如前文所述,filename*遵守的是[RFC 5987] 中定義的編碼規則,在[RFC 5987] 3.2中定義了必須支援的字符集:

recipients implementing this specification
MUST support the character sets "ISO-8859-1" and "UTF-8".

並且在[RFC 5987] 3.2.1規定,百分號編碼遵從 RFC 3986.section 2.1中的定義,摘錄如下:

A percent-encoding mechanism is used to represent a data octet in a
component when that octet's corresponding character is outside the
allowed set or is being used as a delimiter of, or within, the
component.  A percent-encoded octet is encoded as a character
triplet, consisting of the percent character "%" followed by the two
hexadecimal digits representing that octet's numeric value.  For
example, "%20" is the percent-encoding for the binary octet
"00100000" (ABNF: %x20), which in US-ASCII corresponds to the space
character (SP).  Section 2.4 describes when percent-encoding and
decoding is applied.

注意了,[RFC 3986] 明確規定了空格 會被百分號編碼為%20

而在另一份文件 RFC 1866.Section 8.2.1 The form-urlencoded Media Type 中卻規定:

The default encoding for all forms is `application/x-www-form-
   urlencoded'. A form data set is represented in this media type as
   follows:

        1. The form field names and values are escaped: space
        characters are replaced by `+', and then reserved characters
        are escaped as per [URL]

這裡要求application/x-www-form-urlencoded型別的訊息中,空格要被替換為+,其他字元按照[URL]中的定義來轉義,其中的[URL]指向的是RFC 1738 而它的修訂版中和 URL 有關的最新文件恰恰就是 [RFC 3986]

這也就是為什麼很多文件中描述空格(white space)的百分號編碼結果都是 +%20,如:

w3schools:URL encoding normally replaces a space with a plus (+) sign or with %20.

MDN:Depending on the context, the character ' ' is translated to a '+' (like in the percent-encoding version used in an application/x-www-form-urlencoded message), or in '%20' like on URLs.

那麼問題來了,開發過程中,對於空格符的百分號編碼我們應該怎麼處理?

課代表建議大家遵循最新文件,因為 [RFC 1866] 中定義的情況僅適用於application/x-www-form-urlencoded型別, 就百分號編碼的定義來說,我們應該以 [RFC 3986] 為準,所以,任何需要百分號編碼的地方,都應該將空格符 百分號編碼為%20,stackoverflow 上也有支援此觀點的答案:When to encode space to plus (+) or %20?

3. 程式碼實踐

有了理論基礎,程式碼寫起來就水到渠成了,直接上程式碼:

@GetMapping("/downloadFile")
public String download(String serverFileName, HttpServletRequest request, HttpServletResponse response) throws IOException {

    request.setCharacterEncoding("utf-8");
    response.setContentType("application/octet-stream");

    String clientFileName = fileService.getClientFileName(serverFileName);
    // 對真實檔名進行百分號編碼
    String percentEncodedFileName = URLEncoder.encode(clientFileName, "utf-8")
            .replaceAll("\\+", "%20");

    // 組裝contentDisposition的值
    StringBuilder contentDispositionValue = new StringBuilder();
    contentDispositionValue.append("attachment; filename=")
            .append(percentEncodedFileName)
            .append(";")
            .append("filename*=")
            .append("utf-8''")
            .append(percentEncodedFileName);
    response.setHeader("Content-disposition",
            contentDispositionValue.toString());
    
    // 將檔案流寫到response中
    try (InputStream inputStream = fileService.getInputStream(serverFileName);
         OutputStream outputStream = response.getOutputStream()
    ) {
        IOUtils.copy(inputStream, outputStream);
    }

    return "OK!";
}

程式碼很簡單,其中有兩點需要說明一下:

  1. URLEncoder.encode(clientFileName, "utf-8")方法之後,為什麼還要.replaceAll("\\+", "%20")

    正如前文所述,我們已經明確,任何需要百分號編碼的地方,都應該把 空格符編碼為 %20,而URLEncoder這個類的說明上明確標註其會將空格符轉換為+:

    The space character "   " is converted into a plus sign "{@code +}".

    其實這並不怪 JDK,因為它的備註裡說明了其遵循的是application/x-www-form-urlencoded( PHP 中也有這麼一個函式,也是這麼個套路)

    Translates a string into {@code application/x-www-form-urlencoded} format using a specific encoding scheme. This method uses the

    所以這裡我們用.replaceAll("\\+", "%20")+號處理一下,使其完全符合 [RFC 3986] 的百分號編碼規範。這裡為了方便說明問題,把所有操作都展現出來了。當然,你完全可以自己實現一個PercentEncoder類,豐儉由人。

  2. [RFC 6266] 標準中filename=value是不需要編碼的,這裡的filename=後面的 value 為什麼要百分號編碼?

    回顧 [RFC 6266] 文件, filenamefilename*同時出現時取後者,瀏覽器太老不支援新標準時取前者。

    目前主流的瀏覽器都採用自升級策略,所以大部分都支援新標準------除了老版本IE。老版本的IE對 value 的處理策略是 進行百分號解碼 並使用。所以這裡專門把filename=value進行百分號編碼,用來相容老版本 IE。

    PS:課代表實測 IE11 及 Edge 已經支援新標準了。

4. 瀏覽器測試

根據下圖 statcounter 統計的 2019 年中國市場瀏覽器佔有率,課代表設計了一個包含中文,英文,空格的檔名 下載-down test .txt用來測試

image

測試結果:

BrowserVersionpass
Chrome84.0.4147.125true
UCV6.2.4098.3true
Safari13.1.2true
QQ Browser10.6.1(4208)true
IE7-11true
Firefox79.0true
Edge44.18362.449.0true
360安全瀏覽器1212.2.1.362.0true
Edge(chromium)84.0.522.59true

根據測試結果可知:基本已經能夠相容市面上所有主流瀏覽器了。

5.總結

回顧本文內容,其實就是瀏覽器相容性問題引發的附件名亂碼,為了解決這個問題,查閱了兩類標準文件:

  1. HTTP 響應頭相關標準

    [RFC 6266]、[RFC 1866]

  2. 編碼標準

    [RFC 5987]、[RFC 2231]、[3986]、[1738]

我們以 [RFC 6266] 為切入點,全文總共引用了 6 個 [RFC] 相關文件,引用都標明瞭出處,感興趣的同學可以跟著文章思路閱讀一下原文件,相信你會對這個問題有更深入的理解。文中程式碼已上傳 github

最後不禁要感嘆一下:規範真是個好東西,它就像 Java 語言中的 interface,只制定標準,具體實現留給大家各自發揮。

如果覺得本文對你有幫助,歡迎收藏、分享、在看三連

6.參考資料

[1]RFC 6266: https://tools.ietf.org/html/r...

[2]RFC 5987: https://tools.ietf.org/html/r...

[3]RFC 2231: https://tools.ietf.org/html/r...

[4]RFC 3986: https://tools.ietf.org/html/r...

[5]RFC 1866: https://tools.ietf.org/html/r...

[6]RFC 1738: https://tools.ietf.org/html/r...

[7]When to encode space to plus (+) or %20?: https://stackoverflow.com/que...


2020年8月19日更新:
文中內容已合併進入開源框架 若依 (pull request:196


?關注 Java課代表,獲取最新 Java 乾貨?
image

相關文章