做爲Java世界最好的HTML 解析庫,Jsoup的parser實現很是具備表明性。這部分也是Jsoup最複雜的部分,須要一些數據結構、狀態機乃至編譯器的知識。好在HTML語法不復雜,解析只是到DOM樹爲止,因此做爲編譯器入門卻是挺合適的。這一塊不要期望囫圇吞棗,咱們仍是泡一杯咖啡,細細品味其中的奧妙吧。javascript
將計算機語言轉化爲另外一種計算機語言(一般是更底層的語言,例如機器碼、彙編、或者JVM字節碼)的過程就叫作編譯(compile)。編譯器(Compiler)是計算機科學的一個重要領域,已經有不少年曆史了,而最近各類通用語言層出不窮,加上跨語言編譯的興起、DSL概念的流行,都讓編譯器變成了一個很時髦的東西。html
編譯器領域相關有三本公認的經典書籍,龍書《Compilers: Principles, Techniques, and Tools 》,虎書《Modern Compiler Implementation in X (X表示各類語言)》,鯨書《Advanced Compiler Design and Implementation》。其中龍書是編譯理論方面公認的不二之選,然後面兩本則對實踐更有指導意義。另外@裝配腦殼有個很好的編譯器入門系列博客:http://www.cnblogs.com/Ninputer/archive/2011/06/07/2074632.html前端
編譯器的基本流程以下:java
其中詞法分析、語法分析、語義分析這部分又叫編譯器的前端(front-end),而此後的中間代碼生成直到目標生成、優化等屬於編譯器的後端(back-end)。編譯器的前端技術已經很成熟了,也有yacc這樣的工具來自動進行詞法、語法分析(Java裏也有一個相似的工具ANTLR),然後端技術更加複雜,也是目前編譯器研究的重點。git
說了這麼多,回到我們的HTML上來。HTML是一種聲明式的語言,能夠理解它的最終的輸出是瀏覽器裏圖形化的頁面,而並不是可執行的目標語言,所以我將這裏的Translate改成了Render。github
在Jsoup(包括相似的HTML parser)裏,只作了Lex(詞法分析)、Parse(語法分析)兩步,而HTML parse最終產出結果,就是DOM樹。至於HTML的語義解析以及渲染,不妨看看攜程UED團隊的這篇文章:《瀏覽器是怎樣工做的:渲染引擎,HTML解析》。web
Jsoup的詞法分析和語法分析都用到了狀態機。狀態機能夠理解爲一個特殊的程序模型,例如常常跟咱們打交道的正則表達式就是用狀態機實現的。正則表達式
它由狀態(state)和轉移(transition)兩部分構成。根據狀態轉移的可能性,狀態機又分爲DFA(肯定有限狀態機)和NFA(非肯定有限狀態自動機)。這裏拿一個最簡單的正則表達式"a[b]*「做爲例子,咱們先把它映射到一個狀態機DFA,大概是這樣子:編程
狀態機自己是一個編程模型,這裏咱們嘗試用程序去實現它,那麼最直接的方式大概是這樣:後端
public void process(StringReader reader) throws StringReader.EOFException {
char ch;
switch (state) { case Init: ch = reader.read(); if (ch == 'a') { state = State.AfterA; accum.append(ch); } break; case AfterA: ... break; case AfterB: ... break; case Accept: ... break; } }
這樣寫簡單的狀態機倒沒有問題,可是複雜狀況下就有點難受了。還有一種標準的狀態機解法,先創建狀態轉移表,而後使用這個表創建狀態機。這個方法的問題就是,只能作純狀態轉移,沒法在代碼級別操做輸入輸出。
Jsoup裏則使用了狀態模式來實現狀態機,初次看到時,確實讓人眼前一亮。狀態模式是設計模式的一種,它將狀態和對應的行爲綁定在一塊兒。而在狀態機的實現過程當中,使用它來實現狀態轉移時的處理再合適不過了。
「a[b]*「的例子的狀態模式實現以下,這裏採用了與Jsoup相同的方式,用到了枚舉來實現狀態模式:
public class StateModelABStateMachine implements ABStateMachine {
State state; StringBuilder accum; enum State { Init { @Override public void process(StateModelABStateMachine stateModelABStateMachine, StringReader reader) throws StringReader.EOFException { char ch = reader.read(); if (ch == 'a') { stateModelABStateMachine.state = AfterA; stateModelABStateMachine.accum.append(ch); } } }, Accept { ... }, AfterA { ... }, AfterB { ... }; public void process(StateModelABStateMachine stateModelABStateMachine, StringReader reader) throws StringReader.EOFException { } } public void process(StringReader reader) throws StringReader.EOFException { state.process(this, reader); } }
PS:我在github上fork了一份Jsoup的代碼,把這系列文章提交了上去,而且給一些代碼增長了中文註釋,有興趣的能夠看看https://github.com/code4craft/jsoup-learning。本文中提到的幾種狀態機的完整實如今這個倉庫的https://github.com/code4craft/jsoup-learning/tree/master/src/main/java/us/codecraft/learning路徑下。
先介紹如下parser包裏的主要類:
Parser
Jsoup parser的入口facade,封裝了經常使用的parse靜態方法。能夠設置maxErrors
,用於收集錯誤記錄,默認是0,即不收集。與之相關的類有ParseError
,ParseErrorList
。基於這個功能,我寫了一個PageErrorChecker
來對頁面作語法檢查,並輸出語法錯誤。
Token
保存單個的詞法分析結果。Token是一個抽象類,它的實現有Doctype
,StartTag
,EndTag
,Comment
,Character
,EOF
6種,對應6種詞法類型。
Tokeniser
保存詞法分析過程的狀態及結果。比較重要的兩個字段是state
和emitPending
,前者保存狀態,後者保存輸出。其次還有tagPending
/doctypePending
/commentPending
,保存尚未填充完整的Token。
CharacterReader
對讀取字符的邏輯的封裝,用於Tokenize時候的字符輸入。CharacterReader包含了相似NIO裏ByteBuffer的consume()
、unconsume()
、mark()
、rewindToMark()
,還有高級的consumeTo()
這樣的用法。
TokeniserState
用枚舉實現的詞法分析狀態機。
HtmlTreeBuilder
語法分析,經過token構建DOM樹的類。
HtmlTreeBuilderState
語法分析狀態機。
TokenQueue
雖然披了個Token的馬甲,實際上是在query的時候用到,留到select部分再講。
如今咱們來說講HTML的詞法分析過程。這裏借用一下http://ued.ctrip.com/blog/?p=3295裏的圖,圖中描述了一個Tag標籤的狀態轉移過程,
這裏忽略了HTML註釋、實體以及屬性,只保留基本的開始/結束標籤,例以下面的HTML:
<div>test</div>
Jsoup裏詞法分析比較複雜,我從裏面抽取出了對應的部分,就成了咱們的miniSoupLexer(這裏省略了部分代碼,完整代碼能夠看這裏MiniSoupTokeniserState
):
enum MiniSoupTokeniserState implements ITokeniserState {
/**
* 什麼層級都沒有的狀態
* ⬇
* <div>test</div>
* ⬇
* <div>test</div>
*/
Data {
// in data state, gather characters until a character reference or tag is found public void read(Tokeniser t, CharacterReader r) { switch (r.current()) { case '<': t.advanceTransition(TagOpen); break; case eof: t.emit(new Token.EOF()); break; default: String data = r.consumeToAny('&', '<', nullChar); t.emit(data); break; } } }, /** * ⬇ * <div>test</div> */ TagOpen { ... }, /** * ⬇ * <div>test</div> */ EndTagOpen { ... }, /** * ⬇ * <div>test</div> */ TagName { ... }; }
參考這個程序,能夠看到Jsoup的詞法分析的大體思路。分析器自己的編寫是比較繁瑣的過程,涉及屬性值(區分單雙引號)、DocType、註釋、HTML實體,以及一些錯誤狀況。不過了解了其思路,代碼實現也是循序漸進的過程。
最近生活上有點忙,女兒總是半夜不睡,精神狀態也不是很好。工做上的事情也談不上順心,有不少想法可是沒有幾個被承認,有些事情也不是說代碼寫得好就行的。算了,仍是端正態度,畢竟資歷尚淺,我仍是繼續個人。
讀Jsoup源碼並不是無聊,目的實際上是爲了將webmagic作的更好一點,畢竟parser也是爬蟲的重要組成部分之一。讀了代碼後,收穫也很多,對HTML的知識也更進一步了。
這裏單獨將TreeBuilder
部分抽出來叫作語法分析過程可能稍微不妥,其實就是根據Token生成DOM樹的過程,不過我仍是沿用這個編譯器裏的稱呼了。
TreeBuilder
一樣是一個facade對象,真正進行語法解析的是如下一段代碼:
protected void runParser() {
while (true) { Token token = tokeniser.read(); process(token); if (token.type == Token.TokenType.EOF) break; } }
TreeBuilder
有兩個子類,HtmlTreeBuilder
和XmlTreeBuilder
。XmlTreeBuilder
天然是構建XML樹的類,實現頗爲簡單,基本上是維護一個棧,並根據不一樣Token插入節點便可:
@Override protected boolean process(Token token) { // start tag, end tag, doctype, comment, character, eof switch (token.type) { case StartTag: insert(token.asStartTag()); break; case EndTag: popStackToClose(token.asEndTag()); break; case Comment: insert(token.asComment()); break; case Character: insert(token.asCharacter()); break; case Doctype: insert(token.asDoctype()); break; case EOF: // could put some normalisation here if desired break; default: Validate.fail("Unexpected token type: " + token.type); } return true; }
insertNode
的代碼大體是這個樣子(爲了便於展現,對方法進行了一些整合):
Element insert(Token.StartTag startTag) { Tag tag = Tag.valueOf(startTag.name()); Element el = new Element(tag, baseUri, startTag.attributes); stack.getLast().appendChild(el); if (startTag.isSelfClosing()) { tokeniser.acknowledgeSelfClosingFlag(); if (!tag.isKnownTag()) // unknown tag, remember this is self closing for output. see above. tag.setSelfClosing(); } else { stack.add(el); } return el; }
相比XmlTreeBuilder
,HtmlTreeBuilder
則實現較爲複雜,除了相似的棧結構之外,還用到了HtmlTreeBuilderState
來構建了一個狀態機來分析HTML。這是爲何呢?不妨看看HtmlTreeBuilderState
到底用到了哪些狀態吧(在代碼中中用 標明狀態):
<!-- State: Initial --> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <!-- State: BeforeHtml --> <html lang='zh-CN' xml:lang='zh-CN' xmlns='http://www.w3.org/1999/xhtml'> <!-- State: BeforeHead --> <head> <!-- State: InHead --> <script type="text/javascript"> //<!-- State: Text --> function xx(){ } </script> <noscript> <!-- State: InHeadNoscript --> Your browser does not support JavaScript! </noscript> </head> <!-- State: AfterHead --> <body> <!-- State: InBody --> <textarea> <!-- State: Text --> xxx </textarea> <table> <!-- State: InTable --> <!-- State: InTableText --> xxx <tbody> <!-- State: InTableBody --> </tbody> <tr> <!-- State: InRow --> <td> <!-- State: InCell --> </td> </tr> </table> </html>
這裏能夠看到,HTML標籤是有嵌套要求的,例如<tr>
,<td>
須要組合<table>
來使用。根據Jsoup的代碼,能夠發現,HtmlTreeBuilderState
作了如下一些事情:
例如tr
沒有嵌套在table
標籤內,則是一個語法錯誤。當InBody
狀態直接出現如下tag時,則出錯。Jsoup裏遇到這種錯誤,會發現這個Token的解析並記錄錯誤,而後繼續解析下面內容,並不會直接退出。
InBody {
boolean process(Token t, HtmlTreeBuilder tb) { if (StringUtil.in(name, "caption", "col", "colgroup", "frame", "head", "tbody", "td", "tfoot", "th", "thead", "tr")) { tb.error(this); return false; } }
例如head
標籤沒有閉合,就寫入了一些只有body內才容許出現的標籤,則自動閉合</head>
。HtmlTreeBuilderState
有的方法anythingElse()
就提供了自動補全標籤,例如InHead
狀態的自動閉合代碼以下:
private boolean anythingElse(Token t, TreeBuilder tb) { tb.process(new Token.EndTag("head")); return tb.process(t); }
還有一種標籤閉合方式,例以下面的代碼:
private void closeCell(HtmlTreeBuilder tb) { if (tb.inTableScope("td")) tb.process(new Token.EndTag("td")); else tb.process(new Token.EndTag("th")); // only here if th or td in scope }
好了,看了這麼多parser的源碼,不妨回到咱們的平常應用上來。咱們知道,在頁面裏多寫一個兩個未閉合的標籤是很正常的事,那麼它們會被怎麼解析呢?
就拿<div>
標籤爲例:
漏寫了開始標籤,只寫告終束標籤
case EndTag: if (StringUtil.in(name,"div","dl", "fieldset", "figcaption", "figure", "footer", "header", "pre", "section", "summary", "ul")) { if (!tb.inScope(name)) { tb.error(this); return false; } }
恭喜你,這個</div>
會被當作錯誤處理掉,因而你的頁面就毫無疑問的亂掉了!固然,若是單純多寫了一個</div>
,好像也不會有什麼影響哦?(記得有人跟我講過爲了防止標籤未閉合,而在頁面底部多寫了幾個</div>
的故事)
寫了開始標籤,漏寫告終束標籤
這個狀況分析起來更復雜一點。若是是沒法在內部嵌套內容的標籤,那麼在遇到不可接受的標籤時,會進行閉合。而<div>
標籤能夠包括大多數標籤,這種狀況下,其做用域會持續到HTML結束。
好了,parser系列算是分析結束了,其間學到很多HTML及狀態機內容,可是離實際使用比較遠。下面開始select部分,這部分可能對平常使用更有意義一點。