長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?

前言

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

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

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

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

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

模擬的示例代碼如下:

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


輸出:

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

矇混過關?

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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

概念常識

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

utf8mb4

一般我們在數據庫創建表時都會默認使用這種編碼格式:

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

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

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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

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

同樣我們也可以在java代碼中看看emoji佔用幾個字節長度:

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


ASCII碼

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

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

這樣我們就可以使用一個字節來表示現代英文,看起來非常不錯,部分數據對應關係如下:

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

65535

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

表示範圍

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編碼來存儲

判斷是否包含中文

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

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

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

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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


其實這裡並不能說我們的正則判斷有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竟然牽扯出來這麼多東西?


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

-> \uD83D\uDC33

-> \uD83D\uDC33

-> \uD83D\uDC20

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

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

對於這種情況,我們可以通過Character類的靜態方法isHighSurrogate和isLowSurrogate來判斷,單個emoji的組合就是高位+低位,所以對於輔助平面內的代理對,做到整個移除或保留即可。

isHighSurrogate方法的源碼如下:

<code>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);
}

/<code>

這個判斷其實就是上面說的「High Surrogates」的判定方式,我們可以轉換一下:

U+D800 <= ch <= U+DBFF

同理,isLowSurrogate方法的判定方式也是一樣的:

U+DC00 <= ch <= U+DFFF

問題解決

還是先運行一下代碼,看看效果:

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


具體實現代碼如下:

<code>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;
}

/<code>

彩蛋:認領屬於你的emoji

emoji遠遠不止於此,unicode旗下還可以支持對emoji進行捐贈的,當然這個emoji會以捐贈者的名義去命名的。如下是現有的捐贈列表

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?



長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


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

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

長假後靈魂第一問:一個小小emoji竟然牽扯出來這麼多東西?


總結

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

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

參考

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

最後

大家看完有什麼不懂的可以在下方留言討論.
謝謝你的觀看。
覺得文章對你有幫助的話記得關注我點個贊支持一下!

作者:一枝花算不算浪漫
鏈接:https://juejin.im/post/6881336349169811464


分享到:


相關文章: