1 問題
如何編寫一個語法解析器(Parser)呢?在C/C++語言領域,我們有lex & yacc(文法解析器和語法解析器的生成器)及其GNU移植版本flex & bison,yacc是根據大牛Knuth的LALR文法設計的,自底向上進行解析;在Java語言領域,我們有ANTLR,這是是一個基於LL(n)文法的解析器生成器(遞迴下降,向前看n個Token消解衝突)。通過這些工具,我們只要寫出要解析語言的文法、語法定義,就可以讓它們幫我們生成對應的解析器,這通常稱為編譯器的前端(後端指的是程式碼生成和指令優化),此外,還有稱為‘解析器組合子’的半自動工具可用於前端語法分析。
拋開這些工具和第三方庫,現在的問題是:給你一個HTML5檔案,如何僅使用程式語言本身的庫,編寫一個語法解析器程式呢?
首先,一個語法解析器需要文法掃描器(Lexer)提供Token序列的輸入。而文法掃描器的每個Token通常使用正規表示式來定義,對這裡的任務,我們可不想自己實現一套正規表示式引擎(重複造輪子),反之,將依賴本身就提供了正規表示式的程式語言來完成Lexer的編寫。
那麼,哪些程式語言內建正規表示式引擎呢?C沒有,C++ 11之前也沒有(可以使用Boost),C++ 11有,Java、C#、Python、Ruby、PHP、Perl則都提供了支援。這裡我選擇Python,原因無它,相比其他指令碼語言,我個人更熟悉Python。而編譯型語言處理字串則不如指令碼語言靈活。雖然無型別的Python不像C++/C#/Java那樣,有一個好的IDE及除錯環境,但記住一點:開發原型優先選擇靈活的指令碼語言,待技術實現可靠性得到驗證後,可以再移植到編譯型語言以進一步提高效能。這裡值得一說的是,上述語言均支援OOP。我想強調的是,好的OO設計風範(主要涉及類層次結構的定義和核心流程的引數傳遞)對於編寫可讀性佳、可維護性高的程式碼無疑是十分重要的。
2 程式設計思路
2.1 簡化版HTML5語法定義
首先,給出一段要解析的HTML檔案內容如下:
1 2 3 |
<!DOCTYPE html> <html><!-- this is comment--><head><title>Test</title></head> <bodystyle=”background:#000;”><div>Text Content</div></body></html> |
根據上面的簡單用例,我們的程式設計目標限定如下:它能夠處理文件型別宣告(DocType)、元素(Element)、元素屬性(Attr)、Html註釋(Comment)和普通文字(Text),暫不支援內嵌JavaScript 的<script>元素和內嵌CSS的<style>元素。也暫不考慮Unicode的解析,假設輸入檔案是純英文ASCII編碼的。
在此約束條件下,首先來定義此簡化版的HTML5語法定義:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
''' Document = DocType Node* DocType = "<!DOCTYPE" TypeName">" Node = Comment | Element | Text Comment = "<!--" ...any text without'-->'... "-->" Element = "<" TagName Attrs"/"? ">" |"<" TagName Attrs ">" Node* "</" TagName">" Text = ...any characters until '<' TagName = [a-zA-Z][a-zA-Z0-9]* Attrs = <empty> | AttrAttrs Attr = AttrName ( "=" AttrValue)? #No WShere AttrName = [a-zA-Z_][a-zA-Z0-9_\-]* AttrValue = '"' [^"]* '"' ''' |
注意,這裡沒有寫出嚴格的定義。在編寫demo程式的過程中,重要的是保持思路清晰,但不需要把細節問題一步詳細到位,只要保證細枝末節的問題可以隨時擴充套件修正即可。
2.2簡化版DOM資料結構定義
我曾經做過Java XML/DOM解析,也維護過瀏覽器核心DOM模組的程式碼,但對於我們的demo開發而言,沒必要寫一個完善的DOM類層次結構定義。儘管如此,保持簡明扼要還是很重要的。 DOM資料結構的Python程式碼如下:(Python沒有列舉型別,直接使用字串代替)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
class Node: def __init__(self, pos, type): self.type = type self.pos = pos #startposition if ref html string self.parent = None class DocType(Node): def __init__(self, pos,docType): Node.__init__(self, pos,"DocType") self.value = docType class Comment(Node): def __init__(self, pos,comment): Node.__init__(self, pos,"Comment") self.value = comment class Element(Node): def __init__(self, pos,tagName): Node.__init__(self, pos,"Element") self.tagName = tagName self.attrs = [] self.hasEndSlashMark =False #True, if <xxx .... /> self.childrenNodes = [] def addAttr(self, attr): attr.parent = self self.attrs.append(attr) def addChild(self, node): node.parent = self self. childrenNodes.append(node) class Text(Node): def __init__(self, pos, text): Node.__init__(self, pos,"Text") self.text = text class Attr(Node): def __init__(self, pos, name,value=None): Node.__init__(self, pos,"Attr") self.name = name self.value = value class Document(Node): def __init__(self, pos=0): Node.__init__(self, pos,"Document") self.docType = None self.rootElement = None |
這裡Node是所有DOM樹節點的基類,DocType、Comment、Element、Attr、Text、Document都是Node的子類。
2.3 TDD:main程式入口
前面說到,我們使用的是Python語言,讓我們追隨直覺,快速寫下main程式的啟動程式碼吧:
1 2 3 4 5 6 |
if __name__=="__main__": print "Parsing %s" %(sys.argv[1]) html_string =open(sys.argv[1]).read() P = Parser(Lexer(html_string)) P.parse() D = P.getDocument() |
從上面的程式碼可以看到,我們需要實現2個類:Lexer和Parser,一個核心方法parse,解析的結果以Document物件返回。
2.4 Lexer設計與實現
編譯原理裡提到的文法解析通常基於正則文法(有限自動機理論),然而,實際世界中使用的正規表示式引擎則支援更高階的特性,如字元類、命名捕獲、分組捕獲、後向引用等。我們這裡不關心如何實現一個基於正則文法的有限自動機,而只是使用正規表示式引擎實現Lexer。 由於Lexer的輸入為字元流,輸出為Token序列,那麼將此介面命名為nextToken。 首先,它應該帶一個模式(pattern)字串引數,代表我們期望從字元流中掃描的模式,同時,Lexer物件維護一個狀態pos,代表當前掃描的起始位置。 其次,我們給nextToken加上額外的2個引數(注意:這裡的API設計僅僅從權考慮,在正式的產品開發中,可能需要根據實際的需求做出改動):
- skipLeadingWS 代表在掃描呼叫者提供的下一個模式之前,是否先忽略前導空白字串
- groupExtract 有的時候,掃描模式有匹配之後,我們只想提取其中的部分返回,這裡根據正規表示式引擎的一般後向引用定義,0代表整個模式,而1代表第1個左圓括號對應的部分。
OK,Lexer的設計部分大抵差不多了,可以開始寫程式碼了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
class Lexer: WS =re.compile("\s{1,}", re.MULTILINE|re.DOTALL) def nextToken(self, pattern, skipLeadingWS=True, groupExtract=0): if skipLeadingWS: m= Lexer.WS.match(self.html_string, self.pos) ifm: ws_length = len(m.group(0)) self.pos = self.pos + ws_length #Python沒有+=操作 p = re.compile(pattern,re.MULTILINE|re.DOTALL) m = p.match(self.html_string, self.pos) if m: m_length= len(m.group(0)) self.pos= self.pos + m_length return(m.pos, m.group(groupExtract)) #返回值的設計:(pattern的起始位置,token字串) else: return(-1, None) |
2.4.1驗證你不瞭解的API!
請再看一下上面的函式Lexer.nextToken的API設計與實現。這裡的核心要點就是用到了Python 正規表示式庫的API PatternObject.match方法。 這裡的要點是:對於你不瞭解的API(所謂的不瞭解,就是以前你沒怎麼用過),一定要仔細閱讀該API的幫助手冊,最好是編寫簡單的單元測試case來驗證它是否能夠滿足你的需求。 事實上,我一開始使用的是PatternObject.search,而不是match方法,但是我發現了問題:
1 2 |
p=re.compile(r”^[a-z]{1,}”) p.search(“123abc”, 3) #不匹配,雖然模式使用了^,並期望與search方法的第一個起始位置引數共同作用 |
Python幫助手冊裡對此API行為居然做出了明確規定,但我不明白API這樣設計有何合理性——相當地違背直覺嘛。 山窮水盡疑無路,柳暗花明又一村。當感覺有點絕望的時候(實際上也沒那麼誇張,可以用一個方法繞過這個缺陷並仍然使用search API來完成工作,就是會有效能缺陷),再看看match方法:… 嗯?這不就是我想要的API嘛:
1 2 3 4 |
p=re.compile(r”[a-z]{1,}”) m = p.match(“abc456”) #匹配 m = p.match(“123abc456”) #不匹配 m = p.match(“123abc456”, 3) #匹配 |
糟糕的是,match API也有一個問題:m.endpos理所當然的應當返回匹配模式在源字串中的結束位置,但它實際上返回的卻是整個源字串的結束位置(也就是它的長度),還好,這個問題可以用len(m.group(0))巧妙地繞過且不影響效能。 結論:API使用內藏陷阱,請謹慎使用,使用之前先做好單元測試功能驗證。
2.5 Parser設計與實現
讓我們先寫出parse入口函式:
1 2 3 4 5 6 7 |
def parse(self): ifnot self._parseDocType(self.document): pass#Ignore try: return self._parseElement(self.document) #MUST start with <html>? except ParseFinishException, e: return True |
從這個頂層的parse()來看,一個HTML文件由一個開始的DocType節點和一個根<html>元素組成。parse()內部呼叫了2個方法:_ parseDocType和_ parseElement。注意,後2個函式名前面加了下劃線,代表私有函式,不提供外部使用(指令碼語言通常沒有C++的名字可見性概念,通常使用命名規範來達到同樣的目的)。 ParseFinishException的用法請參考2.7節說明。
2.6 驗證Lexer.nextToken:實現_parseDocType()
DocType節點的語法宣告參考2.1,下面是_parseDocType()的實現:
1 2 3 4 5 6 7 8 9 10 |
def_parseDocType(self, ctx): print"_parseDocType: enter" assert ctx.type=="Document" pos,docType = self.lexer.nextToken(r"<!DOCTYPE\s{1,}([a-z]+)>", True, 1) if docType: ctx.docType = DocType(pos, docType) return True else: print "No DOCTYPE node found." return False |
_parseDocType的程式碼完美演示了Lexer.nextToken API的用法,其形參ctx代表當前的上下文節點,比如說,解析DocType時,其ctx就是根Document物件。 這裡_parseDocType使用的掃描模式可以提取出像“<!DOCTYPE html>”中的“html”。不過,也許這裡可以放鬆條件以匹配HTML4的語法。
2.7 實現_parseComment()時的程式碼健壯性考慮
前面實現_parseDocType()時只使用了1次nextToken掃描,這裡實現_parseComment()將考慮使得程式碼更健壯一點。怎麼講呢?HTML註釋節點以“<!–”開始,以“–>”結束,中間是任意的字元(不包含連續的–>)。
如果我們的掃描模式寫成:
p=re.compile(r”<!–(.*)–>”)
則由於正規表示式的預設貪心模式匹配,它將匹配字串“<!—abc–>123–>”中的“abc–>123”,為此,可改用非貪心模式匹配:
p=re.compile(r”<!–(.*?)–>”)
這樣就行了嗎?還不行。當html字串中只有開始的<!–,沒有結束的–>時,將視為一直到文件結束都是註釋。為實現這個規約,需要補充進行一次掃描:
如果p=re.compile(r”<!–(.*?)–>”)掃描失敗,就用p=re.compile(r”<!–(.*?)$“)重新掃描一次。
2.8難點:遞迴的_parseElement()
元素節點的解析存在許多難點,比如說,需要在這裡解析元素屬性、需要遞迴地解析可能的子節點。讓我們嘗試著寫寫看吧:
1 2 3 4 5 6 7 |
def _parseElement(self, ctx): print"_parseElement: enter" pos,tagName = self.lexer.nextToken(r"<([a-zA-Z][a-zA-Z0-9]*)", True, 1) ifnot tagName: print "_parseElement: tagName not found." return False element = Element(pos, tagName) |
這裡的容錯處理邏輯是:至少當匹配了’<’及有效的tagName後,才認為找到了一個元素節點,這時可以建立一個element物件,但這時我們還不知道接下來的解析是否會成功,所以暫時不addChild到ctx父節點上。
接下來是屬性解析:
1 2 |
if not self._parseAttrs(element): return False |
如果屬性解析失敗,則_ parseElement也隨之失敗,否則將element新增到ctx上:
1 2 3 4 |
if ctx.type=="Document": ctx.rootElement = element else: ctx.addChild( element ) |
_parseAttrs函式的一個副作用是設定元素是否直接以‘/>’結束,如果是這樣,則該元素沒有進一步的子節點;否則需要進一步遞迴處理子節點的解析。
1 2 3 4 5 6 7 8 |
if element.hasEndSlashMark: return True #now try to recursive descendant parse childnodes: while True: pos, endTagName =self.lexer.nextToken(r"</([a-zA-Z][a-zA-Z0-9]*)>", True, 1) if endTagName: if endTagName==tagName: break |
由於子節點的數目定義在語法規則中是*(0個或多個),則我們需要向前看,即查詢形如</xxx>這樣的結束標籤。如果匹配到endTagName,並且等於當前元素的tagName,則遞迴解析可以結束;
否則的話,需要向上丟擲一個定製的異常,請考慮下面的case:
1 2 3 4 |
<div> <span id=”x” name=”a”> <img src=”a.jpg”> </div> |
_parseElement在解析img元素時遇到了</div>結束標籤,然後這個結束標籤與它自己並不匹配,於是,它需要通知上上層的處於解析<div>位置的_parseElement:
1 2 3 4 5 6 7 8 9 10 11 12 |
else: find_match = False n = ctx.parent while n: if n.type=="Element" and n.tagName==endTagName: find_match = True break n = n.parent if find_match: raise FindElementEndTagException(endTagName) else: pass #Ignore this unknown </xxx>! |
注意,丟擲FindElementEndTagException異常的是_parseElement,接受此異常的同樣是_parseElement,只不過兩者處於Call Stack的不同位置而已。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
pos_before= self.lexer.pos try: self._parseNode(element) except FindElementEndTagException, e: if e.endTagName==tagName: return True else: raise e pos_after = self.lexer.pos if pos_before==pos_after: if self.lexer.reachEnd(): raise ParseFinishException() else: print"_parseElement: something error happened!" raise ParseError() return True |
由於FindElementEndTagException異常由Call Stack的最底層向上丟擲,tagName與endTagName第一個匹配的_parseElement將捕獲到它。
此外,_parseElement 遞迴呼叫_parseNode的時候,我們要一對變數(pos_before和pos_after)記住Lexer的前後狀態,如果Lexer狀態沒有發生變化(pos_before==pos_after),說明_parseNode失敗。
這對應什麼情況呢?因為Node物件包含3種:Comment、Element、Text,不匹配其中任意一種的話,_parseNode就會失敗,比如說,一個單獨的‘<’。
目前demo程式以丟擲解析異常的方法結束,至於怎麼容錯處理(忽略,或仍然當作Text節點處理),留待讀者自行考慮。
如此,最難的_parseElement()函式就結束了。
3 測試
HTML解析之後是一個DOM樹,其根節點為Document物件,怎麼驗證解析得對不對呢?
把這個DOM樹重新序列化為html字串,與原始輸入進行比較即可。考慮到HTML5的容錯處理,序列化後的結果不能保證與源輸入結構上一致。
DOM樹的序列化程式碼從略,它其實就是一個針對子節點的遞迴呼叫,這裡程式碼從略(完整指令碼程式碼請參考附件)。
OK,現在HTML5語法解析器基本上已經編寫完成了。
4 結語
對於遞迴下降的語法解析器而言,重要的就是要做好“向前看”的工作,自上而下的遞迴解析相比自底向上的解析,實際效能並沒有什麼大的損失,但其程式碼結構的可理解程度就要高不少了。
感謝你的耐心閱讀,歡迎來信交流心得體會。