前言
之前也分享過很多工作中踩坑的經驗:
今天再來分享工作中一個真實的案例:
商品評價列表頁,顯示每條用戶的評價詳情,爲了保護用戶隱私,要求顯示用戶暱稱時只能顯示第一位和最後一位,其他的用※代替。
例如輸入:🐳🐳🐠,輸出:🐳***🐠
看似一個平淡無奇的需求,我也沒有太在意。服務端將用戶的評論信息存儲到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