使用Python语言编写简单的HTML5语法解析器

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设计仅仅从权考虑,在正式的产品开发中,可能需要根据实际的需求做出改动):

  1. skipLeadingWS  代表在扫描调用者提供的下一个模式之前,是否先忽略前导空白字符串
  2. 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()

元素节点的解析存在许多难点,比如说,需要在这里解析元素属性、需要递归地解析可能的子节点。让我们尝试着写写看吧:

 

Python

 

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父节点上。

接下来是属性解析:

Python

 

1

2

if not self._parseAttrs(element):  

      return False

如果属性解析失败,则_ parseElement也随之失败,否则将element添加到ctx上:

Python

 

1

2

3

4

if ctx.type=="Document":  

   ctx.rootElement = element  

else:  

    ctx.addChild( element )

_parseAttrs函数的一个副作用是设置元素是否直接以‘/>’结束,如果是这样,则该元素没有进一步的子节点;否则需要进一步递归处理子节点的解析。

Python

 

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:

Python

 

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的不同位置而已。

Python

 

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     结语

对于递归下降的语法解析器而言,重要的就是要做好“向前看”的工作,自上而下的递归解析相比自底向上的解析,实际性能并没有什么大的损失,但其代码结构的可理解程度就要高不少了。

感谢你的耐心阅读,欢迎来信交流心得体会。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值