簡介: 編碼問題一直困擾着開發人員,尤爲在 Java 中更加明顯,由於 Java 是跨平臺語言,不一樣平臺之間編碼之間的切換較多。本文介紹 Java 中編碼問題出現的根本緣由,你將瞭解到:Java 中常常遇到的幾種編碼格式的區別;Java 中常常須要編碼的場景;出現中文問題的緣由分析;在開發 Java web 程序時可能會存在編碼的幾個地方,一個 HTTP 請求怎麼控制編碼格式?如何避免出現中文問題?html
幾種常見的編碼格式java
爲何要編碼mysql
不知道你們有沒有想過一個問題,那就是爲何要編碼?咱們能不能不編碼?要回答這個問題必需要回到計算機是如何表示咱們人類可以理解的符號的,這些符號也就是咱們人類使用的語言。因爲人類的語言有太多,於是表示這些語言的符號太多,沒法用計算機中一個基本的存儲單元—— byte 來表示,於是必需要通過拆分或一些翻譯工做,才能讓計算機能理解。咱們能夠把計算機可以理解的語言假定爲英語,其它語言要可以在計算機中使用必須通過一次翻譯,把它翻譯成英語。這個翻譯的過程就是編碼。因此能夠想象只要不是說英語的國家要可以使用計算機就必需要通過編碼。這看起來有些霸道,可是這就是現狀,這也和咱們國家如今在大力推廣漢語同樣,但願其它國家都會說漢語,之後其它的語言都翻譯成漢語,咱們能夠把計算機中存儲信息的最小單位改爲漢字,這樣咱們就不存在編碼問題了。web
因此總的來講,編碼的緣由能夠總結爲:算法
如何「翻譯」sql
明白了各類語言須要交流,通過翻譯是必要的,那又如何來翻譯呢?計算中提拱了多種翻譯方式,常見的有 ASCII、ISO-8859-一、GB23十二、GBK、UTF-八、UTF-16 等。它們均可以被看做爲字典,它們規定了轉化的規則,按照這個規則就可讓計算機正確的表示咱們的字符。目前的編碼格式不少,例如 GB23十二、GBK、UTF-八、UTF-16 這幾種格式均可以表示一個漢字,那咱們到底選擇哪一種編碼格式來存儲漢字呢?這就要考慮到其它因素了,是存儲空間重要仍是編碼的效率重要。根據這些因素來正確選擇編碼格式,下面簡要介紹一下這幾種編碼格式。數據庫
學過計算機的人都知道 ASCII 碼,總共有 128 個,用一個字節的低 7 位表示,0~31 是控制字符如換行回車刪除等;32~126 是打印字符,能夠經過鍵盤輸入而且可以顯示出來。apache
128 個字符顯然是不夠用的,因而 ISO 組織在 ASCII 碼基礎上又制定了一些列標準用來擴展 ASCII 編碼,它們是 ISO-8859-1~ISO-8859-15,其中 ISO-8859-1 涵蓋了大多數西歐語言字符,全部應用的最普遍。ISO-8859-1 仍然是單字節編碼,它總共能表示 256 個字符。編程
它的全稱是《信息交換用漢字編碼字符集 基本集》,它是雙字節編碼,總的編碼範圍是 A1-F7,其中從 A1-A9 是符號區,總共包含 682 個符號,從 B0-F7 是漢字區,包含 6763 個漢字。windows
全稱叫《漢字內碼擴展規範》,是國家技術監督局爲 windows95 所制定的新的漢字內碼規範,它的出現是爲了擴展 GB2312,加入更多的漢字,它的編碼範圍是 8140~FEFE(去掉 XX7F)總共有 23940 個碼位,它能表示 21003 個漢字,它的編碼是和 GB2312 兼容的,也就是說用 GB2312 編碼的漢字能夠用 GBK 來解碼,而且不會有亂碼。
全稱是《信息交換用漢字編碼字符集》,是我國的強制標準,它多是單字節、雙字節或者四字節編碼,它的編碼與 GB2312 編碼兼容,這個雖然是國家標準,可是實際應用系統中使用的並不普遍。
說到 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-16 統一採用兩個字節表示一個字符,雖然在表示上很是簡單方便,可是也有其缺點,有很大一部分字符用一個字節就能夠表示的如今要兩個字節表示,存儲空間放大了一倍,在如今的網絡帶寬還很是有限的今天,這樣會增大網絡傳輸的流量,並且也不必。而 UTF-8 採用了一種變長技術,每一個編碼區域有不一樣的字碼長度。不一樣類型的字符能夠是由 1~6 個字節組成。
UTF-8 有如下編碼規則:
Java 中須要編碼的場景
前面描述了常見的幾種編碼格式,下面將介紹 Java 中如何處理對編碼的支持,什麼場合中須要編碼。
I/O 操做中存在的編碼
咱們知道涉及到編碼的地方通常都在字符到字節或者字節到字符的轉換上,而須要這種轉換的場景主要是在 I/O 的時候,這個 I/O 包括磁盤 I/O 和網絡 I/O,關於網絡 I/O 部分在後面將主要以 Web 應用爲例介紹。下圖是 Java 中處理 I/O 問題的接口:
Reader 類是 Java 的 I/O 中讀字符的父類,而 InputStream 類是讀字節的父類,InputStreamReader 類就是關聯字節到字符的橋樑,它負責在 I/O 過程當中處理讀取字節到字符的轉換,而具體字節到字符的解碼實現它由 StreamDecoder 去實現,在 StreamDecoder 解碼過程當中必須由用戶指定 Charset 編碼格式。值得注意的是若是你沒有指定 Charset,將使用本地環境中的默認字符集,例如在中文環境中將使用 GBK 編碼。
寫的狀況也是相似,字符的父類是 Writer,字節的父類是 OutputStream,經過 OutputStreamWriter 轉換字符到字節。以下圖所示:
一樣 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-一、GB23十二、GBK、UTF-1六、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 編碼類圖
首先根據指定的 charsetName 經過 Charset.forName(charsetName) 設置 Charset 類,而後根據 Charset 建立 CharsetEncoder 對象,再調用 CharsetEncoder.encode 對字符串進行編碼,不一樣的編碼類型都會對應到一個類中,實際的編碼過程是在這些類中完成的。下面是 String. getBytes(charsetName) 編碼過程的時序圖
圖 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 編碼,下面是編碼結果:
從上圖看出 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 編碼,下面是編碼結果:
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 編碼,下面是編碼結果:
你可能已經發現上圖與 GB2312 編碼的結果是同樣的,沒錯 GBK 與 GB2312 編碼結果是同樣的,由此能夠得出 GBK 編碼是兼容 GB2312 編碼的,它們的編碼算法也是同樣的。不一樣的是它們的碼錶長度不同,GBK 包含的漢字字符更多。因此只要是通過 GB2312 編碼的漢字均可以用 GBK 進行解碼,反過來則否則。
按照 UTF-16 編碼
字符串「I am 君山」用 UTF-16 編碼,下面是編碼結果:
用 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 編碼,下面是編碼結果:
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 請求的編碼示例
如上圖所示一次 HTTP 請求設計到不少地方須要編解碼,它們編解碼的規則是什麼?下面將會重點闡述一下:
URL 的編解碼
用戶提交一個 URL,這個 URL 中可能存在中文,所以須要編碼,如何對這個 URL 進行編碼?根據什麼規則來編碼?有如何來解碼?以下圖一個 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 的測試結果
君山的編碼結果分別是: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 轉換中編碼和解碼的字符集不一致致使的,因爲每每一次操做涉及到屢次編解碼,因此出現亂碼時很難查找究竟是哪一個環節出現了問題,下面就幾種常見的現象進行分析。
中文變成了看不懂的字符
例如,字符串「淘!我喜歡!」變成了「ì ? £ ?? ò ?2?? £ ?」編碼過程以下圖所示
字符串在解碼時所用的字符集與編碼字符集不一致致使漢字變成了看不懂的亂碼,並且是一個漢字字符變成兩個亂碼字符。 一個漢字變成一個問號
例如,字符串「淘!我喜歡!」變成了「??????」編碼過程以下圖所示
將中文和中文符號通過不支持中文的 ISO-8859-1 編碼後,全部字符變成了「?」,這是由於用 ISO-8859-1 進行編解碼時遇到不在碼值範圍內的字符時統一用 3f 表示,這也就是一般所說的「黑洞」,全部 ISO-8859-1 不認識的字符都變成了「?」。
一個漢字變成兩個問號
例如,字符串「淘!我喜歡!」變成了「????????????」編碼過程以下圖所示
這種狀況比較複雜,中文通過屢次編碼,可是其中有一次編碼或者解碼不對仍然會出現中文字符變成「?」現象,出現這種狀況要仔細查看中間的編碼環節,找出出現編碼錯誤的地方。
一種不正常的正確編碼
還有一種狀況是在咱們經過 request.getParameter 獲取參數值時,當咱們直接調用
String value = request.getParameter(name);
會出現亂碼,可是若是用下面的方式
String value = String(request.getParameter(name).getBytes("
ISO-8859-1"), "GBK");
解析時取得的 value 會是正確的漢字字符,這種狀況是怎麼形成的呢?
看下如所示:
這種狀況是這樣的,ISO-8859-1 字符集的編碼範圍是 0000-00FF,正好和一個字節的編碼範圍相對應。這種特性保證了使用 ISO-8859-1 進行編碼和解碼能夠保持編碼數值「不變」。雖然中文字符在通過網絡傳輸時,被錯誤地「拆」成了兩個歐洲字符,但因爲輸出時也是用 ISO-8859-1,結果被「拆」開的中文字的兩半又被合併在一塊兒,從而又恰好組成了一個正確的漢字。雖然最終能取得正確的漢字,可是仍是不建議用這種不正常的方式取得參數值,由於這中間增長了一次額外的編碼與解碼,這種狀況出現亂碼時由於 Tomcat 的配置文件中 useBodyEncodingForURI 配置項沒有設置爲」true」,從而形成第一次解析式用 ISO-8859-1 來解析才形成亂碼的。
總結
本文首先總結了幾種常見編碼格式的區別,而後介紹了支持中文的幾種編碼格式,並比較了它們的使用場景。接着介紹了 Java 那些地方會涉及到編碼問題,已經 Java 中如何對編碼的支持。並以網絡 I/O 爲例重點介紹了 HTTP 請求中的存在編碼的地方,以及 Tomcat 對 HTTP 協議的解析,最後分析了咱們日常遇到的亂碼問題出現的緣由。
綜上所述,要解決中文問題,首先要搞清楚哪些地方會引發字符到字節的編碼以及字節到字符的解碼,最多見的地方就是讀取會存儲數據到磁盤,或者數據要通過網絡傳輸。而後針對這些地方搞清楚操做這些數據的框架的或系統是如何控制編碼的,正確設置編碼格式,避免使用軟件默認的或者是操做系統平臺默認的編碼格式。
參考資料
學習
Unicode 編碼規範,詳細描述了 Unicode 如何編碼。
ISO-8859-1 編碼,詳細介紹了 ISO-8859-1 的一些細節。
RFC3986 規範,詳細描述了 URL 編碼規範
HTTP 協議,W3C 關於 HTTP 協議的詳細描述。
查看文章 《 Tomcat 系統架構與設計模式》(developerWorks,2010 年 5 月):瞭解 Tomcat 中容器的體系結構,基本的工做原理,以及 Tomcat 中使用的經典的設計模式介紹。
Servlet 工做原理解析,(developerWorks,2011 年 2 月):以 Tomcat 爲例瞭解 Servlet 容器是如何工做的?一個 Web 工程在 Servlet 容器中是如何啓動的? Servlet 容器如何解析你在 web.xml 中定義的 Servlet ?用戶的請求是如何被分配給指定的 Servlet 的? Servlet 容器如何管理 Servlet 生命週期?你還將瞭解到最新的 Servlet 的 API 的類層次結構,以及 Servlet 中一些難點問題的分析。
developerWorks Java 技術專區:這裏有數百篇關於 Java 編程各個方面的文章。