深入分析 Java 中的中文編碼問題 (文章來自網路)

破玉發表於2016-03-14

許令波,developerWorks 中國網站最佳作者,現就職於淘寶網,是一名 Java 開發工程師。對大型網際網路架構設計頗感興趣,喜歡鑽研開源框架的設計原理。有時間將學到的知識整理成文章,也喜歡記錄下工作和生活中的一些思考。個人網站是:http://xulingbo.net。

 

 

 

為什麼要編碼

不知道大家有沒有想過一個問題,那就是為什麼要編碼?我們能不能不編碼?要回答這個問題必須要回到計算機是如何表示我們人類能夠理解的符號的,這些符號也就是我們人類使用的語言。由於人類的語言有太多,因而表示這些語言的符號太多,無法用計算機中一個基本的儲存單元—— byte 來表示,因而必須要經過拆分或一些翻譯工作,才能讓計算機能理解。我們可以把計算機能夠理解的語言假定為英語,其它語言要能夠在計算機中使用必須經過一次翻譯,把它翻譯成英語。這個翻譯的過程就是編碼。所以可以想象只要不是說英語的國家要能夠使用計算機就必須要經過編碼。這看起來有些霸道,但是這就是現狀,這也和我們國家現在在大力推廣漢語一樣,希望其它國家都會說漢語,以後其它的語言都翻譯成漢語,我們可以把計算機中儲存資訊的最小單位改成漢字,這樣我們就不存在編碼問題了。

所以總的來說,編碼的原因可以總結為:

  1. 計算機中儲存資訊的最小單元是一個位元組即 8 個 bit,所以能表示的字元範圍是 0~255 個
  2. 人類要表示的符號太多,無法用一個位元組來完全表示
  3. 要解決這個矛盾必須需要一個新的資料結構 char,從 char 到 byte 必須編碼

如何“翻譯”

明白了各種語言需要交流,經過翻譯是必要的,那又如何來翻譯呢?計算中提拱了多種翻譯方式,常見的有 ASCII、ISO-8859-1、GB2312、GBK、UTF-8、UTF-16 等。它們都可以被看作為字典,它們規定了轉化的規則,按照這個規則就可以讓計算機正確的表示我們的字元。目前的編碼格式很多,例如 GB2312、GBK、UTF-8、UTF-16 這幾種格式都可以表示一個漢字,那我們到底選擇哪種編碼格式來儲存漢字呢?這就要考慮到其它因素了,是儲存空間重要還是編碼的效率重要。根據這些因素來正確選擇編碼格式,下面簡要介紹一下這幾種編碼格式。

  • ASCII 碼

學過計算機的人都知道 ASCII 碼,總共有 128 個,用一個位元組的低 7 位表示,0~31 是控制字元如換行回車刪除等;32~126 是列印字元,可以通過鍵盤輸入並且能夠顯示出來。

  • ISO-8859-1

128 個字元顯然是不夠用的,於是 ISO 組織在 ASCII 碼基礎上又制定了一些列標準用來擴充套件 ASCII 編碼,它們是 ISO-8859-1~ISO-8859-15,其中 ISO-8859-1 涵蓋了大多數西歐語言字元,所有應用的最廣泛。ISO-8859-1 仍然是單位元組編碼,它總共能表示 256 個字元。

  • GB2312

它的全稱是《資訊交換用漢字編碼字符集 基本集》,它是雙位元組編碼,總的編碼範圍是 A1-F7,其中從 A1-A9 是符號區,總共包含 682 個符號,從 B0-F7 是漢字區,包含 6763 個漢字。

  • GBK

全稱叫《漢字內碼擴充套件規範》,是國家技術監督局為 windows95 所制定的新的漢字內碼規範,它的出現是為了擴充套件 GB2312,加入更多的漢字,它的編碼範圍是 8140~FEFE(去掉 XX7F)總共有 23940 個碼位,它能表示 21003 個漢字,它的編碼是和 GB2312 相容的,也就是說用 GB2312 編碼的漢字可以用 GBK 來解碼,並且不會有亂碼。

  • GB18030

