如何优雅的使用 SAX 解析 XML

在 Java 中,解析 XML 文本有两种方式,一种是使用 DOM 方式,另外一种是使用 SAX 方式。

一、使用 DOM 方式解析 XML 的弊端

DOM (Document Object Model) 解析就是指的采用 JAVA 自带的 org.w3c.dom.Document 这个类来解析 XML。使用 DOM 方式可以很直观的来解析 XML 文件,在解析过程中,XML 文件在内存中会被组织成一颗文档树,你可以沿着树根遍历至各个不同的枝叶。节点与节点之间的父子关系、兄弟关系等都非常清晰明了。

假设你正打算开发一个类似于 Tomcat 这样的 Servlet 服务器,而你所做的工作之一就是对 Web 应用程序的配置文件 web.xml 做一个解析。如下 web.xml 示例文件就是你正要打算解析的 XML 文件:

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
<web-app>
<servlet>
<servlet-name>default</servlet-name>
<servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class>
<init-param>
<param-name>debug</param-name>
<param-value>0</param-value>
</init-param>
<init-param>
<param-name>listings</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
<servlet>
<servlet-name>jsp</servlet-name>
<servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
<init-param>
<param-name>fork</param-name>
<param-value>false</param-value>
</init-param>
<init-param>
<param-name>xpoweredBy</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>3</load-on-startup>
</servlet>
</web-app>

你经过一番搜索之后,便被 DOM 这种简单明了的解析方式所吸引,于是写下这样的代码 (下面列举的是伪代码):

1
2
3
4
5
6
7
8
9
10
11
Document document = documentBuilder.parse(new File("web.xml"));
Node webAppNode = document.getByTag("web-app");

for (Node servletNode: webAppNode.getChildNodes()) {

String name = servletNode.getChildByTag("servlet-name").text();
String clazz = servletNode.getChildByTag("servlet-class").text();

// ...

}

然而在你毫无顾虑的穿梭跳跃在 XML 各个节点的时候,你可曾想到过,这简单的背后付出的就是占用大量内存的代价。在未来的某一天里,你会遇见从未遇见过的事情,或许是 XML 文本不再是短短的十几行,或许是你只是想要解析 XML 文本中的某一行 … 你会在某个不经意间询问自己,为什么要把整个 DOM 都加载到内存里面,真是太愚蠢了。

二、SAX! 没那么简单

SAX (Simple API for XML) 是一种基于事件驱动的解析方式。当 SAX 解析器读取这个文件流的时候,它遇见一个标签,便会把这一事件通过回调的方式告诉你。其解析的过程大致如下:

SAX Parse Flow

在多数情况下,SAX 基本上不占用什么内存。它只是在遇到某些标签后,然后通知你它遇到了,它对过去一无所知,它对过往也毫无怀恋。你如获至宝,恨不得立马投入使用,恨不能早些相遇 … 一段时间后,你歪歪斜斜的写下了如下一段代码 (下面列举的是伪代码):

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
41
42
43
44
45
public class WebAppXMLHandler extends DefaultHandler {

boolean meetServletName = false;
boolean meetServletClazz = false;
// ...

Servlet currentServlet = null;

@Override
public void startElement(String qName, Attributes attributes)
throws SAXException {

if (qName.equals("servlet-name")) {
meetServletName = true;
} else if (qName.equals("servlet-class")) {
meetServletClazz = true;
} else {
// ...
}

}

@Override
public void endElement(String qName) throws SAXException {
// ...
}

@Override
public void characters(char ch[], int start, int length) throws SAXException {

String textContent = new String(ch, start, length);

if (meetServletName) {
meetServletName = textContent;
bFirstName = false;
} else if (meetServletClazz) {
servletClazz = textContent;
meetServletClazz = false;
} else {
// ...
}

}

}

你可能感觉自己头皮发麻,因为你可能已经意识到了 SAX 解析中一个非常棘手的问题,你为了判断自己当前到底究竟位于一个什么样的上下文中,不得不维护了许多变量与临时字段,你的 if ... else ... 分支越来越多,很快整个类也越来越复杂。你小心翼翼的行走在这一个又一个忽然点亮又忽然暗下的分支中,你不得不花很多心思来维护这已经庞大冗余繁琐的代码片段。啊!有没有一个更好的办法把我解救出去…

三、Digester! 我的春天来了

我们再来看 Tomcat 是如何处理这类问题的。 在 Tomcat 中,其解析 XML 的部分放在了 Digester 这个类里面,它也同样使用的是 SAX 来解析 XML 。

