【原創】經驗分享:一個小小emoji盡然牽扯出來這麼多東西?

一枝花算不算浪漫發表於2020-10-09

前言

之前也分享過很多工作中踩坑的經驗:

  1. 一個線上問題的思考:Eureka註冊中心叢集如何實現客戶端請求負載及故障轉移?
  2. 【原創】經驗分享:一個Content-Length引發的血案(almost....)

今天再來分享工作中一個真實的案例:

商品評價列表頁,顯示每條使用者的評價詳情,為了保護使用者隱私,要求顯示使用者暱稱時只能顯示第一位和最後一位,其他的用※代替。

例如輸入:???,輸出:?***?

看似一個平淡無奇的需求,我也沒有太在意。服務端將使用者的評論資訊儲存到db中,評價列表介面就是將資料庫中該商品的評論資訊展示出來,特殊處理下評論人的暱稱就可以了。

但是!! 測試同學發現使用者暱稱包含emoji表情時就會出問題,切割的資料會有問號顯示!!

模擬的示例程式碼如下:

字串擷取.png

輸出:

輸出.png

看到這個輸出,我真的是一臉懵逼,這完全不是我想要的結果呀!!!

黑人問號.png

這三個魚可算是難倒我了,難道只能給測試說 emoji太特殊 不予處理?然後撒個嬌矇混過關?

思考了良久,我還是決定要正視這個問題並解決掉它!(畢竟我還是那個不畏困難的小機靈鬼?)

問題不大.png

PS:本文很大程度是受到之前公司一位同事unicode分享的啟發,在這裡向我的這位老師致敬!下面的內容會一步步分析這個問題的產生以及最終的解決方案。

概念常識

要解決這些問題,就必須要鋪墊一些基礎知識,大家等不及看解決方案 可以拉到文章最後的程式碼示例。

utf8mb4

一般我們在資料庫建立表時都會預設使用這種編碼格式:

utf8mb4編碼.png

相信大家對這個編碼格式都不陌生吧,當我們想儲存emoji資料到資料庫中,那麼資料庫的格式就需要指定為utf8mb4了,要不然儲存就會報錯了。所以在很多公司的db規範中,資料庫預設編碼必須為utf8mb4

emoji儲存報錯.png

但是大家有沒有過這樣的疑惑,為何utf8不行而utf8mb4就行?這裡面到底有什麼彎彎道道

這裡面涉及到unicode相關知識,我們下面會提到,大家繼續看。

mysql 5.5 之前,utf8編碼只支援1-3個位元組,從mysql 5.5開始,可支援4個位元組UTF編碼utf8mb4,一個字元最多能有4位元組,所以能支援更多的字符集。

emoji長度.png

這個表格中包含了所有的 emoji 以及它所對應的 unicode編碼,同時也有對應的 utf-8編碼的實現。

從圖中也可以看出 emoji 表情用 utf-8 表示時會佔用 4個位元組,這也就是為什麼資料庫用utf8無法儲存emoji表情的原因了。

同樣我們也可以在java程式碼中看看emoji佔用幾個位元組長度:

emoji長度.png

我們也可以看到String.getBytes(),預設是utf-8編碼的:

String.getBytes編碼格式.png

ASCII碼

上面介紹utf8mb4時有提過unicode,介紹它之前我們也需要先提一嘴我們的老朋友:ASCII

ASCII(American Standard Code for Information Interchange,美國資訊交換標準程式碼)是基於拉丁字母的一套電腦編碼系統。它主要用於顯示現代英語。

這樣我們就可以使用一個位元組來表示現代英文,看起來非常不錯,部分資料對應關係如下:

ASCII碼.png

但這個只能顯示的代表拉丁文,這顯然是遠遠不夠的。

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 個平面出去。

Unicode平面.png

第0平面(Plane 0),是Unicode中的一個編碼區段。編碼從U+0000U+FFFF,這個平面裡面的字元是我們最常用到的。

65535 之後分配的字元大多數是 emoji 表情,比如 ? 是 128570(\uD83D\uDE3A)

這裡推薦一個線上的編碼轉換網站:http://ctf.ssleye.com/cencode.html

線上utf16轉換.png

表示範圍

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-8UTF-16UCS2UCS4/UTF-32等,細分的話還有大小端的區別。