全稱是《資訊交換用漢字編碼字符集》,是我國的強制標準,它可能是單位元組、雙位元組或者四位元組編碼,它的編碼與 GB2312 編碼相容,這個雖然是國家標準,但是實際應用系統中使用的並不廣泛。

  • UTF-16

說到 UTF 必須要提到 Unicode(Universal Code 統一碼),ISO 試圖想建立一個全新的超語言字典,世界上所有的語言都可以通過這本字典來相互翻譯。可想而知這個字典是多麼的複雜,關於 Unicode 的詳細規範可以參考相應文件。Unicode 是 Java 和 XML 的基礎,下面詳細介紹 Unicode 在計算機中的儲存形式。

UTF-16 具體定義了 Unicode 字元在計算機中存取方法。UTF-16 用兩個位元組來表示 Unicode 轉化格式,這個是定長的表示方法,不論什麼字元都可以用兩個位元組表示,兩個位元組是 16 個 bit,所以叫 UTF-16。UTF-16 表示字元非常方便,每兩個位元組表示一個字元,這個在字串操作時就大大簡化了操作,這也是 Java 以 UTF-16 作為記憶體的字元儲存格式的一個很重要的原因。

  • UTF-8

UTF-16 統一採用兩個位元組表示一個字元,雖然在表示上非常簡單方便,但是也有其缺點,有很大一部分字元用一個位元組就可以表示的現在要兩個位元組表示,儲存空間放大了一倍,在現在的網路頻寬還非常有限的今天,這樣會增大網路傳輸的流量,而且也沒必要。而 UTF-8 採用了一種變長技術,每個編碼區域有不同的字碼長度。不同型別的字元可以是由 1~6 個位元組組成。

UTF-8 有以下編碼規則:

  1. 如果一個位元組,最高位(第 8 位)為 0,表示這是一個 ASCII 字元(00 - 7F)。可見,所有 ASCII 編碼已經是 UTF-8 了。
  2. 如果一個位元組,以 11 開頭,連續的 1 的個數暗示這個字元的位元組數,例如:110xxxxx 代表它是雙位元組 UTF-8 字元的首位元組。
  3. 如果一個位元組,以 10 開始,表示它不是首位元組,需要向前查詢才能得到當前字元的首位元組

Java 中需要編碼的場景

前面描述了常見的幾種編碼格式,下面將介紹 Java 中如何處理對編碼的支援,什麼場合中需要編碼。

I/O 操作中存在的編碼

我們知道涉及到編碼的地方一般都在字元到位元組或者位元組到字元的轉換上,而需要這種轉換的場景主要是在 I/O 的時候,這個 I/O 包括磁碟 I/O 和網路 I/O,關於網路 I/O 部分在後面將主要以 Web 應用為例介紹。下圖是 Java 中處理 I/O 問題的介面:


Figure xxx. Requires a heading

Reader 類是 Java 的 I/O 中讀字元的父類,而 InputStream 類是讀位元組的父類,InputStreamReader 類就是關聯位元組到字元的橋樑,它負責在 I/O 過程中處理讀取位元組到字元的轉換,而具體位元組到字元的解碼實現它由 StreamDecoder 去實現,在 StreamDecoder 解碼過程中必須由使用者指定 Charset 編碼格式。值得注意的是如果你沒有指定 Charset,將使用本地環境中的預設字符集,例如在中文環境中將使用 GBK 編碼。

寫的情況也是類似,字元的父類是 Writer,位元組的父類是 OutputStream,通過 OutputStreamWriter 轉換字元到位元組。如下圖所示:


Figure xxx. Requires a heading

同樣 StreamEncoder 類負責將字元編碼成位元組,編碼格式和預設編碼規則與解碼是一致的。

如下面一段程式碼,實現了檔案的讀寫功能:


