下載文件解決中文亂碼及HTTP頭的編碼問題(Content-Disposition)

需要實現一個強制下載功能(即強制彈出下載對話框,阻止瀏覽器嘗試解析顯示某些文件格式),並且文件名必須保持和用戶之前上傳時相同(可能包含非 ASCII 字符)。

前一個需求很容易實現:使用 HTTP Header 的 Content-Disposition: attachment 即可,還可以配合 Content-Type: application/octet-stream 來確保萬無一失。而後一個需求就比較蛋疼了,牽扯到 Header 的編碼問題(文件名是作爲 filename 參數放在 Content-Disposition 裏面的)。衆所周知, HTTP Header 中的 Content-Type 可以指定內容(body)的編碼,可 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 進行百分號編碼(percent encoding)後得到的( PHP 中使用 rawurlencode() 函數)。這幾行也可以合併爲一行(推薦使用一個空格隔開)。

另外,爲了兼容 IE6 ,請保證原始文件名必須包含英文擴展名

追根究底

接下來我們來看看爲什麼要這麼做以及爲什麼能這麼做。

首先,根據 RFC 2616 所定義的 HTTP 1.1 協議( RFC 2068 是最早的版本;2616替代了2068並被最廣泛使用,而後又被其他 RFC 替代,後文將會提及), HTTP 消息格式其實是基於古老的 ARPA Internet Text Messages ,而 ARPA 消息只能是 ASCII 編碼的( RFC 822 Section 3 )。 RFC 2616 Section 2.2 更是再一次強調, TEXT( Section 4.2:Header 中的字段值即爲 TEXT )中若要使用其他字符集,必須使用 RFC 2047 的規則將字符串編碼/逃逸——必須要注意的是,這個規則原本是針對 MIME (電子郵件)的擴展,格式與百分號編碼有很大不同。給一個在 MIME 中的例子:

1

Subject: =?ISO-8859-1?B?SWYgeW91IGNhbiByZWFkIHRoaXMgeW8=?=

在1999年 RFC 2616 推出之時, Content-Dispostion 這個 Header 尚不是正式 HTTP 協議的一部分,只不過是因爲被廣泛使用而從 MIME 標準中直接借用過來了而已( RFC 2616 Section 19.5.1 )。因而幾乎沒有瀏覽器去支持 Content-Disposition 的多語言編碼特性這樣一個“擴展特性的擴展特性”。事實上,RFC 2616 中建議的使用 RFC 2047 來進行多語言編碼的特性從未被主流瀏覽器支持過,所以我們也不用操心上面這個 MIME 方案了……

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

  • IE支持在 filename 中直接使用百分號編碼:filename="$encoded_text"(並非 MIME 編碼!)。本來按照 RFC 2616 ,引號內的部分如果不是 MIME 編碼,則應當直接被當作內容,就算它“看起來像是百分號編碼後的字符串”;可是IE卻會“自動”對這樣的文件名進行解碼——前提是該文件名必須有一個不會被編碼的(即 ASCII)後綴名
  • 其他一些瀏覽器則支持一種更爲粗暴的方式:允許在 filename="TEXT" 中直接使用 UTF-8 編碼的字符串!這也是直接違反了 RFC 2616 HTTP 頭必須是 ASCII 編碼的規定。

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

時代在進步,2010年 RFC 5987 發佈,正式規定了 HTTP Header 中多語言編碼的處理方式採用 parameter*=charset'lang'value 的格式,其中:

  • charset 和 lang 不區分大小寫。
  • lang 是用來標註字段的語言,以供讀屏軟件朗誦或根據語言特性進行特殊渲染,可以留空。
  • value 根據 RFC 3986 Section 2.1 使用百分號編碼,並且規定瀏覽器至少應該支持 ASCII 和 UTF-8 。
  • 當 parameter 和 parameter* 同時出現在 HTTP 頭中時,瀏覽器應當使用後者。

其好處是保持了向前兼容性:一來 HTTP 頭仍然是 ASCII-only ,二來不支持該標準的舊版瀏覽器會按照當年 RFC 2616 的規定,把 parameter* 整體當作一個 field name ,從而當作一個未知的字段來忽略。隨後,2011年 RFC 6266 發佈,正式將 Content-Disposition 納入 HTTP 標準,並再次強調了 RFC 5987 中多語言編碼的方法,還給出了一個範例用於解決向後兼容的問題:

1
2
3

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

這個例子裏,filename 的值是一個同義英語詞組——這樣符合 RFC 2616 ,普通的字段不應當被編碼;至於使用 UTF-8 只是因爲它是標準中強制要求必須支持的。然而,如果我們再仔細想想——目前市場上常見的舊版本瀏覽器多爲 IE 。如此一來,我們可以適當變通一下,將 filename 字段也直接使用百分號編碼後的字符串:

1
2
3

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

對於較新的 Firefox 、 Chrome 、 Opera 、 Safari 等瀏覽器,都支持並會使用新標準規定的 filename* ,即使它們不會自動解碼 filename 也無所謂了;而對於舊版本的IE瀏覽器,它們無法識別 filename* ,會將其自動忽略並使用舊的 filename(唯一的小瑕疵是必須要有一個英文後綴名)。這樣一來就完美解決了多瀏覽器的多語言兼容問題,既不需要 UA 判斷,也較爲符合標準。

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章