Java與Mysql的unicode編碼

前言

  近期工作時候,遇到爆冷知識,主要是因爲mysql的utf8默認是utf8mb3,而用4個字節表示的utf8編碼,存入數據庫,需要mysql的utf8mb4,所以導致數據入庫異常。故而記錄一波。然後自己要做數據插入數據庫前的限制(因爲DBA明確表示目前環境不支持utf8mb4)。

  本篇博文會夾雜一點知識盲區的地方,秉着共享的觀點分享,內容進行了一定語言的整理。

Unicode字符編碼

  要想打開一個文本文件,就必須知道它的編碼方式,否則用錯誤的編碼方式解讀,就會出現亂碼。

  可以想象,如果有一種編碼,將世界上所有的符號都納入其中。每一個符號都給予一個獨一無二的編碼,那麼亂碼問題就會消失。這就是 Unicode,就像它的名字都表示的,這是一種所有符號的編碼。

  Unicode 當然是一個很大的集合,現在的規模可以容納100多萬個符號。每個符號的編碼都不一樣,比如,U+0639表示阿拉伯字母Ain,U+0041表示英語的大寫字母A。具體的符號對應表,可以查詢Unicode網站

Java中的char

  char原本表示單個字符,在java中是2字節大小(16 bit)。以前的unicode可以用一個char表示,而現在存在一些unicode字符使用兩個char表示。

碼點

  碼點:與一個編碼表(例如Unicode編碼表)中的某個字符對於的代碼值。

Unicode碼點的值採用十六進制書寫,並加上前綴U+

  類似於下面所示,一般來說Java中的char可以使用UTF-16的編碼unicode去指定字符,例如\u0022,是字符 " 的Unicode編碼碼點值。

char A = '\u0022';

String testA = "\u0022";// 編譯會出錯,Unicode轉義序列會在解析代碼前處理,無論你是註釋還是非註釋

// C:\user ==> 編譯時會爆非法unicode轉義序列,因爲java認爲\u後跟着的是16進制的unicode編碼,當寫成C:\\user則沒有問題了

  Java中的Unoicode的碼點最大和最小值分別是 Character.MIN_CODE_POINTCharacter.MAX_CODE_POINT。這兩個值用16進製表示,分別是 0x0000000X10FFFF。十進制則是0和‭1114111‬。

  單一的char字符,是16位長度的原數據類型,也就是能表示的範圍只有065536,即碼點值在0x00000XFFFF之間,這之間的字符屬於Unicode 的代碼級別(平面)的第一基本的多語言級別(BMP,basic multilingual plane),剩下的16個代碼級別在 0x0100000X10FFFF,其中包含了輔助字符(supplementary character)。

  oracle是這麼描述的:

The char data type is a single 16-bit Unicode character. It has a minimum value of '\u0000' (or 0) and a maximum value of '\uffff' (or 65,535 inclusive).

碼元和代理對

  BMP級別中,每個字符可以用16位表示,也就是一個char表示,被稱爲代碼單元code unit。而輔助字符則採用一對連續的代碼單元(兩個char)進行編碼,稱爲代理對(surrogate pair)。這種編碼模式就稱爲UTF-16

  意思就是說,輔助字符(碼點值大於0XFFFF)採用一對代理對(高、低代理)表示。

在Java中除非確實要處理UTF16的代碼單元時才採用char,因爲java中char是UTF16編碼的一個代碼單元,它並非代表UTF-8編碼。

  代理所處於BMP區域的碼點值不會有其他字符,所以如果判斷出一個碼點單元的碼點值屬於高代理,那麼就說明,下一個碼點單元是低代理(否則字符串非法)。

碼點值 名字
U+D800-U+DB7F High Surrogates
U+DB80-U+DBFF High Private Use Surrogates
U+DC00-U+DFFF Low Surrogates

現在的問題來了。

輔助字符是如何分爲兩個代碼單元的?

  假設有一個碼點a,(範圍在U+10000-U+10FFFF)之間。

  • 計算高代理步驟

    (1) 碼點值減去0x10000,得到的值的範圍爲20比特長的 0...0xFFFFF

    輔助平面中的碼位從U+10000到U+10FFFF,共計FFFFF個,即220=1,048,5762^{20}=1,048,576個,需要20位bit來表示。

    (2) 高位的10比特的值(值的範圍爲 0...0x3FF)被加上 0xD800 得到第一個碼點值稱爲高代理,值的範圍是 0xD800...0xDBFF

    10 個1組成1111111111,換算16進制就是3FF。

  • 計算低代理步驟

    (3)低位的10比特的值(值的範圍也是 0...0x3FF)被加上 0xDC00,就能得到低位代理。

  java底層很巧妙的這麼計算:

