讓編碼問題不再困惑你

1. ASCII編碼#

上個世紀60年代,美國製定了一套字符編碼,對英語字符與二進制位之間的關係,做了統一規定。這被稱為ASCII碼,一直沿用至今。ASCII碼一共規定了128個字符的編碼,比如空格"SPACE"是32(二進制00100000),大寫的字母A是65(二進制01000001)。這128個符號(包括32個不能打印出來的控制符號),只佔用了一個字節的後面7位,最前面的1位統一規定為0。0~31 是控制字符如換行回車刪除等,32~126 是打印字符,可以通過鍵盤輸入並且能夠顯示出來。

英語用128個符號編碼就夠了,但是用來表示其他語言,128個符號是不夠的。比如,在法語中,字母上方有注音符號,它就無法用ASCII碼錶示。於是,一些歐洲國家就決定,利用字節中閒置的最高位編入新的符號。比如,法語中的é的編碼為130(二進制10000010)。這樣一來,這些歐洲國家使用的編碼體系,可以表示最多256個符號。

但是,這裡又出現了新的問題。不同的國家有不同的字母,因此,哪怕它們都使用256個符號的編碼方式,代表的字母卻不一樣。比如,130在法語編碼中代表了é,在希伯來語編碼中卻代表了字母Gimel (ג),在俄語編碼中又會代表另一個符號。但是不管怎樣,所有這些編碼方式中,0—127表示的符號是一樣的,不一樣的只是128—255的這一段。

至於亞洲國家的文字,使用的符號就更多了,漢字就多達10萬左右。一個字節只能表示256種符號,肯定是不夠的,就必須使用多個字節表達一個符號。比如,簡體中文常見的編碼方式是GB2312,使用兩個字節表示一個漢字,所以理論上最多可以表示65536個符號。

2. Unicode編碼#

可以想象,如果有一種編碼,將世界上所有的符號都納入其中。每一個符號都給予一個獨一無二的編碼,那麼就不會出現上面的問題。Unicode編碼就是這樣一種編碼。

Unicode是一個很大的字符集合,現在的規模可以容納100多萬個符號。每個符號的編碼都不一樣,比如,U+0639表示阿拉伯字母Ain,U+0041表示英語的大寫字母A,U+4E25表示漢字“嚴”。

需要注意的是,Unicode只是一個符號集,它只規定了符號的二進制代碼,卻沒有規定這個二進制代碼應該如何存儲。這就造成了兩個問題:

  • 第一個問題是,如何才能區別unicode和ascii?計算機怎麼知道三個字節表示一個符號,而不是分別表示三個符號呢?
  • 第二個問題是,我們已經知道,英文字母只用一個字節表示就夠了,如果unicode統一規定,每個符號用三個或四個字節表示,那麼每個英文字母前都必然有二到三個字節是0,這對於存儲來說是極大的浪費,文本文件的大小會因此大出二三倍,這是無法接受的。

記住,Unicode只是一個用來映射字符和數字的標準。它對支持字符的數量沒有限制,也不要求字符必須佔兩個、三個或者其它任意數量的字節。Unicode字符是怎樣被編碼成內存中的字節這是另外的話題,它是被UTF(Unicode Transformation Formats)定義的。

3. UTF-8編碼#

互聯網的普及,強烈要求出現一種統一的編碼方式。UTF-8就是在互聯網上使用最廣的一種unicode的實現方式。其他實現方式還包括UTF-16和UTF-32,不過在互聯網上基本不用。重複一遍,這裡的關係是,UTF-8是Unicode的實現方式之一。

UTF-8(8-bit Unicode Transformation Format)是一種針對Unicode的可變長度字符編碼,又稱萬國碼。由Ken Thompson於1992年創建。現在已經標準化為RFC 3629。UTF-8用1到4個字節編碼Unicode字符。用在網頁上可以統一頁面顯示中文簡體繁體及其它語言(如英文,日文,韓文)。

UTF-8最大的一個特點,就是它是一種變長的編碼方式。它可以使用1~4個字節表示一個符號,根據不同的符號而變化字節長度(UTF-8編碼可以容納2^21個字符,總共200多萬個字符)。

UTF-8的編碼規則很簡單,只有二條:

  1. 對於單字節的符號,字節的第一位設為0,後面7位為這個符號的unicode碼。因此對於英語字母,UTF-8編碼和ASCII碼是相同的。
  2. 對於n字節的符號(n>1),第一個字節的前n位都設為1,第n+1位設為0,後面字節的前兩位一律設為10。剩下的沒有提及的二進制位,全部為這個符號的unicode碼。

下表總結了編碼規則,字母x表示可用編碼的位。

