• Jsoup代码解读之四-parser


    •   Jsoup代码解读之四-parser

    作为Java世界最好的HTML 解析库,Jsoup的parser实现非常具有代表性。这部分也是Jsoup最复杂的部分,需要一些数据结构、状态机乃至编译器的知识。好在HTML语法不复杂,解析只是到DOM树为止,所以作为编译器入门倒是挺合适的。这一块不要指望囫囵吞枣,我们还是泡一杯咖啡,细细品味其中的奥妙吧。

    基础知识

    编译器

    将计算机语言转化为另一种计算机语言(通常是更底层的语言,例如机器码、汇编、或者JVM字节码)的过程就叫做编译(compile)。编译器(Compiler)是计算机科学的一个重要领域,已经有很多年历史了,而最近各种通用语言层出不穷,加上跨语言编译的兴起、DSL概念的流行,都让编译器变成了一个很时髦的东西。

    编译器领域相关有三本公认的经典书籍,龙书《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

    编译器的基本流程如下:

    compiler

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

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

    html compiler

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

    状态机

    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部分,这部分可能对日常使用更有意义一点。

  • 相关阅读:
    Golang理解-字符串拼接的几种方式
    Golang理解-匿名函数
    Golang理解-函数变量
    Golang理解-函数声明
    安全生产的规范和机制
    服务可用性指南
    c#中Class和Struct使用与性能的区别
    asp.net HTTP请求过程
    如何设置ASP.NET站点页面运行超时
    Nginx——在Windows环境下安装(一)
  • 原文地址:https://www.cnblogs.com/timssd/p/5467051.html
Copyright © 2020-2023  润新知