http://www.regexlab.com
[原創文章,轉載請保留或注明出處:http://www.regexlab.com/zh/encoding.htm]
級別:初級
摘要:本文將完整,通俗地介紹字符編碼,軟件國際化等相關概念,也就是編碼問題,內容涵蓋常說的“中文問題”,“亂碼問題”。本文針對亞洲的讀者,講解了產生亂碼問題的原理以及解決辦法。同時也針對西方的讀者,講解了字符編碼的概念,為西方國家的朋友開發國際化軟件打一個必要的基礎。
“編碼問題”是一個被經常討論的話題。即使這樣,時常出現的亂碼仍然困擾著大家。雖然有很多的辦法可以用來消除亂碼,但我們并不一定理解這些辦法的內在原理。我們在寫出代碼并嘗試所掌握的辦法之前,仍然無法保證亂碼不會出現。而有的亂碼產生的原因,實際上是 JDBC 驅動或 ODBC 驅動本身有問題所導致的。因此,不僅是初學者會對編碼問題感到模糊,不少的編程高手同樣對編碼問題缺乏準確的理解。
本文將講解編碼問題產生的由來,編碼概念的正確理解,各種環節編碼的處理辦法,以及產生亂碼的原因和解決辦法。
![]() |
最早誕生的 ASCII 碼只包含了英語字母和一些常用標點符號,編號為0~127。后來制定了擴展 ASCII 碼,增加了一些歐洲語言中的字母和其他的一些符號,編號為0~255。在計算機中,使用一個字節來存儲一個字母或符號。
隨著計算機的發展,各個國家和地區為了能在計算機上使用自己的語言,紛紛研究技術方案和制定相關標準。通常采取的辦法是,保留 ASCII 中的0~127部分不變,然后使用128~255范圍的2個字節來表示1個字符。比如:漢字‘中’在 GB2312 標準中,使用 [D6][D0] 這兩個字節表示。不同的國家和地區制定了不同的標準,由此產生了 GB2312, BIG5, JIS 等各自的編碼標準。
這些所有使用 2 個字節來代表一個字符的各種漢字延伸編碼方式,稱為 ANSI 編碼。在簡體中文系統下,ANSI 編碼代表 GB2312 編碼,在繁體中文系統下,ANSI 編碼代表 BIG5 編碼,在日文操作系統下,ANSI 編碼代表 JIS 編碼。
這些標準之間互不兼容,比如:在漢語和日語中都有‘中’字,同樣為了表示‘中’字,GB2312 使用的兩個字節與 JIS 使用的兩個字節是不一樣的。
![]() |
在很長一段時間里,計算機只支持英語和歐洲的語言,一個字母或符號就用一個字節表示,字符的含義就相當于字節。比如 C 語言中的一個字符(char)就表示一個字節。當漢語等語言被支持以后,漢字需要使用多個字節表示,因此,字符的含義不再等同于字節。現在我們必須明確區分這兩者的概念:
字符: | 人們使用的記號,抽象意義上的一個符號。比如:‘1’,‘中’,‘a’,‘$’,‘¥’,……。它們之間的地位完全平等。 |
字節: | 計算機中存儲數據的單元,一個8位的二進制數,是一個很具體的存儲空間。 |
在這里,我們對字符和字節的含義進行了區分。當提到“字符”的時候,我們只需要想到它是一個符號,不要去考慮它在內存中是什么樣子。當我們提到“字節”的時候,我們只需要想到它是一個8位二進制數,不要去考慮它表示哪個字符。對于某個字符,不同的編碼標準可能會規定不同的字節來表示。因此,單純的問:“計算機中某個字符是用怎么存儲的”這個問題是沒有意義的。
在 Java 語言中 char 的含義與在 C 語言中是不同的。Java 中的 char 表示“字符”,代表一個抽象意義上的符號,byte 表示“字節”。而在 C 語言中 char 代表一個字節。
![]() |
不同的國家和地區所制定的不同標準,都只規定了各自所需的字符。比如:漢字標準(GB2312)中就沒有規定韓國語字符的表示辦法。這些標準中規定的內容有兩層含義:
各個國家和地區在制定編碼標準的時候,“字符的集合”和“編碼”都是同時制定的。因此,平常我們所說的“字符集”,比如:GB2312, GBK, JIS 等,除了有“字符的集合”這層含義外,同時也包含了“編碼”的含義。
![]() |
不同字符集的編碼規則互不兼容。兩個不同字符分別屬于兩個字符集,采用各自編碼規則后可能得到相同的字節,因此,當我們想要在計算機中同時表示這兩個字符時就無法做到。
為了解決這個問題,使國際間信息交流更加方便,國際組織制定了 UNICODE 字符集。UNICODE 包含了各種語言里面所使用到的所有字符,并為每一個字符賦予了一個唯一的序號。不論在什么平臺下,什么程序中,不論用在什么語言里,UNICODE 對同一個字符賦予的序號總是相同的。
與其他“字符集”不同的是,“UNICODE”并不代表一種“編碼”,只代表一個“字符的集合”。用來給 UNICODE 字符集編碼的標準有很多種,比如:UTF-8, UTF-7, UTF-16, UnicodeLittle, UnicodeBig 等。
![]() |
在大家正確理解了相關概念后,以下的章節都是對這些概念在實際應用場合的更深入的講解。如果你在進行以下章節的過程中,感覺理解比較困難,說明前面所講的概念你還沒有理解正確,有必要回頭去復習一下第 2 節中的內容。
在 UNICODE 被采用之前,計算機想要記錄一段文字,內存中實際存放的內容是:按照指定編碼規則得到的字節串。也就是按照 ANSI 編碼方式存儲在內存中的。比如:在中文 DOS, Windows 95, Windows 98 操作系統中,字符串 "中文123" 存放在內存中時,實際存放的是 [D6][D0][CE][C4][31][32][33] 這7個字節。('中' 和 '文' 分別占2個字節。)
而在 UNICODE 被采用之后,計算機想要記錄一段文字時,內存中不再存放根據特定編碼而得到的字節串,而改為存放各個字符在 UNICODE 中的序號。比如:在 Windows NT/2000/XP, Linux, Java 系統中,字符串 "中文123" 存放在內存中時,實際記錄的是 20013, 25991, 49, 50, 51 這5個序號。當不同語言中的字符需要同時表示時,不會因為編碼沖突而無法表示。
字符串實際所占內存空間的大小,要取決于當前系統采用多少字節來存放一個“序號”。如果使用2個字節存放一個序號,那么 "中文123" 在內存中就占10個字節。如果使用4個字節存放一個序號,那么 "中文123" 就占20個字節。
![]() |
在 Java 和 C++ 中,分別代表字節和字符的數據類型是:
| 字節 | 字符 | 字節串 | 字符串 |
---|---|---|---|---|
Java | byte | char | byte[] | String |
C++ | char | wchar_t | char* | char*(ANSI), wchar_t*(UNICODE), CString(Visual C++) |
在 Java 中,“字符串”與“字節串”之間可以方便地按照指定編碼規則進行轉化:
byte [] b = "中文123".getBytes("GB2312"); // 從字符串按照 GB2312 得到字節串 System.out.println(b.length); // 得到長度為7個 (D6,D0,CE,C4,31,32,33) String str = new String(b, "GB2312"); // 從字節串按照 GB2312 得到字符串 System.out.println("中文123".length()); // 得到長度為5,因為是5個字符 |
在 java.io.* 包里面有很多類,其中,以“Stream”結尾的類都是用來操作“字節串”的類,以“Reader”,“Writer”結尾的類都是用來操作“字符串”的類。任何一個文件保存到硬盤上的時候,都是以字節為單位保存的,當要把“字符串”保存到硬盤上的文本文件,必然要選擇一種編碼。
有兩種方法來指定編碼:
String str = "中文123"; // 第一種辦法:先用指定編碼轉化成字節串,然后用 Stream 類寫入 OutputStream os = new FileOutputStream("1.txt"); byte [] b = str.getBytes("utf-8"); os.write(b); os.close(); // 第二種辦法:構造指定編碼的 Writer 來寫入字符串 Writer ow = new OutputStreamWriter(new FileOutputStream("2.txt"), "utf-8"); ow.write(str); ow.close(); // 最后得到的 1.txt 和 2.txt 都是 9 個字節。(漢字在 utf-8 編碼規則中占3字節) |
在 C++ 中,操作字節串(char*)和操作字符串(wchar_t*)各有一套函數。在 Windows API 中,字符串與字節串相互轉化的函數是:MultiByteToWideChar() 和 WideCharToMultiByte()。
![]() |
我們在安裝數據庫的時候,比如安裝 MySQL、MS SQL Server、Oracle 時,都會要求選擇一種編碼。數據庫中跟字符串相關的類型有:char, varchar, text, nchar, nvarchar, ntext 等。其中,char, varchar, text 這幾種類型的存儲方式,是按照所選編碼將文本轉化為字節進行存儲的。char(10) 和 varchar(10) 的長度限制,指的是字節的長度限制。另外的 nchar, nvarchar, ntext 這幾種類型的存儲方式,是直接按照字符的 UNICODE 序號進行存儲的,nchar(10) 和 nvarchar(10) 的長度限制,指的是字符數限制。
比如,在使用 GB2312 編碼的 SQL Server 數據庫中,對于如下表:
CREATE TABLE TEST( ) |
能夠存儲的最大字符串長度分別是:
對于 MySQL 和 Oracle ,細節的地方不一定完全相同。
訪問數據庫最常用的接口是 JDBC 或者 ODBC。這些訪問接口在讀取字符串字段時,負責與數據庫服務器交互,獲知數據庫服務器使用的是哪一種編碼,并將數據庫服務器傳輸過來的字節流,按照正確的編碼還原成“字符”串,然后交給應用程序。
![]() |
當網頁上的表單(form)提交數據到 Web 服務器時,'?', '&', '=', '%', '+' 等符號在提交的數據中都有特殊的定義和用途。因此,如果所要提交的某個表單項中,包含這些符號,那么這些符號就需要進行一下轉化,然后服務器再進行逆向轉化而得到本來所要提交的數據。
轉化的格式是:%XX (百分號再跟上被轉化符號的16進制編碼)。比如:'?' 會被轉化成 '%3F',空格 ' ' 會被轉化成 '+' 或者 '%20'。
如果所要提交的表單數據中,包含有漢字,那么瀏覽器會將會將每個漢字根據當前頁面所使用的的編碼轉化成字節,然后將每個字節采用 %XX 的格式提交到服務器。服務器端遇到 %XX 格式的數據時,會根據 XX 編號得到字節,然后再根據同樣的編碼得到字符串。
舉例說明:提交內容“a=%D6%D0%CE%C4%31%32%33”到服務器,通過服務器提供的功能獲取表單項“a”得到的內容是:“中文123”。
![]() |
當一段 Text 或者 HTML 通過電子郵件傳送時,發送的內容首先通過一種指定的字符編碼轉化成“字節串”,然后再將得到的“字節串”通過一種指定的傳輸編碼(Content-Transfer-Encoding)進行轉化得到另一串“字節串”。比如,打開一封電子郵件源代碼,可以看到:
Content-Type: text/plain; Content-Transfer-Encoding: base64 sbG+qcrQuqO17cf4yee74bGjz9W7 |
當二進制文件,比如圖片、word文檔等,通過電子郵件發送時,不存在通過字符編碼進行轉化這個步驟,而是直接按照字節流,通過傳輸編碼(Content-Transfer-Encoding)進行轉化得到另一串“字節串”。
Content-Type: application/x-zip-compressed; Content-Transfer-Encoding: base64 Content-Disposition: attachment; UEsDBAoAAAAAAGNJHzMAAAAA |
使用傳輸編碼進行轉化的目的,主要是為了使電子郵件源代碼都處于可打印字符范圍之內。
最常用的 Content-Transfer-Encoding 有:Base64 和 Quoted-Printable 兩種。在對二進制文件和中文文本進行轉化時,Base64 得到的“字節串”比 Quoted-Printable 更短。在英文文本進行轉化時,Quoted-Printable 得到的“字節串”比 Base64 更短。
需要注意的是,Base64 和 Quoted-Printable 等編碼屬于“Content-Transfer-Encoding”,是將“字節串”轉化成為另一個“字節串”的編碼,與平時提到的 GB2312, BIG5, JIS, UTF8 等字符編碼是兩種完全不同類型的概念,不能混淆。
郵件標題,或者附件的文件名,表示方法為:“=?gb2312?B?XEytWy2LzQLnppcA==?=”,雖然看起來很復雜,其實同樣也是“字符編碼”與“傳輸編碼”兩個概念:
在使用 JavaMail 發送郵件時,字符編碼和傳輸編碼的指定方法為:
// 設定標題 msg.setSubject("標題 - 測試"); // 設定內容,并指定字符編碼 msg.setText // 指定傳輸編碼 msg.setHeader ("Content-Transfer-Encoding","Quoted-Printable"); |
將指定傳輸編碼的 setHeader() 位于 setText() 之前,則不能起作用。
一直在微軟的平臺下進行開發的程序員,很少遇到亂碼問題,比如:Visual Basic, ASP, .NET 等。并不是因為他們全都掌握和理解字符與編碼的相關技術,而是因為微軟的平臺沒有概念上的錯誤,所提供的 ODBC 驅動,ASP 引擎,.NET 平臺等沒有將編碼問題遺留給使用者。
而我們常用的很多開源項目,中間往往有一些處理不正確的環節。比如: Tomcat,JDBC,MySQL 等項目。有些亂碼產生的根源,就是由這些開源項目中的錯誤造成的。這些項目大部分參與者都是西方人,他們對怎樣支持亞洲語言容易產生一個誤解。而 JDK 本身沒有問題。
這些遺留的編碼問題,留給了本不應該來處理這些問題的廣大程序員們,也就是我們。我們中的幾乎所有人,在剛開始遇到亂碼時,都不知所措。現在,經過大家的努力,雖然我們有了很多方法可以消除亂碼,但仍然對編碼問題存在誤解。在想辦法消除亂碼時,只能是試,而并不理解。
![]() | |
非 UNICODE 軟件所使用的字符串,在內存中都是以 ANSI 編碼方式存儲的。軟件在日文環境下開發,軟件中的字符串常量就是以 JIS 編碼的字節形式存儲的,軟件在簡體中文環境下開發,就是以 GB2312 編碼的字節形式存儲的。軟件在運行時,只是直接將這些字節交給系統提供的 API,系統則按照本地 ANSI 編碼的字符串來處理。如果運行時的語言環境與開發時不同,界面上顯示出來的就是亂碼。
相比之下,采用 UNICODE 的軟件,字符串常量都是以 UNICODE 的序號來存儲的。不管在什么語言環境下,同一個編號都代表同一個符號。因此,采用 UNICODE 的日文軟件,在中文操作系統上運行時,仍然可以看到正常的日文界面。
非 UNICODE 軟件將逐漸被淘汰。過渡時期,出現了“南極星”“AppLocale”等軟件,可以用來在一個語言環境下模擬另一個語言環境,運行在不同環境下開發的軟件。
![]() | |
東西方人對編碼誤解是不同的:
對編碼的誤解 | |
---|---|
東方人 | 一直以來的非 UNICODE 軟件開發,字符串都是以 ANSI 編碼的字節形式存在的。這種以字節形式存在的字符串,必須知道是哪種編碼才能被正確顯示。這使我們形成了一個慣性思維:“字符串”的編碼。 當 UNICODE 被支持后,Java 中的 String 是以字符的“序號”來存儲的,不是以“某種編碼的字節”來存儲的,因此已經不存在“字符串的編碼”這個概念了。只有在“字符串”與“字節串”轉化時,或者,將一個“字節串”當成一個 ANSI 字符串時,才有編碼的概念。 |
西方人 | 西方的同行們,在將“字節串”轉化成“字符串”時,容易產生的誤解是:認為每“一個字節”就是“一個字符”。而事實上,應該按照編碼規則,有可能“多個字節”才能得到“一個字符”。 在字符集標準中,ISO-8859-1 字符集的范圍是 0~255,字符與字節的轉換關系是:一一對應。 認為每“一個字節”就是“一個字符”而得到的“字符串”,只能再通過 getBytes() 得到了之前的“字節串”,重新進行處理。這就是為什么我們經常使用 |
底層開發的人對編碼的誤解,導致的亂碼的產生。上層人員對編碼的誤解,導致了消除亂碼的復雜性。只要對概念理解正確,亂碼問題其實也容易解決。
![]() |
如果 ODBC 或 JDBC 驅動在讀取字符串字段時,沒有正確的將數據庫發送過來的“字節流”使用正確的“編碼”進行轉化,所得到的字符串就是亂碼。通常的錯誤同樣是:簡單的認為每“一個字節”就是“一個字符”。而沒有根據數據庫所選的編碼進行轉化。
解決的辦法通常是:getBytes("ISO-8859-1") 的到原始的字節,再使用正確的編碼重新new String()。
![]() |
在 Tomcat 3.3 以前,request.getParameter() 所得到的字符串,都是以每“一個字節”就是“一個字符”的方式返回的。從頁面提交到 Tomcat 服務器的中文內容,request.getParameter() 得到的字符串都是亂碼。
要得到正確的中文字符串,只能是使用 getBytes("ISO-8859-1") 的到原始的“字節串”,再按照正確的編碼重新 new String()。這個亂碼問題的產生,是由 Tomcat 自身引起的,采用 new String(str.getBytes("ISO-8859-1"), "GB2312") 這樣的用法也是迫不得已。
從 Tomcat 4 以后,request 中增加了一個新的接口方法:request.setCharsetEncoding()。這個方法的作用是:在調用 request.getParameter() 之前,設定與提交頁面相同的編碼,則可以使 request.getParameter() 返回正確的字符串。
進行實際測試,對于以下的 JSP 頁面:
<% request.setCharacterEncoding("GB2312"); response.setContentType("text/html; charset=GB2312"); String aaa = request.getParameter("aaa"); if(aaa != null) out.print(aaa); %> <hr> <form method=POST action="?"> </form> <form method=GET> </form> |
輸入中文,進行提交,實際測試結果如下:
Tomcat 3.3.2 | Tomcat 4.1.31 | Tomcat 5.0.28 | Tomcat 5.5.9 | |
---|---|---|---|---|
POST | 顯示亂碼(1) | 正確顯示中文 | 正確顯示中文 | 正確顯示中文 |
GET | 顯示亂碼 | 正確顯示中文 | 顯示亂碼(2) | 顯示亂碼 |
(1) | Tomcat 3.3 以前沒有 request.setCharacterEncoding() 方法,因此自然得到亂碼。 |
(2) | 不知何故,Tomcat 5 以后的 request.setCharacterEncoding() 方法對 GET 方法提交的數據無效,對 POST 提交的數據處理正確。這樣的效果還不如 Tomcat 3.3 那樣更好。 |
大多數瀏覽器都支持的腳本方法 escape(),可以采用漢字的 UNICODE 序號將漢字轉化成 %uXXXX 的格式用來提交到服務器。但 Tomcat 的 request.getParameter() 方法不支持這種格式。
![]() |
電子郵件與其它場合不同點就是,除了有“字符編碼”外,還有“傳輸編碼”(Content-Transfer-Encoding)。郵件內容中編碼的指定方法是:在 MIME 格式的頭部分別指定 charset 和 Content-Transfer-Encoding。郵件標題中編碼的指定方法是:=?xxx?B?xxxxxxx?= 格式,在一行中簡單明了的描述了兩種類型的編碼以及編碼后的標題內容。
如果遇到顯示亂碼,一種可能是:未指定“字符編碼”和“傳輸編碼”,直接將ANSI編碼的“字節流”使用在郵件中。另一種可能是:指定了錯誤的字符編碼。
不可避免我們有時會收到一些不規范的電子郵件,解決的辦法只能是,選擇正確的編碼方式來查看。我們在發送郵件的時候,盡可能的按照規范,正確指定“字符編碼”和“傳輸編碼”。