级别: 中级 Elena Litani (elitani@ca.ibm.com), 软件开发人员, IBM Michael Glavassevich (mrglavas@ca.ibm.com), 软件开发人员, IBM
2004 年 8 月 01 日 编写应用程序来尽可能地获得最佳性能,同时了解有哪些 SAX 或 DOM 操作及特性会对应用程序的性能产生影响。本文是由 3 部分组成的系列文章的第一部分,在本文中,作者 Elena Litani 和 Michael Glavassevich 描述了编写 XML 应用程序和文档最佳实践,同时还介绍了使用标准 SAX 和 DOM API 开发应用程序的最佳实践。
如今,XML 在很多性能关键型场景中扮演着重要角色。虽然很多开发人员都知道如何编写 XML 文档、XML 模式或 DTD,但有些人可能还没有认识到,XML 应用程序的性能取决于构造 XML 文档时所作出的一些决定,以及在解析 XML 文档之前,在解析器上设置了哪些特性。
很多开发人员也知道何时使用 SAX,何时使用 DOM API。通常,如果内存不充裕,而应用程序必须处理较大的文档,或者要在内存中创建自己的表示,那么您最好使用 SAX(而不是 DOM)。另一方面,如果应用程序需要随机访问和修改文档数据,想要实现复杂的搜索,或者计划多次遍历一个文档树,则可以尽量使用 DOM。在本文中,我们将解释哪些 SAX 或 DOM 操作和特性会影响应用程序的性能,并描述如何编写性能最佳的应用程序。
编写 XML 文档
负责编写 XML 文档的开发人员可以做各种事情来提高 XML 应用程序的性能。
每个 XML 文档都可以在 XML 声明中指定一种字符编码。为了达到最佳性能,在编写 XML 文档时应使用 US ASCII (
"US-ASCII" ) 作为编码。用 ASCII 字符编写的文档解析起来是最快的,因为每个字符都肯定是单字节的,可以直接映射到对应的 Unicode 值。如果文档是用 UTF-8 编码的,但是只包含 ASCII 字符,那么有些解析器(例如 Xerces2)处理这类文档时采用的方式与处理用 US-ASCII 编码的等价 XML 文档时采用的方式几乎相同。对于包含 ASCII 以外 的 Unicode 字符的文档,解析器必须为每个字符读取和转换多字节序列。这种转换会损失一定的性能。因为每个字符都规定使用两个字节,同时假设没有代替的字符,所以 UTF-16 编码在一定程度上减轻了这种性能损失。然而,如果使用 UTF-16 的话,原始文档的大小便要加倍,这样的文档解析起来要花更多的时间。
通过减少文档中的新行数目和空格,也可以提高性能。通常,为了编辑方便,开发人员会将文档组织成行 —— 例如,使用回车(
#xD )和换行(
#xA )。XML 解析器必须将双字符序列
#xD #xA 以及所有
#xD (后面没有跟
#xA )转换成单个的
#xA 字符。这种转换并非没有代价。对于解析过程的总体性能影响取决于文档中与新行数目相关的字符数。对于空格的使用也是如此。当您将空格添加到文档中时,解析器就要处理更多的字符,从而最终影响解析过程的性能。
另外,除非绝对有必要,否则应该避免在应用程序中使用名称空间(namespace)。处理启用了名称空间特性的文档会降慢对整个文档的处理。解析器不仅要处理名称空间的声明、验证它们的正确性,而且还要确保 XML 文档是名称空间格式良好的。
对于不需要进行验证的应用程序,它们的文档中应该
不包括
<!DOCTYPE...> 这一行。根据 XML 规范,验证处理程序(例如 Xerces2)必须处理内部和外部 DTD 子集,以获得关于默认属性、属性类型等信息。即使禁用了验证特性,该处理程序也仍然会处理 DTD。
当需要对应用程序进行验证时,应记住,处理和验证 DTD 所花费的代价通常比处理和验证 W3C XML Schema 的要小一些。此外,还应避免使用大量的外部实体 —— 例如外部 DTD 或导入的 XML 模式,因为打开和读取文件是代价很高的操作。同时要避免使用太多的默认属性,因为这会增加验证时间。XML Schema 的
redefine 结构和
identity 约束也应当避免,因为两者都会影响验证过程。
常见 SAX 性能提示
对于更消耗内存的 API(例如 DOM),选择 SAX 可以提高应用程序的性能。但是,您还可以做很多事情来进一步提高性能。试一试下面这些可以提高 SAX 应用程序性能的提示:
字符串内部化
SAX 指定了一个由特性 URI
http://xml.org/sax/features/string-interning 标识的特性。在将该特性设为 true 时,它会通过调用
java.lang.String.intern() 来指示解析器以内部化字符串的形式报告 XML 名称(例如元素和属性的名称)以及名称空间 URI。
为了加快字符串等同性测试的速度,可以打开该特性。这里不需要调用一个字符一个字符地进行比较的
equals() 函数,相反,您可以通过引用将解析器报告的名称与字符串常量进行比较。如果使用解析器报告的 XML 名称作为 hash 表的键,那么,当该表调用
java.lang.String 的
hashCode 方法时,内部化字符串就可以缩短查找时间。虽然在 Javadoc 中没有指定,这个
hashCode 方法的实现通常会在计算 hash 码值之后,将其缓存在对象中。一旦计算出了 hash 码,想获得一个内部化字符串的 hash 码实际上就轻而易举了。
有些解析器的实现可能不支持字符串内部化特性。Xerces2 使用内部化字符串来获得更快的比较速度,所以这一特性永远是开着的。
切换内容处理程序
如果处理大型的 XML 词汇表,您可能会发现在回调方法中有大量的
if 和
else 语句。正如 SAX 规范中所说的那样,在解析过程中的任意时刻,都可以注册一个新的内容处理程序。通过为文档不同部分使用不同的内容处理程序,可以减少回调方法的复杂性和长度。
清单 2中展示的类演示了如何在多个处理程序之间划分对一个文档(如
清单 1所述)的处理任务。
清单 1. 示例 XML 文档
<?xml version="1.0" encoding="US-ASCII"?>
<!DOCTYPE root [
<!ELEMENT root (child*)>
<!ELEMENT child (#PCDATA)>
]>
<root><child/></root> |
清单 2. 使用多个内容处理程序
public class MultipleHandlersExample {
private XMLReader reader;
private ContentHandler docHandler;
private ContentHandler rootContentHandler;
private ContentHandler childContentHandler;
...
public void parse (String uri) throws SAXException, IOException {
reader.setContentHandler(docHandler);
reader.parse(uri);
}
public class DocHandler extends DefaultHandler {
public void startElement(String uri, String localName,
String qName, Attributes atts) {
if ("root".equals(qName)) {
// process root
reader.setContentHandler(rootContentHandler);
}
else {} // error: only root expected here
}
}
public class RootContentHandler extends DefaultHandler {
public void startElement(String uri, String localName,
String qName, Attributes atts) {
if ("child".equals(qName)) {
// process child
reader.setContentHandler(childContentHandler);
}
else {} // error: only child expected here
}
public void endElement(String uri, String localName,
String qName) {
// end of root, set content handler for document
reader.setContentHandler(docHandler);
}
}
public class ChildContentHandler extends DefaultHandler {
public void startElement(String uri, String localName,
String qName, Attributes atts) {
// error: no element content expected here
}
public void endElement(String uri, String localName,
String qName) {
// end of child, set content handler for root
reader.setContentHandler(rootContentHandler);
}
}
} |
当已经报告出某一特定元素(例如上述示例中的
root 或
child )时,便可以注册一个处理程序来处理该元素的内容。在结束对该元素的处理时,需要恢复父元素的内容处理程序。对于比
清单 1给出的文档更复杂的文档,可以通过将内容处理程序推入一个栈或从栈中将其取出来实现这一点。通过以这种方式处理内容,每个处理程序方法中的代码就要少得多。而缩短这些方法的长度有助于使 JIT 编译器更易于优化它们。
根据 SAX 解析器的配置,应用程序执行起来可能各不相同。例如,如果依赖于字符串内部化,但是所使用的解析器又不支持该特性,那么应用程序就必须使用
equals() 来比较字符串。可以用一个内容处理程序来处理这两种情况,但每次解析器调用处理程序时,都需要进行一次检查,以查看到底是哪一种情况需要被处理。相反,您不必写一个单独的处理程序,而是编写两个内容处理程序:一个程序对字符串执行传址方式的比较,另一个则不这么做。在解析之前便可以决定使用哪个处理程序。
用实体分解器装载外部实体
在引用外部 DTD 和/或包含很多引用外部实体的 XML 文档时,处理起来代价很高。对于每个这样的实体,解析器需要找到外面某个地方的资源并读取该资源。如果该资源不在硬盘驱动器里,那么解析器就必须打开一个文件。如果解析器在内部以单一编码处理字符数据(例如 Xerces2),而这种编码总是在内部将字符表示为 16 位的单元(UTF-16),那么它就必须转换每个文件的代码。如果文档包含对网络上或 Internet 上实体的引用(并且您的环境可以访问这些资源),那么就会招致巨大的性能损失,在网络延迟很厉害时,更是如此。很多解析器,包括 Xerces2,都不会将它已经读过的实体保存在内存中。如果文档多次引用一个实体,那么,实体被引用多少次,解析器就要读取该实体多少次。
如果 XML 文档包含了对外部实体或外部 DTD 的引用,那么,通过使用实体分解器将这些实体装载到内存中,可以提高应用程序的性能。编写实体分解器,使它在实体第一次被读取的时候缓存该实体的内容。这样,应用程序每次引用一个实体时只需付出很小一点代价即可。如果您不需要动态装载,那么可以将应用程序与想要从内存中读取的实体一起装载。如果把实体以
java.lang.String 的形式存储在内存中,则可以避免解析器将实体的编码转换成字符时所带来的开销,如
清单 3所示。
清单 3. 从内存装载外部实体
public class MyEntityResolver implements EntityResolver {
private String externalEntity = ...;
InputSource resolveEntity(String publicId, String systemId)
throws SAXException, IOException {
if (systemId.equals("ExternalEntity.xml") {
return new InputSource(new StringReader(externalEntity));
}
return null;
}
} |
避免处理外部实体
尽管应用程序处理的 XML 文档可能包含对外部实体的引用,但是您可能对扩展这些实体不感兴趣。SAX 定义了两个特性,分别由特性 URI
http://xml.org/sax/features/external-general-entities 和
http://xml.org/sax/features/external-parameters-entities 标识,这两个特性分别控制解析器是否处理外部一般实体和是否处理外部参数实体。如果禁用这两个特性,同时在处理一个文档的过程中遇到了对外部实体的引用,那么 SAX 解析器不会报告该实体的内容,但是会将实体的名称报告给内容处理程序的
skippedEntity 回调方法。如果应用程序对外部实体的内容不感兴趣,那么可以关闭这两个特性,从而不必对它们进行处理。
常见 DOM 性能提示
DOM 定义了一些类型的节点,例如
Element 和
Attribute 。当您编写代码来执行基于某一节点类型的特定操作时,应避免使用 Java
instanceof 操作符检查节点类型。相反,可以使用
getNodeType (
Node 接口)方法来获得被处理节点的类型。
在获取属性列表之前,总是先使用
hasAttributes 方法查询一下,看节点是否有属性。如果节点有属性,就将该节点转换成一个
Element 节点,并使用
getAttributes 方法获得属性列表。通过这一系列的操作,可以避免一些不必要的从
Node 节点到
Element 节点的类型转换,同时也避免了创建空的
NamedNodeMap —— 即使一个节点没有属性,
getAttributes 方法也总是返回一个 map。
如果应用程序需要查询或修改一个属性
Node ,则应避免使用
hasAttribute(String) 或
hasAttributeNS(String, String) 方法。相反,要么使用
getAttribute(String) 方法,要么使用
getAttributeNS(String, String) 方法来获取属性
Node ,然后便可以查询和修改属性了。
在 DOM API 中,有些操作的代价很高。
importNode 操作会导致创建新的节点,所以应该考虑转而使用
adoptNode 方法。
getElementByTagName 和
getElementByTagNameNS 方法都要遍历 DOM 树,使用 Java
String.equals() 方法比较名称和名称空间 URI,以查找节点。相反,应用程序可以选择编写它自己的遍历方法,在这个遍历方法中使用 Java
== 来比较字符串(其中字符串是内部化的),或者只在树上的一部分中进行搜索。对于
getElementById 方法也是如此。
此外,要小心使用 DOM Level 3
normalizeDocument 方法。虽然这个方法可以提高验证过程中的性能,但使用它的代价仍然较高。例如,在默认情况下,该方法要确保树是名称空间格式良好的 —— 也就是说,它要检查一下,看是否添加了属性和元素的所有必需的名称空间声明,如果需要的话,可能还会更改一些属性或元素的前缀。如果应用程序代码已经确信树是名称空间格式良好的,那么在调用
normalizeDocument 方法之前,应用程序就应该使用
DOMConfiguration 接口关闭 "namespace" 参数。对于默认值为 true 的格式良好的参数,也是如此。记住,在一般情况下,DOM 树应该是格式良好的。例外情况是,开发人员将
-- 包括在
Comment 节点中或将
]]> 包括在
CDATASection 节点中,或者在正文内容(包括 CDATA 部分和注释)中使用了非 XML 字符。因此,对于大部分应用程序,在调用
normalizeDocument 时都可以放心地禁用格式良好的参数,这样可以显著提高该方法的性能。
DOM Level 3 API
DOM Level 3 Core 和 Load 以及 Save 规范定义了一些新的操作和一个 API,它们可以提高 DOM 应用程序的性能。因此,您应该计划移植 DOM 应用程序,以使用在 J2SE 5.0 中受支持的 DOM Level 3。
重命名节点和移动节点
在 DOM Level 2 中,重命名节点和将节点从一个文档移到另一个文档的代价可能相当高,因为这些操作需要创建新节点,复制那些节点的内容,并将节点插入到树中适当的位置。为了提高这些操作的性能,可以让应用程序使用
renameNode 和
adoptNode 方法。通常,
renameNode 方法只改变一个节点的名称。在某些少见的情况下,该方法最后会创建一个新节点,复制所有信息,并将这个新节点插入到树中。当使用 Xerces2 DOM 时,只有当应用程序创建 non-namespace-aware 节点(这种节点是用 DOM Level 1 方法,例如
createElement 创建的)并且后来又尝试添加名称空间 URI 来重命名该节点时,才会发生上述情况。由于 Xerces2 对 namespace-aware 节点(使用 DOM Level 2 方法,例如
createElementNS 创建的节点)使用了不同的类,所以在执行
renameNode 操作时,Xerces2 DOM 实现不得不为该节点创建一个新的实例。如前所述,这种情况很少见,因为常常在应用程序尝试在文档内混合使用 namespace-aware 和 non-namespace-aware 节点时,才会出现这种情况。混合使用这两种类型的节点很不妥,因为这样会导致无法预料的结果(例如,在验证树的时候)。
在内存中验证
通过使用 DOM Level 3,现在可以在内存中验证 DOM。在过去,如果想要根据一个模式进行验证,那么您要么必须编写自己的验证代码(这可能很复杂),要么必须将 DOM 串行化并使用验证解析器将其装回内存。通过使用
normalizeDocument 方法,只需一个简单的步骤就可以执行对 DOM 树的验证,从而避免其他需要消耗成本的操作。请参阅 "
Discover key features of DOM Level 3 Core, Part 2" 这篇文章,以获得更多关于如何使用
normalizeDocument 的信息。
避免不必要的检查
一般情况下,DOM 实现必须检验操作的正确性,并在应用程序传递错误参数或者执行非法操作时抛出异常。例如,对于
createElementNS 方法,DOM 实现必须检验
qualifiedName 是否与 QName 的定义相符(请参阅
参考资料)。DOM Level 3 将一个新的
strictErrorChecking 属性添加到了
Document 接口中。如果您相信应用程序对 DOM 执行的所有操作都是合法的(例如,DOM 树是用 SAX 事件构建的),那么就可以通过关闭严格的错误检查来提高性能。
使用 Load 和 Save 过滤器 API
有了 DOM Level 3,便不再需要在修改文档结构之前等待解析的完成。新的过滤器 API 使您可以在解析期间通过请求解析器接受、忽略一个节点及其子节点或者将它们从产生的树中删除,来检查和修改文档的结构。您还可以选择使用过滤器 API 中断解析过程,从而只装载 XML 文档的某一部分。这种在解析期间修改文档结构的做法可以使 DOM 树占用更少的内存,而且还可以减少在内存中遍历和修改文档时所花的时间。
通过使用并串行转换器(serializer)过滤器,应用程序可以指定您想要将哪些节点串行化到 XML 中,同时还不必修改原来的 DOM 树。这样就提供了将相同的 DOM 树串行化成多个 XML 文档的灵活性,同时再次避免了可能代价很高的对 DOM 树的遍历和修改。
结束语
在本文中,我们展示了如何提高 XML 应用程序中的性能。开始,我们展示了编写 XML 并达到最佳解析性能的一些技巧。然后,我们描述了如何提高 SAX 和 DOM 应用程序的性能。在本系列的第二篇文章中,我们将解释如何在使用 Xerces2 实现的情况下提高 SAX 和 DOM 应用程序的性能。我们还将向您展示如何重用解析器实例。
参考资料
作者简介  | |  | Elena Litani 是 IBM 的一名软件开发人员,从事 Eclipse Modeling Framework (EMF) 方面的工作。之前,Elena 是 Apache Xerces2 项目的一名主力,负责 Xerces2 XML Schema 和 DOM Level 3 实现以及分析和提高解析器的性能。Elena 还曾经在 W3C DOM Working Group 中代表 IBM 参与了 DOM Level 3 规范的开发。您可以通过
elitani@ca.ibm.com与他联系。
|
 | |  | Michael Glavassevich 是 IBM 多伦多实验室的一名软件开发人员。他从 2003 年开始致力于 Xerces2 项目,现在已经是一名一流开发人员。您可以通过
mrglavas@ca.ibm.com与他联系。
|
对本文的评价
|