在 Perl 中利用 DOM 和 XPath 对 XML 进行有效处理

本文在对几个大型 XML 项目进行分析的基础上研究了如何有效且高效地使用 DOM。开发人员兼作者 Tony Daruger 提供了一组用法样式和一个函数库,以使 DOM 强壮和易用。虽然 DOM 提供了一种灵活而强大的创建、处理和操纵 XML 文档的方式,但是 DOM 的某些方面使其难以使用并可能导致脆弱和错误众多的代码。本文建议了一些避免这些陷阱的方法。Perl 代码样本演示了这些技术。

Parand Darugar (tdarugar@yahoo com), 首席软件设计师, Yahoo! Search Marketing Services

Author photo: Parand Tony DarugarParand Tony Darugar 是 Yahoo! Search Marketing Services (Overture) 的首席软件设计师。他的兴趣包括电子商务高性能系统、智能分布式体系结构、中立网络和人工智能。可以通过 tdarugar@yahoo.com 与他联系。



2001 年 10 月 01 日

“文档对象模型(DOM)”是一个与平台和语言无关的接口,它用于动态访问和更新 XML 文档的内容、结构和样式。DOM 定义了一组表示文档的标准接口、一个用于组合这些对象的标准模型和一组用于访问和操纵它们的标准方法。DOM 是一个“W3C 建议”,这使它成为大家公认的 Web 标准。可以用包括 Perl、C、C++、Java、Tcl 和 Python 在内的多种语言实现它。

正如我在将本文中演示的那样,当基于流的模型(例如 SAX)显得力不从心时,DOM 是处理 XML 的极佳选择。遗憾的是,此规范的几个方面(例如,它的与语言无关的接口以及对“一切都是节点”抽象的使用)使它难以使用,并易于生成脆弱的代码。在我的公司对几个由不同开发人员在去年开发的大型 DOM 项目的回顾中,这点尤为明显。下面讨论这些常见问题及其解决方法。

探索 DOM

DOM 规范被设计成可与任何编程语言一起使用。因此,它试图使用一组公共的、可在所有语言中使用的核心特性。DOM 规范还尝试在其接口定义中保持中立。正因为这点,Perl 程序员可以在使用 Java 时应用他们的 DOM 知识,反之亦然。

此规范还将文档的每一部分当成由一个类型和一个值组成的节点来对待。这为文档所有方面的处理提供了一种极好的概念上的框架。例如,下面这个 XML 片断

the Italicized portion.

就通过下面这个 DOM 结构来表示:

图 1:XML 文档的 DOM 表示
DOM 表示

树中的每个 DocumentElementTextAttr 都是 DOM::Node

设计事项

DOM 与语言无关性的不利之处在于:无法利用每种编程语言中常用的方法和样式。例如,在 Perl 中,把 XML 节点的属性自然地表示成一个散列,因为它们是一组唯一的名称-值对。然而在 DOM 中,它们被表示成一组节点,并且通过单独的函数调用访问每个节点值。程序员必须学习使用一些新数据结构和访问方法,而不是使用简单的散列。这些小小的不便意味着不寻常的编码方式和代码行数的增长。它们还强迫程序员学习做这些事情的 DOM 方法,以替代原来直观地就可以处理它的方法。

“一切都是节点”的抽象虽然相当不错,但却可以导致笨拙的编码情况,例如上面的属性节点示例。当访问 XML 标记中包含的值时也会发生这种事。考虑这个 XML 片断: Value 。您可能认为:可以通过在 tagname 节点上调用 getValue 或类似方法来访问该文本值。事实上,该文本被当作 tagname 节点下的一个或多个子节点处理。因此,为了获得该文本值,需要遍历 tagname 的子节点,并将它们整理成一个字符串。有一个很好的理由来这样做: tagname 可能包含其它嵌入的 XML 标记。如果 tagname 确实包含嵌入的 XML 标记,获取其文本值也没什么意义。然而,在现实世界中,我们常常见到由缺少方便的函数而导致的编码错误。