public static final char MIN_HIGH_SURROGATE = '\uD800'
public static final char MIN_LOW_SURROGATE  = '\uDC00';
public static final int MIN_SUPPLEMENTARY_CODE_POINT = 0x010000;    
public static char highSurrogate(int codePoint) {
     return (char) ((codePoint >>> 10)
         + (MIN_HIGH_SURROGATE - (MIN_SUPPLEMENTARY_CODE_POINT >>> 10)));
}
public static char lowSurrogate(int codePoint) {
    return (char) ((codePoint & 0x3ff) + MIN_LOW_SURROGATE);
}

高位主要是因爲碼點是20比特長度,所以高位往左移10位,留下的便是高代理所需要的10位比特,同時需要減掉的0x10000也進行左移10位。

  個人認爲高位處理可以是下面所示,意思比較明確

(char) (((codePoint-MIN_SUPPLEMENTARY_CODE_POINT) >>> 10) + MIN_HIGH_SURROGATE)

低位和0x3FF進行與操作後,高位的前10位全爲0,然後再加上低代理的最低位,即可。

unicode,UTF-8,UTF-16,UTF-32

  Unicode和UTF-8和UTF-16以及UTF-32,可能容易比較混淆。

  Unicode表示是字符集,和美國的ASCII,西歐的ISO 8859-1,俄羅斯的KOI-8,中國的GB 18030等一樣,表示的是一個編碼字符集,是一組編號的有序字符集或一組字符表,每個字符集都有唯一的編號。 完成字符和碼點值之間映射而用。

  UTF-8和UTF-16以及UTF-32都是unicode字符集的字符編碼方案,是計算機將碼點表示爲八位字節(octets)序列的方式,例如UTF-16。UTF8、UTF16、UTF32是出於要在內存中存儲字符的目的而對unicode字符編號進行編碼。

UTF-8

  UTF-8的設計有以下的多字符組序列的特質:

  • 單字節字符的最高有效byte永遠爲0。
  • 多字節序列中的首個字符組的幾個最高有效byte決定了字節的大小。最高有效位爲110的是2字節,而1110的是三字節,如此類推。
  • UTF-8以字節爲編碼單元,它的字節順序在所有系統中都是一樣的,沒有字節序的問題,也因此它實際上並不需要字節順序標記。(byte-order mark,BOM)。
  • 但存儲文件時,在UTF-8+BOM格式文件的開首,很多時都放置一個U+FEFF字符(UTF-8以EF,BB,BF代表),以顯示這個文本文件是以UTF-8編碼的文件。
  • 兼容ASCII,由於互聯網興起而制定

Java中Unicode和UTF-8之間的轉換關係表

在這裏插入圖片描述

UTF-16

  把Unicode字符集的抽象碼點映射爲採用固定長爲16位碼元(char)的整數的序列,用於數據存儲或傳遞。Unicode字符的碼點值,需要1個或者2個16位長的碼元來表示,因此這是一個變長表示

在java中,UTF-8變長表示是1-4字節。而UTF16則是1-2個碼元(char)。

  UTF-16中,無論是進行編碼成字節序存儲文件時還是將字符串按照UTF16編碼成字節數組,都需要指定字節順序,這個字節順序分兩類,稱爲大端序(Big endian )小端序( Little endian )。默認情況下,java的UTF-16是大端序編碼。

java中,StandardCharsets類的靜態變量。UTF_16是默認的大端序,和UTF_16BE是一樣。UTF_16LE則是小端序。

大小端序

  一個多位的整數,按照存儲地址從低到高排序的字節中,如果該整數的最低有效字節(類似於最低有效位)在最高有效字節的前面,則稱小端序;反之則稱大端序。

在網絡應用中,字節序是一個必須被考慮的因素,因爲不同機器類型可能採用不同標準的字節序,所以均按照網絡標準轉化。

  以漢字爲例,Unicode 碼是4E25,需要用兩個字節存儲,一個字節是4E,另一個字節是25。存儲的時候,4E在前,25在後,這就是大端序 方式;25在前,4E在後,這是小端序方式。

  Unicode 規範定義,每一個文件的最前面分別加入一個表示編碼順序的字符,這個字符的名字叫做"零寬度非換行空格"(zero width no-break space),用FEFF表示。這正好是兩個字節,而且FFFE1

  如果一個文本文件的頭兩個字節是FE FF,就表示該文件採用大端序方式;如果頭兩個字節是FF FE,就表示該文件採用小端序方式。

