1. 程式人生 > >下載的附件名總亂碼?你該去讀一下 RFC 文件了!

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

> 紙上得來終覺淺,絕知此事要躬行 Web 開發過程中,相信大家都遇到過附件下載的場景,其中,各瀏覽器下載後的檔名中文亂碼問題或許一度讓你苦惱不已。 網上搜索一下,大部分都是通過`Request Headers`中的`UserAgent`欄位來判斷瀏覽器型別,根據不同的瀏覽器做不同的處理,類似下面的程式碼: ```java // 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 文件,最終得出了一個優雅的解決方案: ```java // percentEncodedFileName 為百分號編碼後的檔名 response.setHeader("Content-disposition", "attachment;filename=" + percentEncodedFileName + ";filename*=utf-8''" + percentEncodedFileName); ``` 經過測試,這段響應頭可以相容市面上所有主流瀏覽器,由於是 HTTP 協議範疇,所以語言無關。只要按這個規則設定響應頭,就能一勞永逸地解決惱人的附件名中文亂碼問題。 接下來課代表帶大家抽絲剝繭,通過閱讀 RFC 文件,還原一下這個響應頭的產出過程。 ## 1. Content-Disposition 一切要從 [RFC 6266](https://tools.ietf.org/html/rfc6266) 開始,在這份文件中,介紹了`Content-Disposition`響應頭,其實它並不屬於`HTTP`標準,但是因為使用廣泛,所以在該文件中進行了約束。它的語法格式如下: ```java 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 代表應該被儲存到本地,需要配合設定`filename`或`filename*` 注意到`disposition-parm`中的`filename`和`filename*`,文件規定:這裡的資訊可以用於儲存的檔名。 它倆的區別在於,filename 的 value 不進行編碼,而`filename*`遵從 [RFC 5987](https://tools.ietf.org/html/rfc5987)中定義的編碼規則: ```java Producers MUST use either the "UTF-8" ([RFC3629]) or the "ISO-8859-1" ([ISO-8859-1]) character set. ``` 由於`filename*`是後來才定義的,許多老的瀏覽器並不支援,所以文件規定,當二者同時出現在頭欄位中時,需要採用`filename*`,忽略`filename`。 至此,響應頭的骨架已經呼之欲出了,摘錄 [RFC 6266] 中的示例如下: ```java 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](https://tools.ietf.org/html/rfc2231).section 4 中有詳細說明: ```java 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中定義了必須支援的字符集: ```java recipients implementing this specification MUST support the character sets "ISO-8859-1" and "UTF-8". ``` 並且在[RFC 5987] 3.2.1規定,百分號編碼遵從 [RFC 3986](https://tools.ietf.org/html/rfc3986).section 2.1中的定義,摘錄如下: ```java 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](https://tools.ietf.org/html/rfc1866).Section 8.2.1 *The form-urlencoded Media Type* 中卻規定: ```java 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](https://tools.ietf.org/html/rfc1738) 而它的修訂版中和 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?](https://stackoverflow.com/questions/2678551/when-to-encode-space-to-plus-or-20) ## 3. 程式碼實踐 有了理論基礎,程式碼寫起來就水到渠成了,直接上程式碼: ```java @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] 文件, `filename`和`filename*`同時出現時取後者,瀏覽器太老不支援新標準時取前者。 目前主流的瀏覽器都採用自升級策略,所以大部分都支援新標準------除了老版本IE。老版本的IE對 value 的處理策略是 進行百分號解碼 並使用。所以這裡專門把`filename=`的`value`進行百分號編碼,用來相容老版本 IE。 PS:課代表實測 IE11 及 Edge 已經支援新標準了。 ## 4. 瀏覽器測試 根據下圖 statcounter 統計的 2019 年中國市場瀏覽器佔有率,課代表設計了一個包含中文,英文,空格的檔名 `下載-down test .txt`用來測試 ![](https://img2020.cnblogs.com/blog/1181064/202008/1181064-20200812194057839-955114611.png) 測試結果: | Browser | Version | pass | | --------------- | -------------- | ---- | | Chrome | 84.0.4147.125 | true | | UC | V6.2.4098.3 | true | | Safari | 13.1.2 | true | | QQ Browser | 10.6.1(4208) | true | | IE | 7-11 | true | | Firefox | 79.0 | true | | Edge | 44.18362.449.0 | true | | 360安全瀏覽器12 | 12.2.1.362.0 | true | | Edge(chromium) | 84.0.522.59 | true | 根據測試結果可知:基本已經能夠相容市面上所有主流瀏覽器了。 ## 5.總結 回顧本文內容,其實就是瀏覽器相容性問題引發的附件名亂碼,為了解決這個問題,查閱了兩類標準文件: 1. HTTP 響應頭相關標準 [RFC 6266]、[RFC 1866] 2. 編碼標準 [RFC 5987]、[RFC 2231]、[3986]、[1738] 我們以 [RFC 6266] 為切入點,全文總共引用了 6 個 [RFC] 相關文件,引用都標明瞭出處,感興趣的同學可以跟著文章思路閱讀一下原文件,相信你會對這個問題有更深入的理解。文中程式碼已上傳 [github](https://github.com/zhengxl5566/springboot-demo) 最後不禁要感嘆一下:規範真是個好東西,它就像 Java 語言中的 `interface`,只制定標準,具體實現留給大家各自發揮。 如果覺得本文對你有幫助,歡迎收藏、分享、在看三連 ## 6.參考資料 [1]RFC 6266: *https://tools.ietf.org/html/rfc6266* [2]RFC 5987: *https://tools.ietf.org/html/rfc5987* [3]RFC 2231: *https://tools.ietf.org/html/rfc2231* [4]RFC 3986: *https://tools.ietf.org/html/rfc3986* [5]RFC 1866: *https://tools.ietf.org/html/rfc1866* [6]RFC 1738: *https://tools.ietf.org/html/rfc1738* [7]When to encode space to plus (+) or %20?: *https://stackoverflow.com/questions/2678551/when-to-encode-space-to-plus-or-20* ---