清單 1.I/O 涉及的編碼示例

				 
 String file = "c:/stream.txt"; 
 String charset = "UTF-8"; 
 // 寫字元換轉成位元組流
 FileOutputStream outputStream = new FileOutputStream(file); 
 OutputStreamWriter writer = new OutputStreamWriter( 
 outputStream, charset); 
 try { 
    writer.write("這是要儲存的中文字元"); 
 } finally { 
    writer.close(); 
 } 
 // 讀取位元組轉換成字元
 FileInputStream inputStream = new FileInputStream(file); 
 InputStreamReader reader = new InputStreamReader( 
 inputStream, charset); 
 StringBuffer buffer = new StringBuffer(); 
 char[] buf = new char[64]; 
 int count = 0; 
 try { 
    while ((count = reader.read(buf)) != -1) { 
        buffer.append(buffer, 0, count); 
    } 
 } finally { 
    reader.close(); 
 } 

 

在我們的應用程式中涉及到 I/O 操作時只要注意指定統一的編解碼 Charset 字符集,一般不會出現亂碼問題,有些應用程式如果不注意指定字元編碼,中文環境中取作業系統預設編碼,如果編解碼都在中文環境中,通常也沒問題,但是還是強烈的不建議使用作業系統的預設編碼,因為這樣,你的應用程式的編碼格式就和執行環境繫結起來了,在跨環境下很可能出現亂碼問題。

記憶體中操作中的編碼

在 Java 開發中除了 I/O 涉及到編碼外,最常用的應該就是在記憶體中進行字元到位元組的資料型別的轉換,Java 中用 String 表示字串,所以 String 類就提供轉換到位元組的方法,也支援將位元組轉換為字串的建構函式。如下程式碼示例:

 String s = "這是一段中文字串"; 
 byte[] b = s.getBytes("UTF-8"); 
 String n = new String(b,"UTF-8"); 

 

另外一個是已經被被廢棄的 ByteToCharConverter 和 CharToByteConverter 類,它們分別提供了 convertAll 方法可以實現 byte[] 和 char[] 的互轉。如下程式碼所示:

 ByteToCharConverter charConverter = ByteToCharConverter.getConverter("UTF-8"); 
 char c[] = charConverter.convertAll(byteArray); 
 CharToByteConverter byteConverter = CharToByteConverter.getConverter("UTF-8"); 
 byte[] b = byteConverter.convertAll(c); 

 

這兩個類已經被 Charset 類取代,Charset 提供 encode 與 decode 分別對應 char[] 到 byte[] 的編碼和 byte[] 到 char[] 的解碼。如下程式碼所示:

 Charset charset = Charset.forName("UTF-8"); 
 ByteBuffer byteBuffer = charset.encode(string); 
 CharBuffer charBuffer = charset.decode(byteBuffer); 

 

編碼與解碼都在一個類中完成,通過 forName 設定編解碼字符集,這樣更容易統一編碼格式,比 ByteToCharConverter 和 CharToByteConverter 類更方便。

Java 中還有一個 ByteBuffer 類,它提供一種 char 和 byte 之間的軟轉換,它們之間轉換不需要編碼與解碼,只是把一個 16bit 的 char 格式,拆分成為 2 個 8bit 的 byte 表示,它們的實際值並沒有被修改,僅僅是資料的型別做了轉換。如下程式碼所以:

 ByteBuffer heapByteBuffer = ByteBuffer.allocate(1024); 
 ByteBuffer byteBuffer = heapByteBuffer.putChar(c); 

 

以上這些提供字元和位元組之間的相互轉換隻要我們設定編解碼格式統一一般都不會出現問題。


Java 中如何編解碼

前面介紹了幾種常見的編碼格式,這裡將以實際例子介紹 Java 中如何實現編碼及解碼,下面我們以“I am 君山”這個字串為例介紹 Java 中如何把它以 ISO-8859-1、GB2312、GBK、UTF-16、UTF-8 編碼格式進行編碼的。