Unicode符號範圍 | UTF-8編碼方式

UTF字節數 (十六進制) | (二進制)

--------------------+---------------------------------------------一個字節 0000 0000-0000 007F | 0xxxxxxx

兩個字節 0000 0080-0000 07FF | 110xxxxx 10xxxxxx

三個字節 0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx

四個字節 0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

下面, 還是以漢字“嚴”為例,演示如何實現UTF-8編碼。

已知“嚴”的unicode是4E25(100111000100101),根據上表,可以發現4E25處在第三行的範圍內(0000 0800-0000 FFFF),因此“嚴”的UTF-8編碼需要三個字節,即格式是“1110xxxx 10xxxxxx 10xxxxxx”。然後,從“嚴”的最後一個二進制位開始,依次從後向前填入格式中的x,多出的位補0。這樣就得到了,“嚴”的UTF-8編碼是“11100100 10111000 10100101”,轉換成十六進制就是E4B8A5。

4. UTF8、UTF16和UTF32之間的區別#

首先我們要確定一個概念就是Unicode是一個字符集,這個字符集世界上所有的字符定義了一個唯一編碼。其僅僅規定了每個符號的二進制代碼,沒有制定細化的存儲規則。UTF-8、UTF-16、UTF-32才是Unicode的存儲格式定義。(拿一個通信中的列子做個對比,一個信號(類比成Unicode編碼指),通過不同的編碼方式,會被編碼成不同的高低信號)

4.1 UCS-2和UCS-4#

Unicode是為整合全世界的所有語言文字而誕生的。任何文字在Unicode中都對應一個值, 這個值稱為代碼點(code point)。代碼點的值通常寫成 U+ABCD 的格式。而文字和代碼點之間的對應關係就是UCS-2(Universal Character Set coded in 2 octets)。顧名思義,UCS-2是用兩個字節來表示代碼點,其取值範圍為 U+0000~U+FFFF。

為了能表示更多的文字,人們又提出了UCS-4,即用四個字節表示代碼點。它的範圍為 U+00000000~U+7FFFFFFF,其中 U+00000000~U+0000FFFF和UCS-2是一樣的。

要注意,UCS-2和UCS-4只規定了代碼點和文字之間的對應關係,並沒有規定代碼點在計算機中如何存儲。規定存儲方式的稱為UTF(Unicode Transformation Format),其中應用較多的就是UTF-16和UTF-8了。

4.2 UTF-16#

UTF-16由RFC2781規定,它使用兩個字節來表示一個代碼點。不難猜到,UTF-16是完全對應於UCS-2的,即把UCS-2規定的代碼點通過Big Endian或Little Endian方式直接保存下來。UTF-16包括三種:UTF-16,UTF-16BE(Big Endian),UTF-16LE(Little Endian)。UTF-16BE和UTF-16LE不難理解,而UTF-16就需要通過在文件開頭以名為BOM(Byte Order Mark)的字符來表明文件是Big Endian還是Little Endian。BOM為U+FEFF這個字符。其實BOM是個小聰明的想法。由於UCS-2沒有定義U+FEFF,因此只要出現 FF FE 或者 FE FF 這樣的字節序列,就可以認為它是U+FEFF,並且可以判斷出是Big Endian還是Little Endian。

BOM(Byte Order Mark)用來放在文檔的開頭告訴閱讀器該文檔的字節序。UTF-8不需要BOM來表明字節順序,但可以用BOM來表明編碼方式。字符"ZERO WIDTH NO-BREAK SPACE"的UTF-8編碼是EF BB BF。所以如果接收者收到以EF BB BF開頭的字節流,就知道這是UTF-8編碼了。UTF-16才需要加bom。因為它是按unicode順序編碼,在BMP範圍內是二字節,需要識別是大或小字節序。

低字節序(Little Endian)和高字節序(Big Endian)

低字節序和高字節序只是一個關於在內存中存儲和讀取一段字節(被稱作words)的約定。這意味著當你讓計算機用UTF-16把字母A(佔兩個字節)存在內存中時,使用哪種字節序方案決定了你把第一個字節放在第二個字節的前面還是後面。這麼說有點不太容易懂,讓我們來看一個例子:當你使用UTF-16存下某段內容時,在不同的系統中它的後半部分可能是這樣的:

00 68 00 65 00 6C 00 6C 00 6F(高字節序,高位字節被存在前面)

68 00 65 00 6C 00 6C 00 6F 00(低字節序,低位字節被存在前面)

字節序方案只是一個微處理器架構設計者的偏好問題,例如,Intel使用低字節序,Motorola使用高字節序。