UTF-32

  UTF-32編碼長度是固定的,UTF-32中的每個32位值代表一個Unicode碼位,並且與該碼位的數值完全一致。

  UTF-32的主要優點是可以直接由Unicode碼位來索引。在編碼序列中查找第N個編碼是一個常數時間操作。編碼序列中的字符位置可以用一個整數來表示,整數加一即可得到下一個字符的位置,就和ASCII字符串一樣簡單。

  每個碼位使用四個字節,空間浪費較多。

比如ASCII字符a ,用UTF8編碼是1個字節,用UTF-16編碼是2個字節,而用UTF32編碼,卻是4個字節。

  UTF32也有大端序和小端序。

大端序:00 00 FE FF

小端序: FF FE 00 00

java針對UTF-8和UTF-16的額外說明

  考慮下面的字符,ASCII的字符 a,構成的字符串。

String test = "a";// 字符a的Unicode碼\u0041
System.out.println(test.getBytes(StandardCharsets.UTF_8).length);// 1
System.out.println(test.getBytes(StandardCharsets.UTF_16).length);// 4
test = "aa";
System.out.println(test.getBytes(StandardCharsets.UTF_8).length);// 2
System.out.println(test.getBytes(StandardCharsets.UTF_16).length);// 6
test = "aaa";
System.out.println(test.getBytes(StandardCharsets.UTF_8).length);// 3
System.out.println(test.getBytes(StandardCharsets.UTF_16).length);// 8


test = new StringBuilder().appendCodePoint(0x07FF).toString();
System.out.println(test.getBytes(StandardCharsets.UTF_8).length);//2
System.out.println(test.getBytes(StandardCharsets.UTF_16).length);//4
test = new StringBuilder().appendCodePoint(0x07FF).appendCodePoint(0x07FF).toString();
System.out.println(test.getBytes(StandardCharsets.UTF_8).length);//4
System.out.println(test.getBytes(StandardCharsets.UTF_16).length);//6

test = new StringBuilder().appendCodePoint(0X10FFFF).appendCodePoint(0X10FFFF).toString();
System.out.println(test.getBytes(StandardCharsets.UTF_8).length);// 8
System.out.println(test.getBytes(StandardCharsets.UTF_16).length);// 10

  理解2個點:

  • UTF-8是可變長編碼,在U+0000..U+007F之間只需要編碼成1個字節,以此類推(按照前面的表格對應)。
  • UTF-16編碼,需要額外加入編碼的字節數組前加BOM,佔據2個字節。另外的字符再按照字符對應的碼點值進行UTF-16編碼,每個字符佔據2個字節或4個字節(如果碼點值大於0XFFFF)。

  常見的中文,比如“譚”字構成的字符串,UTF-8編碼屬於3字節,UTF16則是4字節。原因是,“譚”字屬於BMP平面內,可以用一個char表示,所以轉換成字符串長度則是1。

java中碼點與char互換的函數(列出部分實用的)

Character類

  • public static boolean isValidCodePoint(int codePoint)

判斷碼點是否是有效碼點

  • public static boolean isBmpCodePoint(int codePoint)

判斷碼點是否屬於BMP平面字符

  • public static boolean isSupplementaryCodePoint(int codePoint)

判斷碼點是否屬於補充字符

  • public static boolean isHighSurrogate(char ch)

判斷char是否屬於高代理區間的字符

  • public static boolean isLowSurrogate(char ch)

判斷char是否屬於低代理區間的字符

  • public static boolean isSurrogate(char ch)

判斷char是否屬於代理區間的字符(可能是高代理,可能是低代理)

  • public static boolean isSurrogatePair(char high, char low)

判斷兩個char是否屬於一對代理對

  • public static int charCount(int codePoint)

判斷碼點需要幾個char表示

  • public static int toCodePoint(char high, char low)

將高低代理字符轉換成碼點

  • public static int codePointAt(CharSequence seq, int index)

給定字符序列及下標,得到對應字符的碼點

  • public static int codePointAt(char[] a, int index)

給定字符數組及下標,得到對應字符的碼點

  • public static int codePointAt(char[] a, int index)

給定字符數組及下標,得到對應字符的碼點

  • public static char highSurrogate(int codePoint)

給定碼點,得到對應的高代理字符

  • public static char lowSurrogate(int codePoint)