清單 2.String 編碼

				 
 public static void encode() { 
        String name = "I am 君山"; 
        toHex(name.toCharArray()); 
        try { 
            byte[] iso8859 = name.getBytes("ISO-8859-1"); 
            toHex(iso8859); 
            byte[] gb2312 = name.getBytes("GB2312"); 
            toHex(gb2312); 
            byte[] gbk = name.getBytes("GBK"); 
            toHex(gbk); 
            byte[] utf16 = name.getBytes("UTF-16"); 
            toHex(utf16); 
            byte[] utf8 = name.getBytes("UTF-8"); 
            toHex(utf8); 
        } catch (UnsupportedEncodingException e) { 
            e.printStackTrace(); 
        } 
 } 

 

我們把 name 字串按照前面說的幾種編碼格式進行編碼轉化成 byte 陣列,然後以 16 進位制輸出,我們先看一下 Java 是如何進行編碼的。

下面是 Java 中編碼需要用到的類圖


圖 1. Java 編碼類圖
圖 1. Java 編碼類圖

首先根據指定的 charsetName 通過 Charset.forName(charsetName) 設定 Charset 類,然後根據 Charset 建立 CharsetEncoder 物件,再呼叫 CharsetEncoder.encode 對字串進行編碼,不同的編碼型別都會對應到一個類中,實際的編碼過程是在這些類中完成的。下面是 String. getBytes(charsetName) 編碼過程的時序圖


圖 2.Java 編碼時序圖
圖 2.Java 編碼時序圖

從上圖可以看出根據 charsetName 找到 Charset 類,然後根據這個字符集編碼生成 CharsetEncoder,這個類是所有字元編碼的父類,針對不同的字元編碼集在其子類中定義瞭如何實現編碼,有了 CharsetEncoder 物件後就可以呼叫 encode 方法去實現編碼了。這個是 String.getBytes 編碼方法,其它的如 StreamEncoder 中也是類似的方式。下面看看不同的字符集是如何將前面的字串編碼成 byte 陣列的?

如字串“I am 君山”的 char 陣列為 49 20 61 6d 20 541b 5c71,下面把它按照不同的編碼格式轉化成相應的位元組。

按照 ISO-8859-1 編碼

字串“I am 君山”用 ISO-8859-1 編碼,下面是編碼結果:


Figure xxx. Requires a heading

從上圖看出 7 個 char 字元經過 ISO-8859-1 編碼轉變成 7 個 byte 陣列,ISO-8859-1 是單位元組編碼,中文“君山”被轉化成值是 3f 的 byte。3f 也就是“?”字元,所以經常會出現中文變成“?”很可能就是錯誤的使用了 ISO-8859-1 這個編碼導致的。中文字元經過 ISO-8859-1 編碼會丟失資訊,通常我們稱之為“黑洞”,它會把不認識的字元吸收掉。由於現在大部分基礎的 Java 框架或系統預設的字符集編碼都是 ISO-8859-1,所以很容易出現亂碼問題,後面將會分析不同的亂碼形式是怎麼出現的。

按照 GB2312 編碼

字串“I am 君山”用 GB2312 編碼,下面是編碼結果:


Figure xxx. Requires a heading

GB2312 對應的 Charset 是 sun.nio.cs.ext. EUC_CN 而對應的 CharsetDecoder 編碼類是 sun.nio.cs.ext. DoubleByte,GB2312 字符集有一個 char 到 byte 的碼錶,不同的字元編碼就是查這個碼錶找到與每個字元的對應的位元組,然後拼裝成 byte 陣列。查表的規則如下:

 c2b[c2bIndex[char >> 8] + (char & 0xff)] 

 

如果查到的碼位值大於 oxff 則是雙位元組,否則是單位元組。雙位元組高 8 位作為第一個位元組,低 8 位作為第二個位元組,如下程式碼所示:

 if (bb > 0xff) {    // DoubleByte 
            if (dl - dp < 2) 
                return CoderResult.OVERFLOW; 
            da[dp++] = (byte) (bb >> 8); 
            da[dp++] = (byte) bb; 
 } else {                      // SingleByte 
            if (dl - dp < 1) 
                return CoderResult.OVERFLOW; 
            da[dp++] = (byte) bb; 
 } 

 

