文章目錄
前言
近期工作時候,遇到爆冷知識,主要是因爲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_POINT
和 Character.MAX_CODE_POINT
。這兩個值用16進製表示,分別是 0x000000
和 0X10FFFF
。十進制則是0
和1114111
。
單一的char字符,是16位長度的原數據類型,也就是能表示的範圍只有0
到65536
,即碼點值在0x0000
和0XFFFF
之間,這之間的字符屬於Unicode 的代碼級別(平面)的第一基本的多語言級別(BMP,basic multilingual plane),剩下的16個代碼級別在 0x010000
和 0X10FFFF
,其中包含了輔助字符(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個,即個,需要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
表示。這正好是兩個字節,而且FF
比FE
大1
。
如果一個文本文件的頭兩個字節是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的碼點部分有了清晰的認知。希望該博文對看者有用。