1. 程式人生 > >正確處理下載檔案時中文檔名亂碼的問題(Content-Disposition)

正確處理下載檔案時中文檔名亂碼的問題(Content-Disposition)

特別宣告:本文非原創,原文:http://blog.robotshell.org/2012/deal-with-http-header-encoding-for-file-download/,如有冒犯,請及時聯絡我。

最近在做專案時遇到了一個 case :需要實現一個強制下載功能(即強制彈出下載對話方塊),並且檔名必須保持和使用者之前上傳時相同(可能包含非 ASCII 字元)。

前一個需求很容易實現:使用 HTTP Header 的 Content-Disposition: attachment 即可,還可以配合 Content-Type: application/octet-stream來確保萬無一失。而後一個需求就比較蛋疼了,牽扯到 Header 的編碼問題(檔名是作為 filename 引數放在 Content-Disposition 裡面的)。眾所周知, HTTP Header 中的 Content-Type 可以指定內容的編碼,可 Header 本身的編碼又該如何制定?甚至, Header 究竟是否允許非 ASCII 編碼呢?

如果放任編碼問題不管,那麼恭喜你,你一定會遇到在某個系統及瀏覽器下下載檔案時檔名亂碼的情況。如果你嘗試搜尋解決,那麼再一次恭喜你,你會找到一堆自相矛盾的解決方案(我可以負責任地告訴你,其中的99%都是不符合標準的 trick 罷了)。讓我們來看看到底應該如何優雅完美地解決這個問題吧!

為了探索這個問題,我走了不少彎路。從自己嘗試,到 Google 、百度(分別嘗試過中英文搜尋),再到閱讀 Discuz 等經典專案的原始碼,眾說紛紜、莫衷一是。最後我才想到迴歸 RFC ,從標準文件中找辦法,果然有所收穫。由於探究過程實在太曲折,我就先把標準做法寫下來。

應該這樣設定 Content-Disposition :

1
2
3
Content-Disposition: attachment;
                     filename="$encoded_fname";
                     filename*=utf-8''$encoded_fname

其中,$encoded_fname指的是將 UTF-8 編碼的原始檔名按照 RFC 3986 進行百分號 urlencode 後得到的( PHP 中使用 rawurlencode()函式)。這幾行也可以合併為一行,推薦使用一個空格隔開。

另外,為了相容 IE6 ,請保證原始檔名必須包含英文副檔名!

好了,接下來我們來看看為什麼要這麼做以及為什麼能這麼做。

首先,根據 HTTP 1.1 協議規範( RFC 2616 Section 4 ), HTTP 訊息格式其實是基於古老的 ARPA INTERNET TEXT MESSAGES ( RFC 822 Section 3 ),根據其規定,訊息只能是 ASCII 編碼的。 RFC 2616 Section 2.2 又一次強調, TEXT 中若要使用其他字符集,必須使用 RFC 2047 的規則將字串編碼為 ASCII 碼(事實上這個規則原本是針對 MIME 的擴充套件,使用的是 base64 編碼,格式與百分號編碼有很大不同)。總而言之,按照標準, HTTP Header 中的文字資料必須是 ASCII 編碼的。

1
2
3
4
filename="TEXT"
;這是 RFC 2616 標準,TEXT必須是 ASCII 字元且被認為就是“原文”
filename*=charset'lang'encoded-text
;這是按照 RFC 2047 擴充套件後的,注意格式上的細微區別,採用 base64 編碼(編碼結果也是 ASCII 字元)

然而,事實上在1999年 HTTP 1.1 標準推出之時, Content-Dispostion 這個 Header 尚不是正式標準的一部分,只不過是因為被廣泛使用而從 MIME 標準中直接借用過來了而已( RFC 2616 Section 19.5.1 )。因而幾乎沒有瀏覽器去支援 Content-Disposition 的多語言編碼特性這樣一個“擴充套件特性的擴充套件特性”(事實上, HTTP 1.1 草案中建議的使用 RFC 2047 來進行多語言編碼的特性從未被主流瀏覽器支援過)。

可是這個問題卻的確是現實需要的,所以瀏覽器就各自想出了一些辦法:

  • IE支援兩種格式的混合版:filename="encoded_text" (這裡採用的是百分號編碼)。本來按照 RFC 2616 ,引號內的部分應當直接被當作內容,就算它“看起來像是編碼後的字串”;可是IE卻會“自動”對這樣的檔名進行解碼——前提是該檔名必須有一個不會被編碼的字尾名(即正常的英文字母后綴名)!
  • 其他一些瀏覽器則支援一種更為粗暴的方式——允許在 filename="TEXT" 中直接使用 UTF-8 編碼的字串!

這兩類瀏覽器的行為是彼此互不相容的。所以你可以判斷 UA 然後對IE使用前一種辦法,其他瀏覽器使用後一種,這樣便可以達到一般情況下能夠 just work 的效果( Discuz 就是這麼做的)。不過對於 Opera 和 Safari ,這樣做可能不一定有效。

時代在進步,2010年 RFC 5987 釋出,正式規定了 HTTP Header 中多語言編碼的處理方式,應當採用類似 MIME 擴充套件的 parameter*=charset'lang'value 的格式,但是其中 value 應根據 RFC 3986 Section 2.1 使用百分號進行編碼,並且規定瀏覽器至少應該支援 ASCII 和 UTF-8 。隨後,2011年 RFC 6266 釋出,正式將 Content-Disposition 納入 HTTP 標準,並再次強調了 RFC 5987 中多語言編碼的方法,還給出了一個範例用於解決向後相容的問題——就是我在一開始給出的例子:

1
2
3
Content-Disposition: attachment;
                     filename="encoded_text";
                     filename*=utf-8''encoded_text

在這個例子中,對於較新的 Firefox 、 Chrome 、 Opera 、 Safari 等瀏覽器,都支援新標準規定的 filename* ,並且會優先使用,所以儘管 filename=”encoded_text” 不被它們支援,仍然不會有問題;至於使用 UTF-8 只是因為它是標準中強制要求必須支援的。而對於舊版本的IE瀏覽器,它們無法識別後面的 filename* ,會自動忽略並使用舊的 filename 。這樣一來就完美解決了多瀏覽器的多語言相容問題,既不需要 UA 判斷,也符合標準。

P.S. 為什麼 PHP 要使用 rawurlencode() 函式呢?因為這才是真正符合 RFC 3986 的“百分號URL編碼”,只是由於歷史原因,之前先有了一個 urlencode() 函式用於實現 HTTP POST 中的類似的編碼規則,故而只好用這麼一個奇怪的名字。兩者的區別在於前者會把空格編碼為%20,而後者則會編碼為+號。如果使用後者,那麼IE6在下載帶有空格的檔名時空格會變為加號。一般情況下,你是不會用到urlencode() 這個函式的( Discuz 某些版本中錯誤地使用它來進行檔名編碼,從而導致空格變加號的BUG)。