從上圖可以看出前 5 個字元經過編碼後仍然是 5 個位元組,而漢字被編碼成雙位元組,在第一節中介紹到 GB2312 只支援 6763 個漢字,所以並不是所有漢字都能夠用 GB2312 編碼。

按照 GBK 編碼

字串“I am 君山”用 GBK 編碼,下面是編碼結果:


Figure xxx. Requires a heading

你可能已經發現上圖與 GB2312 編碼的結果是一樣的,沒錯 GBK 與 GB2312 編碼結果是一樣的,由此可以得出 GBK 編碼是相容 GB2312 編碼的,它們的編碼演算法也是一樣的。不同的是它們的碼錶長度不一樣,GBK 包含的漢字字元更多。所以只要是經過 GB2312 編碼的漢字都可以用 GBK 進行解碼,反過來則不然。

按照 UTF-16 編碼

字串“I am 君山”用 UTF-16 編碼,下面是編碼結果:


Figure xxx. Requires a heading

用 UTF-16 編碼將 char 陣列放大了一倍,單位元組範圍內的字元,在高位補 0 變成兩個位元組,中文字元也變成兩個位元組。從 UTF-16 編碼規則來看,僅僅將字元的高位和地位進行拆分變成兩個位元組。特點是編碼效率非常高,規則很簡單,由於不同處理器對 2 位元組處理方式不同,Big-endian(高位位元組在前,低位位元組在後)或 Little-endian(低位位元組在前,高位位元組在後)編碼,所以在對一串字串進行編碼是需要指明到底是 Big-endian 還是 Little-endian,所以前面有兩個位元組用來儲存 BYTE_ORDER_MARK 值,UTF-16 是用定長 16 位(2 位元組)來表示的 UCS-2 或 Unicode 轉換格式,通過代理對來訪問 BMP 之外的字元編碼。

按照 UTF-8 編碼

字串“I am 君山”用 UTF-8 編碼,下面是編碼結果:


Figure xxx. Requires a heading

UTF-16 雖然編碼效率很高,但是對單位元組範圍內字元也放大了一倍,這無形也浪費了儲存空間,另外 UTF-16 採用順序編碼,不能對單個字元的編碼值進行校驗,如果中間的一個字元碼值損壞,後面的所有碼值都將受影響。而 UTF-8 這些問題都不存在,UTF-8 對單位元組範圍內字元仍然用一個位元組表示,對漢字採用三個位元組表示。它的編碼規則如下:


清單 3.UTF-8 編碼程式碼片段

				 
 private CoderResult encodeArrayLoop(CharBuffer src, 
 ByteBuffer dst){ 
            char[] sa = src.array(); 
            int sp = src.arrayOffset() + src.position(); 
            int sl = src.arrayOffset() + src.limit(); 
            byte[] da = dst.array(); 
            int dp = dst.arrayOffset() + dst.position(); 
            int dl = dst.arrayOffset() + dst.limit(); 
            int dlASCII = dp + Math.min(sl - sp, dl - dp); 
            // ASCII only loop 
            while (dp < dlASCII && sa[sp] < '\u0080') 
                da[dp++] = (byte) sa[sp++]; 
            while (sp < sl) { 
                char c = sa[sp]; 
                if (c < 0x80) { 
                    // Have at most seven bits 
                    if (dp >= dl) 
                        return overflow(src, sp, dst, dp); 
                    da[dp++] = (byte)c; 
                } else if (c < 0x800) { 
                    // 2 bytes, 11 bits 
                    if (dl - dp < 2) 
                        return overflow(src, sp, dst, dp); 
                    da[dp++] = (byte)(0xc0 | (c >> 6)); 
                    da[dp++] = (byte)(0x80 | (c & 0x3f)); 
                } else if (Character.isSurrogate(c)) { 
                    // Have a surrogate pair 
                    if (sgp == null) 
                        sgp = new Surrogate.Parser(); 
                    int uc = sgp.parse(c, sa, sp, sl); 
                    if (uc < 0) { 
                        updatePositions(src, sp, dst, dp); 
                        return sgp.error(); 
                    } 
                    if (dl - dp < 4) 
                        return overflow(src, sp, dst, dp); 
                    da[dp++] = (byte)(0xf0 | ((uc >> 18))); 
                    da[dp++] = (byte)(0x80 | ((uc >> 12) & 0x3f)); 
                    da[dp++] = (byte)(0x80 | ((uc >>  6) & 0x3f)); 
                    da[dp++] = (byte)(0x80 | (uc & 0x3f)); 
                    sp++;  // 2 chars 
                } else { 
                    // 3 bytes, 16 bits 
                    if (dl - dp < 3) 
                        return overflow(src, sp, dst, dp); 
                    da[dp++] = (byte)(0xe0 | ((c >> 12))); 
                    da[dp++] = (byte)(0x80 | ((c >>  6) & 0x3f)); 
                    da[dp++] = (byte)(0x80 | (c & 0x3f)); 
                } 
                sp++; 
            } 
            updatePositions(src, sp, dst, dp); 
            return CoderResult.UNDERFLOW; 
 } 

 

