Jsoup代码解读之四-parser

  •   Jsoup代码解读之四-parser

做为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

compiler

其中词法分析、语法分析、语义分析这部分又叫编译器的前端(front-end),而此后的中间代码生成直到目标生成、优化等属于编译器的后端(back-end)。编译器的前端技术已经很成熟了,也有yacc这样的工具来自动进行词法、语法分析(Java里也有一个相似的工具ANTLR),然后端技术更加复杂,也是目前编译器研究的重点。git

说了这么多,回到我们的HTML上来。HTML是一种声明式的语言,能够理解它的最终的输出是浏览器里图形化的页面,而并不是可执行的目标语言,所以我将这里的Translate改成了Render。github

html compiler

在Jsoup(包括相似的HTML parser)里,只作了Lex(词法分析)、Parse(语法分析)两步,而HTML parse最终产出结果,就是DOM树。至于HTML的语义解析以及渲染,不妨看看携程UED团队的这篇文章:《浏览器是怎样工做的:渲染引擎,HTML解析》web

状态机

Jsoup的词法分析和语法分析都用到了状态机。状态机能够理解为一个特殊的程序模型,例如常常跟咱们打交道的正则表达式就是用状态机实现的。正则表达式

它由状态(state)和转移(transition)两部分构成。根据状态转移的可能性,状态机又分为DFA(肯定有限状态机)和NFA(非肯定有限状态自动机)。这里拿一个最简单的正则表达式"a[b]*“做为例子,咱们先把它映射到一个状态机DFA,大概是这样子:编程

state machine

状态机自己是一个编程模型,这里咱们尝试用程序去实现它,那么最直接的方式大概是这样:后端

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,EOF6种,对应6种词法类型。

  • Tokeniser

    保存词法分析过程的状态及结果。比较重要的两个字段是stateemitPending,前者保存状态,后者保存输出。其次还有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标签的状态转移过程,

lexer

这里忽略了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的知识也更进一步了。

DOM树产生过程

这里单独将TreeBuilder部分抽出来叫作语法分析过程可能稍微不妥,其实就是根据Token生成DOM树的过程,不过我仍是沿用这个编译器里的称呼了。

TreeBuilder一样是一个facade对象,真正进行语法解析的是如下一段代码:

protected void runParser() {
    while (true) { Token token = tokeniser.read(); process(token); if (token.type == Token.TokenType.EOF) break; } } 

TreeBuilder有两个子类,HtmlTreeBuilderXmlTreeBuilderXmlTreeBuilder天然是构建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; } 

HTML解析状态机

相比XmlTreeBuilderHtmlTreeBuilder则实现较为复杂,除了相似的栈结构之外,还用到了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>标签为例:

  1. 漏写了开始标签,只写告终束标签

    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>的故事)

  2. 写了开始标签,漏写告终束标签

    这个状况分析起来更复杂一点。若是是没法在内部嵌套内容的标签,那么在遇到不可接受的标签时,会进行闭合。而<div>标签能够包括大多数标签,这种状况下,其做用域会持续到HTML结束。

好了,parser系列算是分析结束了,其间学到很多HTML及状态机内容,可是离实际使用比较远。下面开始select部分,这部分可能对平常使用更有意义一点。

相关文章
相关标签/搜索