“一切都是节点”的抽象还因为存在一些节点类型和访问方法一致性的缺乏而丧失了一些价值。例如, CharacterData 节点的值是通过使用 insertData 方法来设置的,而 Attr (属性)节点的值是通过直接访问 value 字段设置的。由于为不同的节点提供了不同的接口,模型的一致性和精致性被破坏,并且“学习曲线”也延长了。


常见编码问题

对几个大型 XML 项目的分析揭示了在使用 DOM 中的一些常见问题。下面显示了其中的几个。

代码臃肿

在我们复查时所看的所有项目中,一个总的问题是:做一件简单的事用了许多行代码。在一个示例中,使用了 16 行代码检查一个属性值。但是如果使用改进的健壮性和错误处理机制,只需三行代码即可完成同一任务。造成代码行数量增长的原因是由于 DOM API 的低级特性、对方法和编程样式不正确的应用以及对整个 API 缺乏了解。下面演示了这些问题的特定实例。

遍历 DOM

在我们检查的代码中,最常见的任务是遍历或搜索 DOM。下面是一段代码的精简版,该代码是查找文档 config 部分中名为 "header" 的节点所必需的。

清单 1. 查找文档中某部分内某个节点的精简代码
$document_root  = $dom_document->getDocumentElement();
 my $config_node = $document_root->getFirstChild();
 foreach my $node ( $config_node->getChildNodes() ) {
   if ( $node->getName() eq "header") {
     # do something
   }
 }

通过获得顶级元素,然后获得它的第一个子元素( config_node ),最后分别检查 config_node 的子元素,来从根节点开始遍历文档。不幸的是,这种方法不仅相当罗嗦,而且还很脆弱并可能有错误。

例如,代码的第二行使用 getFirstChild 方法获得中间节点。这时已经存在大量潜在问题了。根节点的第一个子节点实际上可能不是用户正在搜索的那个 config_node 。如果盲目地采用第一个子节点,就忽略了标记的实际名称,并可能正在搜索文档的不正确部分。当源 XML 文档在根节点后包含空白或回车时,这种方案中常常会发生错误;根节点的第一个子节点实际是一个 DOM::Text 节点,而不是想要的节点。要正确浏览到想要的节点,需要检查每个 document_root 的子节点,直到找到一个不是 Text 节点并且具有与我们所找的节点名称相同的节点为止。

我们还忽略了文档可能与我们所期望的结构不同这一可能性。例如,如果 document_root 没有任何子节点,则将 config_node 设置成 undef ,并且该示例的第三行将产生一个错误。因此,要正确浏览文档,不仅要分别检查每个子节点并检查它是否有适当的名称,而且在每一步中都要检查以确保每个方法调用都返回有效值。编写健壮且无错、可以处理任意输入的代码不仅需要对细节极其留意,而且需要很多行代码。

检索标记中的文本值

在 DOM 遍历之后,第二个常见的任务是检索标记中包含的文本值。考虑 XML 片断 The Value 。即便已经浏览到 sometag 节点,又如何捕获它的文本值( The Value )呢?直观的实现可能是:

$sometag->getData();

正如您可能猜出的,上面的代码不会执行预期操作。我们不能在 sometag 节点上调用 getData 或类似的函数,因为实际文本是作为一个或多个子节点存储的。更好的方法可能是:

$sometag->getFirstChild()->getData();

这里的问题在于:值实际上可能不包含在第一个子节点中; sometag 中可能还有处理指令或其它嵌入节点,或者文本值可能包含在几个子节点,而不是一个子节点中。回忆一下,常常将空格表示成一个文本节点,因此通过对 $sometag->getFirstChild() 的调用,您可能只能得到标记和其值之间的回车。事实上,需要遍历所有子节点,检查类型为 Text 的节点,并整理它们的值,直到得到完整的值为止。

getElementsByTagName

DOM 接口包括一个用给定名称查找子节点的方法。例如,调用:

my @results = $document_root->getElementsByTagName("name");

