1月22日,ECUG Con 2015在北京新疆大廈舉行。七牛雲CEO許式偉做了主題為《一周一語言》的演講,他首次對於qnlang(內部已經改名qlang,即Q語言)進行詳細的分析。作為與Go語言交互最便捷的語言,Q語言在很多場合都能夠輔助完成對系統的靈活定製需求。以下是演講實錄。
許式偉:我今天的演講話題叫一周一語言,這個議題坦白來說是標題黨,其實我重點不是講這個語言,而是講這個語言的基礎,也就是我在網上提過的一個叫TPL的東西。
qlang七天誕生記
今天要講的語言叫qlang,也就是Q語言。是在沒有依賴第三方,一步步根據Go標準庫寫出來的。事實上它的誕生真的是在七天內搞定的,這七天我每天的睡眠時間不超過三個小時。
第一天(11.27)
實現一個TPL的基礎版本,實際上2007年我寫過一個C++的版本,大家在網上可以看到。我曾經在多個場合說過,我個人從C++轉到Go語言之後唯一不能割捨的是TPL。所以我在Go裡面做這個非常有激情,這是一個長期以來積累的夙願。
第二天(11.28)
我在TPL的基礎上實現了解析器引擎,開始叫計算器引擎,其實我最後發現就是一個解析器引擎,好處是我可以做任何一個語言,讓它由這個解析器解釋執行。在這個基礎上實現一個支持四則運算和函數調用的計算器的Demo。這是很基礎的,但是這個計算器是很好的基礎,因為我後面寫的qlang,你可以認為它是從這個演化過來的。
第三天(11.29)
Go語言標準庫有理數、複數,所以我又寫了三個不同的計算器,一個是有理數,就是說任意的兩個數相加以後還是一個有理數,計算的精度特別高;還有一個是複數,最後一個是變體(variant)。所謂變體是指這個計算器支持多種類型的數據。前面幾個計算器都只支持一種數據類型,到了變體計算器(vcalc)後我可以同時有整數、浮點、字符串等等。這樣我們就有了四個計算器。然後我在支持變體的版本中加入了變量。有了變量以後看起來更像一個語言了。
第四天(11.30)
在變量的版本裡引入了map、slice的支持。支持了對象成員的訪問,支持模塊,當然最重要的是支持函數定義。在這時我們就可以自己定義函數了。
第五天(12.1)
支持函數調用(function call)、閉包(closure,可以引用父函數的變元),重構 vcalc為微內核設計,除語法結構外,所有功能都做成插件式(plugin),將功能分解到 builtin、math、strings 三個模塊,可隨意組裝。支持boolean 運算,支持 if、switch、for 控制語句,將 vcalc 改名 vlang (V 語言)。
第六天(12.2)
改名為qnlang語言,並明確該語言的優勢為與 golang 的互操作性,表現在 golang 的社區資源可以全部為我所用,無需包裝;潛在市場:如遊戲領域可以取代lua、python、javascript,作為內建腳本。語言特性上,主要是增加了類的支持。支持定義一個類,new一個對象,調用成員方法或函數。
最後一天(12.3)
支持了構造函數、可變參數,支持多返回值。其實不是支持多返回值,因為返回值是Go語言的多個值,到了qlang變成了variantslice。但是variant slice可以賦值給多個變量,所以看起來像是支持了多返回值。最後我們也支持defer。
TPL(Text ProcessingLanguage)詳解
以上這些東西的背後都和TPL有關係,所以我們今天重點講一下TPL。
文本處理語言
什麼是TPL呢?簡單來講它是文本處理語言,我將其定義為一個語言,原因是因為它有一個自己的文法。我在C++裡是用C++的語法表達了TPL的文法,因為C++有這樣的靈活性,我們可以重載各種操作符。但是在Go語言裡面我們沒法這麼做。所以TPL確確實實定義了一個類BNF文法的語言。這麼這樣的文法是誰負責解釋呢?是TPL本身。TPL首先解釋自己的BNF文法,來理解用戶表達的語義,把它編成一個TPL的內部表示,再以此來解釋用戶定義的語言,比如像qlang。
TPL本身的業務是負責把文本進行解析執行,它會有解析器,或者說文本進行一個抽取,這是它的基本功能。它是怎麼工作的呢?和大部分的文本處理模型一模一樣,分兩個階段,一個是Scanner(詞法分析),另外一個是Parser(語法分析)。詞法分析是把文本流變成Token流,變成一個一個語法的單元。語法分析是把Token變成DOM 或action 來執行。
我剛才說TPL其實是一個語言,看一看這個語言的文法是什麼樣的,如圖1代碼所示:
圖1
和編譯原理中的差不多,但是又有些細節的不同。編譯原理喜歡把G*這樣的寫法,把星號放在後面,但是我們這裡用*G,因為我們常規語言操作符喜歡用前綴。其中標紅的部分,我把它叫做標記(mark),是用來和Go語言打交道的,最後解析的時候這裡會轉為一個回調。這整個的代碼非常短,但是真的很強大。
語法解釋
我們解釋一下這個文法,如果學過編譯原理很容易理解,但是我們還是詳細的講一下。首先是基本Token。因為詞法分析過程會把輸入變成Token流,Token流中的這些Token,就是基本Token。基本Token一般是用大寫開頭的符號,比如說FLOAT、IDENT,或者用單引號或雙引號括起來的常量,這兩種都可以。比如這裡例子中的加減乘除是用單引號括起來的常量表示。
除了基本Token以外,剩下的就是一些複合規則了。
第一個是*G,反覆匹配規則G,直到無法成功匹配為止。匹配0次到N次都可以。
第二個是G1 G2 … GN,匹配的文本要求滿足序列 G1 G2 … GN。注意這些規則中間沒有操作符,當然你也可以認為空格就是操作符。
第三個是G1|G2|…|GN,要求匹配的文本滿足規則 G1 G2 ... Gn 中的任意一個。
第四個是G1 % G2,這個大家可能會有點陌生,我把它叫列表運算。從規則來看它等價於G1 *(G2 G1),也就是 G1 G2 G1 G2 … G1 這樣的序列,G1和G2不停交替,G1始終比G2多一個。這個典型的例子就是函數的參數列表。這也是為什麼我們把它叫做列表運算的來由。
第五個是G1 %= G2,它叫可選擇列表運算,等價於 ?(G1 % G2)。規則上來講是和列表運算類似,只是允許列表為空。
第六個是標記 G/mark,它用來和Go代碼建立連接,讓Go語言處理相應的標記。
這樣解釋完了以後,對著前面的例子,這個文法看起來就容易理解了。我們描述的這一系列規則就構成了類BNF文法。
解釋器
有了這樣的文法以後,我們就可以由解釋器來解釋執行。當遇到mark時做一些回調,在解釋器中,這表現為執行某個動作。解釋器要求要求使用方要有一個Fntable(函數表),這樣一道一個mark時去Fntable中查,如果有就去執行,沒有就報錯。解釋器另外一個要求是要有一個Stack(堆棧),但是它是可選的,如果這個解釋器解釋過程不需要堆棧就可以沒有。有堆棧的好處是,如果fntable中的函數第一個參數不是interpreter實例,就基於stackmachine來執行動作。我們看一下計算器的實際代碼。網上有開源的版本,是qlang寫的版本,和Go版本的機理是一樣的。前面是一系列的很普通的函數,我們跳過。再下面是Stack,這個實現也很常規,大家都知道Stack是怎麼回事。再下面是Calculator類,也就是解釋器。這個類實現了fntable和stack這兩個方法,分別返回了函數表和堆棧。最後是fntable表。這一這個表裡面的函數,可以是任意的普通數學函數,比如sin,cos都可以。這樣一個計算器就出來了。
以上就是解釋器。有了這個解釋器引擎以後,形成任意一個新語言代碼量都不會很大。包括我們後面的qlang,也只有1200行左右的代碼。
抽取器
解釋器並不是TPL的唯一用法。剛才我們介紹TPL的時候也提到了DOM抽取。完成這個事情的是抽取器。所謂抽取器,簡單來講就是從任意一個文本裡抽取大家感興趣的內容。剛才我們講在解釋器裡面mark是一個個要執行的動作,在抽取器這裡語義就不是了,它是DOM中的標籤。我們根據DOM結點的特點,把mark分成這樣四類:
第一類是葉結點(leaf mark)。它的名字必須是 xxx_Int,xxx_Float,xxx_String,xxx_Text,xxx_Char這樣的。
第二類是葉數組結點(leaf array mark)。它的名字必須是xxx_IntArray,xxx_FloatArray,...,xxx_CharArray這樣的。
第三類是非葉結點(node mark),它的名字必須是xxx_Node這樣的。
第四類是非葉數組結點(node array mark),它的名字必須是xxx_NodeArray 這樣的。
這裡 mark 的名字有些特別,前面 xxx 是結點名字,後面Int, FloatArray 這些其實是類型,類型是用來協助抽取器如何從文本裡面抽取信息的。
我們來看一個例子,用TPL的抽取器如何抽取一個浮點數組。如圖2代碼所示:
圖2
我們先看抽取的類BNF文法。和前面解釋器用的類BNF文法是一模一樣的,只是mark的命名不太一樣。doc是整個語法的根,FLOAT代表浮點數,列表運算然後緊跟一個逗號,這個要匹配的東西很清晰,就是一個浮點數列表,中間以逗號作為分隔符。拿這個抽取器文法去抽取後面的文本`1.2, 3,4, 5.6`,抽取出來的DOM是{「items」: [1.2, 3,4, 5.6]}。
理解了這個簡單的抽取器樣例後,我們看看圖3,這是一個相對複雜的例子:
圖3
前面我們介紹了計算器的實現。但實際上計算器的實現其實有兩種方法。一種是我們剛才用的解析器,第二種是基於抽取器。也就是先用抽取器把文本解析成DOM,然後再依據這個DOM再去解析執行。
tplconv 工具
在抽取器基礎上我還實現了一個tplconv工具。這是一種萬能的工具,是它能實現任意兩個文本格式之間的變換。如何做到的呢?下面是一些例子:
– tplconv class.cpp
• C++ => DOM => Json
– tplconv -ojson.htm test.ints
• IntArray=> DOM => Html
– tplconv class.cpp | jspt class.php
• C++ =>DOM => Json => Text
tplconv 工具原理
首先是輸入文件經過TPL的抽取器,轉為一個DOM樹。然後對DOM進行格式化輸出,有以下這些可能性:一種是內建的,比如json(默認是這個)。第二種是用html/template這個Go標準庫來生成Html。第三種是對接jspt。jspt是我在2007年發明的詞,全稱是 Json PHP Transformation,它的名字其實是對應 XSLT 的。這樣的好處是可以用PHP這樣擁有強大的格式化能力的語言來做輸出。實際上這個格式化輸出可以有更多,後面還可以不斷地添加。
有了tplconv,我們看到這個時候,TPL可以對接任意輸入,而且它是插件式的,任意的文件格式都可以用TPL的抽取器文法來抽取信息,然後在格式化輸出的時候也是插件式的,最終達到可以做到任意的文本格式輸入,任意的文本格式輸出。其實這個tplconv在C++版本的TPL裡面也有,但是問題是C++TPL抽取器是編譯執行而不是動態解析,所以輸入沒有辦法做到插件式。現在因為TPL抽取器是真正的在運行時解析執行,所以tplconv可以做得很強大。
TPL總結
首先,TPL用類BNF文法來描述要處理的文本格式。描述完了以後可以有兩個選擇,這兩個都是屬於TPL的擴展模塊:第一個是解析器,基於它我們可以輕鬆實現一門語言;第二個是抽取器,可以實現從文本中抽取感興趣的信息構成DOM樹。最後,我們實現了tplconv工具,可以完成任意的文本格式轉換。
qlang
介紹完TPL以後,我們把話題回到qlang的這個事情上,一開始它確實只是一個demo,做著做著我把它做成了一個語言,因為我覺得它有很大的魅力。為什麼這樣講呢?首先是它真的和Go的交互特別好,任何Go的代碼在qlang裡面基本上可以無縫執行的。這讓qlang和Go語言有非常好的交互基礎,你可以非常容易基於qlang來完成對Go語言程序的定製化。其次是它的語法和Go很像,非常精簡。從現代語言的層面來講,它的表達力也很強大。所以我個人認為它是有前途的,這也是我不是把它當demo,而是往語言角度去做的原因。
實現思路
大概講一下qlang的實現思路,實際上和前面看到的計算器沒有本質上的區別,唯一的差別是我們對mark進行了擴展,內建了兩個mark:_mute和_unmute。原來所有的mark都會對應執行一些動作,但是一旦執行了_mute以後這些mark就不再執行動作了,被忽略了。當然前提是所有非下劃線(_)開頭的mark指令都被忽略,下劃線開始的還是照常執行。這樣的好處是有些代碼是不需要一上來就執行的,比如說函數的body,if語句的body,所以這種情況下我們可以用_mute把代碼禁止執行,然後把代碼先記錄到一個變量裡面壓入堆棧。
有了這兩個以後,本質上理解了calc的實現,那麼qlang的實現其實也沒有什麼大的區別。無論多複雜的語法,像if,像函數,所有的信息你都可以理解為參數,這些參數收到以後壓入堆棧,整個語句結束的時候再執行一個總控的動作,由它來判斷整個執行邏輯應該怎樣。
後續優化思路
第一個優化思路是,我們將qlang從純解釋執行轉換成基於字節碼執行。這個已經完成了。這個實現也不費事,只要定義好指令,無非是把代碼解析執行改為翻譯成相應的指令而已。第二個是優化變量的訪問速度。在編譯執行的情況下,我們就可以算出每個變量的地址,變量的地址可以事先安排,而不是通過map查詢來做。第三個是上下文的優化,比如說常量表達式的編譯期計算、精簡冗餘指令。第四個是TPLParser本身的優化,但是它不是非常關鍵,原因是編譯過程通常只發生一次,但是執行器會反覆執行代碼。所以前面三個的優化更加重要一些。
選擇器
我們很快的過了一下qlang是如何去做的,我沒有非常詳細的展開講,因為它和計算器沒有什麼本質的區別。qlang的使用場景我覺得可以作為Go語言的嵌入式腳本,對go程序進行定製。比如說這是我在七牛內部實現的一個使用場景:它通過qlang把DOM變成另外一個DOM,也就是信息抽取。
我們看一下實際的案例,大家都可能聽過XPath,但是我個人認為XPath不足以滿足信息抽取的需求。我把這個東西叫做選擇器,因為本質上來講我在上面做的是選擇節點,然後重構節點位置,變成另外一種DOM的形式。
一個常見的信息抽取工具是XSLT,曾經在XML比較流行的時候大家會聽到。它最初可能沒有想著成為一門語言,但是後面的功能越來越強大,最後變成了語言,擁有語言所有需要的元素。所以我認為XPath不滿足信息抽取,所有的選擇器最終會變成一門語言。
圖4是qlang中的選擇器的一個樣例。這個樣例是抽取github用戶的repo列表。它輸入是doc,也就是整個htmlDOM。輸出是repo列表,每個repo會有用什麼語言,repo地址、標題、從哪裡fork過來的、最後一次更新的時間是多少等等。它的語法還是比較接近於常規選擇器像XPath。
圖4
圖5
我想證明一下選擇器最終會變成語言,所以我要講一下圖5這個例子。前面的例子是比較簡單的,它的語法看起來對語言特性的需求不大,只要有選擇節點的文法就可以了。但是圖5這個例子裡面有非常多的運算,還涉及了字符串的處理。為什麼呢?因為我們在比較複雜的網站裡面抽取東西的時候,往往會要解析文本,在某個非常小的單元裡面。這個代碼是投融資信息的網站,上面會有很多投資信息,哪家公司被什麼機構投資了,投了多少錢之類的。裡面複雜的地方是第幾輪和投了多少錢在網頁展示的時候被放到了同一個字符串裡面。這裡我們就需要把它分解開。最後我們抽取出來的是這個項目的公司名字、網站地址和公司被投資的時間、第幾輪、多少錢、投資機構是誰這些信息。這樣的實際業務場景就導致了選擇器最後不得不成為語言。
qlang應用場景
qlang的第一個應用場景是網絡爬蟲。我們今天假設我們要做去哪兒網,我們要抽取不同航空公司網站的航班信息,不同網站的結構信息肯定是不同的,航空公司非常多,這些邏輯我們可以做在爬蟲的腳本裡面實現,而不是直接在爬蟲Go程序裡面寫死。而且同一個網站的不同時期的頁面結構也會不同,考慮到這些內容,我們需要用一個腳本去描述網站的頁面信息,qlang是非常適合這樣的場景。
第二個場景是網遊,網遊裡面遊戲的策劃是不太穩定的,尤其是生命期比較長的網遊。網遊通常需要不停的調整故事的策劃,或者快速上線一個新的故事,這樣我們就要經常去改遊戲伺服器的代碼。如果我們能夠用外置的腳本來寫遊戲故事,這會是一個比較好的選擇。
提問環節
提問:我問一下為什麼是類BNF,為什麼不是BNF?有什麼差別?
許式偉:我不太好去定義什麼叫做BNF,因為有的人喜歡用冒號。網上用的比較多的是冒號,我是用等於,其實這是一個非常小的差別,所以我叫類BNF,其實就是BNF,主要的差別是有一些擴展,比如我們擴展的mark。只是這些小細節不太一樣。
提問:qlang現在做抽取器是不是特別方便?這是它最主要的使用價值麼?
許式偉:既然它是一個語言,用途只受限於使用者的想像,我自己目前用在爬蟲這個地方。
提問:如果和八爪魚和火車頭這種爬蟲工具相比怎麼樣?因為普通人用的比較簡便,這個至少還得熟悉編程。
許式偉:它是用什麼方式來抽取文本呢?肯定要用某一種文法描述你要抽取的內容,比如正則表達式。我個人覺得正則的表達能力是有限的。抽象一下,正則表達式你可以認為也是一種類XPath的東西,一種定位的方法,它沒有程序邏輯,所以像剛才我展示的那種複雜案例,它是不能實現的。
提問:做爬蟲的需求會不會出現編碼問題?因為我們國內很多語言是按照中文的。
許式偉:這個東西我認為和qlang沒有關係,其實我用的是標準庫裡面的html包,由它再額外改造成支持選擇器的東西。如果那個html包支持gbk這些字符編碼的話qlang就支持,這個具體我需要回去確認一下。
謝孟軍:補充一下,Go多語言支持的應該會很好,明年Gopher China會有Go作者過來講這一塊。
提問:比較qlang兼容go的語法,我想問一下兼容到什麼程度?會不會都支持呢?
許式偉:大家可以看一下這裡調TPL的演示,創建一個TPL的抽取器,這是在go裡面的函數,只是在go裡面是大寫開頭,我們qlang是小寫字母開頭。我這裡調的時候會把函數名改為大寫開頭然後直接調過去,所以說Go裡的代碼在qlang裡面是直接可以調的,這是qlang的最重要的優勢,可以無縫交互。我給大家看一下這個代碼的實現。這是我在qlang裡面引用TPL抽取器的代碼,可以看到就只有一行代碼,就引出了一個new函數,其他什麼都不用做。
提問:我想問一下關於想實現特別小型的,只有變量,執行順序循環,還有函數,怎麼實現和表達我想要的DOM呢?
許式偉:有兩種做法,一種是非常正規的表達方式。以我前面演示的1+2*3為例,這個DOM最頂層的是+,左邊是1,右邊是乘,下面是2和3,這就是最正規的DOM表達。我剛才展示的是DOM不是這樣的,它更接近於解釋器的執行次序,不是正規的DOM結構。這兩種DOM用TPL都是能夠做到的,只是你用TPL描述文法的時候是不這樣的。
提問:我們公司做爬蟲業務,qlang是不是支持超時的機制或者腳本執行完了,佔用的資源所有都釋放了嗎?
許式偉:這個是你可以自己控制的,把腳本對象刪除了就可以釋放。
提問:我們在qlang裡面是否支持JIT技術?qlang用在爬蟲方面,還有沒有其他的適合的應用場景?
許式偉:我們的字節碼首先和其他的JVM平臺語言不太一樣,它是更偏和go交互的支持。我們字節碼可優化的餘地很大,為什麼這樣坦白來講和第二個問題也有關係。第二個問題是qlang還能幹什麼,我自己做qlang的時候,其實是沒有非常強烈的想它的商業目的是什麼。因為大家看它的演化過程,最初它的目的只是實現TPL的一個demo,因為我覺得TPL的用途比qlang廣很多。很多場景下都需要TPL。比如說我們同事自定義了一種SQL查詢的語法,自己用TPL去解析。類似的這種場景的需求需要的是TPL,不是qlang。但是為什麼我們要花一些精力在qlang上呢?因為我覺得這個語言有它很大的獨特性,我認為它在後面的發展有機會成長。我今天講qlang不多,重點講TPL,它才是我一周能實現一個語言的原因,沒有TPL是沒有辦法做到這一點的。有了這個基礎以後,做任何新的語言其實不需要一周。大家都知道Go語言的語法很死板,不會允許出現某個語法只給小眾的人來用,其他領域的人不用它。所以我們沒辦法用Go的語法來實現DSL。所以Go很需要TPL這樣的庫來快速實現DSL。