舉個例子。“ABC”這三個字符用各種方式編碼後的結果如下:

讓編碼問題不再困惑你

4.3 UTF-32#

UTF-32用四個字節表示代碼點,這樣就可以完全表示UCS-4的所有代碼點,而無需像UTF-16那樣使用複雜的算法。 與UTF-16類似,UTF-32也包括UTF-32、UTF-32BE、UTF-32LE三種編碼,UTF-32也同樣需要BOM字符。

4.4 文本編輯器怎麼知道文本的編碼#

當一個軟件打開一個文本時,它要做的第一件事是決定這個文本究竟是使用哪種字符集的哪種編碼保存的。軟件一般採用三種方式來決定文本的字符集和編碼:

  1. 檢測文件頭標識(BOM)
  2. EF BB BF UTF-8
  3. FE FF UTF-16/UCS-2, big endian
  4. FF FE UTF-16/UCS-2, little endian
  5. FF FE 00 00 UTF-32/UCS-4, little endian.
  6. 00 00 FE FF UTF-32/UCS-4, big-endian.
  7. 軟件自己根據編碼規則猜測當前文件的編碼
  8. 提示用戶自己輸入當前文件的編碼

5. GBK、GB2312和GB18030之間的區別#

GB2312是對ASCll碼的擴展,佔用兩個字節。一個小於127的字符的意義與原來相同,但兩個大於127的字符連在一起時,就表示一個漢字,前面的一個字節(他稱之為高字節)從0xA1用到0xF7,後面一個字節(低字節)從0xA1到0xFE,這樣我們就可以組合出大約7000多個簡體漢字了。在這些編碼裡,我們還把數學符號、羅馬希臘的字母、日文的假名們都編進去了,連在 ASCII 裡本來就有的數字、標點、字母都統統重新編了兩個字節長的編碼,這就是常說的"全角"字符,而原來在127號以下的那些就叫"半角"字符了。

GB2312能表示的字符還是不夠用,於是GBK出現了。GBK是對GB1212的擴展,也是佔用2個字節,GBK不再要求低字節一定是127號之後的內碼,只要第一個字節是大於127就固定表示這是一個漢字的開始,不管後面跟的是不是擴展字符集裡的內容。結果擴展之後的編碼方案被稱為 GBK 標準,GBK 包括了 GB2312 的所有內容,同時又增加了近20000個新的漢字(包括繁體字)和符號。

GB18030採用變長編碼,可以是1個字節、2個字節和4個字節。是對GB2312和GBK的擴展,完全兼容兩者。

經過上面介紹,我們可以看出Unicode是一個世界標準,針對世界上所有語言符號制定編碼表,而GBK、GB2312等則主要是針對中國的字符進行編碼。

6. Java中的編碼問題#

我們知道涉及到編碼的地方一般都在字符到字節或者字節到字符的轉換上,而需要這種轉換的場景主要是在 I/O 的時候,這個 I/O 包括磁盤 I/O 和網絡 I/O。而大部分 I/O 引起的亂碼都是網絡 I/O。

用戶從瀏覽器端發起一個 HTTP 請求,需要存在編碼的地方是 URL、Cookie、Parameter。服務器端接受到 HTTP 請求後要解析 HTTP 協議,其中 URI、Cookie 和 POST 表單參數需要解碼,服務器端可能還需要讀取數據庫中的數據,本地或網絡中其它地方的文本文件,這些數據都可能存在編碼問題,當 Servlet 處理完所有請求的數據後,需要將這些數據再編碼通過 Socket 發送到用戶請求的瀏覽器裡,再經過瀏覽器解碼成為文本。這些過程如下圖所示:

讓編碼問題不再困惑你

如上圖所示一次 HTTP 請求設計到很多地方需要編解碼,它們編解碼的規則是什麼?下面將會重點闡述一下:

URL 的編解碼

用戶提交一個 URL,這個 URL 中可能存在中文,因此需要編碼,如何對這個 URL 進行編碼?根據什麼規則來編碼?有如何來解碼?如下圖一個 URL:

讓編碼問題不再困惑你

Port 對應在 Tomcat 的 中配置,而 Context Path 在 中配置,Servlet Path 在 Web 應用的 web.xml 中的

 <servlet-mapping> 
