
一、背景
隨著技術的不斷的發展,在大數據領域出現了越來越多的技術框架。而為了降低大數據的學習成本和難度,越來越多的大數據技術和應用開始支持SQL進行數據查詢。SQL作為一個學習成本很低的語言,支持SQL進行數據查詢可以降低用戶使用大數據的門檻,讓更多的用戶能夠使用大數據。
本篇文章主要介紹如何實現一個SQL解析器來應用的業務當中,同時結合具體的案例來介紹SQL解析器的實踐過程。
二、為什么需要SQL解析器?
在設計項目系統架構時,我們通常會做一些技術調研。我們會去考慮為什么需要SQL解析器?怎么判斷選擇的 SQL 解析器可以滿足當前的技術要求?
2.1 傳統的SQL查詢
傳統的SQL查詢,依賴完整的數據庫協議。比如數據存儲在MySQL、Oracle等關系型數據庫中,有標準的SQL語法。我們可以通過不同的SQL語句來實現業務需求,如下圖所示:

但是,在處理海量數據的時候,關系型數據庫是難以滿足實際的業務需求的,我們需要借助大數據生態圈的技術組件來解決實際的業務需求。
2.2 實際應用場景
在使用大數據生態圈的技術組件時,有些技術組件是自帶SQL的,比如Hive、Spark、Flink等;而有些技術組件本身是不帶SQL的,比如Kafka、HBase。下面,我們可以通過對比不帶SQL和使用SQL解析器后的場景,如下圖所示:

從上圖中,我們可以看到,圖左邊在我們使用不帶SQL的技術組件時,實現一個查詢時,需要我們編寫不同的業務邏輯接口,來與Kafka、HBase這些技術組件來進行數據交互。如果隨著這類組件的增加,查詢功能復雜度的增加,那邊每套接口的復雜度也會隨之增加,對于后續的擴展和維護也是很不方便的。而圖右邊在我們引入SQL解析器后,只需要一套接口來完成業務邏輯,對于不同的技術組件進行適配即可。
三、什么是SQL解析器?
在選擇SQL解析器應用到我們實際的業務場景之前,我們先來了解一下SQL解析器的核心知識點。
3.1 SQL解析器包含哪些內容?
在使用SQL解析器時,解析SQL的步驟與我們解析Java/Python程序的步驟是非常的相似的,比如:
- 在C/C++中,我們可以使用LEX和YACC來做詞法分析和語法分析
- 在Java中,我們可以使用JavaCC或ANTLR
在我們使用解析器的過程當中,通常解析器主要包括三部分,它們分別是:詞法解析、語法解析、語義解析。
3.1.1 什么詞法解析?
如何理解詞法解析呢?詞法解析我們可以這么來進行理解,在啟動詞法解析任務時,它將從左到右把字符一個個的讀取并加載到解析程序里面,然后對字節流進行掃描,接著根據構詞規則識別字符并切割成一個個的詞條,切詞的規則是遇到空格進行分割,遇到分號時結束詞法解析。比如一個簡單的SQL如下所示:
SQL示例
通過詞法解析后,結果如下所示:

3.1.2 什么是語法解析?
如何理解語法解析呢?語法解析我們可以這么來進行理解,在啟動語法解析任務時,語法分析的任務會在詞法分析的結果上將詞條序列組合成不同語法短句,組成的語法短句將與相應的語法規則進行適配,若適配成功則生成對應的抽象語法樹,否則報會拋出語法錯誤異常。比如如下SQL語句:
SQL示例
SELECT name FROM tab WHERE id=1001;
約定規則如下:

上表中,紅色的內容通常表示終結符,它們一般是大寫的關鍵字或者符號等,小寫的內容是非終結符,一般用作規則的命名,比如字段、表名等。具體AST數據結構如下圖所示:

