Web開發中的中文亂碼問題

主要內容
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-8UTF-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. 總結

編解碼問題是多語言交互系統中必然要面對的問題,尤其對于中文環境中的開發者來說,在入門階段或多或少都會遇到此類問題。亂碼問題本質就是通信雙方使用的標準不一致。所以,解決亂碼問題的方法其實也很簡單,統一下編解碼標準即可。此外,深入理解各種編碼標準的原理和關系也非常重要,在以后遇到類似問題的時候能夠更加準確的判斷出造成亂碼的原因。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,333評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,491評論 3 416
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,263評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,946評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,708評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,186評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,255評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,409評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,939評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,774評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,976評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,518評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,641評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,872評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,650評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,958評論 2 373

推薦閱讀更多精彩內容

  • 編碼問題一直困擾著開發人員,尤其在 Java 中更加明顯,因為 Java 是跨平臺語言,不同平臺之間編碼之間的切換...
    x360閱讀 2,491評論 1 20
  • 可以看我的博客 lmwen.top 或者訂閱我的公眾號 簡介有稍微接觸python的人就會知道,python中...
    ayuLiao閱讀 3,141評論 1 5
  • 為什么要編碼 不知道大家有沒有想過一個問題,那就是為什么要編碼?我們能不能不編碼?要回答這個問題必須要回到計算機是...
    艾小天兒閱讀 17,413評論 0 2
  • 編碼規則 如果你已經閱讀了JavaHipster 1中references提到的兩篇文章,你應該明白:從字符集到編...
    褲lue閱讀 1,567評論 2 1
  • 似曾相識是一種什么感覺,大抵是他說一句,當時沒有感覺,后來驚覺為何如此熟悉,就像原本就來自記憶深處似的。或許已經漸...
    云漫漫閱讀 292評論 0 0