從語言識別到通用SQL解析

1、前言

數倉在建設過程當中,逐步借鑑平臺開發經驗,每每也會引入CI/CD理念,須要靜態代碼掃描功能,提早識別如刪庫、刪表、改表等危險操做。此外,對於Hive等計算引擎,能夠經過Hook動態獲取血緣,可是,對於MySQL、Vertica等,沒法動態獲取血緣,須要在任務調度過程當中,靜態解析血緣。全部的這些,都須要一套通用SQL解析工具。但大數據計算引擎較多,如Hive,Spark,Impala,Vertica等,各類計算引擎都有本身的方言,且各個計算引擎使用的開發語言也不太同樣。若是要實現通用SQL解析,必須屏蔽開發語言的差別,從語法規則入手,實現通用SQL解析。下面將分別從語言識別,ANTLR使用,並以Hive SQL爲例,介紹通用SQL解析實現。html

2、語言識別器

SQL語言與咱們日常語言同樣,由語法規則及詞彙符號組成,對SQL的解析其實就是將輸入的一系列字符拆分紅詞法符號Token,並按語法規則生成一顆語法樹的過程,而對SQL的分析則是經過語法樹遍歷實現。java

語言識別過程

如上圖一條賦值語句sp = 100;,將輸入字符流拆分紅一個個不可再分的詞法符號的過程爲詞法分析,將詞法符號流轉換成語法分析樹的過程爲語法分析。node

2.1 詞法分析

詞法分析就是根據詞法規則將輸入字符拆分紅一個個不可再分的詞法符號的過程。以SQL爲例,詞法符號包括下面類型:mysql

  1. 標識符。如letter(letter|digit)*。
  2. 常量。
  3. 關鍵字。如select,from等。
  4. 分界符。如--,;等。
  5. 運算符。如<,<=,>,>=,=,+,-,*等。

詞法分析器每次讀取一個字符,在當前字符與以前的字符所屬分類不一致時,即完成一個詞法符號識別。例如,讀取'SELECT'時,第一個字符是'S',知足關鍵字和標識符的規則,第二個字符也一樣知足,以此類推,直到第7個字符是空格時再也不知足,從而完成一次詞法符號的識別。此時,'SELECT'便可認爲是關鍵字,也能夠認爲是標識符,分析器須要根據優先級來判斷。git

SELECT name, age FROM student WHERE age > 10;
複製代碼

舉個例子,上述SQL通過詞法分析後,能夠獲得以下詞彙符號:github

  • 關鍵字:SELECT,FROM,WHERE
  • 標識符:name,age,student
  • 常量:10
  • 分界符:;
  • 運算符:>

從上面描述的分析過程能夠看出,詞法分析其實就是根據已輸入字符及詞法規則不斷進行狀態轉移的過程,直到全部字符掃描完成。詞法分析實現上是先將正規表達式RE經過Thompson構造法轉換成不肯定性有窮自動機NFA,再經過子集構造法NFA轉換成肯定性有窮狀態機DFA,接着經過DFA最小化進行等價狀態合併,最終經過DFA實現字符掃描得到詞法符號。正則表達式

詞法分析過程

2.2 語法分析

經過詞法分析,能夠將輸入的字符拆分紅一個個詞法符號,這些詞法符號如何按某種規則進行組合,造成有意義的詞句就是語法分析過程。語法分析的難點在於規則處理以及分支的選擇,還有遞歸調用以及複雜的計算表達式。在實現上主要有自頂向下分析以及自底向上分析兩種算法,下面會詳細介紹。redis

2.2.1 上下文無關文法

上下文無關法是一種規則用來約定一個語言的語法規則,由四個部分組成:算法

  • 一個終結符號集合;
  • 一個非終結符號集合;
  • 一個產生式列表;
  • 一個開始符號。

好比說一個算數表達式文法:spring

S -> E
E -> E + E
E -> E - E
E -> (E) | num
複製代碼

在'->'左部稱爲產生式頭部(左部),在'->'右部的稱爲產生式體(右部)。全部的產生式頭部都是一個非終結符號。非終結符號描述的是一種抽象規則,終結符號描述的是一個具體的事物。上面示例中,SE非終結符號,其餘是終結符號

假設有下面文法:

S → AB
A → aA|a
B → b
複製代碼

用上述文法推導字符串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)算法。

3、經常使用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是最佳選擇,下面會着重介紹。

4、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. 匹配優先級

詞法規則在匹配時,若是輸入串可以被多個詞法規則匹配到,那麼聲明在前面的規則優先生效。

  1. 詞法模式

詞法模式容許將詞法規則按上下文分組,詞法分析器以默認模式開始,除非使用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. 語法規則動做、語義判斷、匹配優先級

在語法規則中,一樣支持相似詞法規則動做和語義判斷,匹配優先級與詞法規則也相同。

  1. 結合性

在作加減乘除四則運算時,都是從左向右結合,但在作指數運算時,確是從右向左結合,此時須要用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,沒法傳遞參數。訪問器帶有固定類型的返回值,能夠用來共享數據,但因類型固定,所以使用上較爲受限。

  1. 類成員在事件方法中共享數據

不管是訪問器仍是監聽器,都採用深度優先遍歷方式訪問語法樹,每每會使用棧來存儲中間數據。下面咱們以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語法樹遍歷上具體介紹。

5、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;
  }
  // 其餘部分省略
  ...
}
複製代碼

6、參考文獻

相關文章
相關標籤/搜索