數據庫中間件 Sharding-JDBC 源碼分析 —— SQL 解析(一)之語法解析
關註微信公眾號:【芋艿的後端小屋】有福利:
RocketMQ / MyCAT / Sharding-JDBC 所有源碼分析文章列表
RocketMQ / MyCAT / Sharding-JDBC 中文註釋源碼 GitHub 地址
您對於源碼的疑問每條留言都將得到認真回復。甚至不知道如何讀源碼也可以請教噢。
新的源碼解析文章實時收到通知。每周更新一篇左右。
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 詞法輔助標記
4. 彩蛋
1. 概述
SQL 解析引擎,數據庫中間件必備的功能和流程。Sharding-JDBC 在 1.5.0.M1
正式發布時,將 SQL 解析引擎從 Druid 替換成了自研的。新引擎僅解析分片上下文,對於 SQL 采用"半理解"理念,進一步提升性能和兼容性,同時降低了代碼復雜度(不理解沒關系,我們後續會更新文章解釋該優點)。 國內另一款數據庫中間件 MyCAT SQL 解析引擎也是 Druid,目前也在開發屬於自己的 SQL 解析引擎。
可能有同學看到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.javapublic 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.javapublic final class MySQLLexer extends Lexer { /** * 字典 */ private static Dictionary dictionary = new Dictionary(MySQLKeyword.values()); public MySQLLexer(final String input) { super(input, dictionary); }}// Dictionary.javapublic 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.javaprivate 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 ([email protected] == 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 :分析錯誤。
4. 彩蛋
老鐵,是不是比想象中簡單一些?!繼續加油寫 Parser 相關的文章!來一波微信公眾號關註吧。
Sharding-JDBC 正在收集使用公司名單:傳送門。 你的登記,會讓更多人參與和使用 Sharding-JDBC。Sharding-JDBC 也會因此,能夠覆蓋更廣的場景。登記吧,少年!
我創建了一個微信群【源碼圈】,希望和大家分享交流讀源碼的經驗。
讀源碼先難後易,掌握方法後,可以做更有深度的學習。
而且掌握方法並不難噢。
加群方式:微信公眾號發送關鍵字【qun】。
本文出自 “芋艿的後端小屋” 博客,請務必保留此出處http://yunai.blog.51cto.com/2625549/1950288
數據庫中間件 Sharding-JDBC 源碼分析 —— SQL 解析(一)之語法解析