将从文档中返回一个名为 name 的标记数组(或 NodeList )。这当然比上面所讨论的遍历方法方便。但它同样是一组常见错误的原因。

问题是: getElementsByTagName 递归地遍历文档,返回所有匹配的节点。假设有一个包含客户信息、公司信息和产品信息的文档。所有这三项都可能在其中包含一个 name 标记。如果要调用 getElementsByTagName 搜索客户名称,结果却得到产品和公司名称,您的程序可能会出现错误行为。在文档的子树上调用该函数可以减少这些出错的风险。然而,XML 的灵活特性使我们很难确保:正在操作的子树具有所期望的结构,并且没有具有我们正在搜索的名称的假冒子节点。


DOM 的有效使用

既然 DOM 的设计约束施加了这些限制,您如何才能有效且高效地使用该规范呢?我们提出了几个使用 DOM 的基本原则和指南,并创建一个函数库以使我们的工作更容易。

基本原则

如果您遵循几个基本原则,您使用 DOM 的经验将得到极大改进。

  • 不要使用 DOM 来遍历文档
  • 只要可能,就使用 XPath 来查找节点或遍历文档
  • 使用高级函数库使 DOM 更易于使用

这些原则直接来自于我对常见问题的研究。正如上面所讨论的,DOM 遍历是导致错误的主要原因。然而,它还是最常需要的功能之一。如果不使用 DOM,我们如何遍历文档?

XPath

XPath 是一种寻址、搜索和匹配文档各部分的语言。它是一个“W3C 建议”,这使它成为一个已接受的标准,在大多数语言和 XML 包中都实现了它。您的 DOM 包可能直接或通过附件支持 XPath。

XPath 提供一种遍历和搜索文档的极佳方式。它使用一个类似于文件系统和 URL 中所使用的路径符号来指定和匹配文档各部分。例如,XPath: /x/y/z 在文档中搜索下面依次有 y 和 z 子节点的 x 根节点。该语句返回所有与指定路径结构匹配的节点。

