資料庫中介軟體 Sharding-JDBC 原始碼分析 —— SQL 解析(一)之語法解析
- 1. 概述
- 2. Lexer 詞法解析器
- 3. Token 詞法標記
- 3.2.1 Literals.IDENTIFIER 詞法關鍵詞
- 3.2.2 Literals.VARIABLE 變數
- 3.2.3 Literals.CHARS 字串
- 3.2.4 Literals.HEX 十六進位制
- 3.2.5 Literals.INT 整數
- 3.2.6 Literals.FLOAT 浮點數
- 3.1 DefaultKeyword 詞法關鍵詞
- 3.2 Literals 詞法字面量標記
- 3.3 Symbol 詞法符號標記
- 3.4 Assist 詞法輔助標記
1. 概述
SQL 解析引擎,資料庫中介軟體必備的功能和流程。Sharding-JDBC 在 1.5.0.M1
可能有同學看到SQL 解析會被嚇到,請淡定,耐心往下看。《SQL 解析》內容我們會分成 5 篇相對簡短的文章,讓大家能夠相對輕鬆愉快的去理解:
- 詞法解析
- 插入 SQL 解析
- 查詢 SQL 解析
- 更新 SQL 解析
- 刪除 SQL 解析
SQL 解析引擎在 parsing
- Lexer:詞法解析器。
- Parser:SQL解析器。
兩者都是解析器,區別在於 Lexer 只做詞法的解析,不關注上下文,將字串拆解成 N 個詞法。而 Parser 在 Lexer 的基礎上,還需要理解 SQL 。打個比方:
SQL :SELECT * FROM t_user
Lexer :[SELECT] [ * ] [FROM] [t_user]
Parser :這是一條 [SELECT] 查詢表為 [t_user] ,並且返回 [ * ] 所有欄位的 SQL。
?不完全懂?沒關係,本文的主角是 Lexer,我們通過原始碼一點一點理解。一共 1400 行左右程式碼左右,還包含註釋等等,實際更少噢。
2. Lexer 詞法解析器
Lexer 原理:順序順序順序 解析 SQL,將字串拆解成 N 個詞法。
核心程式碼如下:
// Lexer.java
public class Lexer {
/**
* 輸出字串
* 比如:SQL
*/
@Getter
private final String input;
/**
* 詞法標記字典
*/
private final Dictionary dictionary;
/**
* 解析到 SQL 的 offset
*/
private int offset;
/**
* 當前 詞法標記
*/
@Getter
private Token currentToken;
/**
* 分析下一個詞法標記.
*
* @see #currentToken
* @see #offset
*/
public final void nextToken() {
skipIgnoredToken();
if (isVariableBegin()) { // 變數
currentToken = new Tokenizer(input, dictionary, offset).scanVariable();
} else if (isNCharBegin()) { // N
currentToken = new Tokenizer(input, dictionary, ++offset).scanChars();
} else if (isIdentifierBegin()) { // Keyword + Literals.IDENTIFIER
currentToken = new Tokenizer(input, dictionary, offset).scanIdentifier();
} else if (isHexDecimalBegin()) { // 十六進位制
currentToken = new Tokenizer(input, dictionary, offset).scanHexDecimal();
} else if (isNumberBegin()) { // 數字(整數+浮點數)
currentToken = new Tokenizer(input, dictionary, offset).scanNumber();
} else if (isSymbolBegin()) { // 符號
currentToken = new Tokenizer(input, dictionary, offset).scanSymbol();
} else if (isCharsBegin()) { // 字串,例如:"abc"
currentToken = new Tokenizer(input, dictionary, offset).scanChars();
} else if (isEnd()) { // 結束
currentToken = new Token(Assist.END, "", offset);
} else { // 分析錯誤,無符合條件的詞法標記
currentToken = new Token(Assist.ERROR, "", offset);
}
offset = currentToken.getEndPosition();
// System.out.println("| " + currentToken.getLiterals() + " | " + currentToken.getType() + " | " + currentToken.getEndPosition() + " |");
}
/**
* 跳過忽略的詞法標記
* 1. 空格
* 2. SQL Hint
* 3. SQL 註釋
*/
private void skipIgnoredToken() {
// 空格
offset = new Tokenizer(input, dictionary, offset).skipWhitespace();
// SQL Hint
while (isHintBegin()) {
offset = new Tokenizer(input, dictionary, offset).skipHint();
offset = new Tokenizer(input, dictionary, offset).skipWhitespace();
}
// SQL 註釋
while (isCommentBegin()) {
offset = new Tokenizer(input, dictionary, offset).skipComment();
offset = new Tokenizer(input, dictionary, offset).skipWhitespace();
}
}
}
通過 #nextToken()
方法,不斷解析出 Token(詞法標記)。我們來執行一次,看看 SQL 會被拆解成哪些 Token。
SQL :SELECT i.* FROM t_order o JOIN t_order_item i ON o.order_id=i.order_id WHERE o.user_id=? AND o.order_id=?
literals |
TokenType類 |
TokenType值 |
endPosition |
---|---|---|---|
SELECT |
DefaultKeyword |
SELECT |
6 |
i |
Literals |
IDENTIFIER |
8 |
. |
Symbol |
DOT |
9 |
* |
Symbol |
STAR |
10 |
FROM |
DefaultKeyword |
FROM |
15 |
t_order |
Literals |
IDENTIFIER |
23 |
o |
Literals |
IDENTIFIER |
25 |
JOIN |
DefaultKeyword |
JOIN |
30 |
torderitem |
Literals |
IDENTIFIER |
43 |
i |
Literals |
IDENTIFIER |
45 |
ON |
DefaultKeyword |
ON |
48 |
o |
Literals |
IDENTIFIER |
50 |
. |
Symbol |
DOT |
51 |
order_id |
Literals |
IDENTIFIER |
59 |
= |
Symbol |
EQ |
60 |
i |
Literals |
IDENTIFIER |
61 |
. |
Symbol |
DOT |
62 |
order_id |
Literals |
IDENTIFIER |
70 |
WHERE |
DefaultKeyword |
WHERE |
76 |
o |
Literals |
IDENTIFIER |
78 |
. |
Symbol |
DOT |
79 |
user_id |
Literals |
IDENTIFIER |
86 |
= |
Symbol |
EQ |
87 |
? |
Symbol |
QUESTION |
88 |
AND |
DefaultKeyword |
AND |
92 |
o |
Literals |
IDENTIFIER |
94 |
. |
Symbol |
DOT |
95 |
order_id |
Literals |
IDENTIFIER |
103 |
= |
Symbol |
EQ |
104 |
? |
Symbol |
QUESTION |
105 |
Assist |
END |
105 |
眼尖的同學可能看到了 Tokenizer。對的,它是 Lexer 的好基佬,負責分詞。
我們來總結下, Lexer#nextToken()
方法裡,使用 #skipIgnoredToken()
方法跳過忽略的 Token,通過 #isXXXX()
方法判斷好下一個 Token 的型別後,交給 Tokenizer 進行分詞返回 Token。‼️此處可以考慮做個優化,不需要每次都 newTokenizer(...)
出來,一個 Lexer 搭配一個 Tokenizer。
由於不同資料庫遵守 SQL 規範略有不同,所以不同的資料庫對應不同的 Lexer。
子 Lexer 通過重寫方法實現自己獨有的 SQL 語法。
3. Token 詞法標記
上文我們已經看過 Token 的例子,一共有 3 個屬性:
- TokenType type :詞法標記型別
- String literals :詞法字面量標記
- int endPosition :
literals
在 SQL 裡的結束位置
TokenType 詞法標記型別,一共分成 4 個大類:
- DefaultKeyword :詞法關鍵詞
- Literals :詞法字面量標記
- Symbol :詞法符號標記
- Assist :詞法輔助標記
3.1 DefaultKeyword 詞法關鍵詞
不同資料庫有自己獨有的詞法關鍵詞,例如 MySQL 熟知的分頁 Limit。
我們以 MySQL 舉個例子,當建立 MySQLLexer 時,會載入 DefaultKeyword 和 MySQLKeyword( OracleLexer、PostgreSQLLexer、SQLServerLexer 同 MySQLLexer )。核心程式碼如下:
// MySQLLexer.java
public final class MySQLLexer extends Lexer {
/**
* 字典
*/
private static Dictionary dictionary = new Dictionary(MySQLKeyword.values());
public MySQLLexer(final String input) {
super(input, dictionary);
}
}
// Dictionary.java
public final class Dictionary {
/**
* 詞法關鍵詞Map
*/
private final Map<String, Keyword> tokens = new HashMap<>(1024);
public Dictionary(final Keyword... dialectKeywords) {
fill(dialectKeywords);
}
/**
* 裝上預設詞法關鍵詞 + 方言詞法關鍵詞
* 不同的資料庫有相同的預設詞法關鍵詞,有有不同的方言關鍵詞
*
* @param dialectKeywords 方言詞法關鍵詞
*/
private void fill(final Keyword... dialectKeywords) {
for (DefaultKeyword each : DefaultKeyword.values()) {
tokens.put(each.name(), each);
}
for (Keyword each : dialectKeywords) {
tokens.put(each.toString(), each);
}
}
}
Keyword 與 Literals.IDENTIFIER 是一起解析的,我們放在 Literals.IDENTIFIER 處一起分析。
3.2 Literals 詞法字面量標記
Literals 詞法字面量標記,一共分成 6 種:
- IDENTIFIER :詞法關鍵詞
- VARIABLE :變數
- CHARS :字串
- HEX :十六進位制
- INT :整數
- FLOAT :浮點數
3.2.1 Literals.IDENTIFIER 詞法關鍵詞
詞法關鍵詞。例如:表名,查詢欄位 等等。
解析 Literals.IDENTIFIER 與 Keyword 核心程式碼如下:
// Lexer.java
private boolean isIdentifierBegin() {
return isIdentifierBegin(getCurrentChar(0));
}
private boolean isIdentifierBegin(final char ch) {
return CharType.isAlphabet(ch) || '`' == ch || '_' == ch || '$' == ch;
}
// Tokenizer.java
/**
* 掃描識別符號.
*
* @return 識別符號標記
*/
public Token scanIdentifier() {
// `欄位`,例如:SELECT `id` FROM t_user 中的 `id`
if ('`' == charAt(offset)) {
int length = getLengthUntilTerminatedChar('`');
return new Token(Literals.IDENTIFIER, input.substring(offset, offset + length), offset + length);
}
int length = 0;
while (isIdentifierChar(charAt(offset + length))) {
length++;
}
String literals = input.substring(offset, offset + length);
// 處理 order / group 作為表名
if (isAmbiguousIdentifier(literals)) {
return new Token(processAmbiguousIdentifier(offset + length, literals), literals, offset + length);
}
// 從 詞法關鍵詞 查詢是否是 Keyword,如果是,則返回 Keyword,否則返回 Literals.IDENTIFIER
return new Token(dictionary.findTokenType(literals, Literals.IDENTIFIER), literals, offset + length);
}
/**
* 計算到結束字元的長度
*
* @see #hasEscapeChar(char, int) 處理類似 SELECT a AS `b``c` FROM table。此處連續的 "``" 不是結尾,如果傳遞的是 "`" 會產生誤判,所以加了這個判斷
* @param terminatedChar 結束字元
* @return 長度
*/
private int getLengthUntilTerminatedChar(final char terminatedChar) {
int length = 1;
while (terminatedChar != charAt(offset + length) || hasEscapeChar(terminatedChar, offset + length)) {
if (offset + length >= input.length()) {
throw new UnterminatedCharException(terminatedChar);
}
if (hasEscapeChar(terminatedChar, offset + length)) {
length++;
}
length++;
}
return length + 1;
}
/**
* 是否是 Escape 字元
*
* @param charIdentifier 字元
* @param offset 位置
* @return 是否
*/
private boolean hasEscapeChar(final char charIdentifier, final int offset) {
return charIdentifier == charAt(offset) && charIdentifier == charAt(offset + 1);
}
private boolean isIdentifierChar(final char ch) {
return CharType.isAlphabet(ch) || CharType.isDigital(ch) || '_' == ch || '$' == ch || '#' == ch;
}
/**
* 是否是引起歧義的識別符號
* 例如 "SELECT * FROM group",此時 "group" 代表的是表名,而非詞法關鍵詞
*
* @param literals 識別符號
* @return 是否
*/
private boolean isAmbiguousIdentifier(final String literals) {
return DefaultKeyword.ORDER.name().equalsIgnoreCase(literals) || DefaultKeyword.GROUP.name().equalsIgnoreCase(literals);
}
/**
* 獲取引起歧義的識別符號對應的詞法標記型別
*
* @param offset 位置
* @param literals 識別符號
* @return 詞法標記型別
*/
private TokenType processAmbiguousIdentifier(final int offset, final String literals) {
int i = 0;
while (CharType.isWhitespace(charAt(offset + i))) {
i++;
}
if (DefaultKeyword.BY.name().equalsIgnoreCase(String.valueOf(new char[] {charAt(offset + i), charAt(offset + i + 1)}))) {
return dictionary.findTokenType(literals);
}
return Literals.IDENTIFIER;
}
3.2.2 Literals.VARIABLE 變數
變數。例如: SELECT@@VERSION
。
解析核心程式碼如下:
// Lexer.java
/**
* 是否是 變數
* MySQL 與 SQL Server 支援
*
* @see Tokenizer#scanVariable()
* @return 是否
*/
protected boolean isVariableBegin() {
return false;
}
// Tokenizer.java
/**
* 掃描變數.
* 在 MySQL 裡,@代表使用者變數;@@代表系統變數。
* 在 SQLServer 裡,有 @@。
*
* @return 變數標記
*/
public Token scanVariable() {
int length = 1;
if ('@' == charAt(offset + 1)) {
length++;
}
while (isVariableChar(charAt(offset + length))) {
length++;
}
return new Token(Literals.VARIABLE, input.substring(offset, offset + length), offset + length);
}
3.2.3 Literals.CHARS 字串
字串。例如: SELECT"123"
。
解析核心程式碼如下:
// Lexer.java
/**
* 是否 N
* 目前 SQLServer 獨有:在 SQL Server 中處理 Unicode 字串常數時,必需為所有的 Unicode 字串加上前置詞 N
*
* @see Tokenizer#scanChars()
* @return 是否
*/
private boolean isNCharBegin() {
return isSupportNChars() && 'N' == getCurrentChar(0) && ''' == getCurrentChar(1);
}
private boolean isCharsBegin() {
return ''' == getCurrentChar(0) || '"' == getCurrentChar(0);
}
// Tokenizer.java
/**
* 掃描字串.
*
* @return 字串標記
*/
public Token scanChars() {
return scanChars(charAt(offset));
}
private Token scanChars(final char terminatedChar) {
int length = getLengthUntilTerminatedChar(terminatedChar);
return new Token(Literals.CHARS, input.substring(offset + 1, offset + length - 1), offset + length);
}
3.2.4 Literals.HEX 十六進位制
// Lexer.java
/**
* 是否是 十六進位制
*
* @see Tokenizer#scanHexDecimal()
* @return 是否
*/
private boolean isHexDecimalBegin() {
return '0' == getCurrentChar(0) && 'x' == getCurrentChar(1);
}
// Tokenizer.java
/**
* 掃描十六進位制數.
*
* @return 十六進位制數標記
*/
public Token scanHexDecimal() {
int length = HEX_BEGIN_SYMBOL_LENGTH;
// 負數
if ('-' == charAt(offset + length)) {
length++;
}
while (isHex(charAt(offset + length))) {
length++;
}
return new Token(Literals.HEX, input.substring(offset, offset + length), offset + length);
}
3.2.5 Literals.INT 整數
整數。例如: SELECT*FROM t_user WHERE id=1
。
Literals.INT 與 Literals.FLOAT 是一起解析的,我們放在 Literals.FLOAT 處一起分析。
3.2.6 Literals.FLOAT 浮點數
浮點數。例如: SELECT*FROM t_user WHERE id=1.0
。 浮點數包含幾種:"1.0","1.0F","7.823E5"(科學計數法)。
解析核心程式碼如下:
// Lexer.java
/**
* 是否是 數字
* '-' 需要特殊處理。".2" 被處理成省略0的小數,"-.2" 不能被處理成省略的小數,否則會出問題。
* 例如說,"SELECT a-.2" 處理的結果是 "SELECT" / "a" / "-" / ".2"
*
* @return 是否
*/
private boolean isNumberBegin() {
return CharType.isDigital(getCurrentChar(0)) // 數字
|| ('.' == getCurrentChar(0) && CharType.isDigital(getCurrentChar(1)) && !isIdentifierBegin(getCurrentChar(-1)) // 浮點數
|| ('-' == getCurrentChar(0) && ('.' == getCurrentChar(0) || CharType.isDigital(getCurrentChar(1))))); // 負數
}
// Tokenizer.java
/**
* 掃描數字.
* 解析數字的結果會有兩種:整數 和 浮點數.
*
* @return 數字標記
*/
public Token scanNumber() {
int length = 0;
// 負數
if ('-' == charAt(offset + length)) {
length++;
}
// 浮點數
length += getDigitalLength(offset + length);
boolean isFloat = false;
if ('.' == charAt(offset + length)) {
isFloat = true;
length++;
length += getDigitalLength(offset + length);
}
// 科學計數表示,例如:SELECT 7.823E5
if (isScientificNotation(offset + length)) {
isFloat = true;
length++;
if ('+' == charAt(offset + length) || '-' == charAt(offset + length)) {
length++;
}
length += getDigitalLength(offset + length);
}
// 浮點數,例如:SELECT 1.333F
if (isBinaryNumber(offset + length)) {
isFloat = true;
length++;
}
return new Token(isFloat ? Literals.FLOAT : Literals.INT, input.substring(offset, offset + length), offset + length);
}
這裡要特別注意下:"-"。在數字表達例項,可以判定為 負號 和 減號(不考慮科學計數法)。
- ".2" 解析結果是 ".2"
- "-.2" 解析結果不能是 "-.2",而是 "-" 和 ".2"。
3.3 Symbol 詞法符號標記
詞法符號標記。例如:"{", "}", ">=" 等等。
解析核心程式碼如下:
// Lexer.java
/**
* 是否是 符號
*
* @see Tokenizer#scanSymbol()
* @return 是否
*/
private boolean isSymbolBegin() {
return CharType.isSymbol(getCurrentChar(0));
}
// CharType.java
/**
* 判斷是否為符號.
*
* @param ch 待判斷的字元
* @return 是否為符號
*/
public static boolean isSymbol(final char ch) {
return '(' == ch || ')' == ch || '[' == ch || ']' == ch || '{' == ch || '}' == ch || '+' == ch || '-' == ch || '*' == ch || '/' == ch || '%' == ch || '^' == ch || '=' == ch
|| '>' == ch || '<' == ch || '~' == ch || '!' == ch || '?' == ch || '&' == ch || '|' == ch || '.' == ch || ':' == ch || '#' == ch || ',' == ch || ';' == ch;
}
// Tokenizer.java
/**
* 掃描符號.
*
* @return 符號標記
*/
public Token scanSymbol() {
int length = 0;
while (CharType.isSymbol(charAt(offset + length))) {
length++;
}
String literals = input.substring(offset, offset + length);
// 倒序遍歷,查詢符合條件的 符號。例如 literals = ";;",會是拆分成兩個 ";"。如果基於正序,literals = "<=",會被解析成 "<" + "="。
Symbol symbol;
while (null == (symbol = Symbol.literalsOf(literals))) {
literals = input.substring(offset, offset + --length);
}
return new Token(symbol, literals, offset + length);
}
3.4 Assist 詞法輔助標記
Assist 詞法輔助標記,一共分成 2 種:
- END :分析結束
- ERROR :分析錯誤。