从语言识别到通用SQL解析

2,301 阅读21分钟

一、前言

数仓在建设过程中,逐步借鉴平台开发经验,往往也会引入CI/CD理念,需要静态代码扫描功能,提前识别如删库、删表、改表等危险操作。此外,对于Hive等计算引擎,可以通过Hook动态获取血缘,但是,对于MySQL、Vertica等,无法动态获取血缘,需要在任务调度过程中,静态解析血缘。所有的这些,都需要一套通用SQL解析工具。但大数据计算引擎较多,如Hive,Spark,Impala,Vertica等,各种计算引擎都有自己的方言,且各个计算引擎使用的开发语言也不太一样。如果要实现通用SQL解析,必须屏蔽开发语言的差异,从语法规则入手,实现通用SQL解析。下面将分别从语言识别,ANTLR使用,并以Hive SQL为例,介绍通用SQL解析实现。

二、语言识别器

SQL语言与我们平常语言一样,由语法规则及词汇符号组成,对SQL的解析其实就是将输入的一系列字符拆分成词法符号Token,并按语法规则生成一颗语法树的过程,而对SQL的分析则是通过语法树遍历实现。

语言识别过程

如上图一条赋值语句sp = 100;,将输入字符流拆分成一个个不可再分的词法符号的过程为词法分析,将词法符号流转换成语法分析树的过程为语法分析。

2.1 词法分析

词法分析就是根据词法规则将输入字符拆分成一个个不可再分的词法符号的过程。以SQL为例,词法符号包括下面类型:

  1. 标识符。如letter(letter|digit)*。
  2. 常量。
  3. 关键字。如select,from等。
  4. 分界符。如--,;等。
  5. 运算符。如<,<=,>,>=,=,+,-,*等。

词法分析器每次读取一个字符,在当前字符与之前的字符所属分类不一致时,即完成一个词法符号识别。例如,读取'SELECT'时,第一个字符是'S',满足关键字和标识符的规则,第二个字符也同样满足,以此类推,直到第7个字符是空格时不再满足,从而完成一次词法符号的识别。此时,'SELECT'即可认为是关键字,也可以认为是标识符,分析器需要根据优先级来判断。

SELECT name, age FROM student WHERE age > 10;

举个例子,上述SQL经过词法分析后,可以得到如下词汇符号:

  • 关键字:SELECT,FROM,WHERE
  • 标识符:name,age,student
  • 常量:10
  • 分界符:;
  • 运算符:>

从上面描述的分析过程可以看出,词法分析其实就是根据已输入字符及词法规则不断进行状态转移的过程,直到所有字符扫描完成。词法分析实现上是先将正规表达式RE通过Thompson构造法转换成不确定性有穷自动机NFA,再通过子集构造法NFA转换成确定性有穷状态机DFA,接着通过DFA最小化进行等价状态合并,最终通过DFA实现字符扫描获得词法符号。

词法分析过程

2.2 语法分析

通过词法分析,可以将输入的字符拆分成一个个词法符号,这些词法符号如何按某种规则进行组合,形成有意义的词句就是语法分析过程。语法分析的难点在于规则处理以及分支的选择,还有递归调用以及复杂的计算表达式。在实现上主要有自顶向下分析以及自底向上分析两种算法,下面会详细介绍。

2.2.1 上下文无关文法

上下文无关法是一种规则用来约定一个语言的语法规则,由四个部分组成:

  • 一个终结符号集合;
  • 一个非终结符号集合;
  • 一个产生式列表;
  • 一个开始符号。

比如说一个算数表达式文法:

S -> E
E -> E + E
E -> E - E
E -> (E) | num

在'->'左部称为产生式头部(左部),在'->'右部的称为产生式体(右部)。所有的产生式头部都是一个非终结符号。非终结符号描述的是一种抽象规则,终结符号描述的是一个具体的事物。上面示例中,SE非终结符号,其他是终结符号

假设有下面文法:

S → AB
A → aA|a
Bb

用上述文法推导字符串aab过程如下:

S → AB → aAB → aaB → aab

2.2.2 推导和规约

  • 推导:从开始符号出发,利用文法推导给定的字符串,即用产生式的右部替换产生式的左部。如上面示例:
S → AB → aAB → aaB → aab
  • 归约:规约是推导的逆过程,就是把字符串变成非终结符,再把非终结符变成非终结符,不断进行直到能到根节点。同样以上面字符串为例:
aab → aAb → Ab → AB → S       

总是选择最左非终结串进行替换的推导为最左推导,总是选择最右非终结符进行替换的推导为最右推导,也叫规范推导。推导过程一定对应一颗语法树,但推导过程可能不唯一,对应的语法树也可能不唯一。

规约作为推导的逆过程,最右推导的逆过程称为最左规约,也即规范规约最左推导的逆过程称为最右规约

还以上面的文法和字符串为例,说明一下推导和规约流程: 推导规约示例

