你曾經對神秘的Content-Type標籤感到好奇嗎?就是那個在HTML中經常用到但是很少有人瞭解為什麼要去使用它的標籤。
你曾經收到過一封來自保加利亞的朋友發給你的郵件,郵件的標題是“???? ?????? ??? ????” ?
我很失望的發現有非常多的軟件開發者並不瞭解字符集,編碼,unicode等相關的知識。幾年前, FogBUGZ網站的一個測試人員想要知道它是否能夠成功接收來自日本的郵件。日本?日本也要用這個郵件系統?我一頭霧水。在仔細研究用來解析MIME郵件消息的商業ActiveX控制器後,發現它解析字符集的方式是完全錯誤的,所以我們不得不大膽的寫一些代碼來糾正錯誤的轉化使其正確解析。看了其他的商業化代碼庫之後,發現它們的字符解析實現也非常的簡陋。我聯繫了那個庫的開發者,他們的態度是“我們啥都做不了”。和很多程序員一樣,他希望這件事情可以就這麼過去了。
但是顯然這個問題不能就這麼算了。當我發現PHP這個如此流行的Web開發工具都幾乎完全無視了字符編碼的問題, 隨意的用着8位存儲的字符,使得幾乎無法用其開發國際化網頁應用。我覺得真的夠了,再也忍不了了!
所以在此我要鄭重聲明:如果你現在是一名程序員卻不瞭解字符,字符集,編碼和Unicode的基礎知識,一旦被我發現,我就要罰你到深海潛水艇上寂寞的剝6個月的洋葱!
我還要説一點,這個問題並沒有想象中的那麼難!
這篇文章我會聊一些每一個程序員所必須知道的內容。什麼“plain text = ascii = 8位自符”這些東西簡直是大錯特錯。如果你還用那種思路編程,就彷彿是一個不相信細菌存在的外科醫生。請在閲讀完本文之後再去繼續你的編碼生涯。
在開始之前,我要提醒那些極少數瞭解國際化編程的同學,你們會發現這篇文章的內容有些過度簡化。因為我只分享了最基礎的內容,從而讓每一個人能夠理解並且試着寫出一個非英語環境下都能夠正確運行的程序。我還要聲明,正確的字符編碼只是國際化程序能夠良好運行的一個很小的前提,但這次不擴大範圍,先只聊這件事。
歷史的視角
瞭解這個問題最好的方式就是沿着時間線追溯。
你可能以為我要説一説非常古老的字符集EBCDIC,但是我不~EBCDIC已經和我們現在的編碼無關了,我們不需要追溯那麼遠的歷史。
在上古時期,當Unix剛剛被髮明出來,K&R還在寫C語言的時候,一切都是那麼的簡單。EBCDIC剛剛被淘汰出局,我們只需要關注一種字符類型,那就是英文字母。我們使用了一種叫做ASCII的編碼方式,通過32和127之間的數字來表示任意一個字符。比如Space的編碼是32,A的編碼是65。這種編碼可以用7位輕鬆存儲。那個年代大多數的電腦都使用8位字節,因此我們不僅可以存儲每個ASCII碼字符,還有一個空閒位來支持一些控制指令,比如7可以表示讓電腦告警,12可以命令打印機的當前頁移出並引入新的紙張。
一切看上去是那麼美好,前提是你是一個英文開發者。
因為一個字節有8位而ASCII編碼只用了其中的7位,很多人都開始想,“誒喲,我們可以自定義128~255這個區間所代表的字符”。問題是,當時很多人同時產生了這個想法,並且發明了各式各樣的自定義編碼映射。IBM電腦提出了一個稱為OEM的字符集,其中包含了一些歐洲語言中帶有音調的字符和一些繪圖式字符… 比如水平線,垂直線,帶有小箭頭的水平線等等。你可以用這些線狀字符在屏幕上繪製出精美的盒子形狀圖形,直到現在還能在一些裝有8088芯片的洗衣機上看到這些圖形。事實上,隨着美國之外的人們開始買電腦,各種各樣的字符集應運而生,各自都有着不同的含義。比如,在一些電腦上130編碼代表é,但是在一些以色列售賣的電腦上卻是希伯來語Gimel()。所以當美國人將résumés發送到以色列,它將被翻譯成r
sum
。甚至是一個國家內,比如俄羅斯,對於128位以上的字符都有很多不同的映射,所以同一份俄語文件都可能被解釋成不同的內容。
最終,這些隨意的OEM編碼們在ANSI標準中得以改變。在ANSI標準中,每個人對於128以下的編碼內容達成一致,這部分基本和ASCII編碼,但是對於128以上的編碼映射在不同的地區有不同的處理方式。這些不同的區域編碼系統被稱為_編碼頁_。比如在以色列的DOS系統中用的編號862的編碼頁,而希臘用户使用編號737的編碼頁。這些編碼頁在128以下的內容相同,但是在128位以上的字符就五花八門了。MS-DOS的國際版本有幾十個這樣的編碼頁,用於處理各種各樣的語言,甚至有一些編碼也能夠同時支持多種語言!但是,換句話説,要想用一個編碼頁在一台電腦上同時支持希伯來語和希臘語是不可能的,除非寫一個自定義的程序來展示位圖圖形,因為希伯來語和希臘語需要使用不同的編碼頁來翻譯高位的編碼。
於此同時,在亞洲,編碼變得更加瘋狂,因為亞洲的語言通常有上千個字母,根本無法只用8位來表示這些字母。這個問題通常用一個叫做DBCS(double byte character set)的很糟糕的系統來解決,這個系統中部分字符用一字節來表示,一些用兩字節來表示。這樣的設計使得在string中從前往後遍歷很輕鬆,但是幾乎不可能從後往前遍歷。程序員通常被建議不要使用s++或者s--來前移或後移,而是調用函數如Windows的AnsiNext和AnsiPrev,讓操作系統決定如何處理這些字符。
即便如此,很多人依然認為一個字節就是一個字符,一個字符是8位。只要不將這個字符串移動到另一台電腦上,或者這個字符串不涉及別的語言,這一切都看上去很正常。但是,隨着國際化趨勢,將字符串移動到另一台電腦變成了一件很常見的事情,於是一切開始崩塌。幸好,Unicode隨之問世了。
Unicode
Unicode做了一個大膽的嘗試,它創建了一個字符集編碼將這個星球上所有的合理的或是編造的(如Klingon)語言都囊括進來。有些人誤以為Unicode就是一種長度為16位的編碼,每16位代表一個自負,因此一共有65,536中可能的字符。這個理解不完全正確。這也是對於Unicode最常見的誤解。所以如果你也是這麼認為的,不用覺得沮喪。
事實上,Unicode用一種全新的方式來翻譯字符。試着用它的方式來思考才能夠真正明白Unicode的編碼方式。
現在,我們假設一個字母被映射成一些二進制位從而能夠存儲到磁盤或者內存中:
A -> 0100 0001
在Unicode中,一個字母映射到一個稱為代碼點(code point)的東西,這仍然只是一個理論上的概念。至於這個代碼點是如何在內存或者磁盤上表示的就是另一個問題了。
在Unicode中,A這個字母是一個理想化的符號。這個理想化的A不等於B,也不等於a,但是和 不同形式的_A_ 和A卻是相同的。在一種字體下的A和另一種字體下的A被認為是一個符號,但是和小寫的a相比就是不同的符號。這看上去沒什麼爭議,但是在一些語言中明確一個字符究竟是什麼就會產生爭議。比如德語字母ß究竟是一個理想化的符號還是隻是用來表達ss的簡寫?如果一個字母的在單詞末尾時形狀改變了,那它是否是另一個字母?希伯來語對這個問題的回答是肯定的,但是阿拉伯語卻不是。總而言之,那些發明Unicode的聰明人兒在過去十年將這個問題想明白了,雖然伴隨這很多高度政治化的爭論,但是他們終究還是梳理清楚了。
每一個理想符號都被分配了一個類似於U+0639的魔法值。這個魔法值被成為代碼點(code point)。U+代表是Unicode編碼,後面緊跟着十六進制的數字。U+0639代表阿拉伯字母Ain,而英文字母A則是U+0041。你可以在Windows 2000/XP的charmap工具或者Unicode網站上查看全部的編碼信息。
Unicode能夠定義的字母數量其實沒有上限,它們早就超過了65,536個字母,所以並不是每個Unicode字母都能夠被壓縮進兩個字節,這個問題到本文目前為止還是一個謎。
好了,假設我們現在又一個字符串Hello,在Unicode中對應這麼5個代碼點U+0048 U+0065 U+006C U+006C U+006F。至於這些代碼點將如何在內存中存儲或者在郵件中展示,我們還沒有做介紹。
編碼
接着就要聊一聊編碼了。
早期Unicode的編碼採用了兩個字節來存儲,所以Hello這個單詞被編碼成00 48 00 65 00 6C 00 6C 00 6F。看上去還不錯~等下,那是不是也可以被編碼成48 00 65 00 6C 00 6C 00 6F 00。事實上這麼編碼也不是不可以,而早期的開發者希望能夠根據具體的CPU架構來選擇是採用高位模式還是低位模式來進行存儲。所以人們不得不遵循一種奇怪的約定,在每個Unicode字符串前存儲一個FE EF前綴,這個前綴被稱為Unicode字節順序標記位(Unicode Byte Order Mark)。而如果你將字符串的高低位對換位置後,你就需要加上FF FE前綴,從而讓閲讀者知道這裏需要做一次交換。但是,並不是每一個Unicode字符串的開頭都有字節順序標記位的。
這樣一度看起來很不錯,但是有些程序員開始抱怨了。“嘿!看這一大串零!”,因為這些人是美國人,而英文很少會用到 U+00FF以上的編碼。這意味着這些零導致的雙倍的存儲空間。而且現在已經有了那麼基於ANSI和DBCS字符集編碼的文檔,誰來將他們轉換成Unicode編碼。因此很長一段時間大多數人都無視了Unicode編碼,而於此同時,編碼不統一帶來的問題開始變得越發嚴重。
因此UTF-8隨之誕生。UTF-8是另一個使用8比特位將Unicode代碼點的字符串(那些神奇的U+數字)存儲在內存中的系統。在UTF-8中,每個0-127之間的代碼點用一個字節來存儲,只有128及以上的用2,3個甚至6個字節來存儲。
這種設計最大的好處就是英文的編碼和ASCII編碼一摸一樣,所以美國人幾乎不會發現有什麼區別,而其它國家則氣的跳腳。比如Hello,本來應該是 U+0048 U+0065 U+006C U+006C U+006F,會被存儲成48 65 6C 6C 6F。就和ASCII,ANSI和任何OEM字符集編碼產生的內容一樣。現在,假如你大膽的使用一些其他國家的語言如希臘字母或克林貢字母,你就需要用額外的字節來存儲一個代碼位。(UTF-8還具有一個不錯的屬性,即那些使用單個0字節作為空終止符的老舊字符串處理UTF-8代碼不會截斷字符串)
目前為止我已經告訴你Unicode編碼的三種方式,傳統的那種全部用兩個字節存儲的方法叫做UCS-2(因為它由兩個字節構成)或者UTF-16(因為它有16位),但是你依然需要區分是高位的UCS-2或者是低位的UCS-2。還有就是比較流行的UTF-8標準,可以同時兼容英語字母的歷史編碼和其它語種的編碼。
還有一些別的Unicode編碼方式,比如有一個叫做UTF-7,它和UTF-8很類似,但是它確保高位永遠都是0.所以如果你想要將Unicode在某些郵件系統中傳遞,而7位的長度已經足夠,那麼這種編碼能夠提供很好的壓縮。還有UCS-4,它用4個字節來存儲每個代碼點,因此每個代碼點編碼後都是等長的。但是很少有人能夠接受這樣的存儲空間浪費。
現在當你再看看這些用Unicode代碼點表示的每一個理想字符,這些Unicode代碼點可以用任何一種老式的編碼工具進行編碼。比如你能夠將Hello這個Unicode字符串用ASCII或者老式的希臘OEM,或者希伯來ANSI進行,或者上百種現有的編碼方式進行編碼。但是可能有一個問題,一些字母可能展示不出來。如果Unicode的代碼點在當前的編碼集中沒有對應的字符,它可能會變成一個小小的問號?
大多數的傳統編碼只能正確的存儲部分代碼點,而其他的代碼點會被翻譯成問號。一些比較流行的英文文本編碼如Windows-1252 ,ISO-8859-1,當你是這用這些編碼來翻譯俄文或者希伯來文時,你會生成一大堆問號。UTF 7, 8, 16, 和 32都能夠正確的存儲任何的代碼點。
關於編碼必須知道的最重要的一點
如果你已經忘了我剛剛説的一切,請至少記住最重要的一點。當你拿到一個字符串卻不知道它的編碼的話,這個字符串本質上毫無意義。你不能在把腦袋埋在沙堆裏假裝它默認是ASCII編碼。這世界上不存在默認編碼這回事!
如果你在內存、文件或者郵件中有一個字符串,你必須知道它的編碼格式,否則你無法正確的翻譯或展示它。
幾乎每一個愚蠢的問題,如“我的網站看上去在胡言亂語”或者“我使用方言的時候她看不懂我的郵件”,都來自於一個不懂這個簡單道理的天真的程序員。如果不告訴你這個字符串是用UTF-8 還是 ASCII還是ISO 8859-1 (Latin 1)還是 Windows 1252 編碼的,你根本沒法正確的展示它,或者是找到這個句子的結束符。這世界上有上百種編碼,猜測127之上的編碼方式就是一種徒勞。
Content-Type: text/plain; charset="UTF-8"
對於一個網頁,最初的想法是web服務端返回一個類似Content-Type的HTTP請求頭和相應的網頁。也就是説不是HTML網頁本身攜帶Content-Type定義,而是讓請求頭來標記這個網頁的編碼。但是這種方式帶來了一些問題。假如你擁有一個大型的web網站和大量的網頁,這些網頁由來自各個國家的人用不同的語言參與開發,並且使用了開發工具推薦的各種各樣不同的編碼。web服務器自己都不知道每個文件具體的編碼形式,因此它無法確定Content-Type頭的內容。
相比而言,直接將HTML文件的Content-Type用特殊的標籤保存在HTML正文中就顯得更加方便一些。當然這可能讓一些追求極致的人抓狂...你怎麼能在解析了HTML後才知道具體的編碼格式呢?幸好,幾乎每一種編碼在32和127之前的實現是基本類似的,所以你可以在解析如下的HTML的時候得到正確的內容:
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
但是這個meta標籤一定要放在<head>標籤中的第一位,因為web瀏覽器一旦讀取到這個標籤就會暫停解析頁面,並使用指定的編碼重新翻譯。
如果web瀏覽器沒有在http報文頭或者meta標籤中找到Content-Type信息怎麼處理?IE瀏覽器會做一件很有趣的事情:它會基於當前不同字符出現的頻率來猜測使用的語言和編碼。因為不同的語言對於字符有不同的使用規律,這個功能還真的有一定的可用性。這也是為什麼一些天真的網頁開發人員發現即使不加入Content-Type標籤,網頁看上去也很正常,直到有一天他們編寫了一個不遵循他們母語使用規律的網頁,而IE判斷出這是一個韓國網頁並按照相應的編碼進行解析。這也證明了伯斯塔爾法則所説的“接受多變,輸出保守”並不是一條很好的軟件工程法則。總之,那些可憐的網站用户在看到本應該是保加利亞語編寫的網頁被翻譯成韓語(甚至不是連貫的韓語)時會怎麼辦?他可能會使用View | Encoding工具並嘗試一系列不同的編碼,直到生成一個看上去正常的結果頁。前提是他知道瀏覽器有這麼一個工具,而其實大多數人都不知道這個功能。