UTF-8 編碼與 GBK 和 GB2312 不同,不用查碼錶,所以在編碼效率上 UTF-8 的效率會更好,所以在儲存中文字元時 UTF-8 編碼比較理想。

幾種編碼格式的比較

對中文字元後面四種編碼格式都能處理,GB2312 與 GBK 編碼規則類似,但是 GBK 範圍更大,它能處理所有漢字字元,所以 GB2312 與 GBK 比較應該選擇 GBK。UTF-16 與 UTF-8 都是處理 Unicode 編碼,它們的編碼規則不太相同,相對來說 UTF-16 編碼效率最高,字元到位元組相互轉換更簡單,進行字串操作也更好。它適合在本地磁碟和記憶體之間使用,可以進行字元和位元組之間快速切換,如 Java 的記憶體編碼就是採用 UTF-16 編碼。但是它不適合在網路之間傳輸,因為網路傳輸容易損壞位元組流,一旦位元組流損壞將很難恢復,想比較而言 UTF-8 更適合網路傳輸,對 ASCII 字元采用單位元組儲存,另外單個字元損壞也不會影響後面其它字元,在編碼效率上介於 GBK 和 UTF-16 之間,所以 UTF-8 在編碼效率上和編碼安全性上做了平衡,是理想的中文編碼方式。


Java Web 涉及到的編碼

對於使用中文來說,有 I/O 的地方就會涉及到編碼,前面已經提到了 I/O 操作會引起編碼,而大部分 I/O 引起的亂碼都是網路 I/O,因為現在幾乎所有的應用程式都涉及到網路操作,而資料經過網路傳輸都是以位元組為單位的,所以所有的資料都必須能夠被序列化為位元組。在 Java 中資料被序列化必須繼承 Serializable 介面。

這裡有一個問題,你是否認真考慮過一段文字它的實際大小應該怎麼計算,我曾經碰到過一個問題:就是要想辦法壓縮 Cookie 大小,減少網路傳輸量,當時有選擇不同的壓縮演算法,發現壓縮後字元數是減少了,但是並沒有減少位元組數。所謂的壓縮只是將多個單位元組字元通過編碼轉變成一個多位元組字元。減少的是 String.length(),而並沒有減少最終的位元組數。例如將“ab”兩個字元通過某種編碼轉變成一個奇怪的字元,雖然字元數從兩個變成一個,但是如果採用 UTF-8 編碼這個奇怪的字元最後經過編碼可能又會變成三個或更多的位元組。同樣的道理比如整型數字 1234567 如果當成字元來儲存,採用 UTF-8 來編碼佔用 7 個 byte,採用 UTF-16 編碼將會佔用 14 個 byte,但是把它當成 int 型數字來儲存只需要 4 個 byte 來儲存。所以看一段文字的大小,看字元本身的長度是沒有意義的,即使是一樣的字元采用不同的編碼最終儲存的大小也會不同,所以從字元到位元組一定要看編碼型別。