在文档结构方面以及节点值及其属性方面还可以由更复杂的匹配。 /x/y/* 语句返回父节点为 x 的任何节点 y 下的所有节点。 /x/y[@name='a'] 匹配所有具有父节点 x 且具有名为 name 且值为 a 的属性的节点 y。

对 XPath 及其用法的完整研究超出了本文的范畴。有关一些极佳教程的链接,请参阅 参考资料。花一点时间学习 XPath,然后您就能够更加轻松地处理 XML 文档。


函数库

我们对 DOM 项目的进行检查时,一个令人吃惊的方面之一就是存在大量复制和粘贴代码。一个文件中的代码段可以被复制和粘贴到很多其它项目中,以实现类似的功能。为什么采用良好编程方式的有经验开发人员要使用复制和粘贴方法,而不创建助手库呢?我们相信,这是因为大多数程序员不是 DOM 专家,并且他们将很高兴地采用他们最先所遇到的能解决他们问题的代码段。他们对自己的 DOM 技巧没有足够的信心来创建自己的、构成助手库的规范函数。

创建和使用助手库来实现常用功能相当容易;它只需少许训练即可。下面是一些使您入门的基本助手函数。

getValue

使用 XML 文档时,最常执行的操作是查找给定节点的值。正如上面所讨论的,这会为遍历文档来查找期望的节点和检索节点值带来困难。可以使用 XPath 简化遍历,值的检索可以只编码一次,然后重用。我们已经用两个低级函数助手 findNodegetTextContents 实现了 getValue 函数。 findNode 这个助手查找并返回第一个与给定 XPath 表达式匹配的节点,而 getTextContents 以非递归方式返回传入节点下的文本节点连接值,如清单 2 所示。

清单 2. getValue 示例
sub getTextContents {
  my ($node, $strip)= @_;
  my $contents;
  if (! $node ) 
  { 
    return; 
  }
  for my $child ($node->getChildNodes()) {
    if ( ! is_element_node($child) ) {
       $contents .= $child->getData();
    }
  }
  if ($strip) {
    $contents =~ s/^\s+//;
    $contents =~ s/\s+$//;
  }
  return $contents;
}
sub findNode {
  my ($node, $xpath) = @_;
  if (! defined($node) || ! defined($xpath) )
  {
    return undef;
  }
  my $match = ($node->xql($xpath))[0];
  if (! $match )
  {
    return undef;
  }
  return $match;
}
sub getValue {
  my ($node, $xpath) = @_;
  my $match = findNode( $node, $xpath );
  if (! defined($match) )
  {
    return undef;
  }
  return getTextContents( $match );
}

通过传入一个开始搜索起始节点和一个指定正在搜索节点的 XPath 语句来调用 getValue 。该函数查找匹配给定 XPath 的第一个节点并抽取其文本值。

setValue

另一项常见操作是将节点值设置成期望的值,如清单 3 中所示。

清单 3. 设置节点值
sub setValue {
  my ($node, $xpath, $value) = @_;
  my $match = findNode( $node, $xpath );
  if (! defined($match) )
  {
    return undef;
  }
  
  foreach my $child ( $match->getChildNodes() ) 
  {
    $match->removeChild ($child);
  }
  $match->addText($value);
  return $match;
}

此函数采用一个起始节点和一个 XPath 语句 ― 就像 getValue 一样 ― 和一个设置匹配节点值的字符串。它使用 findNode 查找期望的节点,除去它的所有子节点(因而也就除去了它所包含的所有文本和其他元素),然后将它的文本内容设置成传入的字符串。

appendNode

一些程序查询和修改 XML 文档中包含的值,而其它程序则通过添加和除去节点来修改文档本身的结构。此助手函数简化了将一个节点添加到文档中的步骤,如清单 4 所示。

清单 4. 添加一个节点
sub appendNode {
  my ($doc, $nodename, $xpath, $value) = @_;
  if (! defined($nodename) || ($nodename eq "") ) {
    return undef;
  }
  my $match = findNode( $doc, $xpath );
  if (! defined($match) )
  {
    return undef;
  }
  my $newnode;
  eval {
    $newnode = $doc->createElement( $nodename );
  };
  if ($@ || (! defined($newnode) )) {
    return undef;
  }
  
  $match->appendChild( $newnode );
  
  if ( defined($value) ) {
    $newnode->addText($value);
  }
  return $newnode;
}

传递给此函数的参数是 DOM 文档、要添加的节点名、指定要将节点添加至何处的 XPath 语句(即,新节点的父节点是什么)以及可选的该节点的文本值。将新节点附加到指定的父节点,并将其值设置成传入的字符串。

copySubTree

将一个文档的一部分复制到另一位置或文档,虽然不是非常常见的操作,但却是导致很多困惑的原因,并会产生各种有创意的复制过程。如清单 5 所示,实际上,它实现起来相当简单。

清单 5. 复制文档的一部分
sub copySubTree
{
  my ($sourcenode, $destnode) = @_;
  my $copy_node =  $sourcenode->cloneNode(1);
  if ( $sourcenode->getOwnerDocument() ne $destnode->getOwnerDocument() ) 
  {
    $copy_node->setOwnerDocument( $destnode->getOwnerDocument() );
  }
  $destnode->appendChild($copy_node);
  return $copy_node;
}

此函数接受源节点,并将其作为子节点复制到目标节点下。目标节点可以在另一个文档中,在这种情况下,在两个文档之间复制子树。


结束语

DOM 一直被诬蔑成相当困难且不直观的操纵 XML 文档的方式。事实上,它形成了一个非常有效的基础,通过遵循几个简单原则就可以在其上构建易于使用的系统。DOM 已经在大多数平台上得以实现和优化,对于需要在复杂过程中搜索和操纵 XML 文档的应用程序来说,它是个极佳选择。

参考资料

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=XML
ArticleID=22066
ArticleTitle=在 Perl 中利用 DOM 和 XPath 对 XML 进行有效处理
publish-date=10012001