本文將簡述字符集,字符編碼的概念。以及在遭遇亂碼時的一些常用診斷技巧。
背景:字符集和編碼無疑是IT菜鳥甚至是各種大神的頭痛問題。當(dāng)遇到紛繁復(fù)雜的字符集,各種火星文和亂碼時,問題的定位往往變得非常困難。本文就將會從原理方面對字符集和編碼做個簡單的科普介紹,同時也會介紹一些通用的亂碼故障定位的方法以方便讀者以后能夠更從容的定位相關(guān)問題。在正式介紹之前,先做個小申明:如果你希望非常精確的理解各個名詞的解釋,那么可以查閱wikipedia。本文是博主通過自己理解消化后并轉(zhuǎn)化成易懂淺顯的表述后的介紹。
在介紹字符集之前,我們先了解下為什么要有字符集。我們在計算機(jī)屏幕上看到的是實(shí)體化的文字,而在計算機(jī)存儲介質(zhì)中存放的實(shí)際是二進(jìn)制的比特流。那么在這兩者之間的轉(zhuǎn)換規(guī)則就需要一個統(tǒng)一的標(biāo)準(zhǔn),否則把我們的U盤插到老板的電腦上,文檔就亂碼了;小伙伴QQ上傳過來的文件,在我們本地打開又亂碼了。于是為了實(shí)現(xiàn)轉(zhuǎn)換標(biāo)準(zhǔn),各種字符集標(biāo)準(zhǔn)就出現(xiàn)了。簡單的說字符集就規(guī)定了某個文字對應(yīng)的二進(jìn)制數(shù)字存放方式(編碼)和某串二進(jìn)制數(shù)值代表了哪個文字(解碼)的轉(zhuǎn)換關(guān)系。
那么為什么會有那么多字符集標(biāo)準(zhǔn)呢?這個問題實(shí)際非常容易回答。問問自己為什么我們的插頭拿到英國就不能用了呢?為什么顯示器同時有DVI,VGA,HDMI,DP這么多接口呢?很多規(guī)范和標(biāo)準(zhǔn)在最初制定時并不會意識到這將會是以后全球普適的準(zhǔn)則,或者處于組織本身利益就想從本質(zhì)上區(qū)別于現(xiàn)有標(biāo)準(zhǔn)。于是,就產(chǎn)生了那么多具有相同效果但又不相互兼容的標(biāo)準(zhǔn)了。
說了那么多我們來看一個實(shí)際例子,下面就是屌
這個字在各種編碼下的十六進(jìn)制和二進(jìn)制編碼結(jié)果,怎么樣有沒有一種很屌的感覺?
字符集 | 16進(jìn)制編碼 | 對應(yīng)的二進(jìn)制數(shù)據(jù) |
---|---|---|
UTF-8 | 0xE5B18C | 1110 0101 1011 0001 1000 1100 |
UTF-16 | 0x5C4C | 1011 1000 1001 1000 |
GBK | 0x8CC5 | 1000 1100 1100 0101 |
字符集只是一個規(guī)則集合的名字,對應(yīng)到真實(shí)生活中,字符集就是對某種語言的稱呼。例如:英語,漢語,日語。對于一個字符集來說要正確編碼轉(zhuǎn)碼一個字符需要三個關(guān)鍵元素:字庫表(character repertoire)、編碼字符集(coded character set)、字符編碼(character encoding form)。其中字庫表是一個相當(dāng)于所有可讀或者可顯示字符的數(shù)據(jù)庫,字庫表決定了整個字符集能夠展現(xiàn)表示的所有字符的范圍。編碼字符集,即用一個編碼值code point
來表示一個字符在字庫中的位置。字符編碼,將編碼字符集和實(shí)際存儲數(shù)值之間的轉(zhuǎn)換關(guān)系。一般來說都會直接將code point
的值作為編碼后的值直接存儲。例如在ASCII中A
在表中排第65位,而編碼后A
的數(shù)值是0100 0001
也即十進(jìn)制的65的二進(jìn)制轉(zhuǎn)換結(jié)果。
看到這里,可能很多讀者都會有和我當(dāng)初一樣的疑問:字庫表
和編碼字符集
看來是必不可少的,那既然字庫表中的每一個字符都有一個自己的序號,直接把序號作為存儲內(nèi)容就好了。為什么還要多此一舉通過字符編碼
把序號轉(zhuǎn)換成另外一種存儲格式呢?其實(shí)原因也比較容易理解:統(tǒng)一字庫表的目的是為了能夠涵蓋世界上所有的字符,但實(shí)際使用過程中會發(fā)現(xiàn)真正用的上的字符相對整個字庫表來說比例非常低。例如中文地區(qū)的程序幾乎不會需要日語字符,而一些英語國家甚至簡單的ASCII字庫表就能滿足基本需求。而如果把每個字符都用字庫表中的序號來存儲的話,每個字符就需要3個字節(jié)(這里以Unicode字庫為例),這樣對于原本用僅占一個字符的ASCII編碼的英語地區(qū)國家顯然是一個額外成本(存儲體積是原來的三倍)。算的直接一些,同樣一塊硬盤,用ASCII可以存1500篇文章,而用3字節(jié)Unicode序號存儲只能存500篇。于是就出現(xiàn)了UTF-8這樣的變長編碼。在UTF-8編碼中原本只需要一個字節(jié)的ASCII字符,仍然只占一個字節(jié)。而像中文及日語這樣的復(fù)雜字符就需要2個到3個字節(jié)來存儲。
看完上面兩個概念解釋,那么解釋UTF-8和Unicode的關(guān)系就比較簡單了。Unicode就是上文中提到的編碼字符集,而UTF-8就是字符編碼,即Unicode規(guī)則字庫的一種實(shí)現(xiàn)形式。隨著互聯(lián)網(wǎng)的發(fā)展,對同一字庫集的要求越來越迫切,Unicode標(biāo)準(zhǔn)也就自然而然的出現(xiàn)。它幾乎涵蓋了各個國家語言可能出現(xiàn)的符號和文字,并將為他們編號。詳見:Unicode on Wikipedia。Unicode的編號從0000
開始一直到10FFFF
共分為16個Plane,每個Plane中有65536個字符。而UTF-8則只實(shí)現(xiàn)了第一個Plane,可見UTF-8雖然是一個當(dāng)今接受度最廣的字符集編碼,但是它并沒有涵蓋整個Unicode的字庫,這也造成了它在某些場景下對于特殊字符的處理困難(下文會有提到)。
為了更好的理解后面的實(shí)際應(yīng)用,我們這里簡單的介紹下UTF-8的編碼實(shí)現(xiàn)方法。即UTF-8的物理存儲和Unicode序號的轉(zhuǎn)換關(guān)系。
UTF-8編碼為變長編碼。最小編碼單位(code unit
)為一個字節(jié)。一個字節(jié)的前1-3個bit為描述性部分,后面為實(shí)際序號部分。
具體每個字節(jié)的特征可見下表,其中x
代表序號部分,把各個字節(jié)中的所有x
部分拼接在一起就組成了在Unicode字庫中的序號
Byte 1 | Byte 2 | Byte3 |
---|---|---|
0xxx xxxx | ||
110x xxxx | 10xx xxxx | |
1110 xxxx | 10xx xxxx | 10xx xxxx |
我們分別看三個從一個字節(jié)到三個字節(jié)的UTF-8編碼例子:
實(shí)際字符 | 在Unicode字庫序號的十六進(jìn)制 | 在Unicode字庫序號的二進(jìn)制 | UTF-8編碼后的二進(jìn)制 | UTF-8編碼后的十六進(jìn)制 |
$ | 0024 | 010 0100 | 0010 0100 | 24 |
¢ | 00A2 | 000 1010 0010 | 1100 0010 1010 0010 | C2 A2 |
€ | 20AC | 0010 0000 1010 1100 | 1110 0010 1000 0010 1010 1100 | E2 82 AC |
細(xì)心的讀者不難從以上的簡單介紹中得出以下規(guī)律:
E
開頭的C
或D
開頭的8
小的數(shù)字開頭的先科普下亂碼
的英文native說法是mojibake
簡單的說亂碼的出現(xiàn)是因?yàn)椋壕幋a和解碼時用了不同或者不兼容的字符集。對應(yīng)到真實(shí)生活中,就好比是一個英國人為了表示祝福在紙上寫了bless
(編碼過程)。而一個法國人拿到了這張紙,由于在法語中bless表示受傷的意思,所以認(rèn)為他想表達(dá)的是受傷
(解碼過程)。這個就是一個現(xiàn)實(shí)生活中的亂碼情況。在計算機(jī)科學(xué)中一樣,一個用UTF-8編碼后的字符,用GBK去解碼。由于兩個字符集的字庫表不一樣,同一個漢字在兩個字符表的位置也不同,最終就會出現(xiàn)亂碼。
我們來看一個例子:假設(shè)我們用UTF-8編碼存儲很屌
兩個字,會有如下轉(zhuǎn)換:
字符 | UTF-8編碼后的十六進(jìn)制 |
---|---|
很 | E5BE88 |
屌 | E5B18C |
于是我們得到了E5BE88E5B18C
這么一串?dāng)?shù)值。而顯示時我們用GBK解碼進(jìn)行展示,通過查表我們獲得以下信息:
兩個字節(jié)的十六進(jìn)制數(shù)值 | GBK解碼后對應(yīng)的字符 |
---|---|
E5BE | 寰 |
88E5 | 堝 |
B18C | 睂 |
解碼后我們就得到了寰堝睂
這么一個錯誤的結(jié)果,更要命的是連字符個數(shù)都變了。
要從亂碼字符中反解出原來的正確文字需要對各個字符集編碼規(guī)則有較為深刻的掌握。但是原理很簡單,這里用最常見的UTF-8被錯誤用GBK展示時的亂碼為例,來說明具體反解和識別過程。
假設(shè)我們在頁面上看到寰堝睂
這樣的亂碼,而又得知我們的瀏覽器當(dāng)前使用GBK編碼。那么第一步我們就能先通過GBK把亂碼編碼成二進(jìn)制表達(dá)式。當(dāng)然查表編碼效率很低,我們也可以用以下SQL語句直接通過MySQL客戶端來做編碼工作:
1 2 3 4 5 6 7 | mysql [localhost] {msandbox} > select hex( convert ( '寰堝睂' using gbk)); + -------------------------------------+ | hex( convert ( '寰堝睂' using gbk)) | + -------------------------------------+ | E5BE88E5B18C | + -------------------------------------+ 1 row in set (0.01 sec) |
現(xiàn)在我們得到了解碼后的二進(jìn)制字符串E5BE88E5B18C
。然后我們將它按字節(jié)拆開。
Byte 1 | Byte 2 | Byte 3 | Byte 4 | Byte 5 | Byte 6 |
---|---|---|---|---|---|
E5 | BE | 88 | E5 | B1 | 8C |
然后套用之前UTF-8編碼介紹章節(jié)中總結(jié)出的規(guī)律,就不難發(fā)現(xiàn)這6個字節(jié)的數(shù)據(jù)符合UTF-8編碼規(guī)則。如果整個數(shù)據(jù)流都符合這個規(guī)則的話,我們就能大膽假設(shè)亂碼之前的編碼字符集是UTF-8
然后我們就能拿著E5BE88E5B18C
用UTF-8解碼,查看亂碼前的文字了。當(dāng)然我們可以不查表直接通過SQL獲得結(jié)果:
1 2 3 4 5 6 7 | mysql [localhost] {msandbox} ((none)) > select convert( 0xE5BE88E5B18C using utf8); +------------------------------------+ | convert( 0xE5BE88E5B18C using utf8) | +------------------------------------+ | 很屌 | +------------------------------------+ 1 row in set ( 0.00 sec) |
所謂Emoji就是一種在Unicode位于\u1F601
-\u1F64F
區(qū)段的字符。這個顯然超過了目前常用的UTF-8字符集的編碼范圍\u0000
-\uFFFF
。Emoji表情隨著IOS的普及和微信的支持越來越常見。下面就是幾個常見的Emoji:
那么Emoji字符表情會對我們平時的開發(fā)運(yùn)維帶來什么影響呢?最常見的問題就在于將他存入MySQL數(shù)據(jù)庫的時候。一般來說MySQL數(shù)據(jù)庫的默認(rèn)字符集都會配置成UTF-8(三字節(jié)),而utf8mb4在5.5以后才被支持,也很少會有DBA主動將系統(tǒng)默認(rèn)字符集改成utf8mb4。那么問題就來了,當(dāng)我們把一個需要4字節(jié)UTF-8編碼才能表示的字符存入數(shù)據(jù)庫的時候就會報錯:ERROR 1366: Incorrect string value: '\xF0\x9D\x8C\x86' for column
。 如果認(rèn)真閱讀了上面的解釋,那么這個報錯也就不難看懂了。我們試圖將一串Bytes插入到一列中,而這串Bytes的第一個字節(jié)是\xF0
意味著這是一個四字節(jié)的UTF-8編碼。但是當(dāng)MySQL表和列字符集配置為UTF-8的時候是無法存儲這樣的字符的,所以報了錯。
那么遇到這種情況我們?nèi)绾谓鉀Q呢?有兩種方式:升級MySQL到5.6或更高版本,并且將表字符集切換至utf8mb4。第二種方法就是在把內(nèi)容存入到數(shù)據(jù)庫之前做一次過濾,將Emoji字符替換成一段特殊的文字編碼,然后再存入數(shù)據(jù)庫中。之后從數(shù)據(jù)庫獲取或者前端展示時再將這段特殊文字編碼轉(zhuǎn)換成Emoji顯示。第二種方法我們假設(shè)用-*-1F601-*-
來替代4字節(jié)的Emoji,那么具體實(shí)現(xiàn)python代碼可以參見Stackoverflow上的回答