作者:vivo 互联网搜索团队- Deng Jie
一、布景
跟着技能的不断的发展,在大数据领域呈现了越来越多的技能结构。而为了下降大数据的学习本钱和难度,越来越多的大数据技能和运用开端支撑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示例
SELECT name FROM tab;
经过词法解析后,成果如下所示:

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官方供给了很多常用的言语的语法文件,能够进行修改后直接进行复用:github.com/antlr/gramm…
在运用语法的时分,需求留意以下事项:
- 语法称号和文件名要共同;
- 语法剖析器规矩以小写字母开端;
- 词法剖析器规矩以大写字母开端;
- 用’string’单引号引出字符串;
- 不需求指定开端符号;
- 规矩以分号结束;
- …
4.1.3 ANTLR4完成简略核算功用
下面经过简略示例,说明ANTLR4的用法,需求完成的功用作用如下:
ANTLR示例
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
经过ANTLR处理流程如下图所示:

整体来说一个准则,递归下降。即界说一个表达式(如expr),能够循环调用直接也能够调用其他表达式,可是终究肯定会有一个最中心的表达式不能再继续往下调用了。
进程一:界说词法规矩文件(CommonLexerRules.g4)
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编译命令
mvn generate-sources
进程四:编写简略的示例代码
待预算的示例文本:
示例文本
1+2
1+2*4
1+2*4-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");
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的;
参阅资料:
- github.com/smartloli/E…
- github.com/antlr/antlr…
- github.com/antlr/gramm…
- github.com/apache/calc…