2.2.3 LL分析与LR分析

  • LL分析:从语句最左侧符号开始读取,从左向右,使用最左推导,直到达到终结符或者报错退出。第一个L含义为从左向右读取字符,第二个L含义为使用最左推导。表现上就是从文法规则到语句字符串的分析过程,即自顶向下的处理流程。
  • LR分析:从语句最左侧符号开始读取,从左向右,使用最右推导(最左规约)的分析方法。第一个L含义为从左向右读取字符,第二个R含义为使用最右推导。表现上就是从语句字符串到文法规则的分析过程,即自底向上的处理流程。

在LL分析中,通常有预测/输出匹配供语法分析器选择,其中:

  • 预测/输出:根据最左侧的非终结符以及一系列向前看词汇符号,确定当前输入下匹配概率最高的产生式,并输出或者进行其他动作。
  • 匹配:根据输入最左侧未被匹配的符号,来匹配上一阶段所预测的产生式。

以上述aab为例,使用LL(1)分析过程如下,其中1表示每次向前读取一个字符,

Production       Input              Action  
--------------------------------------------------------- 
S                aab                Predict S -> AB
AB               aab                Predict A -> aA
aAB              aab                Match a
AB               ab                 Predict A -> a
aB               ab                 Match a
B                b                  Predict B -> b
b                b                  Match b
                                    Accept

在LR分析中,通常有移入规约两个动作供语法分析器选择,其中:

  • 移入:将当前被指向的词汇符号放入到缓冲区(通常为栈)中。
  • 规约:通过将产生式与缓冲区中一个或多个符号进行逆向匹配,将该符号串转换为对应产生式中的非终结符号。

同样以上述aab为例,使用LR(1)分析过程如下:

Buffer           Input              Action
---------------------------------------------------------
                 aab                Shift
a                ab                 Shift
aa               b                  Reduce A -> a
aA               b                  Reduce A -> aA
A                b                  Shift
Ab                                  Reduce B -> b
AB                                  Reduce S -> AB
S                                   Accept

从上面处处理流程差异可以看出,LLLR有下面区别:

  1. LL是自上而下的分析过程,从文法规则出发,根据产生式推导给定的符号串,用的是推导。LR是自下而上的分析过程,从给定的符号串规约到文法的符号,用的是规约。
  2. LRLL效率更高,没有左递归及二义性,如E -> E + E这种规则,应用LL时将会有递归产生。
  3. LR生成的代码与LL相比,过于晦涩难懂。JavaCC是LL(1)算法,ANTLR是LL(n, n>=1)算法。

三、常用SQL解析器对比

当前,对于SQL的解析工具主要有两大类:

  • 通过手工编写Parser,典型代表如SQL Parser(Druid中的一个模块),JSQLParser等。
  • 通过语法解析工具生成Parser的自定义语法类型解析器,典型代表如ANTLR,JavaCC等。

两者Parser在生成上的差异性,决定了他们在使用上的差异性:

  • 性能上:手工编写的Parser,可以做各种优化,性能要远高于工具生成的Parser。比如,SQL Parser,性能比ANTLR、JavaCC工具生成的Parser快10倍甚至100倍以上。
  • 语法支持上:两者均支持多种语法,但工具生成的Parser实现更容易,支持也更多。比如SQL Parser,对于Oracle、Hive、DB2等只支持常见的DML和DDL。
  • 可读性和可维护性:以ANTLR为例,其语法与代码解耦,可读性更好,在新增语法时,只需要简单修改一下语法文件即可实现。而SQL Parser将语法规则与代码耦合,可读性较差,很少的语法变更就需要改动大量代码。
  • 语法树遍历上:SQL Parser采用Visitor模式将抽象语法树完全封装,外围程序无法直接访问抽象语法树,在无需完全遍历树时,代码比较繁琐。而ANTLR支持visitor和listenor访问方式,可以控制语法树的遍历。

当然,对于自定义语法类型解析器,ANTLR和JavaCC,两者在功能上差不多,但ANLTR更丰富一点,且跨语言,而JavaCC只能在Java中使用。

显然,从语法支持上,可读性和可维护性,语法树遍历上,ANTLR是最佳选择,下面会着重介绍。

四、ANTLR

ANTLR是Another Tool for Language Recognition的简写,是一个用Java语言编写的识别器工具。它能够自动生成解析器,并将用户编写的ANTLR语法规则直接生成目标语言的解析器,它能够生成Java、Go、C等语言的解析器客户端。

ANTLR所生成的解析器客户端将输入的文本生成抽象语法树,并提供遍历树的接口,以访问文本的各个部分。ANTLR的实现与前文所讲述的词法分析与语法分析是一致的。词法分析器根据词法规则做词法单元的拆分;语法分析器对词法单元做语义分析,并对规则进行优化以及消除左递归等操作。

ANTLR的安装使用可参考官网

4.1 语法和词法规则

4.1.1 语法文件结构