3.1.3 什么是語義解析?
如何理解語義解析呢?語義解析我們可以這么來進行理解,語義分析的任務是對語法解析得到的抽象語法樹進行有效的校驗,比如字段、字段類型、函數、表等進行檢查。比如如下語句:
SQL示例
SELECT name FROM tab WHERE id=1001;
上述SQL語句,語義分析任務會做如下檢查:
- SQL語句中表名是否存在;
- 字段name是否存在于表tab中;
- WHERE條件中的id字段類型是否可以與1001進行比較操作。
上述檢查結束后,語義解析會生成對應的表達式供優化器去使用。
四、 如何選擇SQL解析器?
在了解了解析器的核心知識點后,如何選擇合適的SQL解析器來應用到我們的實際業務當中呢?下面,我們來對比一下主流的兩種SQL解析器。它們分別是ANTLR和Calcite。
4.1 ANTLR
ANTLR是一款功能強大的語法分析器生成器,可以用來讀取、處理、執行和轉換結構化文本或者二進制文件。在大數據的一些SQL框架里面有有廣泛的應用,比如Hive的詞法文件是ANTLR3寫的,Presto詞法文件也是ANTLR4實現的,SparkSQLambda詞法文件也是用Presto的詞法文件改寫的,另外還有HBase的SQL工具Phoenix也是用ANTLR工具進行SQL解析的。
使用ANTLR來實現一條SQL,執行或者實現的過程大致是這樣的,實現詞法文件(.g4),生成詞法分析器和語法分析器,生成抽象語法樹(也就是我常說的AST),然后再遍歷抽象語法樹,生成語義樹,訪問統計信息,優化器生成邏輯執行計劃,再生成物理執行計劃去執行。

官網示例:
ANTLR表達式
assign : ID '=' expr ';' ;
解析器的代碼類似于下面這樣:
ANTLR解析器代碼
void assign() {
match(ID);
match('=');
expr();
match(';');
}
4.1.1 Parser
Parser是用來識別語言的程序,其本身包含兩個部分:詞法分析器和語法分析器。詞法分析階段主要解決的問題是關鍵字以及各種標識符,比如INT(類型關鍵字)和ID(變量標識符)。語法分析主要是基于詞法分析的結果,構造一顆語法分析數,流程大致如下:

因此,為了讓詞法分析和語法分析能夠正常工作,在使用ANTLR4的時候,需要定義語法(Grammar)。
我們可以把字符流(CharStream),轉換成一棵語法分析樹,字符流經過詞法分析會變成Token流。Token流再最終組裝成一棵語法分析樹,其中包含葉子節點(TerminalNode)和非葉子節點(RuleNode)。具體語法分析樹如下圖所示:

4.1.2 Grammar
ANTLR官方提供了很多常用的語言的語法文件,可以進行修改后直接進行復用:
??https://github.com/antlr/grammars-v4??
在使用語法的時候,需要注意以下事項:
- 語法名稱和文件名要一致;
- 語法分析器規則以小寫字母開始;
- 詞法分析器規則以大寫字母開始;
- 用'string'單引號引出字符串;
- 不需要指定開始符號;
- 規則以分號結束;
- ...
4.1.3 ANTLR4實現簡單計算功能
下面通過簡單示例,說明ANTLR4的用法,需要實現的功能效果如下:
ANTLR示例
1+2 => 1+2=3
1+24 => 1+24=9
1+24-5 => 1+24-5=4
1+24-5+20/5 => 1+24-5+20/5=8
(1+2)*4 => (1+2)*4=12
通過ANTLR處理流程如下圖所示:

整體來說一個原則,遞歸下降。即定義一個表達式(如expr),可以循環調用直接也可以調用其他表達式,但是最終肯定會有一個最核心的表達式不能再繼續往下調用了。
步驟一:定義詞法規則文件
CommonLexerRules.g4
// 定義詞法規則
lexer grammar CommonLexerRules;
//////// 定義詞法
// 匹配ID
ID
: [a-zA-Z]+ ;
// 匹配INT
INT : [0-9]+ ;
// 匹配換行符
NEWLINE: '\n'('\r'?);
// 跳過空格、跳格、換行符
WS : [ \t\n\r]+ -> skip;
//////// 運算符
DIV:'/';
MUL:'*';
ADD:'+';
SUB:'-';
EQU:'=';
步驟二:定義語法規則文件(LibExpr.g4)
LibExpr.g4
// 定于語法規則
grammar LibExpr;
// 導入詞法規則
import CommonLexerRules;
// 詞法根
prog:stat+ EOF?;
// 定義聲明
stat:expr (NEWLINE)? # printExpr
| ID '=' expr (NEWLINE)? # assign
| NEWLINE # blank
;
// 定義表達式
expr:expr op=('*'|'/') expr # MulDiv
|expr op=('+'|'-') expr # AddSub
|'(' expr ')' # Parens
|ID # Id
|INT # Int
;
步驟三:編譯生成文件
如果是Maven工程,這里在pom文件中添加如下依賴:
ANTLR依賴JAR
<dependencies>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>antlr4</artifactId>
<version>4.9.3</version>
</dependency>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>antlr4-runtime</artifactId>
<version>4.9.3</version>
</dependency>
</dependencies>
然后,執行Maven編譯命令即可:
Maven編譯命令
步驟四:編寫簡單的示例代碼
待預算的示例文本:
示例文本
1+2
1+24
1+24-5
1+2*4-5+20/5
(1+2)*4
加減乘除邏輯類:
邏輯實現類
package com.vivo.learn.sql;
import java.util.HashMap;
import java.util.Map;
/**
* 重寫訪問器規則,實現數據計算功能
* 目標:
* 1+2 => 1+2=3
* 1+2*4 => 1+2*4=9
* 1+2*4-5 => 1+2*4-5=4
* 1+2*4-5+20/5 => 1+2*4-5+20/5=8
* (1+2)*4 => (1+2)*4=12
*/
public class LibExprVisitorImpl extends LibExprBaseVisitor<Integer> {
// 定義數據
Map<String,Integer> data = new HashMap<String,Integer>();
// expr (NEWLINE)? # printExpr
@Override
public Integer visitPrintExpr(LibExprParser.PrintExprContext ctx) {
System.out.println(ctx.expr().getText()+"="+visit(ctx.expr()));
return visit(ctx.expr());
}
// ID '=' expr (NEWLINE)? # assign
@Override
public Integer visitAssign(LibExprParser.AssignContext ctx) {
// 獲取id
String id = ctx.ID().getText();
// // 獲取value
int value = Integer.valueOf(visit(ctx.expr()));
// 緩存ID數據
data.put(id,value);
// 打印日志
System.out.println(id+"="+value);
return value;
}
// NEWLINE # blank
@Override
public Integer visitBlank(LibExprParser.BlankContext ctx) {
return 0;
}
// expr op=('*'|'/') expr # MulDiv
@Override
public Integer visitMulDiv(LibExprParser.MulDivContext ctx) {
// 左側數字
int left = Integer.valueOf(visit(ctx.expr(0)));
// 右側數字
int right = Integer.valueOf(visit(ctx.expr(1)));
// 操作符號
int opType = ctx.op.getType();
// 調試
// System.out.println("visitMulDiv>>>>> left:"+left+",opType:"+opType+",right:"+right);
// 判斷是否為乘法
if(LibExprParser.MUL==opType){
return left*right;
}
// 判斷是否為除法
return left/right;
}
// expr op=('+'|'-') expr # AddSub
@Override
public Integer visitAddSub(LibExprParser.AddSubContext ctx) {
// 獲取值和符號
// 左側數字
int left = Integer.valueOf(visit(ctx.expr(0)));
// 右側數字
int right = Integer.valueOf(visit(ctx.expr(1)));
// 操作符號
int opType = ctx.op.getType();
// 調試
// System.out.println("visitAddSub>>>>> left:"+left+",opType:"+opType+",right:"+right);
// 判斷是否為加法
if(LibExprParser.ADD==opType){
return left+right;
}
// 判斷是否為減法
return left-right;
}
// '(' expr ')' # Parens
@Override
public Integer visitParens(LibExprParser.ParensContext ctx) {
// 遞歸下調
return visit(ctx.expr());
}
// ID # Id
@Override
public Integer visitId(LibExprParser.IdContext ctx) {
// 獲取id
String id = ctx.ID().getText();
// 判斷ID是否被定義
if(data.containsKey(id)){
// System.out.println("visitId>>>>> id:"+id+",value:"+data.get(id));
return data.get(id);
}
return 0;
}
// INT # Int
@Override
public Integer visitInt(LibExprParser.IntContext ctx) {
// System.out.println("visitInt>>>>> int:"+ctx.INT().getText());
return Integer.valueOf(ctx.INT().getText());
}
}
Main函數打印輸出結果類:
package com.vivo.learn.sql;
import org.antlr.v4.runtime.tree.ParseTree;
import java.io.FileNotFoundException;
import java.io.IOException;
import org.antlr.v4.runtime.*;
/**
? 打印語法樹
*/
public class TestLibExprPrint {
// 打印語法樹 input -> lexer -> tokens -> parser -> tree -> print
public static void main(String args[]){
printTree("E:\\smartloli\\hadoop\\sql-parser-example\\src\\main\\resources\\testCase.txt");
}
/**? 打印語法樹 input -> lexer -> token -> parser -> tree
? @param fileName
*/
private static void printTree(String fileName){
// 定義輸入流
ANTLRInputStream input = null;
// 判斷文件名是否為空,若不為空,則讀取文件內容,若為空,則讀取輸入流
if(fileName!=null){
try{
input = new ANTLRFileStream(fileName);
}catch(FileNotFoundException fnfe){
System.out.println("文件不存在,請檢查后重試!");
}catch(IOException ioe){
System.out.println("文件讀取異常,請檢查后重試!");
}
}else{
try{
input = new ANTLRInputStream(System.in);
}catch(FileNotFoundException fnfe){
System.out.println("文件不存在,請檢查后重試!");}catch(IOException ioe){
System.out.println("文件讀取異常,請檢查后重試!");
}
}
// 定義詞法規則分析器
LibExprLexer lexer = new LibExprLexer(input);
// 生成通用字符流
CommonTokenStream tokens = new CommonTokenStream(lexer);
// 語法解析
LibExprParser parser = new LibExprParser(tokens);
// 生成語法樹
ParseTree tree = parser.prog();
// 打印語法樹
// System.out.println(tree.toStringTree(parser));
// 生命訪問器
LibExprVisitorImpl visitor = new LibExprVisitorImpl();
visitor.visit(tree);
}
}
執行代碼,最終輸出結果如下圖所示:

4.2 Calcite
上述ANTLR內容演示了詞法分析和語法分析的簡單流程,但是由于ANTLR要實現SQL查詢,需要自己定義詞法和語法相關文件,然后再使用ANTLR的插件對文件進行編譯,然后再生成代碼(與Thrift的使用類似,也是先定義接口,然后編譯成對應的語言文件,最后再繼承或者實現這些生成好的類或者接口)。
4.2.1 原理及優勢
而Apache Calcite的出現,大大簡化了這些復雜的工程。Calcite可以讓用戶很方便的給自己的系統套上一個SQL的外殼,并且提供足夠高效的查詢性能優化。
- query language;
- query optimization;
- query execution;
- data management;
- data storage;
上述這五個功能,通常是數據庫系統包含的常用功能。Calcite在設計的時候就確定了自己只關注綠色的三個部分,而把下面數據管理和數據存儲留給各個外部的存儲或計算引擎。
數據管理和數據存儲,尤其是數據存儲是很復雜的,也會由于數據本身的特性導致實現上的多樣性。Calcite拋棄這兩部分的設計,而是專注于上層更加通用的模塊,使得自己能夠足夠的輕量化,系統復雜性得到控制,開發人員的精力也不至于耗費的太多。
同時,Calcite也沒有重復去早輪子,能復用的東西,都是直接拿來復用。這也是讓開發者能夠接受去使用它的一個原因。比如,如下兩個例子:
- 例子1:作為一個SQL解析器,關鍵的SQL解析,Calcite沒有重復造輪子,而是直接使用了開源的JavaCC,來將SQL語句轉化為Java代碼,然后進一步轉化成一棵抽象語法樹(AST)以供下一階段使用;
- 例子2:為了支持后面會提到的靈活的元數據功能,Calcite需要支持運行時編譯Java代碼。默認的JavaC太重,需要一個更輕量級的編譯器,Calcite同樣沒有選擇造輪子,而是使用了開源了Janino方案。

上面的圖是Calcite官方給出的架構圖,從圖中我們可以獲取到的信息是,一方面印證了我們上面提到的,Calcite足夠的簡單,沒有做自己不該做的事情;另一方面,也是更重要的,Calcite被設計的足夠模塊化和可插拔。
- 【JDBC Client】:這個模塊用來支持使用JDBC Client的應用;
- 【SQL Parser and Validator】:該模塊用來做SQL解析和校驗;
- 【Expressions Builder】:用來支持自己做SQL解析和校驗的框架對接;
- 【Operator Expressions】:該模塊用來處理關系表達式;
- 【Metadata Provider】:該模塊用來支持外部自定義元數據;
- 【Pluggable Rules】:該模塊用來定義優化規則;
- 【Query Optimizer】:最核心的模塊,專注于查詢優化。
功能模塊的劃分足夠合理,也足夠獨立,使得不用完整集成,而是可以只選擇其中的一部分使用,而基本上每個模塊都支持自定義,也使得用戶能夠更多的定制系統。

上面列舉的這些大數據常用的組件都Calcite均有集成,可以看到Hive就是自己做了SQL解析,只使用了Calcite的查詢優化功能。而像Flink則是從解析到優化都直接使用了Calcite。
上面介紹的Calcite集成方法,都是把Calcite的模塊當做庫來使用。如果覺得太重量級,可以選擇更簡單的適配器功能。通過類似Spark這些框架里自定義的Source或Sink的方式,來實現和外部系統的數據交互操作。

上圖就是比較典型的適配器用法,比如通過Kafka的適配器就能直接在應用層通過SQL,而底層自動轉換成Java和Kafka進行數據交互(后面部分有個案例操作)。
4.2.2 Calcite實現KSQL查詢Kafk
參考了EFAK(原Kafka Eagle開源項目)的SQL實現,來查詢Kafka中Topic里面的數據。
1).常規SQL查詢
SQL查詢
select * from video_search_query where partition in (0) limit 10
預覽截圖:

2).UDF查詢
SQL查詢
select JSON(msg,'query') as query,JSON(msg,'pv') as pv from video_search_query where partition in (0) limit 10
預覽截圖:

4.3 ANTLR4 和 Calcite SQL解析對比
4.3.1 ANTLR4解析SQL
ANTLR4解析SQL的主要流程包含:定義詞法和語法文件、編寫SQL解析邏輯類、主服務調用SQL邏輯類。
1)..定義詞法和語法文件
可參考官網提供的開源地址:詳情
2).編寫SQL解析邏輯類
這里,我們編寫一個實現解析SQL表名的類,具體實現代碼如下所示:
解析表名
public class TableListener extends antlr4.sql.MySqlParserBaseListener {
private String tableName = null;
public void enterQueryCreateTable(antlr4.sql.MySqlParser.QueryCreateTableContext ctx) {
List<MySqlParser.TableNameContext> tableSourceContexts = ctx.getRuleContexts(antlr4.sql.MySqlParser.TableNameContext.class);
for (antlr4.sql.MySqlParser.TableNameContext tableSource : tableSourceContexts) {
// 獲取表名
tableName = tableSource.getText();
}
}
public String getTableName() {
return tableName;
}
}
3).主服務調用SQL邏輯類
對實現SQL解析的邏輯類進行調用,具體代碼如下所示:
主服務
public class AntlrClient {
public static void main(String[] args) {
// antlr4 格式化SQL
antlr4.sql.MySqlLexer lexer = new antlr4.sql.MySqlLexer(CharStreams.fromString("create table table2 select tid from table1;"));
antlr4.sql.MySqlParser parser = new antlr4.sql.MySqlParser(new CommonTokenStream(lexer));
// 定義TableListener
TableListener listener = new TableListener();
ParseTreeWalker.DEFAULT.walk(listener, parser.sqlStatements());
// 獲取表名
String tableName= listener.getTableName();
// 輸出表名
System.out.println(tableName);
}
}
4.3.2 Calcite解析SQL
Calcite解析SQL的流程相比較ANTLR是比較簡單的,開發中無需關注詞法和語法文件的定義和編寫,只需關注具體的業務邏輯實現。比如實現一個SQL的COUNT操作,Calcite實現步驟如下所示。
1).pom依賴
Calcite依賴JAR
<dependencies>
<!-- 這里對Calcite適配依賴進行封裝,引入下列包即可 -->
<dependency>
<groupId>org.smartloli</groupId>
<artifactId>jsql-client</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
2).實現代碼
Calcite示例代碼
package com.vivo.learn.sql.calcite;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.smartloli.util.JSqlUtils;
public class JSqlClient {
public static void main(String[] args) {
JSONObject tabSchema = new JSONObject();
tabSchema.put("id","integer");
tabSchema.put("name","varchar");
package com.vivo.learn.sql.calcite;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.smartloli.util.JSqlUtils;
public class JSqlClient {
public static void main(String[] args) {
JSONObject tabSchema = new JSONObject();
tabSchema.put("id","integer");
tabSchema.put("name","varchar");
JSONArray datasets = JSON.parseArray("[{\"id\":1,\"name\":\"aaa\",\"age\":20},{\"id\":2,\"name\":\"bbb\",\"age\":21},{\"id\":3,\"name\":\"ccc\",\"age\":22}]");
String tabName = "userinfo";
String sql = "select count(*) as cnt from \"userinfo\"";
try{
String result = JSqlUtils.query(tabSchema,tabName,datasets,sql);
System.out.println("result: "+result);
}catch (Exception e){
e.printStackTrace();
}
}
}
3).預覽截圖

4.3.3 對比結果

綜合對比,我們從對兩種技術的學習成本、使用復雜度、以及靈活度來對比,可以優先選擇Calcite來作為SQL解析器來處理實際的業務需求。
五、總結
另外,在單機模式的情況下,執行計劃可以較為簡單的翻譯成執行代碼,但是在分布式領域中,因為計算引擎多種多樣,因此,還需要一個更加貼近具體計算引擎的描述,也就是物理計劃。換言之,邏輯計劃只是抽象的一層描述,而物理計劃則和具體的計算引擎直接掛鉤。

滿足上述場景,通常都可以引入SQL解析器:
- 給關系型數據庫(比如MySQL、Oracle)這類提供定制化的SQL來作為交互查詢;
- 給開發人員提供了JDBC、ODBC之類和各種數據庫的標準接口;
- 對數據分析師等不太會編程語言的但又需要使用數據的人;
- 大數據技術組件不自帶SQL的;