<servlet-name>junshanExample/<servlet-name>
<url-pattern>/servlets/servlet/*/<url-pattern>
/<servlet-mapping>

中配置,PathInfo 是我們請求的具體的 Servlet,QueryString 是要傳遞的參數,注意這裡是在瀏覽器裡直接輸入 URL 所以是通過 Get 方法請求的,如果是 POST 方法請求的話,QueryString 將通過表單方式提交到服務器端,這個將在後面再介紹。

上圖中 PathInfo 和 QueryString 出現了中文,當我們在瀏覽器中直接輸入這個 URL 時,在瀏覽器端和服務端會如何編碼和解析這個 URL 呢?為了驗證瀏覽器是怎麼編碼 URL 的我們選擇 FireFox 瀏覽器並通過 HTTPFox 插件觀察我們請求的 URL 的實際的內容,以下是 URL:HTTP://localhost:8080/examples/servlets/servlet/君山?author= 君山 在中文 FireFox3.6.12 的測試結果:

讓編碼問題不再困惑你

君山的編碼結果分別是:e5 90 9b e5 b1 b1,be fd c9 bd,查閱上一屆的編碼可知,PathInfo 是 UTF-8 編碼而 QueryString 是經過 GBK 編碼,至於為什麼會有“%”?查閱 URL 的編碼規範 RFC3986 可知瀏覽器編碼 URL 是將非 ASCII 字符按照某種編碼格式編碼成 16 進制數字然後將每個 16 進製表示的字節前加上“%”,所以最終的 URL 就成了上圖的格式了。

從上面測試結果可知瀏覽器對 PathInfo 和 QueryString 的編碼是不一樣的,不同瀏覽器對 PathInfo 也可能不一樣,這就對服務器的解碼造成很大的困難,下面我們以 Tomcat 為例看一下,Tomcat 接受到這個 URL 是如何解碼的。
protected void convertURI(MessageBytes uri, Request request)
throws Exception {
ByteChunk bc = uri.getByteChunk();
int length = bc.getLength();
CharChunk cc = uri.getCharChunk();
cc.allocate(length, -1);
String enc = connector.getURIEncoding();
if (enc != null) {
B2CConverter conv = request.getURIConverter();
try {
if (conv == null) {
conv = new B2CConverter(enc);
request.setURIConverter(conv);
}
} catch (IOException e) {...}
if (conv != null) {
try {
conv.convert(bc, cc, cc.getBuffer().length -
cc.getEnd());
uri.setChars(cc.getBuffer(), cc.getStart(),
cc.getLength());
return;
} catch (IOException e) {...}
}
}
// Default encoding: fast conversion
byte[] bbuf = bc.getBuffer();

char[] cbuf = cc.getBuffer();
int start = bc.getStart();
for (int i = 0; i < length; i++) {
cbuf[i] = (char) (bbuf[i + start] & 0xff);
}
uri.setChars(cbuf, 0, length);
}

從上面的代碼中可以知道對 URL 的 URI 部分進行解碼的字符集是在connector的 中定義的,如果沒有定義,那麼將以默認編碼 ISO-8859-1 解析。所以如果有中文 URL 時最好把 URIEncoding 設置成 UTF-8 編碼。

QueryString 又如何解析? GET 方式 HTTP 請求的 QueryString 與 POST 方式 HTTP 請求的表單參數都是作為 Parameters 保存,都是通過 request.getParameter 獲取參數值。對它們的解碼是在 request.getParameter 方法第一次被調用時進行的。request.getParameter 方法被調用時將會調用 org.apache.catalina.connector.Request 的 parseParameters 方法。這個方法將會對 GET 和 POST 方式傳遞的參數進行解碼,但是它們的解碼字符集有可能不一樣。POST 表單的解碼將在後面介紹,QueryString 的解碼字符集是在哪定義的呢?它本身是通過 HTTP 的 Header 傳到服務端的,並且也在 URL 中,是否和 URI 的解碼字符集一樣呢?從前面瀏覽器對 PathInfo 和 QueryString 的編碼採取不同的編碼格式不同可以猜測到解碼字符集肯定也不會是一致的。的確是這樣 QueryString 的解碼字符集要麼是 Header 中 ContentType 中定義的 Charset 要麼就是默認的 ISO-8859-1,要使用 ContentType 中定義的編碼就要設置 connector 的 中的 useBodyEncodingForURI 設置為 true。這個配置項的名字有點讓人產生混淆,它並不是對整個 URI 都採用 BodyEncoding 進行解碼而僅僅是對 QueryString 使用 BodyEncoding 解碼,這一點還要特別注意。

從上面的 URL 編碼和解碼過程來看,比較複雜,而且編碼和解碼並不是我們在應用程序中能完全控制的,所以在我們的應用程序中應該儘量避免在 URL 中使用非 ASCII 字符,不然很可能會碰到亂碼問題,當然在我們的服務器端最好設置 中的 URIEncoding 和 useBodyEncodingForURI 兩個參數。

HTTP Header 的編解碼

當客戶端發起一個 HTTP 請求除了上面的 URL 外還可能會在 Header 中傳遞其它參數如 Cookie、redirectPath 等,這些用戶設置的值很可能也會存在編碼問題,Tomcat 對它們又是怎麼解碼的呢?

對 Header 中的項進行解碼也是在調用 request.getHeader 是進行的,如果請求的 Header 項沒有解碼則調用 MessageBytes 的 toString 方法,這個方法將從 byte 到 char 的轉化使用的默認編碼也是 ISO-8859-1,而我們也不能設置 Header 的其它解碼格式,所以如果你設置 Header 中有非 ASCII 字符解碼肯定會有亂碼。

我們在添加 Header 時也是同樣的道理,不要在 Header 中傳遞非 ASCII 字符,如果一定要傳遞的話,我們可以先將這些字符用 org.apache.catalina.util.URLEncoder 編碼然後再添加到 Header 中,這樣在瀏覽器到服務器的傳遞過程中就不會丟失信息了,如果我們要訪問這些項時再按照相應的字符集解碼就好了。

POST 表單的編解碼

在前面提到了 POST 表單提交的參數的解碼是在第一次調用 request.getParameter 發生的,POST 表單參數傳遞方式與 QueryString 不同,它是通過 HTTP 的 BODY 傳遞到服務端的。當我們在頁面上點擊 submit 按鈕時瀏覽器首先將根據 ContentType 的 Charset 編碼格式對錶單填的參數進行編碼然後提交到服務器端,在服務器端同樣也是用 ContentType 中字符集進行解碼。所以通過 POST 表單提交的參數一般不會出現問題,而且這個字符集編碼是我們自己設置的,可以通過 request.setCharacterEncoding(charset) 來設置。

另外針對 multipart/form-data 類型的參數,也就是上傳的文件編碼同樣也是使用 ContentType 定義的字符集編碼,值得注意的地方是上傳文件是用字節流的方式傳輸到服務器的本地臨時目錄,這個過程並沒有涉及到字符編碼,而真正編碼是在將文件內容添加到 parameters 中,如果用這個編碼不能編碼時將會用默認編碼 ISO-8859-1 來編碼。

HTTP BODY 的編解碼

當用戶請求的資源已經成功獲取後,這些內容將通過 Response 返回給客戶端瀏覽器,這個過程先要經過編碼再到瀏覽器進行解碼。這個過程的編解碼字符集可以通過 response.setCharacterEncoding 來設置,它將會覆蓋 request.getCharacterEncoding 的值,並且通過 Header 的 Content-Type 返回客戶端,瀏覽器接受到返回的 socket 流時將通過 Content-Type 的 charset 來解碼,如果返回的 HTTP Header 中 Content-Type 沒有設置 charset,那麼瀏覽器將根據 Html 的 中的 charset 來解碼。如果也沒有定義的話,那麼瀏覽器將使用默認的編碼來解碼。

其他需要注意編碼的地方

除了 URL 和參數編碼問題外,在服務端還有很多地方可能存在編碼,如可能需要讀取 xml、velocity 模版引擎、JSP 或者從數據庫讀取數據等。

xml 文件可以通過設置頭來制定編碼格式

  

Velocity 模版設置編碼格式:

services.VelocityService.input.encoding=UTF-8 

JSP 設置編碼格式:


訪問數據庫都是通過客戶端 JDBC 驅動來完成,用 JDBC 來存取數據要和數據的內置編碼保持一致,可以通過設置 JDBC URL 來制定如 MySQL:

url="jdbc:mysql://localhost:3306/DB?useUnicode=true&characterEncoding=GBK"

8. 亂碼問題分析#

下面看一下,當我們碰到一些亂碼時,應該怎麼處理這些問題?出現亂碼問題唯一的原因都是在 char 到 byte 或 byte 到 char 轉換中編碼和解碼的字符集不一致導致的,由於往往一次操作涉及到多次編解碼,所以出現亂碼時很難查找到底是哪個環節出現了問題。根據自己的經驗,往往從最源頭開始一步步查原因是最快的。

9. 參考#

編碼博客

為什麼Java最多隻能標識65535個字符

Unicode本身只是一個標準,不是具體實現,並沒有限定字節數。目前用於實用的 Unicode 版本對應於 UCS-2,使用16位的編碼空間,因此最大能表示65535個字符。Unicode是發展的,6萬個確實不夠,事實上現在的Unicode已經支持超過10萬個字符(第10萬個於2005年被採納,為馬來亞拉姆語。當前的Unicode版本為6.3,2013年9月30日製定。Java中使用的仍是UCS-2。

"


分享到:


相關文章: