作為一名天朝程序員,除了看慣了亂碼之外,在日常的工作中經常會碰到編碼和轉義。如果能掌握這塊領域的一些常識,就可以在開發和支持時游刃有余。
編碼(encoding)
ASCII & EASCII & ISO-8859-1
要聊編碼,就需要從ASCII開始。眾所周知,計算機的世界裡,數據都是0和1這樣的二進制。用它們的組合來表示字母、數字和常用符號的最通用編碼標准就是ASCII(American Standard Code for Information Interchange,美國信息交換標准代碼)。完整的ASCII編碼可以從這裡找到。Mac或Linux可以用以下命令來查看所有的ASCII字符:
man ascii
其中,00000000到00011111的前32位字符和01111111是控制字符,00100000到01111110之間的都是可顯示字符,一個字符占8位(bit),第1位總是0,這樣能夠支持2的7次方即128個符號編碼。雖然ASCII編碼能搞定美國大多數的應用場景,但是對於其它發達國家的語言來說就無能為力了。於是在其上發展出了EASCII(extended ASCII),通過擴展最前面的一位為1來提供多達256個符號編碼的支持。可是這樣又帶來了兩個問題:一來即使是256個編碼,對於世界范圍尤其是像中日這樣的漢字國家來說還是遠遠不夠;二來各個國家規定的EASCII編碼都不一樣,比如對於希臘來說EASCII表示的就是希臘字母,而對於法國來說可能就是某個帶有注音符號的字母。這樣的背景下,ISO(International Standard Organization,國際標准化組織)設計了ISO/IEC 8859字符集(不包含ASCII),力圖一統拉丁語系。其實現的編碼表ISO-8859-1(包含ASCII)應用得非常廣泛。
GB2312 & GBK & GB18030 & ANSI
本節介紹的是解決EASCII帶來的第一個問題的方法。對於中文來說,8位的編碼遠遠不夠,於是就會想到用兩個8位來表示一個漢字。為了與ASCII碼兼容,如果碰到0~127的字符,需要認定為ASCII編碼字符。只有當兩個大於127的字符連在一起時,才表示一個漢字。前一個字符稱為高字節,後一個稱為低字節,這樣就誕生了GB2312編碼。每一個雙字節字符就稱為一個全角字符,而單字節字符就稱為半角字符。再後來,發現編碼還是不夠用,干脆就允許低字節也使用0~127的字符,反正用高字節就能判斷是否是漢字,這樣就誕生了GBK(K表示“擴展”)編碼。GBK裡甚至還包含了日語的假名和俄語字母。GB2312和GBK這兩種編碼都是單字節(表示ASCII)和雙字節(表示漢字)混合使用的編碼。我國最新的漢字編碼國標是GB18030,這是一種類似下文UTF-8那樣的變長編碼。
雖然中國解決了中文問題,但是世界各國都搞出了一套自己的編碼系統,還是不能輕易相互轉化。例如台灣用BIG5,日本用Shift-JIS。要想解決EASCII的第二個問題,還需要另尋他途。Window系統的記事本裡,默認編碼為ANSI,即根據系統語言的不同,而選用不同的編碼。
Unicode & UFT-8
本節說的是解決EASCII帶來的第二個問題的方法。ISO帶來了一個囊括全球所有文字的編碼:Unicode。它最初規定了所有的字符(包括ASCII)都使用兩個字節來表示,這個版本稱為UCS-2(Universal Multiple-Octet Coded Character Set)或UTF-16。對於ASCII碼來說,在它的前面加上00000000作為高字節即可。這樣的好處是,由於高低字節可以同時包含0~256,能表示的字符數量就更多了,理論上可以達到256×256=65536個。即使如此,也只能說是基本上夠用,要囊括所有文明的文字,還需要更多的字節。目前最多支持4個字節代表一個字符,稱為UCS-4或UTF-32,它的最高位規定必須為0,可以表示65536×65536÷2=2147483648個字符(這樣是不是統一銀河系也夠用了)。與此同時,它包含的字符集也在不斷的增加,甚至收錄了emoji(繪文字),大大增加了文字符號的表現力,看看????????????????????????????????,是不是增加了很多樂趣呢。
Unicode就像是“書同文、車同軌”,極大地方便了各國的交流。可是它也有自身的缺點。一個問題是它與各國自身的標准不兼容(例如GB18030),但是這個問題貌似無解,因為各國的標准本來就是排斥的。另一個問題是隨著Unicode標准的發展,出現了4個字節的字符。但是當設計Java的時候,是將unicode當做2個字節的定長字符來看待的。這樣就導致Java裡需要用兩個char來表示一個4字節的字符,如emoji(????=\uD83D\uDE02)。Java平台中的增補字符就是Oracle官方寫來專門解決長字節Unicode的。打開鏈接就會看到一堆的亂碼,說明編碼問題還真是普遍存在並難以解決的啊。好在還有英文版可供閱讀。還有一個問題就是對於英文來說,用高字節為固定值的兩個字節來保存數據,就會使原來一個字節的數據量翻倍,對於傳輸和存儲來說都是較大負擔。
解決上面這個問題的辦法就是UTF-8。它是一種變長的編碼方式。如果是ASCII碼的字符,就用一個字節表示。否則就在前面增加一個高位字節(但是在8個bit之內)。這回英文符號是滿意了,但是中文字符可能就會因為增加的高位字節從Unicode的占用兩個字節變成UTF-8的占用三個字節。沒有兩全其美的事啊!這也是為什麼GB2312和GBK今天仍被廣泛使用的原因之一,我們也不想增加傳輸和存儲的負擔呀。
如果要打開一個文本文件,首先需要知道它的編碼。位於文件頭的BOM(Byte order mark,字節順序標記)可以用來標記文件的編碼類型。它分為BE(big-endian,大端序)和LE(little-endian,小端序),指的是高字節的位置在前還是在後。但是在類Unix系統中,它很可能因為無法被程序識別而帶來一系列問題。所以一般的純文本文件還是建議保存為不帶BOM形式的編碼。Window系統的記事本裡,如果輸入聯通保存,便會將其保存為無BOM的GB格式,再次用記事本打開此文件時,因為沒有BOM信息,記事本就需要自己推斷這個文件的編碼是什麼。顯然window是上這個推斷很有問題,誤認為是UTF-8格式(可以從文件菜單裡的“另存為”看出來)。而mac上默認的文本編輯表現還是不錯的。如果用word來打開它,便可以在一系列的編碼中,自行尋找合適的編碼來打開。如果用記事本另存為UTF-8格式,便不會有問題。Sublime Text可以支持用許多不同的編碼來打開或是保存,光是UTF系列的就不少,如下圖:
sublime-text-encoding
對於Java來說,內部的String編碼默認為UTF-16,但如果由於用不著而覺得浪費內存的話,可以在JVM打開-XX:+UseCompressedStrings,就會變成ISO-8859-1了。Intellij IDEA的Preference裡,有兩個關於encoding的選項:
Intellij-IDEA-encoding
可以通過Project Encoding來指定項目的JVM裡String的內部編碼,默認為UTF-8。可以通過下面這兩個表達式來看到,它們的編碼是完全一致的:
"懶".getBytes() "懶".getBytes("UTF-8")
Java裡可以用Integer.toHexString來看到漢字的unicode編碼:
System.out.println("\\u" + Integer.toHexString('懶')); System.out.println("\u61d2");
通過下面的語句,可以將字節數組byte[]還原為原先的字符串。如果指定錯了編碼,就會看到亂碼產生啦:
System.out.println(new String("懶".getBytes("UTF-8"), "UTF-8")); // 正常 System.out.println(new String("懶".getBytes("UTF-8"), "UTF-16")); // 亂碼:?? System.out.println(new String("懶".getBytes("UTF-16"), "UTF-8")); // 亂碼:??a?
讀文件、流也是一樣的道理,知道了它們的編碼才能正確地讀取,否則只好像微軟的記事本那樣去猜啦。Java還提供了一個小工具native2ascii,可以把本地編碼的文件轉換為各種格式:
echo 懶程序員 > ggg.txt native2ascii -encoding UTF-8 ggg.txt out.txt cat out.txt native2ascii -reverse -encoding UTF-8 out.txt base64 & UTF-7
Base64是一種在網絡上傳遞信息時常見的編碼。它相當於是一張64條記錄的映射表,鍵從000000到111111,值就是64個不同的字符。編碼時,如果原字符的bit數正好能被6整除,那就查表得到每6個bit所對應的值,合起來就是base64編碼的結果。如果不能被6整除,那就在末尾用0補足。每補兩個0,就在最終結果的後面加一個=號。所以如果一段數據以等號結尾,那十有八九就是base64編碼。Mac或Linux可以用以下命令來進行base64編碼及解碼:
echo -n A | base64 echo -n AB | base64 echo -n ABC | base64 echo -n QQ== | base64 --decode echo -n QUI= | base64 --decode echo -n QUJD | base64 --decode
UTF-7理論上也屬於一種base64編碼,只不過它的64行映射表不一樣罷了。過去的SMTP協議僅能接受7個bit(ASCII)的字符,Unicode無法直接傳輸。所以通過UTF-7編碼的方式,將Unicode字符轉換為7個bit以內的字符。UTF-7本身並不是Unicode的標准,現在也已經由於郵件和傳輸都支持UTF-8而退出歷史舞台了。
寫到這裡感覺得收一下了,不然MD5、SHA什麼的都要出來了。對散列、加密有興趣的童鞋們可以參考我以前寫的另一篇文章《證書的那些事兒》。
轉義(escaping)
html & url
下面說說轉義,不少人都把它與編碼混而一談,以至於它也算作編碼的一部分了。從最簡單的html聊起吧。在html裡,如果只寫上一些文本,那當我們用浏覽器打開這個html時,就會完完整整地顯示這些文本的內容。我們也知道,html裡無論輸入多少個空格,只會顯示一個空格。因為在html裡,把空格當成了特殊字符。在這種情況下,如果想要在html裡放上空格,就需要對空格編碼,也就是大家熟知的 。其中nbsp大名喚作Non-Breaking Space(不換行空格),除了名字以外,它也有自己的編碼: 。除了空格,常見的還有代表標簽的<和>。完整的html轉義可以從這裡找到。奇怪的是這麼常用的轉義,js居然沒有原生的函數支持。如果要轉義,可以使用下面這條語句來得到<div>:
function htmlEncode(html) { return document.createElement('a').appendChild(document.createTextNode(html)).parentNode.innerHTML; }; htmlEncode('');
解碼的話,這樣做:
function htmlDecode(html) { var a = document.createElement('a'); a.innerHTML = html; return a.textContent; }; htmlDecode('<div>');
如果使用jQuery,思路一致,但是代碼可以稍微短一點:
function htmlEncode(value){ return $('').text(value).html(); } htmlEncode(''); function htmlDecode(value){ return $('').html(value).text(); } htmlDecode('<div>');
可惜的是上面的函數並不能解決空格和 之間的轉換。想要個萬能的?也許只好使用replace一個個地慢慢替換了。
想要請求一個html,需要先輸入一個url。這裡就涉及到了url轉義。因為url裡可能會有類似?name=ggg這樣的參數,所以起碼就需要對?和=進行轉義。轉義之後分別為%3F和%3D,這與ASCII碼是相對應的。完整的url編碼可以從這裡找到。這回js終於有原生的函數支持了:
encodeURI('http://qinghua.github.io?name=g gg'); encodeURIComponent('http://qinghua.github.io?name=g gg');
用encodeURI函數的網址,不會去碰http://,所以編碼後還是一個合法的網址。而encodeURIComponent會將一切都進行編碼,網址也就不是網址了。不過它很適合將網址作為參數來使用。解碼的話,這樣做:
decodeURI('http://qinghua.github.io?name=g%20gg'); decodeURIComponent('http%3A%2F%2Fqinghua.github.io%3Fname%3Dg%20gg');
在Java裡可以用以下語句來完成url的轉義:
URLEncoder.encode("懶", "UTF-8"); URLDecoder.decode("%E6%87%92", "UTF-8");
XML & YAML & JSON & CSV
在這些數據格式中,對xml的轉義基本上跟html差不多,這裡就不再贅述了。對於yaml來說,規則如下:
在一個單引號標注的字符串中,一個單引號需要轉義成兩個單引號
在一個雙引號標注的字符串中,大部分符號都需要用反斜槓來轉義
如果字符串中有控制字符(如\0、\n等),需要用雙引號來標注
如果字符串看起來像下面的樣子,需要用引號(無所謂哪種)來標注:
true或false
null或~
看起來像數字,如2,14.9,12e7等
看起來像日期,如2014-12-31
完整的規則可以參考yaml規范。
對與json來說,需要轉義的字符如下圖:
json string escape
對於csv來說,轉義的規則只有兩條:
如果值裡有逗號、換行或是雙引號,需要用雙引號來標注
如果值裡有雙引號,需要把它轉義成兩個雙引號""
Java & .NET & JS & SQL
對於大部分的編程語言,例如Java、.NET還有JavaScript,甚至C、GO、Ruby等等來說,通常的轉義都是通過反斜槓\來實現的。一般都包括如下幾項:
退格: \b
換行: \n
制表符: \t
回車: \r
換頁: \f
雙引號: \"
反斜槓: \\
不過C和C++支持的16進制\x,在java裡不被支持。所以\x61\xd2的這個“懶”字,在java中可以通以下這兩個表達式來得到真實的字符:
"\u61d2" new String(new byte[] {(byte) 0x61, (byte) 0xd2}, "unicode")
SQL有些不一樣。它從語法層面支持模糊查詢,所以即使在完全匹配中使用了%也不需要轉義。但是代表字符串的單引號'還是不得不轉義成兩個單引號''。
後記
以上是關於編碼和轉義的一些常識,知乎上轉的這篇回答,系統地介紹了從ASCII到UTF-8,寫得非常贊。平時需要編碼和轉義的時候,可以使用這個網站在線轉換,也挺方便的。