使用Python語言編寫簡單的HTML5語法解析器

發表於2016-01-07

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檔案內容如下:

根據上面的簡單用例,我們的程式設計目標限定如下:它能夠處理文件型別宣告(DocType)、元素(Element)、元素屬性(Attr)、Html註釋(Comment)和普通文字(Text),暫不支援內嵌JavaScript 的<script>元素和內嵌CSS的<style>元素。也暫不考慮Unicode的解析,假設輸入檔案是純英文ASCII編碼的。

在此約束條件下,首先來定義此簡化版的HTML5語法定義:

注意,這裡沒有寫出嚴格的定義。在編寫demo程式的過程中,重要的是保持思路清晰,但不需要把細節問題一步詳細到位,只要保證細枝末節的問題可以隨時擴充套件修正即可。

2.2簡化版DOM資料結構定義

我曾經做過Java XML/DOM解析,也維護過瀏覽器核心DOM模組的程式碼,但對於我們的demo開發而言,沒必要寫一個完善的DOM類層次結構定義。儘管如此,保持簡明扼要還是很重要的。 DOM資料結構的Python程式碼如下:(Python沒有列舉型別,直接使用字串代替)

這裡Node是所有DOM樹節點的基類,DocType、Comment、Element、Attr、Text、Document都是Node的子類。

2.3 TDD:main程式入口

前面說到,我們使用的是Python語言,讓我們追隨直覺,快速寫下main程式的啟動程式碼吧:

從上面的程式碼可以看到,我們需要實現2個類:Lexer和Parser,一個核心方法parse,解析的結果以Document物件返回。

2.4 Lexer設計與實現

編譯原理裡提到的文法解析通常基於正則文法(有限自動機理論),然而,實際世界中使用的正規表示式引擎則支援更高階的特性,如字元類、命名捕獲、分組捕獲、後向引用等。我們這裡不關心如何實現一個基於正則文法的有限自動機,而只是使用正規表示式引擎實現Lexer。 由於Lexer的輸入為字元流,輸出為Token序列,那麼將此介面命名為nextToken。 首先,它應該帶一個模式(pattern)字串引數,代表我們期望從字元流中掃描的模式,同時,Lexer物件維護一個狀態pos,代表當前掃描的起始位置。 其次,我們給nextToken加上額外的2個引數(注意:這裡的API設計僅僅從權考慮,在正式的產品開發中,可能需要根據實際的需求做出改動):

  1. skipLeadingWS  代表在掃描呼叫者提供的下一個模式之前,是否先忽略前導空白字串
  2. groupExtract      有的時候,掃描模式有匹配之後,我們只想提取其中的部分返回,這裡根據正規表示式引擎的一般後向引用定義,0代表整個模式,而1代表第1個左圓括號對應的部分。

OK,Lexer的設計部分大抵差不多了,可以開始寫程式碼了:

2.4.1驗證你不瞭解的API!

請再看一下上面的函式Lexer.nextToken的API設計與實現。這裡的核心要點就是用到了Python 正規表示式庫的API PatternObject.match方法。 這裡的要點是:對於你不瞭解的API(所謂的不瞭解,就是以前你沒怎麼用過),一定要仔細閱讀該API的幫助手冊,最好是編寫簡單的單元測試case來驗證它是否能夠滿足你的需求。 事實上,我一開始使用的是PatternObject.search,而不是match方法,但是我發現了問題:

Python幫助手冊裡對此API行為居然做出了明確規定,但我不明白API這樣設計有何合理性——相當地違背直覺嘛。 山窮水盡疑無路,柳暗花明又一村。當感覺有點絕望的時候(實際上也沒那麼誇張,可以用一個方法繞過這個缺陷並仍然使用search API來完成工作,就是會有效能缺陷),再看看match方法:… 嗯?這不就是我想要的API嘛:

糟糕的是,match API也有一個問題:m.endpos理所當然的應當返回匹配模式在源字串中的結束位置,但它實際上返回的卻是整個源字串的結束位置(也就是它的長度),還好,這個問題可以用len(m.group(0))巧妙地繞過且不影響效能。 結論:API使用內藏陷阱,請謹慎使用,使用之前先做好單元測試功能驗證。

2.5 Parser設計與實現

讓我們先寫出parse入口函式:

從這個頂層的parse()來看,一個HTML文件由一個開始的DocType節點和一個根<html>元素組成。parse()內部呼叫了2個方法:_ parseDocType和_ parseElement。注意,後2個函式名前面加了下劃線,代表私有函式,不提供外部使用(指令碼語言通常沒有C++的名字可見性概念,通常使用命名規範來達到同樣的目的)。 ParseFinishException的用法請參考2.7節說明。

2.6 驗證Lexer.nextToken:實現_parseDocType()

DocType節點的語法宣告參考2.1,下面是_parseDocType()的實現:

_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()

元素節點的解析存在許多難點,比如說,需要在這裡解析元素屬性、需要遞迴地解析可能的子節點。讓我們嘗試著寫寫看吧:

 

這裡的容錯處理邏輯是:至少當匹配了’<’及有效的tagName後,才認為找到了一個元素節點,這時可以建立一個element物件,但這時我們還不知道接下來的解析是否會成功,所以暫時不addChild到ctx父節點上。

接下來是屬性解析:

如果屬性解析失敗,則_ parseElement也隨之失敗,否則將element新增到ctx上:

_parseAttrs函式的一個副作用是設定元素是否直接以‘/>’結束,如果是這樣,則該元素沒有進一步的子節點;否則需要進一步遞迴處理子節點的解析。

由於子節點的數目定義在語法規則中是*(0個或多個),則我們需要向前看,即查詢形如</xxx>這樣的結束標籤。如果匹配到endTagName,並且等於當前元素的tagName,則遞迴解析可以結束;

否則的話,需要向上丟擲一個定製的異常,請考慮下面的case:

_parseElement在解析img元素時遇到了</div>結束標籤,然後這個結束標籤與它自己並不匹配,於是,它需要通知上上層的處於解析<div>位置的_parseElement:

注意,丟擲FindElementEndTagException異常的是_parseElement,接受此異常的同樣是_parseElement,只不過兩者處於Call Stack的不同位置而已。

 

由於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     結語

對於遞迴下降的語法解析器而言,重要的就是要做好“向前看”的工作,自上而下的遞迴解析相比自底向上的解析,實際效能並沒有什麼大的損失,但其程式碼結構的可理解程度就要高不少了。

感謝你的耐心閱讀,歡迎來信交流心得體會。

相關文章