主要內容
1. 字符編碼理論簡述
1.1 ASCII
1.2 ISO8859-1
1.3 Unicode
1.4 GBK
2. 可能發生的中文亂碼
2.1 中文變問號,如:???
2.2 中文變奇怪字符,如:?? ?¥? 或者 ??o?
2.3 中文變“復雜中文”,如:浣犲ソ
2.4 中文變成一堆黑色菱形+問號,如:?????
3. Web開發中涉及到的中文編解碼
3.1 URL中出現的中文
3.2 Form表單中出現的中文
3.3 JSP中涉及的編碼
3.4 文件的上傳和下載中涉及到的中文亂碼
4. 總結
1. 字符編碼理論簡述
本文主要是圍繞Web開發中涉及到的中文編碼這一常見問題展開,包括了對字符編碼基礎理論的簡述以及常見幾種編碼標準的介紹。其中包括:ASCII、ISO8859-1、Unicode、GBK。下面先對這些字符編碼集進行簡單的介紹。
1.1 ASCII
ASCII也就是美國信息交換標準碼,采用單字節編碼方案,但是編碼只用了后七位字節,表示范圍0-127共128個字符。ASCII碼相對于其它編碼也是最早出現的。從上世紀60年代提出開始,到1986年最終定型。
為什么選擇7位編碼?ASCII在最初設計的時候需要至少能表示64個碼元:包括26個字母+10個數字+圖形標示+控制字符,如果用6bit編碼,可擴展部分沒有了,所以至少需要7bit。那么8bit呢?最終也被標準委員會否定,原因很簡單:滿足編碼需求的前提下,最小化傳輸開銷。
1.2 ISO8859-1
ISO-8859-1也被稱為Latin1,使用單字節8bit編碼,可以表示256個西歐字符。其隸屬于ISO8859標準的一部分,還有ISO8859-2、ISO8859-3等等。每一種編碼都對應一個地區的字符集。比如:ISO8859-1表示西歐字符,ISO-8859-16表示中歐字符集,等等。
1.3 Unicode
不管是ASCII還是ISO8859-1,其編碼范圍都是有局限的。而Unicode標準的目標就是消除傳統編碼的局限性。
這里的局限性一方面指編碼范圍的局限性:比如ASCII只能表示128個字符。還有編碼兼容性方面的局限性:比如ISO8859代表的一系列編碼字符集雖然可以表示大部分國家地區的字符,但是彼此的兼容性做的不好。Unicode的目標就如同其名稱的含義一樣:“實現字符編碼統一”
Unicode標準的實現方案有如下三種:UTF-8、UTF-16和UTF-32**.
UTF-8是變長編碼,使用1到4個字節。UTF-8在設計時考慮到向前兼容,所以其前128個字符和ASCII完全一樣,也就是說,所有ASCII同時也都符合UTF-8編碼格式。其格式如下:
0xxxxxxx
110xxxxx 10xxxxxx
1110xxxx 10xxxxxx 10xxxxxx
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
字節首部為0的話,也就是前面說的ASCII了。此外,字節首部連續1的個數就代表了該字符編碼后所占的字節數。目前全世界的網頁編碼絕大多數使用的就是UTF-8,占比接近90%。
UTF-16也是變長編碼,但其最初是固定16-bit寬度的定長編碼,主要因為Unicode涵蓋的字符太多了。兩字節更本不夠用!
UTF-32是32-bit定長編碼,優點:定長編碼在處理效率上相對于變長編碼要高,此外,可通過索引訪問任意字符是其另一大優勢;缺點也很明顯:32bit太浪費了!存儲效率太低!
big-endian和little-endian?在多字節編碼標準中可能會遇到這樣的問題:假如一個字符用兩個字節表示,那么當讀取這個字符的時候,哪個字節表示高有效位?哪個表示低有效位呢?這就涉及到字節的存儲順序問題。在Unicode中UTF-16和UTF-32都會面臨這個問題。通常用BOM(Byte Order Mark)來進行區分。BOM用一個"U+FEFF"來表示,這個值在
Unicode中是沒有對應字符的。不僅可以用其來指定字節順序,還可以表示字節流的編碼方式。
System.out.println("len1:" + "a".getBytes("UTF16").length);
System.out.println("len2:" + "aa".getBytes("UTF16").length);
輸出結果:
len1:4
len2:6
為什么是4和6,不應該是2和4嗎!?。輸出編碼后的字節序列可以發現,起始的兩個字節都是:"fe ff"。
Java的char類型用什么編碼格式?Java語言規范規定了Java的char類型使用的是UTF-16。這就是為什么Java的char占用兩個字節的原因。此外,Java標準庫實現的對char與String的序列化規定使用UTF-8。Java的Class文件中的字符串常量與符號名字也都規定用UTF-8編碼。這大概是當時設計者為了平衡運行時的時間效率(采用定長編碼的UTF-16,當然,在設計java的時候UTF-16還是定長的)與外部存儲的空間效率(采用變長的UTF-8編碼)而做的取舍。
1.4 GBK
GBK是用于對簡體中文進行編碼。每個字符用兩字節表示,同時兼容GB2312標準。
2. 可能發生的中文亂碼
這一小節介紹軟件開發中常見的中文編碼亂碼問題,在下面示例中:對于給定的一個包含中文的字符串"你好Java",看一下都會出現哪些亂碼問題。
2.1 中文變問號,如:?????
"你好Java" ------> "??Java"
這種情況一般是由于中文字符經ISO8859-1編碼造成的。下面是編碼的具體過程:
原字符串:"你好Java"
你 | 好 | J | a | v | a |
---|---|---|---|---|---|
4f60 | 597d | 4a | 61 | 76 | 61 |
經ISO8859-1編碼后:
你 | 好 | J | a | v | a |
---|---|---|---|---|---|
3f | 3f | 4a | 61 | 76 | 61 |
編碼后字符串:"??Java"
String str = "你好Java";
System.out.println(byteToHexString(str.getBytes(CHARSET_ISO88591)));
System.out.println(new String(str.getBytes(CHARSET_ISO88591)));
輸出:
3f 3f 4a 61 76 61
??Java
我們知道ISO8859-1是單字節編碼,而對于漢字已經超出ISO8859-1的編碼范圍,會被轉化為"3f",我們查表可知,"3f"對應的字符正是"?"。
中文變問號的亂碼情況是非常常見的,大部分開源軟件的默認編碼設置成了ISO8859-1,這點需要格外注意。
2.2 中文變奇怪字符,如:?? ?¥? 或者 ??o?
"你好Java" ------> "?? ?¥?Java"
原字符串:"你好Java"
你 | 好 | J | a | v | a |
---|---|---|---|---|---|
4f60 | 597d | 4a | 61 | 76 | 61 |
經UTF-8編碼后,一個中文用三個字節表示:
你 | 好 | J| a| v| a
---|---|---|---|---|---|---|---
e4 bd a0 | e5 a5 bd | 4a| 61| 76| 61
亂碼原因:UTF8編碼或GBK編碼,再由ISO8859-1解碼。對照ISO8859-1編碼表后發現:e4 bd a0分別對應三個字符:"?? ",e5 a5 bd分別對應三個字符"?¥?",
2.3 中文變“復雜中文”如:浣犲ソ
下面依然是"你好Java"經過UTF-8編碼后對應的字節序列:
你 | 好 | J| a| v| a
---|---|---|---|---|---|---|---
e4 bd a0 | e5 a5 bd | 4a| 61| 76| 61
在GBK表中查找:e4 bd對應字符:"浣",a0 e5對應字符:"犲",a5 bd對應字符:"ソ"
同理,如果GBK編碼的中文用UTF-8來解碼的話,同樣會出現亂碼問題。
2.4 中文變成一堆黑色菱形+問號,如:?????
首先問號+黑色菱形的字符是Unicode中的"REPLACEMENT CHARACTER",該字符的主要作用是用來表示不識別的字符。
所以產生亂碼的原因可能有很多,下面通過原字符串:"你好Java",重現一種亂碼方式:
原字符串:String str = "你好Java"
你 | 好 | J| a| v| a
---|---|---|---|---|---
4f60 | 597d | 4a| 61| 76| 61
UTF-16編碼后
fe ff 4f 60 59 7d 0 4a 0 61 0 76 0 61
其中"fe ff"就是字節流起始的BOM標識符。"fe ff"在Unicode標準中屬于"noncharacters",只用于內部使用。所以,
在輸出該字節序列的時候,沒有該碼元對應的字符,對于不識別字符,就會用??替代。
3. Web開發中涉及到的中文編解碼
Web中的數據大多通過http協議進行傳輸,所涉及到的一些編解碼問題都圍繞著http協議。下面以Tomcat作為Web服務器,
探討下一個完整的請求響應流程中哪些地方會涉及到中文的編解碼。
3.1 url編解碼
web環境中的中文亂碼問題,實驗如下:
jsp中的form表單:
<body>
<form name="form" method="post" action="manager/codec/你好">
<table>
<tr>
<td>用戶名: <input type="text" name="name" id="name" />
</td>
<td>地址 <input type="text" name="address" id="address" />
</td>
<th><input type="submit" name="submit" value="保存" /></th>
</tr>
</table>
</form>
</body>
后端使用SpringMVC的Controller:
@Controller()
@RequestMapping("/manager")
public class ManagerController {
@RequestMapping("/test/{param}")
@ResponseBody
public String test(@PathVariable String param, HttpServletRequest request){
String name = request.getParameter("name");
System.out.println("name:" + name + ",param:" + param);
return "test";
}
}
表單中填入內容:
用戶名:你好 Java
地址:123
提交請求,firebug中的顯示的url如下:
http://localhost:8080/fdyuntu-ssm/manager/codec/%E4%BD%A0%E5%A5%BD
查閱編碼可以,firefox對url中出現的中文使用了UTF-8的編碼方式。之所以url中出現%,這是因為根據URL編碼規范,瀏覽器會將非ASCII字符編成16進制后,每個字節前需要加%。
后端控制臺輸出:
name:?? ?¥? Java,param:?? ?¥?
可見無論是url中的中文信息或是post表單中的中文都出現了亂碼現象,從前一節中關于亂碼情況的分析來看,這里應該是中文字符經過瀏覽器UTF-8編碼后,Server端用ISO8859-1進行解碼所致。下面逐個分析url和post表單如何進行編解碼的。
在tomcat中url的byte -> char的轉換是在org.apache.catalina.connector.CoyoteAdapter類的convertURI(MessageBytes uri, Request request)方法中執行的,源碼如下:
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);
//這里獲取的connector的URIEncoding屬性,即server.xml文件中connector元素的URIEncoding屬性
String enc = connector.getURIEncoding();
if (enc != null) {
B2CConverter conv = request.getURIConverter();
try {
if (conv == null) {
conv = new B2CConverter(enc, true);
request.setURIConverter(conv);
} else {
conv.recycle();
}
} catch (IOException e) {
log.error("Invalid URI encoding; using HTTP default");
connector.setURIEncoding(null);
}
if (conv != null) {
try {
conv.convert(bc, cc, true);
uri.setChars(cc.getBuffer(), cc.getStart(), cc.getLength());
return;
} catch (IOException ioe) {
request.getResponse().sendError(
HttpServletResponse.SC_BAD_REQUEST);
}
}
}
// 如果沒有配置URIEncoding,則在ByteChunk中默認使用ISO8859-1。
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);
}
在org.apache.tomcat.util.buf.ByteChunk中可以看到默認編碼的定義:
public final class ByteChunk implements Cloneable, Serializable {
//。。。
public static final Charset DEFAULT_CHARSET = B2CConverter.ISO_8859_1;
//。。。
}
所以對于請求url中的中文,我們按UTF-8進行編碼,在服務端卻按ISO8859-1進行解碼,所以出現亂碼現象。我們可以再Tomcat的server.xml中指定url的編解碼格式,如下:
<Connector URIEncoding="UTF-8" 。。。>
此時重復上面實驗,后端控制臺輸出:name:?? ?¥? Java,param:你好
雖然url中的參數可以正常顯示了,但是form表單中的參數name依然亂碼,下面進一步分析。
3.2 form表單元素的編解碼
name參數的編碼依然是亂碼的,為啥?首先定位form表單中參數是在哪里進行解碼的。Form表單中的字符解碼時機是發生在第一次調用request.getParameter時,可以通過request.setCharacterEncoding設置。需要注意的是setCharacterEncoding必須在getParameter之前調用!否則,setCharacterEncoding不會起作用。
Tomcat中HttpServletRequest接口的實現類是org.apache.catalina.connector.Request。下面是Request類中getParameter源碼:
@Override
public String getParameter(String name) {
//判斷參數是否被解析過
if (!parametersParsed) {
parseParameters();//第一次參數解析
}
return coyoteRequest.getParameters().getParameter(name);
}
//下面是parseParameters部分源碼
protected void parseParameters() {
//設為true,表示參數已解析過
parametersParsed = true;
//Parameters對象封裝了form表單參數
Parameters parameters = coyoteRequest.getParameters();
boolean success = false;
try {
// Set this every time in case limit has been changed via JMX
parameters.setLimit(getConnector().getMaxParameterCount());
//獲取字符編碼格式
String enc = getCharacterEncoding();
boolean useBodyEncodingForURI = connector.getUseBodyEncodingForURI();
if (enc != null) {
//getCharacterEncoding不為null,則對應設置編碼方式
parameters.setEncoding(enc);
if (useBodyEncodingForURI) {
parameters.setQueryStringEncoding(enc);
}
} else {
//如果enc為null,則編碼方式設置為DEFAULT_CHARACTER_ENCODING,也就是ISO8859-1
parameters.setEncoding
(org.apache.coyote.Constants.DEFAULT_CHARACTER_ENCODING);
if (useBodyEncodingForURI) {
parameters.setQueryStringEncoding
(org.apache.coyote.Constants.DEFAULT_CHARACTER_ENCODING);
}
}
parameters.handleQueryParameters();
。。。
}
}
從以上源碼中可以看出為什么需要在第一次調用getParameter之前設置CharacterEncoding。因為第一次執行parseParameters時,會把parametersParsed變量設為true。所以parseParameters只會在第一次getParameter時調用。有時會出現這么一種怪像:通過request.getCharacterEncoding()得到的是我們認為正確的編碼字符集,但是request.getParameter得到的依然是亂碼。此時就需要考慮下我們調用setCharacterEncoding之前是否已經調用過getParameter方法了。
經過上面的分析后,對于form表單參數亂碼問題就很好解決了,在第一次調用request.getParameter方法前,通過request.setCharacterEncoding("Expected_Encoding");設置即可。這一步可以用Servlet標準中的Filter實現,不過,常用的MVC框架中已經有現成的Filter實現了,比如SpringMVC中的org.springframework.web.filter.CharacterEncodingFilter,如下:
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (this.encoding != null && (this.forceEncoding || request.getCharacterEncoding() == null)) {
request.setCharacterEncoding(this.encoding);//設置指定的編碼
if (this.forceEncoding) {
response.setCharacterEncoding(this.encoding);
}
}
filterChain.doFilter(request, response);
}
3.3 JSP中涉及的編碼
jsp中可以通過page指令指定一些編碼參數,如下:
<%@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
pageEncoding="UTF-8"在什么時候起作用?
在Servlet標準中,jsp最終也會被編譯成一個servlet。index.jsp->index_jsp.java.pageEncoding="UTF-8"就是在這個解析過程中起作用的。
contentType="text/html; charset=UTF-8"的作用?
contentType是響應頭中特定信息,主要的作用是告訴瀏覽器response中存放的主體對象類型和編碼,這樣瀏覽器就可以對指定類型進行正確解碼,保證了數據在server和client端的一致性。當進行Servlet編程的時候,可以手動進行設置,如下:
response.setContentType("text/html; charset=UTF-8");
3.4 文件的上傳和下載中涉及到的中文亂碼
Web中的文件操作主要是上傳和下載,這個過程也是依托于Http協議作為數據載體。所以,最終是否亂碼重點在于是否正確的設置http的request、response的header中的相關字段。如ContentType、Content-Disposition的設定等。如下:
response.setHeader("Pragma", "no-cache");
response.setHeader("Cache-Control", "no-cache");
response.setDateHeader("Expires", 0);
response.setContentType("application/x-msdownload");
response.addHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
這里需要注意的是Content-Disposition的filename屬性值,如果fileName含有中文,那么要格外注意fileName字符串的編碼格式。在rfc5987對于HTTP的Header中參數的編碼做出了明確的規定:
By default, message header field parameters in Hypertext Transfer Protocol (HTTP) messages cannot carry characters outside the ISO-8859-1 character set.
也就是說默認情況下,Http的Header中的參數只能用ISO-8859-1字符集中的字符,那么是否意味著Content-Disposition中的fileName字符串也要轉成ISO-8859-1了呢?答案是:NO!原因如下:Content-Disposition其實不屬于Http/1.1標準。這在RFC2616中有明確的說明。只因為其使用廣泛,HTTP才對其支持。在rfc6266中也詳細介紹了Content-Disposition的filename參數含義和用法。下面是對于下載包含中文名稱的文件時的解決方案。
解決方案
最簡單就是直接用ISO8859-1對文件名進行編碼,大多數瀏覽器都支持。如下:
exportFileName.getBytes("UTF-8"),"ISO8859-1");//這里的UTF-8也可能是別的編碼,主要依據系統默認的編碼來設定。
或通過其它編碼,如UTF-8。
response.addHeader("Content-Disposition",
"attachment; filename*=UTF-8''" + URLEncoder.encode(exportFileName, "UTF8"));
4. 總結
編解碼問題是多語言交互系統中必然要面對的問題,尤其對于中文環境中的開發者來說,在入門階段或多或少都會遇到此類問題。亂碼問題本質就是通信雙方使用的標準不一致。所以,解決亂碼問題的方法其實也很簡單,統一下編解碼標準即可。此外,深入理解各種編碼標準的原理和關系也非常重要,在以后遇到類似問題的時候能夠更加準確的判斷出造成亂碼的原因。