另外一個問題,你是否考慮過,當我們在電腦中某個文字編輯器裡輸入某個漢字時,它到底是怎麼表示的?我們知道,計算機裡所有的資訊都是以 01 表示的,那麼一個漢字,它到底是多少個 0 和 1 呢?我們能夠看到的漢字都是以字元形式出現的,例如在 Java 中“淘寶”兩個字元,它在計算機中的數值 10 進位制是 28120 和 23453,16 進位制是 6bd8 和 5d9d,也就是這兩個字元是由這兩個數字唯一表示的。Java 中一個 char 是 16 個 bit 相當於兩個位元組,所以兩個漢字用 char 表示在記憶體中佔用相當於四個位元組的空間。

這兩個問題搞清楚後,我們看一下 Java Web 中那些地方可能會存在編碼轉換?

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


圖 3. 一次 HTTP 請求的編碼示例(檢視大圖
圖 3. 一次 HTTP 請求的編碼示例

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

URL 的編解碼

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


圖 4.URL 的幾個組成部分
圖 4.URL 的幾個組成部分

上圖中以 Tomcat 作為 Servlet Engine 為例,它們分別對應到下面這些配置檔案中:

Port 對應在 Tomcat 的 <Connector port="8080"/> 中配置,而 Context Path 在 <Context path="/examples"/> 中配置,Servlet Path 在 Web 應用的 web.xml 中的

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

 

<url-pattern> 中配置,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 的測試結果


圖 5. HTTPFox 的測試結果
圖 5. HTTPFox 的測試結果

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

預設情況下中文 IE 最終的編碼結果也是一樣的,不過 IE 瀏覽器可以修改 URL 的編碼格式在選項 -> 高階 -> 國際裡面的傳送 UTF-8 URL 選項可以取消。

從上面測試結果可知瀏覽器對 PathInfo 和 QueryString 的編碼是不一樣的,不同瀏覽器對 PathInfo 也可能不一樣,這就對伺服器的解碼造成很大的困難,下面我們以 Tomcat 為例看一下,Tomcat 接受到這個 URL 是如何解碼的。

解析請求的 URL 是在 org.apache.coyote.HTTP11.InternalInputBuffer 的 parseRequestLine 方法中,這個方法把傳過來的 URL 的 byte[] 設定到 org.apache.coyote.Request 的相應的屬性中。這裡的 URL 仍然是 byte 格式,轉成 char 是在 org.apache.catalina.connector.CoyoteAdapter 的 convertURI 方法中完成的:

 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 的 <Connector URIEncoding=”UTF-8”/> 中定義的,如果沒有定義,那麼將以預設編碼 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 的 <Connector URIEncoding=”UTF-8” useBodyEncodingForURI=”true”/> 中的 useBodyEncodingForURI 設定為 true。這個配置項的名字有點讓人產生混淆,它並不是對整個 URI 都採用 BodyEncoding 進行解碼而僅僅是對 QueryString 使用 BodyEncoding 解碼,這一點還要特別注意。

從上面的 URL 編碼和解碼過程來看,比較複雜,而且編碼和解碼並不是我們在應用程式中能完全控制的,所以在我們的應用程式中應該儘量避免在 URL 中使用非 ASCII 字元,不然很可能會碰到亂碼問題,當然在我們的伺服器端最好設定 <Connector/> 中的 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 的 <meta HTTP-equiv="Content-Type" content="text/html; charset=GBK" /> 中的 charset 來解碼。如果也沒有定義的話,那麼瀏覽器將使用預設的編碼來解碼。

其它需要編碼的地方

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

xml 檔案可以通過設定頭來制定編碼格式

 <?xml version="1.0" encoding="UTF-8"?> 

 

Velocity 模版設定編碼格式:

 services.VelocityService.input.encoding=UTF-8 

 

JSP 設定編碼格式:

 <%@page contentType="text/html; charset=UTF-8"%> 

 

訪問資料庫都是通過客戶端 JDBC 驅動來完成,用 JDBC 來存取資料要和資料的內建編碼保持一致,可以通過設定 JDBC URL 來制定如 MySQL:url="jdbc:mysql://localhost:3306/DB?useUnicode=true&characterEncoding=GBK"。 


常見問題分析

在瞭解了 Java Web 中可能需要編碼的地方後,下面看一下,當我們碰到一些亂碼時,應該怎麼處理這些問題?出現亂碼問題唯一的原因都是在 char 到 byte 或 byte 到 char 轉換中編碼和解碼的字符集不一致導致的,由於往往一次操作涉及到多次編解碼,所以出現亂碼時很難查詢到底是哪個環節出現了問題,下面就幾種常見的現象進行分析。

中文變成了看不懂的字元

例如,字串“淘!我喜歡!”變成了“Ì Ô £ ¡Î Ò Ï²»¶ £ ¡”編碼過程如下圖所示


Figure xxx. Requires a heading

字串在解碼時所用的字符集與編碼字符集不一致導致漢字變成了看不懂的亂碼,而且是一個漢字字元變成兩個亂碼字元。

一個漢字變成一個問號

例如,字串“淘!我喜歡!”變成了“??????”編碼過程如下圖所示


Figure xxx. Requires a heading

將中文和中文符號經過不支援中文的 ISO-8859-1 編碼後,所有字元變成了“?”,這是因為用 ISO-8859-1 進行編解碼時遇到不在碼值範圍內的字元時統一用 3f 表示,這也就是通常所說的“黑洞”,所有 ISO-8859-1 不認識的字元都變成了“?”。

一個漢字變成兩個問號

例如,字串“淘!我喜歡!”變成了“????????????”編碼過程如下圖所示


Figure xxx. Requires a heading

這種情況比較複雜,中文經過多次編碼,但是其中有一次編碼或者解碼不對仍然會出現中文字元變成“?”現象,出現這種情況要仔細檢視中間的編碼環節,找出出現編碼錯誤的地方。

一種不正常的正確編碼

還有一種情況是在我們通過 request.getParameter 獲取引數值時,當我們直接呼叫

 String value = request.getParameter(name); 

 

會出現亂碼,但是如果用下面的方式

 String value = String(request.getParameter(name).getBytes("
 ISO-8859-1"), "GBK"); 
			

 

解析時取得的 value 會是正確的漢字字元,這種情況是怎麼造成的呢?

看下如所示:


Figure xxx. Requires a heading

這種情況是這樣的,ISO-8859-1 字符集的編碼範圍是 0000-00FF,正好和一個位元組的編碼範圍相對應。這種特性保證了使用 ISO-8859-1 進行編碼和解碼可以保持編碼數值“不變”。雖然中文字元在經過網路傳輸時,被錯誤地“拆”成了兩個歐洲字元,但由於輸出時也是用 ISO-8859-1,結果被“拆”開的中文字的兩半又被合併在一起,從而又剛好組成了一個正確的漢字。雖然最終能取得正確的漢字,但是還是不建議用這種不正常的方式取得引數值,因為這中間增加了一次額外的編碼與解碼,這種情況出現亂碼時因為 Tomcat 的配置檔案中 useBodyEncodingForURI 配置項沒有設定為”true”,從而造成第一次解析式用 ISO-8859-1 來解析才造成亂碼的。


總結

本文首先總結了幾種常見編碼格式的區別,然後介紹了支援中文的幾種編碼格式,並比較了它們的使用場景。接著介紹了 Java 那些地方會涉及到編碼問題,已經 Java 中如何對編碼的支援。並以網路 I/O 為例重點介紹了 HTTP 請求中的存在編碼的地方,以及 Tomcat 對 HTTP 協議的解析,最後分析了我們平常遇到的亂碼問題出現的原因。

綜上所述,要解決中文問題,首先要搞清楚哪些地方會引起字元到位元組的編碼以及位元組到字元的解碼,最常見的地方就是讀取會儲存資料到磁碟,或者資料要經過網路傳輸。然後針對這些地方搞清楚操作這些資料的框架的或系統是如何控制編碼的,正確設定編碼格式,避免使用軟體預設的或者是作業系統平臺預設的編碼格式。

相關文章