前言
之前也分享過很多工作中踩坑的經驗:
今天再來分享工作中一個真實的案例:
商品評價列表頁,顯示每條使用者的評價詳情,為了保護使用者隱私,要求顯示使用者暱稱時只能顯示第一位和最後一位,其他的用※代替。
例如輸入:???,輸出:?***?
看似一個平淡無奇的需求,我也沒有太在意。服務端將使用者的評論資訊儲存到db
中,評價列表介面就是將資料庫中該商品的評論資訊展示出來,特殊處理下評論人的暱稱就可以了。
但是!! 測試同學發現使用者暱稱包含emoji表情
時就會出問題,切割的資料會有問號顯示!!
模擬的示例程式碼如下:
輸出:
看到這個輸出,我真的是一臉懵逼,這完全不是我想要的結果呀!!!
這三個魚可算是難倒我了,難道只能給測試說 emoji太特殊 不予處理?然後撒個嬌矇混過關?
思考了良久,我還是決定要正視這個問題並解決掉它!(畢竟我還是那個不畏困難的小機靈鬼?)
PS:本文很大程度是受到之前公司一位同事unicode分享的啟發,在這裡向我的這位老師致敬!下面的內容會一步步分析這個問題的產生以及最終的解決方案。
概念常識
要解決這些問題,就必須要鋪墊一些基礎知識,大家等不及看解決方案 可以拉到文章最後的程式碼示例。
utf8mb4
一般我們在資料庫建立表時都會預設使用這種編碼格式:
相信大家對這個編碼格式都不陌生吧,當我們想儲存emoji
資料到資料庫中,那麼資料庫的格式就需要指定為utf8mb4
了,要不然儲存就會報錯了。所以在很多公司的db規範
中,資料庫預設編碼必須為utf8mb4
但是大家有沒有過這樣的疑惑,為何utf8
不行而utf8mb4
就行?這裡面到底有什麼彎彎道道?
這裡面涉及到unicode
相關知識,我們下面會提到,大家繼續看。
在mysql 5.5
之前,utf8
編碼只支援1-3
個位元組,從mysql 5.5
開始,可支援4個位元組UTF
編碼utf8mb4
,一個字元最多能有4位元組,所以能支援更多的字符集。
這個表格中包含了所有的 emoji
以及它所對應的 unicode
編碼,同時也有對應的 utf-8
編碼的實現。
從圖中也可以看出 emoji
表情用 utf-8
表示時會佔用 4個位元組,這也就是為什麼資料庫用utf8
無法儲存emoji
表情的原因了。
同樣我們也可以在java
程式碼中看看emoji
佔用幾個位元組長度:
我們也可以看到String.getBytes()
,預設是utf-8
編碼的:
ASCII碼
上面介紹utf8mb4
時有提過unicode
,介紹它之前我們也需要先提一嘴我們的老朋友:ASCII
碼
ASCII(American Standard Code for Information Interchange,美國資訊交換標準程式碼)是基於拉丁字母的一套電腦編碼系統。它主要用於顯示現代英語。
這樣我們就可以使用一個位元組來表示現代英文,看起來非常不錯,部分資料對應關係如下:
但這個只能顯示的代表拉丁文,這顯然是遠遠不夠的。
Unicode
顯而易見,計算機的發展並不是只支援英文一種語言的,ASCII
的侷限在於只能顯示26個
基本拉丁字母、阿拉伯數字和英式標點符號,因此只能用於顯示現代美國英語。
這時如果能有一種包含了世界上所有的文字的字符集,每一個地區的文字都在這個字符集中有唯一的二進位制表示,這樣便不會出現亂碼問題了。所以Unicode
也應運而生了。
概念
Unicode,中文又稱萬國碼、國際碼、統一碼、單一碼,是電腦科學領域裡的一項業界標準。它對世界上大部分的文字系統進行了整理、編碼,使得電腦可以用更為簡單的方式來呈現和處理文字。
平面
Unicode
首先承認了 ASCII
佔用 0-127 整數資源的合法性,之後又一次佔用了 128-65535
的整數資源,有了這麼多的整數資源,我們就可以把世界各種文字的每一種字元分配一個整數來表示了。
之後,Unicode
聯盟發現 65536 個整數也不夠分配的,於是就索性一次性又把之後的 16 個 65536 的數字即 65536-1114111 的整數資源給佔了,然後把多佔的 16 個 65536 的段分別命名為 16 個平面,加上原來的 0-65535 平面,Unicode
總共有 17 個平面。比如第 1 平面就是 65536-131072。當然,到目前為止,還只分配了 7 個平面出去。
第0平面(Plane 0),是Unicode
中的一個編碼區段。編碼從U+0000
至U+FFFF
,這個平面裡面的字元是我們最常用到的。
65535 之後分配的字元大多數是 emoji
表情,比如 ? 是 128570(\uD83D\uDE3A)
這裡推薦一個線上的編碼轉換網站:http://ctf.ssleye.com/cencode.html
表示範圍
Unicode
表示範圍:U+0000 ~ U+10FFFF
- 也就大概是:U+0000~U+110000(加上1),也就是17個FFFF(65535)
- 差不多17*6w,大概有100w個碼點可以用來對映字元
- 準確的值是 1114,112,差不多112w個碼點
- 最新版本的Unicode含有136,690 個字元,離100w還很遠。
- Unicode 官方表示目前的碼點已經夠用,以後不再擴充
實現方式
Unicode
的實現方式不同於編碼方式。一個字元的Unicode
編碼是確定的。但是在實際傳輸過程中,由於不同系統平臺的設計不一定一致,以及出於節省空間的目的,對Unicode
編碼的實現方式有所不同。Unicode
的實現方式稱為Unicode
轉換格式(Unicode Transformation Format,簡稱為UTF)。
對於被Unicode
收錄的字元其編碼是唯一且確定的。但是Unicode
的實現方式(出於傳輸、儲存、處理或向後相容的考慮)卻有不同的幾種,其中最流行的是UTF-8
、UTF-16
、UCS2
、UCS4/UTF-32
等,細分的話還有大小端的區別。
對於我們Java
而言,可以從char
佔用2位元組來推斷出使用的是UTF-16
編碼來儲存
對於各種編碼問題推薦一篇好文:深入分析 Java 中的中文編碼問題
判斷是否包含中文
上面大概瞭解了Unicode
的含義及用途,那麼瞭解這個玩意有什麼實際作用呢?
我們再來看一個小的需求,比如:如何判斷一個字串中包含中文?
相信大家也遇到過這種需求吧,一般我們都會去百度一通,一定都能找到一個判斷是否包含中文的正規表示式,然後滿心歡喜解決了問題。
恰巧我們系統中也有這麼一個正則判斷,是架構組的同事封裝好的,一起來看下:
顯然,這裡是通過Unicode
區間去判斷的,有沒有問題呢?
這裡的區間是用的中日韓統一表意文字,但是這個是1993年的版本,包含了大部分我們常用的中文,共有20902個字,看到後面補充的版本,還新增了很多字,由此可想像我們現在使用的判斷方式肯定會漏掉後新增的字:
我們用2000年增加的中日韓統一表意文字擴充套件區A 來舉例測試一下:
這裡加了很多生僻字,甚至都沒有我認識的,我們用第二排的資料來做一個驗證:
看到這裡是不是很驚訝?並高呼你們這裡寫了一個bug
,哈哈。
其實這裡並不能說我們的正則判斷有bug
,這個需要看我們的需求是否精準到所有的生僻詞都得識別到。根據使用者的使用習慣,輸入這些生僻字的概率不是很高,所以這個正則並沒有小夥伴反饋有問題。
解決emoji擷取的問題
言歸正傳,我們終究還是要解決開頭提出的問題,如何正確的擷取含有emoji
的字串?這裡從UTF-16
編碼開始說起。
UTF-16
UTF-16 具體定義了 Unicode 字元在計算機中存取方法。UTF-16 用兩個位元組來表示 Unicode 轉化格式,這個是定長的表示方法,不論什麼字元都可以用兩個位元組表示,兩個位元組是 16 個 bit,所以叫 UTF-16。UTF-16 表示字元非常方便,每兩個位元組表示一個字元,這個在字串操作時就大大簡化了操作,這也是 Java 以 UTF-16 作為記憶體的字元儲存格式的一個很重要的原因。
在基本多語言平面(碼位範圍U+0000-U+FFFF)內的碼位UTF-16
編碼使用1個碼元且其值與Unicode
是相等的(不需要轉換),這個就是我們正常的漢字,比如在輔助平面(碼位範圍U+10000-U+10FFFF)內的碼位在UTF-16
中被編碼為一對16bit
的碼元(即32bit,4位元組),稱作代理對(surrogate pair)。組成代理對的兩個碼元前一個稱為 前導代理(lead surrogates) 範圍為0xD800-0xDBFF
,後一個稱為 後尾代理(trail surrogates) 範圍為0xDC00-0xDFFF
surrogate
上面有提到surrogate
,surrogate
是代理的意思, 這個概念不是來自 Java
語言,而是來自 Unicode
編碼方式之一 UTF-16
。具體請見:UTF-16
簡而言之,Java
語言內部的字元資訊是使用 UTF-16
編碼。因為char
這個型別是 16-bit
的。它可以有65536
種取值,即65536
個編號,每個編號可以代表1種字元。但是,Unicode
包含的字元已經遠遠超過65536
個。那麼編號大於65536
的,還要用 16-bit
編碼,該怎麼辦?於是Unicode
標準制定組想出的辦法就是,從這65536
個編號裡,拿出2048
個,規定它們是「Surrogates」
,讓它們兩個為一組,來代表編號大於65536
的那些字元。
更具體地,編號為 U+D800
至 U+DBFF
的規定為「High Surrogates」
,共1024
個。編號為 U+DC00
至 U+DFFF
的規定為「Low Surrogates」
,也是1024
個。它們兩兩組合出現,就又可以多表示1048576
種字元。
emoji擷取異常原因
上面都是一些概念性的知識,如果硬看確實容易懵,我們還是回過頭看一下吧,從程式碼入手:
我們可以把emoji
分離出來,如下:
? -> \uD83D\uDC33
? -> \uD83D\uDC33
? -> \uD83D\uDC20
emoji
肯定是大於65536
的,所以這裡就用「High Surrogates」
和「Low Surrogates」
兩兩組合的方式來呈現的。
由上面的UTF-16
編碼知識可以推斷出,我們的emoji
表情擷取一個char
後出現亂碼的原因,是因為它是屬於UTF-16
編碼輔助平面內的代理對,而我們如果擷取時將代理對拆分開 就會出現異常的問題。
對於這種情況,我們可以通過Character
類的靜態方法isHighSurrogate
和isLowSurrogate
來判斷,單個emoji
的組合就是高位+低位,所以對於輔助平面內的代理對,做到整個移除或保留即可。
isHighSurrogate
方法的原始碼如下:
public static final char MIN_HIGH_SURROGATE = '\uD800';
public static final char MAX_HIGH_SURROGATE = '\uDBFF';
public static boolean isHighSurrogate(char ch) {
return ch >= MIN_HIGH_SURROGATE && ch < (MAX_HIGH_SURROGATE + 1);
}
這個判斷其實就是上面說的「High Surrogates」
的判定方式,我們可以轉換一下:
U+D800 <= ch <= U+DBFF
同理,isLowSurrogate
方法的判定方式也是一樣的:
U+DC00 <= ch <= U+DFFF
問題解決
還是先執行一下程式碼,看看效果:
具體實現程式碼如下:
public static void main(String[] args) {
// 使用者暱稱為:???,正常結果應該為:?***?
String context = "\uD83D\uDC33\uD83D\uDC33\uD83D\uDC20";
int realNameLength = realStringLength(context);
String namePrefix = subString(context, 1, 0);
String nameSuffix = subString(context, realNameLength - 1, 1);
context = String.format("%s%s%s", namePrefix, "***", nameSuffix);
System.out.println(context);
}
/**
* 包含emoji表情的subString方法
*
* @param str 原有的str
* @param len str長度
* @param type type = 0 代表prefix,其他代表suffix
*/
private static String subString(String str, int len, int type) {
if (len < 0) {
return str;
}
int count = 0;
for (int i = 0; i < str.length(); i++) {
if (count == len) {
// type = 0 代表prefix,其他代表suffix
if (type == 0) {
return str.substring(0, i);
}
return str.substring(i);
}
char c = str.charAt(i);
if (Character.isHighSurrogate(c) || Character.isLowSurrogate(c)) {
i++;
}
count++;
}
return str;
}
/**
* 包含emoji表情的字串實際長度
*
* @param str 原有str
* @return str實際長度
*/
private static int realStringLength(String str) {
int count = 0;
for (int i = 0; i < str.length(); i++) {
char c = str.charAt(i);
if (Character.isHighSurrogate(c) || Character.isLowSurrogate(c)) {
i++;
}
count++;
}
return count;
}
彩蛋:認領屬於你的emoji
emoji
遠遠不止於此,unicode
旗下還可以支援對emoji
進行捐贈的,當然這個emoji
會以捐贈者的名義去命名的。如下是現有的捐贈列表:
看到第一個就是elastic.co捐贈的,而且點選連結可以直接進入他們官網。第二個捐贈列表中還有一個是我同事捐贈的,哈哈,很有意思。
如果想自己捐贈也可以直接進入到emoji捐贈網站去填寫個人資訊,一共有三個檔位,捐贈後這個列表就會顯示由你定義的emoji
資訊了,簡直太酷了?:
總結
一個小小的emoji
真是學問無窮,由於篇幅的問題我這裡還省略了很多東西,比如UTF-8
和UTF-16
兩種編碼形式並沒有深入講解,這裡面又會牽扯到很多內容。
我希望這篇文章能夠做到一個拋磚引玉的作用,激發小夥伴們一起去探究更多的奧祕。
參考
- 維基百科 Unicode:https://zh.wikipedia.org/wiki/Unicode
- 維基百科 Unicode字元平面對映:https://zh.wikipedia.org/wiki/Unicode字元平面對映
- 不要小看小小的 emoji 表情:https://juejin.im/post/6844903938878078990
- 談談字元編碼:Unicode、UTF-8 和 char[]:https://luan.ma/post/character-encoding/
- 字元截斷引發的emoji表情亂碼問題:https://superxlcr.github.io/2018/06/19/字元截斷引發的emoji表情亂碼問題/
- emoji捐贈列表:https://www.unicode.org/consortium/adopted-characters.html