PEG.js 介紹與基礎使用

HSunboy發表於2018-11-14

寫在前面

在介紹 PEG.js 之前,我們先來說下我們為什麼需要它。

假如有這樣一個場景:使用者輸入一句簡單的 sql 查詢語句 select name from user , 我們的任務是獲取到裡面的列名表名。在現有工具下,我們第一個想到的就是正規表示式,熟悉的同學應該能很快寫出這樣的正規表示式:

/^select\s+([A-Za-z_]*)\s+from\s+([A-Za-z_]*)$/.exec("select name from user")
//["select t from", "name", "user"]
複製程式碼

完成了之後發現正規表示式稍微有點長,但是整體結構還是蠻簡單的。不過仔細測試發現,我們的正規表示式考慮的還不是很周全,例如: select * from user , select user.name from user 這兩種情況我們都沒考慮到,所以我們還需要完善一下正規表示式

/^select\s+([A-Za-z_]*|\*|[A-Za-z_]*.[A-Za-z_]*)\s+from\s+([A-Za-z_]*)$/.exec("select user.name from user")
//["select user.name from user", "user.name", "user"]
複製程式碼

很明顯,正規表示式已經比較長了,看過去會有一點眼花,可是,這條正規表示式其實並不完善,例如沒有考慮到多列名的情況 select name, age from user ,和多表的情況 select user.name, class.name from user,class ,假如繼續擴充套件下去,我們的正規表示式將會變得十分龐大,並且當別人來修改自己的程式碼的時候,也會變得十分困難,久而久之,就會變得難以維護。

我們仔細思考一下上面的流程,其實正規表示式主要幫助我們做了一件事,那就是將一條 sql 語句轉化為結構化的資料 ["select t from", "name", "user"] ,從而我們根據結構化的資料來獲取自己想要的資訊。

PEG.js 介紹與基礎使用
這裡的正規表示式就是一個用來解析 sql 語句的 parser ,那麼有沒有一種更加簡便可維護的工具能用來編寫 parser 呢? PEG.js 就是一個很適合我們的工具。 不過,它和正規表示式還是有一點區別。

PEG.js 介紹與基礎使用

PEG.js 是一個 parser generator ,它會將我們用 PEG.js 語法編寫的 sql 語法檔案轉化為可以直接執行的 parser , 而我們也是通過這個parser去解析 sql 語句。所以我們的工作就是編寫 sql 語法檔案。

安裝

首先,我們將 PEG.js 安裝到全域性下

npm install -g pegjs
複製程式碼

安裝好了可以驗證一下

$ pegjs -v
PEG.js 0.10.0
複製程式碼

然後我們在新建一個目錄用來存放我們程式碼。本文的結構如下

PEG.js 介紹與基礎使用
peg資料夾用來存放我們的語法檔案, dist 資料夾用來存放我們生成的 parser。建立完專案之後,我們下面就開始來正式使用 PEG.js 來解析 sql。

開始使用

首先,我們先確定一下我們要實現的 select 語句,本文為了便捷,不會編寫完善的語法檔案,所以這裡以最簡單的 select 語句為例子。

select
    column_name | *
from
    table_name;
複製程式碼

開始編寫 PEG 檔案

在 PEG.js 檔案的開頭,我們來寫下第一個規則 ,PEG.js 預設從第一個規則開始解析。

start
= selectStatement
複製程式碼

這裡我們定義了一個 start 規則,而這個 start解析表示式由一個 selectStatement 規則組成,而這個 selectStatement 就是我們剛剛定義的 select 語句了。

那麼按照我們之前的定義,selectStatement 又由什麼組成呢?

經過一些思考,我們應該能寫下以下規則

selectStatement
= select colunm_clause from table_name ';'
複製程式碼

selectStatement 的解析表示式由5個部分組成,其中,';'代表一個分號字串,來代表一條 sql 語句的結尾,而其他4個則代表 selectStatement 解析表示式的四個組成部分。所以,我們只要從 selectStatement 開始自頂向下 ,為每一個規則新增解析表示式就好了。

selectStatement
= select colunm_clause from table_name ';'

colunm_clause
= column_name
/ '*'

column_name
= ident_part+

table_name
= ident_part+

ident_part
= [A-Za-z0-9]

select
= 'select'i

from
= 'from'i
複製程式碼

這裡 colunm_clause 的解析表示式有一個 / ,它代表 的意思,意思是 colunm_clause 是由 column_name 或者 一個 * 組成。 除此之外,還可以發現,有很多語法和 JS 的正規表示式是相似的,比如

  • [A-Za-z0-9]
  • ident_part+
  • 'from'i

