java中byte, iso-8859-1, UTF-8,亂碼的根源
Post@https://ryan-miao.github.io 測試程式碼https://github.com/Ryan-Miao/someTest/commit/50241e50d4b6ecdb8820e58f4cb9628bfb7d77ec
背景
還是多語言, 在專案中遇到本地環境和服務端環境不一致亂碼的情形。因此需要搞清楚亂碼產生的過程,來分析原因。
獲取多語言程式碼如下:
private Map<String, String> getLocalizationContent(Locale locale) { ResourceBundle bundle = ResourceBundle.getBundle(this.resourceBundleName, Locale.US); ResourceBundle bundleLocale = ResourceBundle.getBundle(this.resourceBundleName, locale); Set<String> keys = bundle.keySet(); Map<String, String> map = new HashMap(); String key; String translation; for(Iterator var6 = keys.iterator(); var6.hasNext(); map.put(key, translation)) { key = (String)var6.next(); try { translation = bundleLocale.getString(key); translation = new String(translation.getBytes("ISO-8859-1"), "UTF-8"); translation = this.escapeStringForJavaScript(translation); } catch (UnsupportedEncodingException | MissingResourceException var10) { translation = bundle.getString(key); } } return map; }
其中,因為ResourceBundle
通過PropertyResourceBundle
讀取properties
檔案。 這就要看以哪種方式load Properties了。提供了兩種建構函式:
public PropertyResourceBundle (InputStream stream) throws IOException { Properties properties = new Properties(); properties.load(stream); lookup = new HashMap(properties); } public PropertyResourceBundle (Reader reader) throws IOException { Properties properties = new Properties(); properties.load(reader); lookup = new HashMap(properties); }
通過跟蹤ResourceBundle.getBundle(this.resourceBundleName, locale);
原始碼發現建立bundle的方法為:
public ResourceBundle newBundle(String baseName, Locale locale, String format, ClassLoader loader, boolean reload) throws IllegalAccessException, InstantiationException, IOException { String bundleName = toBundleName(baseName, locale); ResourceBundle bundle = null; if (format.equals("java.class")) { try { @SuppressWarnings("unchecked") Class<? extends ResourceBundle> bundleClass = (Class<? extends ResourceBundle>)loader.loadClass(bundleName); // If the class isn't a ResourceBundle subclass, throw a // ClassCastException. if (ResourceBundle.class.isAssignableFrom(bundleClass)) { bundle = bundleClass.newInstance(); } else { throw new ClassCastException(bundleClass.getName() + " cannot be cast to ResourceBundle"); } } catch (ClassNotFoundException e) { } } else if (format.equals("java.properties")) { final String resourceName = toResourceName0(bundleName, "properties"); if (resourceName == null) { return bundle; } final ClassLoader classLoader = loader; final boolean reloadFlag = reload; InputStream stream = null; try { stream = AccessController.doPrivileged( new PrivilegedExceptionAction<InputStream>() { public InputStream run() throws IOException { InputStream is = null; if (reloadFlag) { URL url = classLoader.getResource(resourceName); if (url != null) { URLConnection connection = url.openConnection(); if (connection != null) { // Disable caches to get fresh data for // reloading. connection.setUseCaches(false); is = connection.getInputStream(); } } } else { is = classLoader.getResourceAsStream(resourceName); } return is; } }); } catch (PrivilegedActionException e) { throw (IOException) e.getException(); } if (stream != null) { try { bundle = new PropertyResourceBundle(stream); } finally { stream.close(); } } } else { throw new IllegalArgumentException("unknown format: " + format); } return bundle; }
也就是說,最終通過properties.load(stream);
的方法讀取properties檔案的。
The load(Reader) / store(Writer, String) methods load and store properties from and to a character based stream in a simple line-oriented format specified below. The load(InputStream) / store(OutputStream, String) methods work the same way as the load(Reader)/store(Writer, String) pair, except the input/output stream is encoded in ISO 8859-1 character encoding. Characters that cannot be directly represented in this encoding can be written using Unicode escapes as defined in section 3.3 of The Java™ Language Specification; only a single 'u' character is allowed in an escape sequence. The native2ascii tool can be used to convert property files to and from other character encodings.
@Test
public void unicodeToChar(){
char aChar = 'u4E2D';
Assert.assertEquals('中', aChar);
String aStr = "u4E2Du6587";
Assert.assertEquals("中文", aStr);
}
根據官方文件,使用Unicode轉義可以識別中文字元的。按照之前本地的表現,Properties檔案以中文原樣書寫,並且檔案字符集為utf8,生成位元組流的時候中文肯定會變成多個位元組。這樣系統讀取之後的字元是不對的。需要再次使用utf8編碼為正確的字元。而服務端的表現是:不需要再次編碼,讀出來的字元就是正確的。那麼就可以證明服務端的Properties檔案的中文經過了轉義,或者讀取的時候進行了轉義。目前本地和服務端的唯一區別就是系統。一個是打包的過程,本地編譯是否和服務端編譯不同?一個是服務端的jvm,到現在沒搞清楚服務端jvm的版本。看訊息說,java9可以支援直接使用中文而不用轉碼了。
所以, 問題的根源找到了: 先證明打包是否有問題--將服務端的包在本地跑一下。然後驗證服務端的jvm是否有直接讀取utf8的能力---編寫一個簡單的讀寫code。
找問題的時候找了很久,經過高人指導後又靜心查閱了編碼的資料才能融會貫通。以下是查資料時整理的對理解編碼和亂碼有用的文章。
亂碼的分類
目前看到兩種亂碼:問號和ISO符號亂碼。
1. 開始學習亂碼之道
以下內容轉載自深入分析 Java 中的中文編碼問題, 作者:許令波,發表時間:2011 年 7 月 06 日。
1.1 結論放在開頭
-
iso-8859-1
以一個位元組(1 byte)儲存字元。即字元a
儲存為一個位元組,即8位(8 bit)。 -
utf-8
變長位元組儲存字元,最小單位是一個位元組。iso-8859-1
正好相當於utf-8
的一個單位。因此,將以utf-8
編碼的位元組流用iso-8859-1
的方式讀取後字元亂碼但資訊不丟失,只需要將字元還原成byte陣列(str.getBytes("ISO-8859-1")
),重新以utf-8
讀取(new String(byte[], "UTF-8")
)即可。
1.2 為什麼要編碼,我們認知的符號地如何存在的
1.2.1 java中的編碼
- 計算機中儲存資訊的最小單元是一個位元組即 8 個 bit,所以能表示的字元範圍是 0~255 個
- 人類要表示的符號太多,無法用一個位元組來完全表示
- 要解決這個矛盾必須需要一個新的資料結構 char(16bit, 2byte),從 char 到 byte 必須編碼
1.2.2 幾個重要的編碼
ASCII (發音: /ˈæski/ ass-kee[1],American Standard Code for Information Interchange,美國資訊交換標準程式碼)是基於拉丁字母的一套電腦編碼系統。它主要用於顯示現代英語,而其擴充套件版本EASCII則可以部分支援其他西歐語言,並等同於國際標準ISO/IEC 646。至今為止共定義了128個字元。
用一個位元組的低 7 位表示,0~31 是控制字元如換行回車刪除等;32~126 是列印字元,可以通過鍵盤輸入並且能夠顯示出來。
ISO 8859-1 正式編號為ISO/IEC 8859-1:1998,又稱Latin-1或“西歐語言”,是國際標準化組織內ISO/IEC 8859的第一個8位字符集。它以ASCII為基礎,在空置的0xA0-0xFF的範圍內,加入96個字母及符號,藉以供使用附加符號的拉丁字母語言使用。
ISO-8859-1 仍然是單位元組編碼,它總共能表示 256 個字元。
GB2312
它的全稱是《資訊交換用漢字編碼字符集 基本集》,它是雙位元組編碼,總的編碼範圍是 A1-F7,其中從 A1-A9 是符號區,總共包含 682 個符號,從 B0-F7 是漢字區,包含 6763 個漢字。
GBK
全稱叫《漢字內碼擴充套件規範》,是國家技術監督局為 windows95 所制定的新的漢字內碼規範,它的出現是為了擴充套件 GB2312,加入更多的漢字,它的編碼範圍是 8140~FEFE(去掉 XX7F)總共有 23940 個碼位,它能表示 21003 個漢字,它的編碼是和 GB2312 相容的,也就是說用 GB2312 編碼的漢字可以用 GBK 來解碼,並且不會有亂碼。
UTF-16
具體定義了 Unicode 字元在計算機中存取方法。UTF-16不是定長兩位元組,它是變長,有二或四位元組,Unicode的碼點最大已經到了U+10FFFF. 轉化格式,這個是定長的表示方法,不論什麼字元都可以用兩個位元組表示,兩個位元組是 16 個 bit,所以叫 UTF-16。UTF-16 表示字元非常方便,每兩個位元組表示一個字元,這個在字串操作時就大大簡化了操作,這也是 Java 以 UTF-16 作為記憶體的字元儲存格式的一個很重要的原因。
Unicode(中文:萬國碼、國際碼、統一碼、單一碼)是電腦科學領域裡的一項業界標準。它對世界上大部分的文字系統進行了整理、編碼,使得電腦可以用更為簡單的方式來呈現和處理文字。 在表示一個Unicode的字元時,通常會用“U+”然後緊接著一組十六進位制的數字來表示這一個字元。在基本多文種平面(英文:Basic Multilingual Plane,簡寫BMP。又稱為“零號平面”、plane 0)裡的所有字元,要用四個數字(即兩個char,16bit ,例如U+4AE0,共支援六萬多個字元);在零號平面以外的字元則需要使用五個或六個數字。舊版的Unicode標準使用相近的標記方法,但卻有些微小差異:在Unicode 3.0裡使用“U-”然後緊接著八個數字,而“U+”則必須隨後緊接著四個數字。
UTF-8(8-bit Unicode Transformation Format)
UTF-16 統一採用兩個位元組表示一個字元,雖然在表示上非常簡單方便,但是也有其缺點,有很大一部分字元用一個位元組就可以表示的現在要兩個位元組表示,儲存空間放大了一倍,在現在的網路頻寬還非常有限的今天,這樣會增大網路傳輸的流量,而且也沒必要。而 UTF-8 採用了一種變長技術,每個編碼區域有不同的字碼長度。不同型別的字元可以是由 1~6 個位元組組成。
UTF-8 有以下編碼規則:
- 如果一個位元組,最高位(第 8 位)為 0,表示這是一個 ASCII 字元(00 - 7F)。可見,所有 ASCII 編碼已經是 UTF-8 了。
- 如果一個位元組,以 11 開頭,連續的 1 的個數暗示這個字元的位元組數,例如:110xxxxx 代表它是雙位元組 UTF-8 字元的首位元組。
- 如果一個位元組,以 10 開始,表示它不是首位元組,需要向前查詢才能得到當前字元的首位元組
1.2.3 java中編碼的流程
1.2.3.1 什麼時候需要編碼
將字元轉換為位元組,以及將位元組轉換字元的時候。
1.2.3.2 Java在什麼時候編碼
通過I/O讀寫的時候,以及自定義轉碼的時候。I/O又區分為磁碟I/O和網路I/O。
java中關於編碼有位元組流和字元流。最初學java的時候肯定不去想為啥搞這東西。等用的時候才發現真是有用的。
位元組流就是可以理解為byte陣列, 一個byte就是一個位元組,一個位元組等於8位, 即8個0和1的二進位制,也即兩位的十六進位制(FF)。ISO的編碼就是基於單位元組的,每個位元組都可以對映為一個字元。
字元流當然就是面向字元的。這個是在位元組流之上做了重組。字元流的最小單位是一個字元,可以理解為char陣列。a
和中
都是一個字元,但如果用位元組表示的話,a
是一個位元組,中
是兩個。
下面介紹位元組流和字元流的互動。
1.2.3.3 Java中的I/O流程
Reader
是Java IO中讀取字元的父類,InputStream
是讀取位元組的父類,InputStreamReader
是位元組到字元的橋樑,具體通過StreamDecoder
實現。其中StreamDecoder
需要指定Charset編碼格式,如果使用者不指定,則採用本地環境預設字符集。
Writer
是寫字元的父類,OutputStream
是寫位元組的父類,OutputStreamWriter
是字元到位元組的橋樑。
demo:
@Test
public void test_write_read_encoding() throws IOException {
String file = this.getClass().getClassLoader().getResource("").getPath()+File.separator+"test.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);
StringBuilder sb = new StringBuilder();
int charRead = reader.read();
while (charRead != -1){
sb.append((char) charRead);
charRead = reader.read();
}
System.out.println(sb.toString());
}
文章最初的亂碼是因為write的時候是以utf-8
編碼,而讀取的時候按照iso-8859-1
解碼。這時候亂碼就是:这是è¦ä¿å˜çš„ä¸æ–‡å—符
。
1.2.3.4 記憶體中的編碼
除了讀寫檔案,還可以在記憶體中轉換編碼。
@Test
public void testConvert() throws UnsupportedEncodingException {
String s = "這是一段中文字串";
byte[] b = s.getBytes("UTF-8");
String utf8 = new String(b,"UTF-8");
String iso = new String(b,"iso-8859-1");
Assert.assertEquals(s, utf8);
Assert.assertEquals("è¿u0099æu0098¯ä¸u0080段ä¸u00ADæu0096u0087åu00ADu0097符串", iso);
}
@Test
public void testEncodingCharSet(){
String aStr = "中文";
Charset charset = Charset.forName("UTF-8");
ByteBuffer byteBuffer = charset.encode(aStr);
CharBuffer charBuffer = charset.decode(byteBuffer);
Assert.assertEquals(aStr, charBuffer.toString());
}
1.2.3.5 java如何編碼
通過例項分析編碼過程。
@Test
public void testEncoder(){
String name = "I am 君山";
char[] chars = name.toCharArray();
for (char c : chars) {
System.out.printf(c +"("+(int)c+ ")=" + Integer.toHexString(c) +" | ");
}
System.out.println();
try {
byte[] iso8859 = name.getBytes("ISO-8859-1");
System.out.println("iso:");
toHex(iso8859);
byte[] utf8 = name.getBytes("UTF-8");
System.out.println("utf8:");
toHex(utf8);
byte[] gb2312 = name.getBytes("GB2312");
System.out.println("gb2312:");
toHex(gb2312);
byte[] gbk = name.getBytes("GBK");
System.out.println("gbk:");
toHex(gbk);
byte[] utf16 = name.getBytes("UTF-16");
System.out.println("utf16:");
toHex(utf16);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
private void toHex(byte[] data) {
for (byte b: data){
byte[] bytes = {b};
System.out.printf(Hex.encodeHexString(bytes) + " | ");
}
System.out.println();
}
註釋:
- java中char轉換成int是因為char是16位的,int是32位,強轉不丟失。
- char轉換成int的數值表示什麼?明天去看看java程式設計思想,應該是該字元在Unicode字符集中的排序位置。
- 本例項中將char轉換的數值轉為16進位制(Hex)來代表這個字元。比如
君
的int值為21531
,轉換成16進製為541b
。而君
的Unicode也正好是u541b
。所以,++Java中char是通過儲存字元的16進位制的數值來表示該字元的++。
java編碼需要用的類圖:
首先根據Charset.forName(charsetName)
查詢Charset
,然後建立CharsetEncoder
, 最後呼叫CharsetEncoder.encode
進行編碼。其中UTF-8
等編碼子類中內部類Encoder
都繼承了CharsetEncoder
。
String. getBytes(charsetName)
時序圖:
下面分析字串編碼的具體過程: 首先,控制檯輸出內容:
I(73)=49 | (32)=20 | a(97)=61 | m(109)=6d | (32)=20 | 君(21531)=541b | 山(23665)=5c71 |
iso:
49 | 20 | 61 | 6d | 20 | 3f | 3f |
utf8:
49 | 20 | 61 | 6d | 20 | e5 | 90 | 9b | e5 | b1 | b1 |
gb2312:
49 | 20 | 61 | 6d | 20 | be | fd | c9 | bd |
gbk:
49 | 20 | 61 | 6d | 20 | be | fd | c9 | bd |
utf16:
fe | ff | 00 | 49 | 00 | 20 | 00 | 61 | 00 | 6d | 00 | 20 | 54 | 1b | 5c | 71 |
對應關係如下圖,具體規則請參考原文,這裡只share圖:
從上圖看出 7 個 char 字元經過 ISO-8859-1 編碼轉變成 7 個 byte 陣列,ISO-8859-1 是單位元組編碼,中文“君山”被轉化成值是 3f 的 byte。3f 也就是“?”字元,所以經常會出現中文變成“?”很可能就是錯誤的使用了 ISO-8859-1 這個編碼導致的。中文字元經過 ISO-8859-1 編碼會丟失資訊,通常我們稱之為“黑洞”,它會把不認識的字元吸收掉。由於現在大部分基礎的 Java 框架或系統預設的字符集編碼都是 ISO-8859-1,所以很容易出現亂碼問題,後面將會分析不同的亂碼形式是怎麼出現的。
UTF-8 對單位元組範圍內字元仍然用一個位元組表示,對漢字採用三個位元組表示。UTF-8 編碼與 GBK 和 GB2312 不同,不用查碼錶,所以在編碼效率上 UTF-8 的效率會更好,所以在儲存中文字元時 UTF-8 編碼比較理想
1.2.3.6 幾種編碼比較
對中文字元後面四種編碼格式都能處理,GB2312 與 GBK 編碼規則類似,但是 GBK 範圍更大,它能處理所有漢字字元,所以 GB2312 與 GBK 比較應該選擇 GBK。UTF-16 與 UTF-8 都是處理 Unicode 編碼,它們的編碼規則不太相同,相對來說 UTF-16 編碼效率最高,字元到位元組相互轉換更簡單,進行字串操作也更好。它適合在本地磁碟和記憶體之間使用,可以進行字元和位元組之間快速切換,如 Java 的記憶體編碼就是採用 UTF-16 編碼。但是它不適合在網路之間傳輸,因為網路傳輸容易損壞位元組流,一旦位元組流損壞將很難恢復,想比較而言 UTF-8 更適合網路傳輸,對 ASCII 字符采用單位元組儲存,另外單個字元損壞也不會影響後面其它字元,在編碼效率上介於 GBK 和 UTF-16 之間,所以 UTF-8 在編碼效率上和編碼安全性上做了平衡,是理想的中文編碼方式。
1.2.4 繼續舉例分析字元在java中的亂碼情況
你是否考慮過,當我們在電腦中某個文字編輯器裡輸入某個漢字時,它到底是怎麼表示的?我們知道,計算機裡所有的資訊都是以 01 表示的,那麼一個漢字,它到底是多少個 0 和 1 呢?我們能夠看到的漢字都是以字元形式出現的,例如在 Java 中“淘寶”兩個字元,它在計算機中的數值 10 進位制是 28120 和 23453,16 進位制是 6bd8 和 5d9d,也就是這兩個字元是由這兩個數字唯一表示的。Java 中一個 char 是 16 個 bit 相當於兩個位元組,所以兩個漢字用 char 表示在記憶體中佔用相當於四個位元組的空間。
1.2.4.1 中文變成了看不懂的字元, 一個漢字變成兩個亂碼字元
例如,字串“淘!我喜歡!”變成了“Ì Ô £ ¡Î Ò Ï²»¶ £ ¡”編碼過程如下圖所示
1.2.4.2 一個漢字變成一個問號
1.2.4.3 一個漢字變成兩個問號
1.2.4.4 不應該這樣編碼,即使結果是正確的
//亂碼
String value = request.getParameter(name);
//正常
String value = String(request.getParameter(name).getBytes("
ISO-8859-1"), "GBK");
這種情況是這樣的,ISO-8859-1 字符集的編碼範圍是 0000-00FF,正好和一個位元組的編碼範圍相對應。這種特性保證了使用 ISO-8859-1 進行編碼和解碼可以保持編碼數值“不變”。雖然中文字元在經過網路傳輸時,被錯誤地“拆”成了兩個歐洲字元,但由於輸出時也是用 ISO-8859-1,結果被“拆”開的中文字的兩半又被合併在一起,從而又剛好組成了一個正確的漢字。雖然最終能取得正確的漢字,但是還是不建議用這種不正常的方式取得引數值,因為這中間增加了一次額外的編碼與解碼,這種情況出現亂碼時因為 Tomcat 的配置檔案中 useBodyEncodingForURI 配置項沒有設定為”true”,從而造成第一次解析式用 ISO-8859-1 來解析才造成亂碼的。
1.3 java web的一些編碼知識
1.3.1 URL的編碼和解碼
首先,估計絕大部分搞web的不一定說的出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 的測試結果
君山的編碼結果分別是: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=UTF-8" />
中的 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=UTF-8"
Post@https://ryan-miao.github.io