# 編碼問題一直困擾著程式開發人員,尤其是在 Java 中更加明顯,因為 Java 是跨平台的語言,在不同平台的編碼之間的切換較多。接下來將介紹Java 編碼問題出現的根本原因;在Java 中經常遇到的幾種編碼格式的區別;在Java 中經常需要編碼的場景;出現中文問題的原因分析;在開發Java Web 中可能存在編碼的幾個地方;一個HTTP 請求怎麼控制編碼格式;如何避免中文編碼問題等。
# 在電腦中儲存資訊的最小單元是 1 個位元組,即 8 個 bit, 所以能表示的字元範圍是 0 ~ 255 個。
# 要表示的符號太多,無法用 1 個位元組來完全表示。
計算機中提供多種翻譯方式,常見的有 ASCII、ISO-8859-1、GB2312、GBK、UTF-8、UTF-16等。這些都規定了轉換的規則,依照這個規則就可以讓電腦正確的表示我們的字元。以下介紹這幾種編碼格式:
ASCII 碼
總共有 128 個,以 1 個位元組的低 7 位表示, 0 ~ 31 是控製字符如換行、回車、刪除等,32 ~ 126 是打印字符,可以通過鍵盤輸入並且能夠顯示出來。
# ISO-8859-1
128 個字符顯然是不夠用的,所以 ISO 組織在 ASCII 的基礎上擴展,他們是 ISO-8859-1 至 ISO-8859-15,前者涵蓋大多數字符,應用最廣。 ISO-8859-1 仍是單字節編碼,它總歸能表示 256 個字元。
# GB2312
它是雙位元組編碼,總的編碼範圍是 A1 ~ F7,其中 A1 ~ A9 是符號區,總共包含 682 個符號;B0 ~ F7 是漢字區,包含 6763 個漢字。
# GBk
GBK 為《漢字內碼擴展規範》,為GB2312 的擴展,它的編碼範圍是8140 ~ FEFE(去掉XX7F),總共有23940 個碼位,能表示21003 個漢字,和GB2312的編碼兼容,不會有亂碼。
# UTF-16
它具體定義了 Unicode 字元在電腦中的存取方法。 UTF-16 用兩個位元組來表示 Unicode 的轉換格式,它採用定長的表示方法,即不論什麼字元用兩個位元組表示。兩個位元組是 16 個 bit,所以叫 UTF-16。它表示字符非常方便,沒兩個字節表示一個字符,這就大大簡化了字串操作。
# UTF-8
雖然UTF-16 統一採用兩個位元組表示一個字元很簡單方便,但是很大一部分字元用一個位元組就可以表示,如果用兩個位元組表示,儲存空間放大了一倍,在網路頻寬有限的情況下會增加網路傳輸的流量。 UTF-8 採用了一種變長技術,每個編碼區域有不同的字元長度不同類型的字元可以由 1 ~ 6 個位元組組成。
UTF-8 有以下編碼規則:
#
# 如果是1 個位元組,最高位元(第8 位元)為0,則表示這是一個ASCII 字元(00 ~ 7F)
如果是1 個位元組,以11 開頭,則連續的1 的個數暗示這個字元的位元組數
如果是1 個字節,以10 開頭,表示它不是首字節,則需要向前查找才能得到當前字元的首字節
# 如上圖:Reader 類別是在Java 的I/O 中讀取符號的父類,而InputStream 類別是讀取位元組的父類, InputStreamReader 類別就是關聯位元組到字元的橋樑,它負責在I/O 過程中處理讀取位元組到字元的轉換,而對特定位元組到字元的解碼實現,它又委託StreamDecoder 去做,在StreamDecoder 解碼過程中必須由使用者指定Charset 編碼格式。值得注意的是,如果你沒有指定 Charset,則會使用本地環境中預設的字元集,如在中文環境中將使用 GBK 編碼。
如下面一段程式碼,實作了檔案的讀寫功能:
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 字元集,一般不會出現亂碼問題。
在記憶體中進行從字元到位元組的資料型別轉換。
1、String 类提供字符串转换到字节的方法,也支持将字节转换成字符串的构造函数。
String s = "字符串"; byte[] b = s.getBytes("UTF-8"); String n = new String(b, "UTF-8");
2、Charset 提供 encode 与 decode,分别对应 char[] 到 byte[] 的编码 和 byte[] 到 char[] 的解码。
Charset charset = Charset.forName("UTF-8"); ByteBuffer byteBuffer = charset.encode(string); CharBuffer charBuffer = charset.decode(byteBuffer);
...
Java 编码类图
首先根据指定的 charsetName 通过 Charset.forName(charsetName) 设置 Charset 类,然后根据 Charset 创建 CharsetEncoder 对象,再调用 CharsetEncoder.encode 对字符串进行编码,不同的编码类型都会对应到一个类中,实际的编码过程是在这些类中完成的。下面是 String. getBytes(charsetName) 编码过程的时序图
Java 编码时序图
从上图可以看出根据 charsetName 找到 Charset 类,然后根据这个字符集编码生成 CharsetEncoder,这个类是所有字符编码的父类,针对不同的字符编码集在其子类中定义了如何实现编码,有了 CharsetEncoder 对象后就可以调用 encode 方法去实现编码了。这个是 String.getBytes 编码方法,其它的如 StreamEncoder 中也是类似的方式。
经常会出现中文变成“?”很可能就是错误的使用了 ISO-8859-1 这个编码导致的。中文字符经过 ISO-8859-1 编码会丢失信息,通常我们称之为“黑洞”,它会把不认识的字符吸收掉。由于现在大部分基础的 Java 框架或系统默认的字符集编码都是 ISO-8859-1,所以很容易出现乱码问题,后面将会分析不同的乱码形式是怎么出现的。
对中文字符后面四种编码格式都能处理,GB2312 与 GBK 编码规则类似,但是 GBK 范围更大,它能处理所有汉字字符,所以 GB2312 与 GBK 比较应该选择 GBK。UTF-16 与 UTF-8 都是处理 Unicode 编码,它们的编码规则不太相同,相对来说 UTF-16 编码效率最高,字符到字节相互转换更简单,进行字符串操作也更好。它适合在本地磁盘和内存之间使用,可以进行字符和字节之间快速切换,如 Java 的内存编码就是采用 UTF-16 编码。但是它不适合在网络之间传输,因为网络传输容易损坏字节流,一旦字节流损坏将很难恢复,想比较而言 UTF-8 更适合网络传输,对 ASCII 字符采用单字节存储,另外单个字符损坏也不会影响后面其它字符,在编码效率上介于 GBK 和 UTF-16 之间,所以 UTF-8 在编码效率上和编码安全性上做了平衡,是理想的中文编码方式。
对于使用中文来说,有 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 来存储。所以看一段文本的大小,看字符本身的长度是没有意义的,即使是一样的字符采用不同的编码最终存储的大小也会不同,所以从字符到字节一定要看编码类型。
我们能够看到的汉字都是以字符形式出现的,例如在 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 发送到用户请求的浏览器里,再经过浏览器解码成为文本。这些过程如下图所示:
一次 HTTP 请求的编码示例
用户提交一个 URL,这个 URL 中可能存在中文,因此需要编码,如何对这个 URL 进行编码?根据什么规则来编码?有如何来解码?如下图一个 URL:
上图中以 Tomcat 作为 Servlet Engine 为例,它们分别对应到下面这些配置文件中:
Port 对应在 Tomcat 的 2d40a7d47a79d09f1e13ed7afa569ba8 中配置,而 Context Path 在 cc9811071a6b9ec4ee8fd6740dade5e9 中配置,Servlet Path 在 Web 应用的 web.xml 中的
<servlet-mapping> <servlet-name>junshanExample</servlet-name> <url-pattern>/servlets/servlet/*</url-pattern> </servlet-mapping>
66e1775cbd9d5002635ae3285442ba88 中配置,PathInfo 是我们请求的具体的 Servlet,QueryString 是要传递的参数,注意这里是在浏览器里直接输入 URL 所以是通过 Get 方法请求的,如果是 POST 方法请求的话,QueryString 将通过表单方式提交到服务器端。
上图中 PathInfo 和 QueryString 出现了中文,当我们在浏览器中直接输入这个 URL 时,在浏览器端和服务端会如何编码和解析这个 URL 呢?为了验证浏览器是怎么编码 URL 的我选择的是360极速浏览器并通过 Postman 插件观察我们请求的 URL 的实际的内容,以下是 URL:
HTTP://localhost:8080/examples/servlets/servlet/君山?author=君山
君山的编码结果是:e5 90 9b e5 b1 b1,和《深入分析 Java Web 技术内幕》中的结果不一样,这是因为我使用的浏览器和插件和原作者是有区别的,那么这些浏览器之间的默认编码是不一样的,原文中的结果是:
君山的编码结果分别是: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 是如何解码的。
解析请求的 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 的 fb5e5a423fbf569b89d2849dcbd1c1d3 中定义的,如果没有定义,那么将以默认编码 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 的68bb9799e142932663394f93e704e7c1 中的useBodyEncodingForURI 設定為true。這個配置項目的名字有點讓人產生混淆,它並不是對整個 URI 都採用 BodyEncoding 進行解碼而僅僅是對 QueryString 使用 BodyEncoding 解碼,這一點還要特別注意。
從上面的URL 編碼和解碼過程來看,比較複雜,而且編碼和解碼並不是我們在應用程式中能完全控制的,所以在我們的應用程式中應該盡量避免在URL 中使用非ASCII 字符,不然很可能會碰到亂碼問題,當然在我們的伺服器端最好設定5de43e088bab17594573baec6d1f4ce2 中的URIEncoding 和useBodyEncodingForURI 兩個參數。
# 當客戶端發起一個 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 表單提交的參數的解碼是在第一次呼叫 request.getParameter 發生的,POST 表單參數傳遞方式與 QueryString 不同,它是透過 HTTP 的 BODY 傳遞到服務端的。當我們在頁面上點擊 submit 按鈕時瀏覽器首先將根據 ContentType 的 Charset 編碼格式對表單填的參數進行編碼然後提交到伺服器端,在伺服器端同樣也是用 ContentType 中字元集進行解碼。所以透過 POST 表單提交的參數一般不會有問題,而且這個字元集編碼是我們自己設定的,可以透過 request.setCharacterEncoding(charset) 來設定。
另外針對multipart/form-data 類型的參數,也就是上傳的檔案編碼同樣也是使用ContentType 定義的字元集編碼,值得注意的地方是上傳檔案是用位元組流的方式傳輸到伺服器的本地臨時目錄,這個過程並沒有涉及到字元編碼,而真正編碼是在將檔案內容新增到parameters 中,如果用這個編碼不能編碼時將會用預設編碼ISO-8859-1 來編碼。
當使用者要求的資源已經成功取得後,這些內容將透過 Response 傳回給客戶端瀏覽器,這個過程先要經過編碼再到瀏覽器進行解碼。這個過程的編解碼字元集可以透過response.setCharacterEncoding 來設置,它將會覆蓋request.getCharacterEncoding 的值,並且透過Header 的Content-Type 傳回客戶端,瀏覽器接受到傳回的socket 串流時將會透過Content-Type的charset 來解碼,如果回傳的HTTP Header 中Content-Type 沒有設定charset,那麼瀏覽器將根據Html 的deb1b3130017b0a7adff93154dd9effc
Velocity 模版设置编码格式:
services.VelocityService.input.encoding=UTF-8
JSP 设置编码格式:
b17dd5d8ec994687dd967facddfb5350
访问数据库都是通过客户端 JDBC 驱动来完成,用 JDBC 来存取数据要和数据的内置编码保持一致,可以通过设置 JDBC URL 来制定如 MySQL:url="jdbc:mysql://localhost:3306/DB?useUnicode=true&characterEncoding=GBK"。
下面看一下,当我们碰到一些乱码时,应该怎么处理这些问题?出现乱码问题唯一的原因都是在 char 到 byte 或 byte 到 char 转换中编码和解码的字符集不一致导致的,由于往往一次操作涉及到多次编解码,所以出现乱码时很难查找到底是哪个环节出现了问题,下面就几种常见的现象进行分析。
例如,字符串“淘!我喜欢!”变成了“Ì Ô £ ¡Î Ò Ï²»¶ £ ¡”编码过程如下图所示:
字符串在解码时所用的字符集与编码字符集不一致导致汉字变成了看不懂的乱码,而且是一个汉字字符变成两个乱码字符。
例如,字符串“淘!我喜欢!”变成了“??????”编码过程如下图所示:
将中文和中文符号经过不支持中文的 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 协议的解析,最后分析了我们平常遇到的乱码问题出现的原因。
綜上所述,要解決中文問題,首先要搞清楚哪些地方會引起字符到字節的編碼以及字節到字符的解碼,最常見的地方就是讀取會存儲數據到磁盤,或者數據要經過網絡傳輸。然後針對這些地方搞清楚操作這些資料的框架的或系統是如何控制編碼的,正確設定編碼格式,避免使用軟體預設的或是作業系統平台預設的編碼格式。
註明:文章大部分參考書籍《深入 Java Web 技術內幕》第三章,自己有刪減,二次轉載請也務必註明此出處。
以上是深入分析 Java Web 中的中文編碼問題的詳細內容。更多資訊請關注PHP中文網其他相關文章!