給定碼點,得到對應的低代理字符

  • public static char[] toChars(int codePoint)

給定碼點,得到對應的UTF-16字符表示

String類

  • public int codePointAt(int index)

得到對應下標的UTF-16字符所對應的碼點

  • codePoints()

得到字符串對應的碼點流

  • public int codePointCount(int beginIndex, int endIndex)

返回beginIndex到endIndex-1之間的碼點數量。

StringBuilder,StringBuffer類

  • appendCodePoint(int codePoint)

通過碼點追加字符

mysql的UTF-8和utf8mb4

  近期由測試人員,使用apache的commons-lang3包。maven引入如下所示。

<dependency>
   <groupId>org.apache.commons</groupId>
   <artifactId>commons-lang3</artifactId>
   <version>3.9</version>
</dependency>

  RandomStringUtils生成的字符串構建的字符串,生成後插入mysql的數據庫時,由於Mysql採用的是utf8編碼,當遇到碼點值不在BMP內的字符時,無法插入數據庫,就會拋出異常,報錯信息類似於。

 Cause: java.sql.SQLException: Incorrect string value: '\xF0\xA9\xB8\x80' for column 'columnName' at row 1

UTF-8編碼,第一個字符\F0標明,該字符采用的是UTF8的4字節編碼。

  主要是該列採用的是varchar類型,而且插入的mysql數據庫,默認的數據集是utf8,默認的排序規則是utf8_general_ci,mysql的驅動版本是5.1.25,版本還在5.7的時候,而mysql該版本的utf8默認是utf8mb3,限制是在1-3字節的unicode字符,即BMP字符。

字符集和排序規則不再贅述,相關含義參見MYSQL官方給的解釋

  後面給出的解決方案大概分兩個類型,一個是mysql底層數據庫改成utf8mb4的字符集,以及對應的排序規則。另一個是不改變底層數據庫字符集的情況下,兼容改造或者限制。

  由於DBA明確表示,不支持utf-8的編碼字符集。所以只能採取第二種方式。最後敲定是進行限制。

  解決一共就有兩種方向:

  • 將varchar類型改成varbinary類型
  • 限制插入的數據屬於utf8mb3的字符

varchar類型改成varbinary類型

  • 將出錯字段改成byte[]數組而非String類型,所要插入的數據用UTF8模式進行編碼,然後mybatis讀取回來後的byte[]數組用UTF-8進行解碼成String
  • 出錯字段依舊保持是String類型,將mysql驅動升級爲5.1.48,數據庫連接參數上加上字符集是characterEncoding=UTF-8的配置

在5.1.25的驅動時,因爲底層jdbc的驅動中,ResultSetImpl的getString,將得到的byte[]數據以US-ASCII進行編碼。

  區別在於下面是5.1.48時,判斷元數據的列的類型是binary的類型時,採用連接時的參數編碼。

String encoding = metadata.getCollationIndex() == CharsetMapping.MYSQL_COLLATION_INDEX_binary ? this.connection.getEncoding()
                    : metadata.getEncoding();

  字段依舊是String的情況下,採用varbinary,能夠解決mysql僅採用utf8編碼但可以保存4字節UTF8的問題。

限制字段爲utf8mb3

  處於utf8mb4的字符屬於十分不常見,很少用的字符。所以最後是對其作出限制。如何限制成爲一個問題,即給定一個字符串,如果判斷不是utf8mb4格式的?或者說如何判斷該字符串是utf8mb3格式的?

  實際上是對字符串所用的碼點進行判斷,是否每個碼點都是BMP字符,存在一個不是,就說明不符合要求。

public static boolean isMysqlSupportString(String content) {
        return StringUtils.isBlank(content) || content.codePoints().allMatch(Character::isBmpCodePoint);
}

  然後後面又想到一個優化,大概是這樣也可以進行判斷。

public static boolean isMysqlSupportString2(String content) {
        return StringUtils.isBlank(content) || content.codePoints().toArray().length == content.length();
    }

  等同於下面的判斷:

    public static boolean isMysqlSupportString2(String content) {
        return StringUtils.isBlank(content) || content.codePointCount(0,content.length()) == content.length();
    }

結語

  本次算是漲了一波知識,主要是對於字符編碼,即Java的碼點部分有了清晰的認知。希望該博文對看者有用。

參考文獻

  1. java官方文檔-原數據類型
  2. UTF-8
  3. UTF-16
  4. utf-32
  5. 字符編碼-阮一峯
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章