在ANTLR中,语法文件以.g4结尾,如果语法规则和文法规则放在一个文件中,针对Name语法文件名为Name.g4,如果语法文件和词法文件单独放,则语法文件必须命名为NameParser.g4,词法文件必须命名为NameLexer.g4。一个基本语法文件结构如下: 语法文件规则

  • grammar:指定了语法名。纯语法文件声明使用parser grammar Name;,纯词法文件声明使用lexer grammar Name;
  • options:预留功能。
  • tokens:声明词法符号,存在意义在于语法文件中可能未定义词法符号,但在语法文件中要使用,一般和action配合使用。
  • actionName:在语法规则之外使用动作,用于目标语言中,对于JAVA目前有header何members,如果期望只在词法分析器中使用,使用@lexer::name,如果期望只在语法分析器中使用,使用@parser::name
    • header:定义类文件头,比如嵌入java的package、import声明。
    • members:定义类文件内容,比如类成员、方法。

4.1.2 语法文件示例

为了区分语法和词法规则,首字母小写的为语法规则首字母大写的为词法规则。以josn语法文件示例,语法名为JSON,文件名为JSON.g4,具体内容如下:

// 指定语法名
grammar JSON;

// 一条语法规则
json
   : value
   ;

// 带有多个备选分支的语法规则
// 对于'true',为隐式定义的词法符号
// 备选分支中#代表标签,可以生成更加精确的监听器事件,
// 一条规则中的备选分支要么全部带上标签,要么全部不带标签
value
   : STRING       # ValueString
   | NUMBER       # ValueNumber
   | obj          # ValueObj
   | arr          # ValueArr
   | 'true'       # ValueTrue
   | 'false'      # ValueFalse
   | 'null'       # ValueNull
   ;

obj
   : '{' pair (',' pair)* '}'
   | '{' '}'
   ;

pair
   : STRING ':' value
   ;

arr
   : '[' value (',' value)* ']'
   | '[' ']'
   ;

// 一条词法规则
// 和普通的正则表达式类似,可以使用通配符,|表示或,*表示出现0次或以上
// ?表示出现0次或1次,+表示出现1次或以上,~表示取反,?同样支持通配符的贪婪与非贪婪模式
STRING
   : '"' (ESC | SAFECODEPOINT)* '"'
   ;