Tomcat 很聪明的利用了 XML 文本本身的特点 — 栈的特性。在解析遇到一个新的元素的时候,便把这个元素节点到根节点的整个路径放大栈里面,当它遇到这个元素结束的时候,便把路径从栈里面取出来。

Digester XML Path

栈顶始终存放的都是当前正在处理的路径,每个路径在 startElement 方法的时候被压到栈里面,然后在 endElement 的时候又被弹出来。

我们称这个刚才提到的栈为路径栈

只有一个路径栈是不够的,我们还希望在每遇到一个特定路径的时候都能执行一个或者多个不同的行为:

  • 当遇到 web-app 节点的时候,我想要创建一个 WebApp 对象
  • 当遇到 servlet 节点的时候,我想要创建一个 Servlet 对象,并将这个 Servlet 对象添加到刚才的 WebApp 对象里面
  • 当遇到 servlet-name 节点的时候,我想要为刚刚创建的 Servlet 对象,设置它的名字为 default

Tomcat 将这个行为抽象了出来,命名为 Rule:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public abstract class Rule {

public void begin(String namespace, String name, Attributes attributes) throws Exception {
// NO-OP by default.
}

public void body(String namespace, String name, String text) throws Exception {
// NO-OP by default.
}

public void end(String namespace, String name) throws Exception {
// NO-OP by default.
}

}

Tomcat 预先定义了一系列的行为,比如:

  • 创建对象的行为
  • 为对象设置属性的行为
  • 调用方法的行为

为了能够使这些行为针对不同的对象都能够很方便的使用,Tomcat 在这些行为中大量使用反射来进行对象的创建、方法的调用等操作。例如 ObjectCreateRule 对象创建行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class ObjectCreateRule extends Rule {

@Override
public void begin(String namespace, String name, Attributes attributes)
throws Exception {

Class<?> clazz = digester.getClassLoader().loadClass(realClassName);
Object instance = clazz.getConstructor().newInstance();
digester.push(instance);

}

@Override
public void end(String namespace, String name) throws Exception {

digester.pop();

}

}

在引入 Rule 行为规则之后,我们便可以为我们每一个路径都注册一个或者多个不同的行为:

XML Digester RuleSet

四、peek(n)! 不只是查看栈顶元素

在 Digester 中,解析 XML 的过程就是一个创建对象的过程。而对象与对象一般都不是互相孤立的,它们之间可能需要某种关系将它们联系起来。思考上面那步中的添加 Rule 规则:我们在创建一个 Servlet 对象之后,还需要将 Servlet 对象添加到 WebApp 对象中。这只是一个简单的二级约束关系,更为复杂的对象依赖,可能还需要更多的之前已经创建好的对象参与进来,那么如何实现这个需求呢,这就需要引入第二个栈了: 对象栈

Digester Object Stack

如图所示,随着 XML 的一层一层解析,我们的对象便一个一个被创建出来,对象与对象之间的关系也要同步的维护起来。假设我们现在想要将 Servlet 对象添加到 WebApp 中,我们可能会这么写:

1
2
Servlet servlet = new Servlet();
webApp.add( servlet );

然而我们怎么拿到 WebApp 这个对象呢? 当前这个对象可是位于栈底呢!我们发现传统的只支持 peek() 栈顶元素的栈是不符合我们需求的,我们需要的是能够查看不同位置的元素的栈。因此 Tomcat 基于 ArrayList 实现了一个支持 peek(n) 元素的栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ArrayStack<E> extends ArrayList<E> {

public E peek() throws EmptyStackException {
int n = size();
if (n <= 0) {
throw new EmptyStackException();
} else {
return get(n - 1);
}
}

public E peek(int n) throws EmptyStackException {
int m = (size() - n) - 1;
if (m < 0) {
throw new EmptyStackException();
} else {
return get(m);
}
}

}

这样一来,我们添加 Servlet 到 WebApp 对象的 Rule 就可以这样写:

1
2
3
4
WebApp webApp = (WebApp) peek(1);
Servlet servlet = (Servlet) peek(0);

webApp.add(servlet);

如此一来,我们便可以很方便拿到任意的父对象。

五、写在最后

在 IBM 的技术文章 Java 处理 XML 的三种主流技术及介绍 中指出,Digester 最早是归属于 Struts 框架的,后来这种处理 XML 的技术单独的变成了一个 Apache Commons 中的一个独立组件。所以,有兴趣的同学可以进一步看一下 Apache Commons Digester 这个项目的相关技术文档。

推荐文章