對於我們Java而言,可以從char佔用2位元組來推斷出使用的是UTF-16編碼來儲存

對於各種編碼問題推薦一篇好文:深入分析 Java 中的中文編碼問題

判斷是否包含中文

上面大概瞭解了Unicode的含義及用途,那麼瞭解這個玩意有什麼實際作用呢?

我們再來看一個小的需求,比如:如何判斷一個字串中包含中文?

相信大家也遇到過這種需求吧,一般我們都會去百度一通,一定都能找到一個判斷是否包含中文的正規表示式,然後滿心歡喜解決了問題。

恰巧我們系統中也有這麼一個正則判斷,是架構組的同事封裝好的,一起來看下:

是否含有中文.png

顯然,這裡是通過Unicode區間去判斷的,有沒有問題呢?

這裡的區間是用的中日韓統一表意文字,但是這個是1993年的版本,包含了大部分我們常用的中文,共有20902個字,看到後面補充的版本,還新增了很多字,由此可想像我們現在使用的判斷方式肯定會漏掉後新增的字:

中日韓統一表意文字.png

我們用2000年增加的中日韓統一表意文字擴充套件區A 來舉例測試一下:

中日韓統一表意文字擴充套件區A.png

這裡加了很多生僻字,甚至都沒有我認識的,我們用第二排的資料來做一個驗證:

驗證是否包含中文.png

看到這裡是不是很驚訝?並高呼你們這裡寫了一個bug,哈哈。

寫Bug.png

其實這裡並不能說我們的正則判斷有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

上面有提到surrogatesurrogate是代理的意思, 這個概念不是來自 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+D800U+DBFF 的規定為「High Surrogates」,共1024個。編號為 U+DC00 U+DFFF 的規定為「Low Surrogates」,也是1024個。它們兩兩組合出現,就又可以多表示1048576種字元。

emoji擷取異常原因

上面都是一些概念性的知識,如果硬看確實容易懵,我們還是回過頭看一下吧,從程式碼入手:

暱稱.png

我們可以把emoji分離出來,如下:

? -> \uD83D\uDC33

? -> \uD83D\uDC33

? -> \uD83D\uDC20

emoji肯定是大於65536的,所以這裡就用「High Surrogates」「Low Surrogates」兩兩組合的方式來呈現的。

由上面的UTF-16編碼知識可以推斷出,我們的emoji表情擷取一個char後出現亂碼的原因,是因為它是屬於UTF-16編碼輔助平面內的代理對,而我們如果擷取時將代理對拆分開 就會出現異常的問題。

對於這種情況,我們可以通過Character類的靜態方法isHighSurrogateisLowSurrogate來判斷,單個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

問題解決

還是先執行一下程式碼,看看效果:

問題解決.png

具體實現程式碼如下:

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會以捐贈者的名義去命名的。如下是現有的捐贈列表

捐助列表.png

捐助列表2.png

看到第一個就是elastic.co捐贈的,而且點選連結可以直接進入他們官網。第二個捐贈列表中還有一個是我同事捐贈的,哈哈,很有意思。

如果想自己捐贈也可以直接進入到emoji捐贈網站去填寫個人資訊,一共有三個檔位,捐贈後這個列表就會顯示由你定義的emoji資訊了,簡直太酷了?:

一枝花.png

總結

一個小小的emoji真是學問無窮,由於篇幅的問題我這裡還省略了很多東西,比如UTF-8UTF-16兩種編碼形式並沒有深入講解,這裡面又會牽扯到很多內容。

我希望這篇文章能夠做到一個拋磚引玉的作用,激發小夥伴們一起去探究更多的奧祕。

參考

  1. 維基百科 Unicodehttps://zh.wikipedia.org/wiki/Unicode
  2. 維基百科 Unicode字元平面對映https://zh.wikipedia.org/wiki/Unicode字元平面對映
  3. 不要小看小小的 emoji 表情https://juejin.im/post/6844903938878078990
  4. 談談字元編碼:Unicode、UTF-8 和 char[]https://luan.ma/post/character-encoding/
  5. 字元截斷引發的emoji表情亂碼問題https://superxlcr.github.io/2018/06/19/字元截斷引發的emoji表情亂碼問題/
  6. emoji捐贈列表https://www.unicode.org/consortium/adopted-characters.html

歡迎關注我的公眾號,一起交流學習:

歡迎關注

相關文章