// 使用fragment修饰的词法规则,该标识表示该词法规则不能单独应用于语法规则中,只能作为词法规则的一个词法片段
fragment ESC
   : '\\' (["\\/bfnrt] | UNICODE)
   ;

fragment UNICODE
   : 'u' HEX HEX HEX HEX
   ;

fragment HEX
   : [0-9a-fA-F]
   ;

fragment SAFECODEPOINT
   : ~ ["\\\u0000-\u001F]
   ;

NUMBER
   : '-'? INT ('.' [0-9] +)? EXP?
   ;

fragment INT
   : '0' | [1-9] [0-9]*
   ;

fragment EXP
   : [Ee] [+\-]? INT
   ;

// 隐藏通道,用于将不需要关注的如注释、空格等发送到隐藏通道中,需要使用时使用ANLTR的API获取
WS
   : [ \t\n\r] + -> skip
   ;

以下面josn文本为例:

{
    "string":"字符串",
    "num":2,
    "obj":{
        "arr":[
            "English",
            "中文",
            123,
            -12.45,
            23.64e+3,
            true,
            "abc{db}def",
            {

            }
        ]
    }
}

通过词法分析器,可以将输入字符转换成词法符号流:

[@0,0:0='{',<'{'>,1:0]
[@1,6:13='"string"',<STRING>,2:4]
[@2,14:14=':',<':'>,2:12]
[@3,15:19='"字符串"',<STRING>,2:13]
[@4,20:20=',',<','>,2:18]
[@5,26:30='"num"',<STRING>,3:4]
[@6,31:31=':',<':'>,3:9]
[@7,32:32='2',<NUMBER>,3:10]
[@8,33:33=',',<','>,3:11]
[@9,39:43='"obj"',<STRING>,4:4]
[@10,44:44=':',<':'>,4:9]
[@11,45:45='{',<'{'>,4:10]
[@12,55:59='"arr"',<STRING>,5:8]
[@13,60:60=':',<':'>,5:13]
[@14,61:61='[',<'['>,5:14]
[@15,75:83='"English"',<STRING>,6:12]
[@16,84:84=',',<','>,6:21]
[@17,98:101='"中文"',<STRING>,7:12]
[@18,102:102=',',<','>,7:16]
[@19,116:118='123',<NUMBER>,8:12]
[@20,119:119=',',<','>,8:15]
[@21,133:138='-12.45',<NUMBER>,9:12]
[@22,139:139=',',<','>,9:18]
[@23,153:160='23.64e+3',<NUMBER>,10:12]
[@24,161:161=',',<','>,10:20]
[@25,163:166='true',<'true'>,11:0]
[@26,167:167=',',<','>,11:4]
[@27,181:192='"abc{db}def"',<STRING>,12:12]
[@28,193:193=',',<','>,12:24]
[@29,211:211='{',<'{'>,17:12]
[@30,226:226='}',<'}'>,19:12]
[@31,236:236=']',<']'>,20:8]
[@32,242:242='}',<'}'>,21:4]
[@33,244:244='}',<'}'>,22:0]
[@34,246:245='<EOF>',<EOF>,23:0]

通过语法分析器,可以实现将词法符号转换为语法树: json示例

4.1.3 常见词法规则

  1. 匹配优先级 词法规则在匹配时,如果输入串能够被多个词法规则匹配到,那么声明在前面的规则优先生效。

  2. 词法模式 词法模式允许将词法规则按上下文分组,词法分析器以默认模式开始,除非使用mode指令指定,否则都处于默认模式下。比如对XML的分析,标签体内,需要切割出多个属性等,标签体外,整体文本当作一个标签体。

<<rules in default mode>>
...
mode MODE1;
<<rules in MODE1>>
...
mode MODE2;
<<rules in MODE2>>
...
mode MODEN;
<<rules in MODEN>>

以XMLLexer.g4片段为例:

// 遇到'<',进入INSIDE模式
OPEN        :   '<'                     -> pushMode(INSIDE) ;


// INSIDE模式词汇规则定义
mode INSIDE;
// 遇到'>',退出INSIDE模式
CLOSE       :   '>'                     -> popMode ;
SLASH       :   '/' ;
EQUALS      :   '=' ;
STRING      :   '"' ~[<"]* '"'
            |   '\'' ~[<']* '\''
            ;
  1. 词法规则动作 词法分析器在匹配到一条词法规则后会生成一个词法符号对象,如果期望在匹配过程中修改词法符号类型,可以通过词法规则动作来实现。
ENUM : 'enum' {if (!enumIsKeyword) setType(Identifier);};
  1. 语义判断 在词法分析过程中,常常需要动态地开启和关闭词法符号,此时可以通过语义判断来实现。
ENUM : 'enum' {java5}? ;
ID : [a-zA-Z]+

比如在java 1.5版本之前,enum只是一个标识符,可以用来定义变量,在1.5版本之后,enum被用作关键字,如果用1.5版本之后的语法规则去编译1.5版本之前的代码,会编译失败,此时,通过语义判断可以实现词法规则的关闭,当java5值为true时,打开该词法规则,否则会关闭该词法规则。注意,因ENUMID两条均可以匹配enum这个输入串,如前面所述,必须将ENUM放在前面,让词法分析器优先匹配。

4.1.4 常见语法规则

  1. 备选分支标签 ANTLR根据语法文件生成的用于语法树分析的监听器中,每个语法规则都会创建一个方法,但对于一条规则有多个备选分支时,使用较为不便,可以给每个备选分支增加分支标签,这样在生成监听器时,每个备选分支都会生成一个方法。上面的JSON语法文件中value规则为例:
// 使用备选分支生成的源码
public class JSONBaseListener implements JSONListener {
	@Override public void enterJson(JSONParser.JsonContext ctx) { }
	@Override public void exitJson(JSONParser.JsonContext ctx) { }
	
	// 使用备选分支标签时,不对规则生成方法,只对标签生成方法
	@Override public void enterValueString(JSONParser.ValueStringContext ctx) { }
	@Override public void exitValueString(JSONParser.ValueStringContext ctx) { }
	@Override public void enterValueNumber(JSONParser.ValueNumberContext ctx) { }
	@Override public void exitValueNumber(JSONParser.ValueNumberContext ctx) { }
    
    ...
	
	@Override public void visitTerminal(TerminalNode node) { }
	@Override public void visitErrorNode(ErrorNode node) { }
}

// 未使用备选分支生成的源码
public class JSONBaseListener implements JSONListener {
	@Override public void enterJson(JSONParser.JsonContext ctx) { }
	@Override public void exitJson(JSONParser.JsonContext ctx) { }
    
    ...
	
	// 未使用备选分支标签时,只对规则生成了方法
	@Override public void enterValue(JSONParser.ValueContext ctx) { }
	@Override public void exitValue(JSONParser.ValueContext ctx) { }
	
	...

	@Override public void visitTerminal(TerminalNode node) { }
	@Override public void visitErrorNode(ErrorNode node) { }
}
  1. 语法规则动作、语义判断、匹配优先级 在语法规则中,同样支持类似词法规则动作和语义判断,匹配优先级与词法规则也相同。

  2. 结合性 在做加减乘除四则运算时,都是从左向右结合,但在做指数运算时,确是从右向左结合,此时需要用assoc来手动指定结合性。这样输入2^3^4就会被识别成2^(3^4),语法规则如下:

expr : <assoc=right> expr '^' xpr
     | INT
     ;

4.2 错误报告

默认情况下,ANTLR将所有的错误消息送至标准错误输出,同时,ANTLR也提供了ANTLRErrorListener来改变这些消息的目标输出以及样式。该接口有一个同时用于词法分析器和语法分析器的syntaxError()方法。ANTLRErrorListener接口方法较多,ANTLR提供了BaseErrorListener类作为其基类实现,在使用时,只要重写该接口并修改ANTLR的错误监听器即可。

来看下ANTLR的源码:

public class ConsoleErrorListener extends BaseErrorListener {
  public static final ConsoleErrorListener INSTANCE = new ConsoleErrorListener();

  @Override
  public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line, 
    int charPositionInLine, String msg, RecognitionException e) {
    // 向控制台输出错误信息
    System.err.println("line " + line + ":" + charPositionInLine + " " + msg);
  }
}


public abstract class Recognizer<Symbol, ATNInterpreter extends ATNSimulator> {
  ...
  // 默认使用控制台错误监听器
  private List<ANTLRErrorListener> _listeners = new CopyOnWriteArrayList<ANTLRErrorListener>() {{
    add(ConsoleErrorListener.INSTANCE);
  }};
  ...
}

在使用时,为了更好地展示错误消息,可以重写报错方法,如下面SyntaxErrorListener。此外,在发生词法或者语法错误时,ANTLR具有一定修复手段,保证解析可以继续执行,但在某些情况下,比如SQL语句,或者Shell脚本,语法发生错误时,后续都不应该再执行,因此,在监听到语法或者词法错误时,可以通过抛出异常来终止解析过程。

public class SyntaxErrorListener extends BaseErrorListener {

  @Override
  public void syntaxError(Recognizer<?, ?> recognizer, Object offendingSymbol, int line,
      int charPositionInLine, String msg, RecognitionException e) {
    List<String> stack = ((Parser) recognizer).getRuleInvocationStack();
    Collections.reverse(stack);
    SyntaxException exception = new SyntaxException("line " + line + ":" + charPositionInLine + " at " + offendingSymbol + ": " + msg, e);
    exception.setLine(line);
    exception.setCol(charPositionInLine);
    exception.setSymbol(String.valueOf(offendingSymbol));
    
    throw exception;
  }

}

4.3 语法树遍历

ANTLR提供两种遍历树机制,即监听器和访问器。

4.3.1 监听器

监听器类似于XML解析器生成的SAX文档对象,SAX监听器接收类似于startDocument()和endDocument()的事件通知。一个监听器的方法实际上就是回调函数,ANTLR会深度优先遍历语法树,在进入或者离开节点时会触发回调函数。以一条简单的赋值语法为例:

grammar Stat;

stat : assign;
assign : 'sp' '=' expr ';';
expr : Expr;

Expr : [1-9][0-9]*;
WS : [ \t\n\r] + -> skip;

ANTLR生成的监听器UML图如下:

监听器

StatListener接口提供了所有语法规则进入(enter)、离开(exit)时回调的抽象方法,StatBaseListener类则对所有接口做了默认实现,使用时只需要继承StatBaseListener类,重写关注的方法接口即可。

sp = 100;为例,ANTLR对其遍历顺序如下:

监听器深度优先遍历

API调用顺序

4.3.2 访问器

访问器同样采用深度优先遍历方式遍历语法树,与监听器不同的是访问器采用显示调用方式访问节点,因此遍历过程可以控制。

访问器UML图如下:

访问器

StatVisitor接口提供了所有语法规则的抽象方法,如果想访问特定的语法规则,只需调用对应的接口即可。当然,ANTLR同样提供了默认实现类StatBaseVisitor,使用时只要继承该类即可。此外,从方法定义上也可以看出,每个方法均有返回值,只是返回值类型固定,约束较大。

对于sp = 100;,访问器遍历顺序如下:

访问器遍历流程

4.3.3 数据传递机制

在语法树遍历过程中,我们往往需要传递数据,在事件方法中,目前有三种共享信息的方法。

  1. 使用方法返回值 从访问器监听器实现原理上可以看出,监听器采用的是回调方式,因此返回值都为void,无法传递参数。访问器带有固定类型的返回值,可以用来共享数据,但因类型固定,因此使用上较为受限。

  2. 类成员在事件方法中共享数据 无论是访问器还是监听器,都采用深度优先遍历方式访问语法树,往往会使用栈来存储中间数据。下面我们以JSON语法树的遍历为例,介绍下类成员在事件方法中的使用,同时介绍下访问器监听器的具体使用。

有时为了存储便利,配置信息往往以JSON格式存储,在使用时需要转换成properties文件。下面使用上文提到的JSON.g4语法,将下面的JSON数据转换成标准的properties文件,考虑通用性,会去掉语法文件中语法规则的备选分支标签。

{
    "spring":{
        "datasource":{
            "driver-class-name":"com.mysql.cj.jdbc.Driver",
            "jdbc-url":"jdbc:mysql://127.0.0.1:3306/db",
            "username":"root",
            "password":"password",
            "type":"com.zaxxer.hikari.HikariDataSource",
            "hikari":{
                "pool-name":"HikariCP",
                "minimum-idle":5,
                "maximum-pool-size":50,
                "idle-timeout":600000,
                "max-lifetime":1800000
            }
        },
        "redis":{
            "database":0,
            "host":"127.0.0.1",
            "port":6379,
            "password":"123456"
        }
    }
}

转换后的properties文件内容如下:

spring.redis.database = 0
spring.redis.password = 123456
spring.redis.host = 127.0.0.1
spring.redis.port = 6379
spring.datasource.hikari.pool-name = HikariCP
spring.datasource.password = password
spring.datasource.driver-class-name = com.mysql.cj.jdbc.Driver
spring.datasource.hikari.idle-timeout = 600000
spring.datasource.username = root
spring.datasource.hikari.maximum-pool-size = 50
spring.datasource.hikari.max-lifetime = 1800000
spring.datasource.jdbc-url = jdbc:mysql://127.0.0.1:3306/db
spring.datasource.type = com.zaxxer.hikari.HikariDataSource
spring.datasource.hikari.minimum-idle = 5

监听器实现如下:

public class MyListener extends JSONBaseListener {

  /**
   * 键片段
   */
  private Stack<String> keys = new Stack<>();

  /**
   * 属性
   */
  @Getter
  private Map<String, String> prop = new HashMap<>();

  @Override
  public void enterValue(ValueContext ctx) {
    if (ctx.arr() != null || ctx.obj() != null) {
      return;
    }
    if (ctx.STRING() != null) {
      addProp(ctx.getText().substring(1, ctx.getText().length() - 1));
    } else {
      addProp(ctx.getText());
    }
  }

  @Override
  public void enterPair(PairContext ctx) {
    String text = ctx.STRING().getText();
    keys.push(text.substring(1, text.length() - 1));
  }

  @Override
  public void exitPair(PairContext ctx) {
    keys.pop();
  }

  private String getKey() {
    StringBuilder sb = new StringBuilder();
    for (String key : keys) {
      if (StringUtils.isNotBlank(key)) {
        sb.append(key.trim()).append(".");
      }
    }
    if (sb.length() == 0) {
      return "";
    }
    sb.deleteCharAt(sb.length() - 1);
    return sb.toString();
  }

  private void addProp(String value) {
    String key = getKey();
    // 存在同名配置时做简单处理,使用最后一次读取内容覆盖
    prop.put(key, value);
  }
}

public static void transformByListener(String json) {
  CharStream input = CharStreams.fromString(json);
  // 词法解析器将字符流转换为词法符号流
  JSONLexer lexer = new JSONLexer(input);
  CommonTokenStream tokens = new CommonTokenStream(lexer);
  // 语法解析器将词法符号流转换成语法树
  JSONParser parser = new JSONParser(tokens);
  ParserRuleContext context = parser.json();
  // 监听器实现语法树遍历
  ParseTreeWalker walker = new ParseTreeWalker();
  MyListener listener = new MyListener();
  walker.walk(listener, context);
  Map<String, String> prop = listener.getProp();
  for (String key : prop.keySet()) {
    System.out.println(key + " = " + prop.get(key) );
  }
}

访问器实现如下:

public class MyVisitor extends JSONBaseVisitor<Void> {

  /**
   * 键片段
   */
  private Stack<String> keys = new Stack<>();

  /**
   * 属性
   */
  @Getter
  private Map<String, String> prop = new HashMap<>();

  @Override
  public Void visitValue(ValueContext ctx) {
    if (ctx.obj() != null) {
      visitObj(ctx.obj());
    } else if (ctx.arr() != null) {
      visitArr(ctx.arr());
    } else if (ctx.STRING() != null) {
      addProp(ctx.getText().substring(1, ctx.getText().length() - 1));
    } else {
      addProp(ctx.getText());
    }
    return null;
  }

  @Override
  public Void visitObj(ObjContext ctx) {
    List<PairContext> pairs = ctx.pair();
    if (pairs != null && pairs.size() > 0) {
      pairs.forEach(this::visitPair);
    }
    return null;
  }

  @Override
  public Void visitPair(PairContext ctx) {
    String text = ctx.STRING().getText();
    keys.push(text.substring(1, text.length() - 1));
    visitValue(ctx.value());
    keys.pop();
    return null;
  }

  private String getKey() {
    StringBuilder sb = new StringBuilder();
    for (String key : keys) {
      if (StringUtils.isNotBlank(key)) {
        sb.append(key.trim()).append(".");
      }
    }
    if (sb.length() == 0) {
      return "";
    }
    sb.deleteCharAt(sb.length() - 1);
    return sb.toString();
  }

  private void addProp(String value) {
    String key = getKey();
    // 存在同名配置时做简单处理,使用最后一次读取内容覆盖
    prop.put(key, value);
  }

}


public static void transformByVisitor(String json) {
  CharStream input = CharStreams.fromString(json);
  // 词法解析器将字符流转换为词法符号流
  JSONLexer lexer = new JSONLexer(input);
  CommonTokenStream tokens = new CommonTokenStream(lexer);
  // 语法解析器将词法符号流转换成语法树
  JSONParser parser = new JSONParser(tokens);
  ParserRuleContext context = parser.json();
  // 访问器实现语法树遍历
  MyVisitor visitor = new MyVisitor();
  visitor.visit(context);
  Map<String, String> prop = visitor.getProp();
  for (String key : prop.keySet()) {
    System.out.println(key + " = " + prop.get(key));
  }
}
  1. 对语法分析树的节点进行标注来存储相关数据 从ANTLR生成的代码可以看出,对于每条语法规则,都会生成一个上下文类,因此可以通过该类对象共享数据。例如:
e returns [int value]
  : e '*' e
  | e '+' e
  | INT
  ;
  
public static class EContext extends ParserRuleContext {
  public int value;
  ...
}

这种方式会将语法与特定的编程语言绑定而丧失灵活性。从思路上讲,无非就是实现了节点与值的关联,对此,ANTLR针对JAVA还提供了ParseTreeProperty辅助类,用于维护节点与值的关系,如何使用将会在后面SQL语法树遍历上具体介绍。

五、SQL解析实现

回到本文一开始提到的SQL解析,无论是哪种方言,只要找到语法文件,根据需要对语法文件进行定制化改造,并实现语法树遍历逻辑,即可实现输入输出表解析,血缘解析等功能。

下面以Hive SQL 2.x版本为例,简单介绍一下。Hive 2.x版本的语法文件在Hive源码中采用了ANTLR 3.x版本实现,语法文件和代码文件耦合性较强,需要使用 4.x版本规则进行改造,改造好的语法文件见Hive SQL 2.x语法文件

该语法文件也存在下面问题:

  1. 不支持保留字,这在低版本语法中是支持的,如Hive 2.1.1版本。
  2. 不支持set参数,add jar命令,这虽然在原生Hive 中也是不支持的,但是在做SQL解析时,传入的往往是多条SQL,可能带有上述命令,支持后可避免过滤,也可实现更加丰富的功能,比如解析SQL时,可以告知输入输出表在文件中的行列号等。

5.1 语法文件改造

  1. 支持保留字
  • IdentifiersParser.g4文件在最下方增加保留字词法规则
// The following SQL2011 reserved keywords are used as identifiers in many q tests, they may be added back due to backward compatibility.
// We are planning to remove the following whole list after several releases.
// Thus, please do not change the following list unless you know what to do.
sql11ReservedKeywordsUsedAsIdentifier
    :
    KW_ALL | KW_ALTER | KW_ARRAY | KW_AS | KW_AUTHORIZATION | KW_BETWEEN | KW_BIGINT | KW_BINARY | KW_BOOLEAN
    | KW_BOTH | KW_BY | KW_CREATE | KW_CUBE | KW_CURRENT_DATE | KW_CURRENT_TIMESTAMP | KW_CURSOR | KW_DATE | KW_DECIMAL | KW_DELETE | KW_DESCRIBE
    | KW_DOUBLE | KW_DROP | KW_EXISTS | KW_EXTERNAL | KW_FALSE | KW_FETCH | KW_FLOAT | KW_FOR | KW_FULL | KW_GRANT
    | KW_GROUP | KW_GROUPING | KW_IMPORT | KW_IN | KW_INNER | KW_INSERT | KW_INT | KW_INTERSECT | KW_INTO | KW_IS | KW_LATERAL
    | KW_LEFT | KW_LIKE | KW_LOCAL | KW_NONE | KW_NULL | KW_OF | KW_ORDER | KW_OUT | KW_OUTER | KW_PARTITION
    | KW_PERCENT | KW_PROCEDURE | KW_RANGE | KW_READS | KW_REVOKE | KW_RIGHT
    | KW_ROLLUP | KW_ROW | KW_ROWS | KW_SET | KW_SMALLINT | KW_TABLE | KW_TIMESTAMP | KW_TO | KW_TRIGGER | KW_TRUE
    | KW_TRUNCATE | KW_UNION | KW_UPDATE | KW_USER | KW_USING | KW_VALUES | KW_WITH
// The following two keywords come from MySQL. Although they are not keywords in SQL2011, they are reserved keywords in MySQL.
    | KW_REGEXP | KW_RLIKE
    | KW_PRIMARY
    | KW_FOREIGN
    | KW_CONSTRAINT
    | KW_REFERENCES
    ;

  • IdentifiersParser.g4文件标识符支持SQL保留字
identifier
    : Identifier
    | nonReserved
    // 新增,Hive 2.1.1版本支持保留字作为标识符,当前的2.3.8后续版本已不支持,因此需要加上
    | sql11ReservedKeywordsUsedAsIdentifier
    ;
  1. 针对set参数、add jar改造 set参数值样式非常丰富,已有的词法规则并不满足,如果以语法规则形式支持,需要对词法规则做大量改造,我们采用投机取巧方式,使用通道实现set及add jar语法的过滤。在HiveLexer.g4增加下面规则:
// 增加动作,指定header
@lexer::header {
import java.util.Iterator;
import java.util.LinkedList;
}


// 增加动作,用于检测set,add行为
@lexer::members {

  public static int CHANNEL_SET_PARAM = 2;

  public static int CHANNEL_USE_JAR = 3;

  private LinkedList<Token> selfTokens = new LinkedList<>();

  @Override
  public void emit(Token token) {
    this._token = token;
    if (token != null) {
      selfTokens.add(token);
    }
  }

  @Override
  public void reset() {
    super.reset();
    this.selfTokens.clear();
  }

  public boolean isStartCmd() {
    Iterator<Token> it = this.selfTokens.descendingIterator();
    while (it.hasNext()) {
      Token previous = it.next();
      if (previous.getType() == HiveLexer.WS || previous.getType() == HiveLexer.LINE_COMMENT
          || previous.getType() == HiveLexer.SHOW_HINT || previous.getType() == HiveLexer.HIDDEN_HINT
          || previous.getType() == HiveLexer.QUERY_HINT) {
        continue;
      }
      return previous.getType() == HiveLexer.SEMICOLON;
    }
    return true;
  }

}

// 增加词法规则,检测SET参数操作
SET_PARAM
    : {isStartCmd()}? KW_SET (~('='|';'))+ '=' (~(';'))+ -> channel(2)
    ;

// 增加词法规则,检测add jar操作
ADD_JAR
    : {isStartCmd()}? KW_ADD (~(';'))+ -> channel(3)
    ;

5.2 语法树遍历实现

public class HiveTableVisitor extends HiveParserBaseVisitor<Void> {

  @Setter
  private String curDb;

  /**
   * 当前SQL解析出的实体
   */
  private ParseTreeProperty<List<Entity>> curProp = new ParseTreeProperty<>();

  // 其他部分省略
  ...

  @Override
  public Void visitStatement(StatementContext ctx) {
    if (ctx.execStatement() == null) {
      return null;
    }
    visitExecStatement(ctx.execStatement());
    addProp(ctx, curProp.get(ctx.execStatement()));
    return null;
  }

  // 切换数据库
  @Override
  public Void visitSwitchDatabaseStatement(SwitchDatabaseStatementContext ctx) {
    String db = ctx.identifier().getText();
    this.curDb = trimQuota(db);
    return null;
  }

  // 删除表操作
  @Override
  public Void visitDropTableStatement(DropTableStatementContext ctx) {
    TableNameContext fullCtx = ctx.tableName();
    Opt opt = new Opt(OptType.DROP, ctx.getStart().getLine(), ctx.getStart().getCharPositionInLine());
    addProp(ctx, buildTbl(fullCtx, opt));
    return null;
  }

  // 查询操作
  @Override
  public Void visitAtomSelectStatement(AtomSelectStatementContext ctx) {
    if (ctx.fromClause() != null) {
      visitFromClause(ctx.fromClause());
      Opt opt = new Opt(OptType.SELECT, ctx.getStart().getLine(), ctx.getStart().getCharPositionInLine());
      fillOpt(opt, curProp.get(ctx.fromClause()));
      addProp(ctx, curProp.get(ctx.fromClause()));
    } else if (ctx.selectStatement() != null) {
      visitSelectStatement(ctx.selectStatement());
      addProp(ctx, curProp.get(ctx.selectStatement()));
    }
    return null;
  }

  @Override
  public Void visitTableSource(TableSourceContext ctx) {
    TableNameContext fullCtx = ctx.tableName();
    addProp(ctx, buildTbl(fullCtx));
    return null;
  }

  private Entity buildTbl(TableNameContext fullCtx, Opt opt) {
    Entity entity = buildTbl(fullCtx);
    entity.setOpt(opt);
    return entity;
  }

  private Entity buildTbl(TableNameContext fullCtx) {
    Tbl tbl;
    if (fullCtx.DOT() != null) {
      IdentifierContext dbCtx = fullCtx.identifier().get(0);
      IdentifierContext tblCtx = fullCtx.identifier().get(1);
      tbl = new Tbl(
          Db.buildDb(
              curDb,
              trimQuota(dbCtx.getText()),
              dbCtx.getStart().getLine(),
              dbCtx.getStart().getCharPositionInLine()
          ),
          trimQuota(tblCtx.getText()),
          tblCtx.getStart().getLine(),
          tblCtx.getStart().getCharPositionInLine()
      );
    } else {
      IdentifierContext tblCtx = fullCtx.identifier().get(0);
      Integer line = tblCtx.getStart().getLine();
      Integer col = tblCtx.getStart().getCharPositionInLine();
      tbl = new Tbl(
          Db.buildDb(curDb, null, line, col),
          trimQuota(tblCtx.getText()),
          line,
          col
      );
    }
    return new Entity(Type.TBL).setTbl(tbl);
  }

  private void fillOpt(Opt opt, Entity entity) {
    if (entity == null || entity.getOpt() != null) {
      return;
    }
    entity.setOpt(opt);
  }

  private void fillOpt(Opt opt, List<Entity> entities) {
    if (entities == null || entities.size() == 0) {
      return;
    }
    for (Entity entity : entities) {
      if (entity.getOpt() != null) {
        continue;
      }
      entity.setOpt(opt);
    }
  }

  private String trimQuota(String name) {
    if (name == null || name.length() <= 2) {
      return name;
    }
    char start = name.charAt(0);
    char end = name.charAt(name.length() - 1);
    if (start == '`' && end == '`') {
      name = name.substring(1, name.length() - 1).replaceAll("``", "`");
    }
    return name;
  }
  // 其他部分省略
  ...
}

六、参考文献