分別代表的意思是

  • 匹配字母和數字
  • 匹配1個或多個 ident_part 規則
  • 忽略 from 的大小寫

編寫完了之後我們嘗試將這個語法檔案編譯成 JS 可以使用的 parser

pegjs -o dist/selectParser.js peg/select.peg
複製程式碼

我們這裡指定了一個編譯輸出檔案 dist/selectParser.js 和待編譯的語法檔案 peg/select.peg ,執行結束後,我們可以在 dist 資料夾中看見看見我們的 parser 檔案 selectParser.js

生成 parser 檔案之後就是使用它了

const selectParser=require("./dist/selectParser.js");
console.log(selectParser.parse("select name from user;"))
複製程式碼

這裡呼叫了 parser 檔案的 parse 方法來開始解析,不過,我們卻得到了一個錯誤

> node index.js
SyntaxError: Expected "*" or [A-Za-z0-9] but " " found.
複製程式碼

這裡的意思是,parser希望收到 "*"[A-Za-z0-9] ,不過卻得到了一個空字串。回到我們的語法解析檔案開頭

selectStatement
= select colunm_clause from table_name ';'
複製程式碼

只有 colunm_clause 是由 "*"[A-Za-z0-9] 組成的,那看來是 parser 把我們的輸入 select name from user;selectname 中間的空格當作 colunm_clause 來解析了,導致報錯,所以我們需要再完善一下原來的語法檔案,加入空格的解析。

selectStatement
= select _ colunm_clause _ from _ table_name __ ';'

__ 
= whitespace*
_ 
= whitespace+

whitespace
= [ \t\r\n];
複製程式碼

現在我們再次編譯執行,我們得到了正確的輸出

> node index.js
[ 'select',
  [ ' ' ],
  [ 'n', 'a', 'm', 'e' ],
  [ ' ' ],
  'from',
  [ ' ' ],
  [ 'u', 's', 'e', 'r' ],
  [],
  ';' 
]
複製程式碼

現在結構是解析出來了,不過,展示上卻不是非常的美觀,比如 nameuser 被拆分成了字元陣列,這是因為我們在 table_namecolumn_name 中使用了 + ,所以輸出的時候,也會變成陣列輸出;還有,我們其實並不需要空白字元,但是結果裡卻也包含了它 [ ' ' ]。因此,我們需要再處理一下匹配的資料。

selectStatement
= select _ colunm_clause:colunm_clause _ from _ table_name:table_name __ ';'{ return `column_name=${colunm_clause}, table_name=${table_name}`}

column_name
= name:ident_part+ {return name.join("")}

table_name
= name:ident_part+ {return name.join("")}
複製程式碼

這裡我們用到了 PEG.js 的 action ,我們可以在 aciton 裡面寫 JS 程式碼,它會在規則匹配成功的時候執行,同時,我們也給規則取了名字(例如 name:ident_part+ 中的 name),以便於我們在 action 中使用。

現在,再次執行編譯執行過程,不出意外,我們可以得到以下輸出

> node index.js
column_name=name, table_name=user
複製程式碼

這樣,一個最最基礎的 select 語句解析就完成了~我們可以錦上添花,讓它支援多條 sql 語句。完整程式碼如下:

PEG

start
= selectStatements:selectStatement* 
{ 
    return selectStatements.join("\n")
}

selectStatement
= select _ colunm_clause:colunm_clause _ from _ table_name:table_name __ ';'{ return `column_name=${colunm_clause}, table_name=${table_name}`}

colunm_clause
= column_name
/ '*'

column_name
= name:ident_part+ {return name.join("")}

table_name
= name:ident_part+ {return name.join("")}

ident_part
= [A-Za-z0-9]

select
= 'select'i

from
= 'from'i

__ = whitespace*
_ = whitespace+

whitespace
= [ \t\r\n];

複製程式碼

JS

const addParser=require("./dist/addParser.js");
const selectParser=require("./dist/selectParser.js");
const sqls=[
    'select * from user;',
    'select name from user;',
    'select id from user;'
].join("")
console.log(selectParser.parse(sqls))
複製程式碼

執行

> node index.js
column_name=*, table_name=user
column_name=name, table_name=user
column_name=id, table_name=user
複製程式碼

回過頭來,我們發現,藉助 PEG.js 編寫的 parser 很容易維護,整個語法的描述都是有結構的,並且藉助於 action ,我們可以很方便的返回我們所需要的結構。

和正規表示式相比,唯一的缺點就是 PEG.js 生成的 parser 更佔空間,載入上相對慢一些,不過,我們開發效率和程式碼的可維護性也有了比較大的提升,綜合比較下, PEG.js 還是一個很不錯的解決方案。

相關文章