内容


避免常见的 XSLT 错误

改掉坏习惯,生成良好代码

Comments

使用 XSLT 编写处理 XML 转换的代码要比使用其他常用的编程语言简单得多。但是 XSLT 语言的语法和处理模型与传统编程语言有很大不同,因此要掌握 XSLT 的所有细微之处需要花费一些时间。

本文并不是一篇内容全面并且深入的 XSLT 教程。相反,它首先解释了一些使经验不足的 XML 和 XSLT 开发人员感到最头痛的问题。随后,介绍了与样式表整体设计和性能有关的主题。

使用名称空间

尽管使用名称空间的 XML 文档越来越普遍,但是要通过不同的技术正确使用它们,似乎仍然存在很多令人困惑的地方。很多文档使用前缀表示名称空间中的元素,并且这种显式的名称空间表示一般不会引起误解。清单 1 展示了一个简单的 SOAP 消息,它使用了两个名称空间 — 一个名称空间用于 SOAP 信封,另一个用于实际的负载。

清单 1. 带有名称空间的 XML 文档
<env:Envelope xmlns:env="http://www.w3.org/2003/05/soap-envelope"> 
 <env:Body>
  <p:itinerary
    xmlns:p="http://travelcompany.example.org/reservation/travel">
   <p:departure>
     <p:departing>New York</p:departing>
     <p:arriving>Los Angeles</p:arriving>
     <p:departureDate>2001-12-14</p:departureDate>
     <p:departureTime>late afternoon</p:departureTime>
     <p:seatPreference>aisle</p:seatPreference>
   </p:departure>
   <p:return>
     <p:departing>Los Angeles</p:departing>
     <p:arriving>New York</p:arriving>
     <p:departureDate>2001-12-20</p:departureDate>
     <p:departureTime>mid-morning</p:departureTime>
     <p:seatPreference/>
   </p:return>
  </p:itinerary>
 </env:Body>
</env:Envelope>

由于原文档中的元素使用了前缀,因此可以很明显地看出它们属于一个名称空间。任何人在使用 XSLT 处理这类文档时都不会出现错误。只需要在样式表中复制来自源文档的名称空间声明。尽管可以使用任意的前缀,但是使用与典型输入文档相同的前缀通常会更加方便,如 清单 2 所示。

清单 2. 访问使用名称空间的文档内的信息的样式表
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
    version="1.0"
    xmlns:env="http://www.w3.org/2003/05/soap-envelope"
    xmlns:p="http://travelcompany.example.org/reservation/travel">

<xsl:template match="/">
  Departure location:
  <xsl:value-of select="/env:Envelope/env:Body/p:itinerary/p:departure/p:departing"/>
</xsl:template>

</xsl:stylesheet>

可以看到,这段代码声明了对根元素 xsl:stylesheet 使用 envp 前缀的名称空间。这类声明随后将被继承到样式表中的所有元素,这样就可以应用到任何内嵌的 XPath 表达式中。还需注意,在 XPath 表达式中,必须使用相应的名称空间前缀作为所有元素的前缀。如果有任何一个步骤忘记使用这个前缀,那么表达式不会返回任何内容,并且很难搞清楚究竟是哪一步出的错。

如果名称空间不够明显,那么使用该名称空间的文档通常会引起问题。如果在一个名称空间中使用了大量元素,那么使用 xmlns 属性将该名称空间定义为默认名称空间。来自默认名称空间的元素不使用前缀;因此,很容易忘记这些元素实际上位于一个名称空间。假设您必须转换 清单 3 中的 XHTML 文档。

清单 3. XHTML 使用默认名称空间的文档
<html xmlns="http://www.w3.org/1999/xhtml">
  <head>
    <title>Example XHTML document</title>
  </head>
  <body>
    <p>Sample content</p>
  </body>
</html>

您可能只是随便扫了一眼 xmlns="http://www.w3.org/1999/xhtml",或者只看到默认名称空间声明的前面放了一些其他属性,因此很容易忽略第 167 列的内容 — 即使您使用的是宽屏显示。比较常见的情况是编写 /html/head/title 这样的 XPath 表达式,但是这类表达式会返回一个空的节点集,因为输入文档不会包含 title 这样的元素。输入文档中的所有元素都属于 http://www.w3.org/1999/xhtml 名称空间,并且反映到 XPath 表达式中。

要访问 XPath 中带名称空间的元素,必须为它们的名称空间定义一个前缀。比如,如果希望访问样例 XHTML 文档中的标题,必须为 XHTML 名称空间定义一个前缀,然后在所有 XPath 步骤中使用这个前缀,如 清单 4 所示。

清单 4. 即使使用默认名称空间的输入文档,在转换时也必须使用名称空间前缀
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Format"
    version="1.0"
    xmlns:h="http://www.w3.org/1999/xhtml">

<xsl:template match="/">
  Title of document:
  <xsl:value-of select="/h:html/h:head/h:title"/>
</xsl:template>

</xsl:stylesheet>

同样,必须非常小心地处理 XPath 表达式中的前缀。丢失任何一个前缀,都会导致错误的结果。

不幸的是,XSLT 版本 1.0 没有提供类似于默认名称空间的概念;因此,必须不断重复名称空间前缀。这一问题在 XSLT 版本 2.0 中得到了解决,在 XSLT 版本 2.0 中,可以指定一个默认名称空间并应用到 XPath 表达式中未使用前缀的元素上。在 XSLT 2.0 中,可以简化前面的样式表,如 清单 5 所示。

清单 5. XSLT 2.0 中 XPath 的默认名称空间声明
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Format"
    version="2.0"
    xpath-default-namespace="http://www.w3.org/1999/xhtml">

<xsl:template match="/">
  Title of document:
  <xsl:value-of select="/html/head/title"/>
</xsl:template>

</xsl:stylesheet>

错误使用节点测试 text()

很多样式表都包含了一些负责处理输入文档叶元素的简单模板。比如,可以在一个元素中存储一个价格:

<price>124.95</price>

并且您希望将它输出为一个新的 HTML 段落,并添加货币单位和标签:

<p>Price: 124.95 USD</p>

在我见过的许多样式表中,处理这一功能的模板常常会失败。因为在模板体内使用了 text() 节点测试,这极容易引起代码崩溃。下面所示的模板有什么问题呢?

<xsl:template match="price">
  <p>Price: <xsl:value-of select="text()"/> USD</p>
</xsl:template>

xsl:value-of 指令中的 XPath 表达式被缩减为表达式 child::text()。这个表达式选择 <price> 元素的子元素之间的所有文本节点。一般来说,这样的节点只有一个,并且所有内容都可以按预期工作。但是想象一下您将一条注释或处理指令放到了 <price> 元素的中间:

<price>12<!-- I'm a comment. I should be ignored. -->4.95</price>

这个表达式现在返回两个文本节点:124.95。但是 xsl:value-of 的语义是只返回节点集的第一个节点。在这种情况下,将会得到一个错误的输出:

<p>Price: 12 USD</p>

由于 xsl:value-of 要求一个单独的节点,因此必须将它用于可以返回单独节点的表达式。在很多情况下,引用当前的节点(.)是正确的方法。下面展示了以上示例模板的正确形式:

<xsl:template match="price">
  <p>Price: <xsl:value-of select="."/> USD</p>
</xsl:template>

当前节点(.)现在返回了整个 <price> 元素。xsl:value-of 指令自动返回节点的字符串值,该值将所有文本节点后代串联起来。这种方法可以保证您始终能够获得元素的完整内容,不管它是否包含注释、处理质量或子元素。

在 XSLT 2.0 中,修改了 xsl:value-of 指令的语义,并且它将返回所有 已传输节点的字符串值 — 而不仅仅是第一个。但是,更好的方法仍然是引用内容将被返回给其文本节点的元素。通过这种方式,在添加新的子元素以提供更细粒度标记时,代码就不会发生崩溃。

不要丢失上下文节点

每一个模板(xsl:template)或迭代(xsl:for-each)都通过一个当前节点实例化。所有相对 XPath 表达式都从这个当前节点开始进行计算。如果使用 / 作为一个 XPath 表达式的开头,那么就不会对当前节点计算这个表达式;相反,计算将从文档的根节点开始。这类表达式的结果将始终是相同的,并且不会关联到当前节点。

假设您需要处理如 清单 6 所示的简单发票。

清单 6. 示例发票
<invoice>
  <item>
    <description>Pilsner Beer</description>
    <qty>6</qty>
    <unitPrice>1.69</unitPrice>
  </item>
  <item>
    <description>Sausage</description>
    <qty>3</qty>
    <unitPrice>0.59</unitPrice>
  </item>
  <item>
    <description>Portable Barbecue</description>
    <qty>1</qty>
    <unitPrice>23.99</unitPrice>
  </item>
  <item>
    <description>Charcoal</description>
    <qty>2</qty>
    <unitPrice>1.19</unitPrice>
  </item>
</invoice>

如果忘记编写当前节点的相对表达式,那么最后就会得到如 清单 7 所示的错误样式表。

清单 7. 丢失上下文的错误表达式示例
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">

<xsl:template match="/">
  <html>
    <head>
      <title>Invoice</title>
    </head>
    <body>
      <table>
        <xsl:for-each select="/invoice/item">
          <tr>
            <td><xsl:value-of select="/invoice/item/description"/></td>             
            <td><xsl:value-of select="/invoice/item/qty"/></td>
            <td><xsl:value-of select="/invoice/item/unitPrice"/></td>
          </tr>          
        </xsl:for-each>
      </table>      
    </body>
  </html>  
</xsl:template>

xsl:for-each 中的 /invoice/item 表达式正确选择了发票中所有的项。但是 xsl:for-each 内部的表达式是错误的,因为它们是以 / 开头的,这表示它们是绝对表达式。这类表达式将始终返回第一个项的描述、数量和价格(回想一下上一小节 xsl:value-of 只返回节点集的第一个节点),因为绝对表达式不会依赖当前节点,当前节点对应于当前处理的项。

要修复这个问题很简单,可以在 xsl:for-each 内部使用一个相对表达式,如 清单 8 所示。

清单 8. 在迭代体内使用相对 XPath 表达式
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">

<xsl:template match="/">
  <html>
    <head>
      <title>Invoice</title>
    </head>
    <body>
      <table>
        <xsl:for-each select="/invoice/item">
          <tr>
            <td><xsl:value-of select="description"/></td>             
            <td><xsl:value-of select="qty"/></td>
            <td><xsl:value-of select="unitPrice"/></td>
          </tr>          
        </xsl:for-each>
      </table>      
    </body>
  </html>  
</xsl:template>

</xsl:stylesheet>

XSLT 的长处是自动执行常见任务。解析内容表就属于比较单调、费劲的任务。使用 XSLT,您可以自动生成这种表。您只需生成一些锚(anchor),然后生成指向这些锚的链接。在 HTML 中,创建锚非常简单,只需将一个惟一的标识符放到 id 属性内:

<div id="label">…</div>

在构建指向这个锚的链接时,在片段(fragment)标识符(#)之后添加 label,表示该链接指向文档内的一个特殊位置:

<a href="#label">link to …</a>

真正的样式表通常会使用 generate-id() 函数或输入文档提供的真正的标识符生成标签和链接。

这种链接产生的问题实际上并不出在 XSLT 本身,而是出在某些 “过分智能” 的 Web 浏览器。我见到许多样式表将片段标识符(#)错误地添加到锚中。然后只在 Windows® Internet Explorer® 测试了样式表输出。不幸的是,Internet Explorer 能够恢复 HTML 代码中的许多错误,因此在用户看来这些链接是完全正确的。但是如果在 Mozilla Firefox 或 Opera 这类浏览器中试着打开相同的页面,这些链接就是坏的,因为这些浏览器不能解决过度使用 # 带来的问题。

为了避免发生其他类似问题,最佳解决方法就是在多个浏览器中测试样式表生成的输出。

通过修改上下文节点简化样式表

如果处理的是商业文档或面向数据的 XML,那么通常不会特别依赖模板机制,而是简单挑选所需的内容并将其集合到一个更大模板的理想表单中。假设您需要处理如 清单 9 所示的发票。

清单 9. 使用复杂结构的发票
<Invoice>
  <ID>IN 2003/00645</ID>
  <IssueDate>2003-02-25</IssueDate>
  <TaxPointDate>2003-02-25</TaxPointDate>
  <OrderReference>
    <BuyersID>S03-034257</BuyersID>
    <SellersID>SW/F1/50156</SellersID>
    <IssueDate>2003-02-03</IssueDate>
  </OrderReference>
  <BuyerParty>
    <Party>
      <Name>Jerry Builder plc</Name>
      <Address>
	<StreetName>Marsh Lane</StreetName>
	<CityName>Nowhere</CityName>
	<PostalZone>NR18 4XX</PostalZone>
	<CountrySubentity>Norfolk</CountrySubentity>
      </Address>
      <Contact>Eva Brick</Contact>
    </Party>
  </BuyerParty>
  …
</Invoice>

处理这一文档的典型样式表(参见 清单 10)将在 XPath 表达式中包含大量重复的路径,因为相当一部分信息都位于输入 XML 树的同一部分中。

清单 10. 原生样式表使用了大量重复的 XPath 代码
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">

<xsl:template match="/">
  <html>
    <head>
      <title>Invoice #<xsl:value-of select="/Invoice/ID"/></title>
    </head>
    <body>
      <h1>Invoice #<xsl:value-of select="/Invoice/ID"/>
          issued on <xsl:value-of select="/Invoice/IssueDate"/></h1>

      <div>
        <h2>Buyer:</h2>

        <p>
          <b><xsl:value-of select="/Invoice/BuyerParty/Party/Name"/></b>
        </p>

        <p>Address:<br/>
          <xsl:value-of select="/Invoice/BuyerParty/Party/Address/StreetName"/><br/>
          <xsl:value-of select="/Invoice/BuyerParty/Party/Address/CityName"/><br/>
          <xsl:value-of select="/Invoice/BuyerParty/Party/Address/PostalZone"/>
        </p>
        
        <p>Contact person: <xsl:value-of select="/Invoice/BuyerParty/Party/Contact"/></p>
        …
      </div>
    </body>
  </html>  
</xsl:template>

</xsl:stylesheet>

XPath 表达式中的这些重复内容非常单调 — 您必须一再重复它们。并且这些内容在将来会成为负担。对输入文档的结构进行任何修改,都需要对表达式进行大量调整。通过提取出表达式的常用部分,您可以对样式表进行简化。具体方法是使用指令修改当前节点 —xsl:templatexsl:for-each清单 11 中的样式表包含经过简化的信息。

清单 11. 不包含常用 XPath 路径的样式表
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">

<xsl:template match="Invoice">
  <html>
    <head>
      <title>Invoice #<xsl:value-of select="ID"/></title>
    </head>
    <body>
      <h1>Invoice #<xsl:value-of select="ID"/>
          issued on <xsl:value-of select="IssueDate"/></h1>

      <div>
        <h2>Buyer:</h2>

        <xsl:for-each select="BuyerParty/Party">
          <p>
            <b><xsl:value-of select="Name"/></b>
          </p>

          <xsl:for-each select="Address">
            <p>Address:<br/>
              <xsl:value-of select="StreetName"/><br/>
              <xsl:value-of select="CityName"/><br/>
              <xsl:value-of select="PostalZone"/>
            </p>
          </xsl:for-each>

          <p>Contact person: <xsl:value-of select="Contact"/></p>
        </xsl:for-each>

        …
      </div>
    </body>
  </html>  
</xsl:template>

</xsl:stylesheet>

我将模板匹配从 / 修改为 Invoice,这样就不需要在每个 XPath 表达式的开头部分重复根元素名称。在模板内部,我使用 xsl:for-each 将当前节点临时修改为 buyerBuyerParty/Party)并在其内部再一次将当前节点修改为 addressAddress)。对非重复性元素使用 xsl:for-each 看上去有些奇怪,但这是完全正确的:迭代体将只被调用一次,但是修改了当前节点将节省大量重复输入。

处理混合内容

混合内容通常使用面向文档的 XML 表示。混合内容 表示一种结构,其中同时包含元素的子元素和文本节点的子元素。混合内容的一个典型例子就是包含文本和额外标记的段落,比如表示强调的标记或链接:

<para><emphasis>Douglas Adams</emphasis> was an English author, comic
radio dramatist, and musician. He is best known as the author of the
<link url="http://en.wikipedia.org/wiki/The_Hitchhiker's_Guide_to_the_Galaxy">Hitchhiker's
Guide to the Galaxy</link> series.</para>

按照文档顺序处理混合内容非常重要;否则,会导致混乱的输出,并且句子的顺序完全错乱。处理混合内容的最常见方法是对具有混合内容的元素或对其所有子元素调用 xsl:apply-templates。后续模板可以处理强调标记或链接等内嵌的标记。

我见到过许多样式表使用了 “择优” 方法处理混合内容。这种方法非常适合使用常规结构的文档,但是混合文档通常使用不同的内部结构,因此很难通过这种方式正确地处理。因此,如果遇到混合文档,尽量不要使用 xsl:value-ofxsl:for-each,而应该考虑使用模板。

样式表的效率

如果对非常小的数据集编写小型转换 — 比如,Web 应用程序中的一个视图层 — 您很可能不是特别关心转换本身的性能,因为这一过程对于其余的处理通常影响不大。但是,如果一个 XSLT 样式表对一个大型输入文档执行复杂操作或处理,那么应该考虑样式表所用结构对性能的影响。

一般而言,仅仅根据 XSLT 代码是很难作出判断的,因为它依赖于特定的 XSLT 实现 — 通过使用一些优化是否能够更好地处理代码并提高速度。

不管怎样,在实际的样式表中可以适当跳过某些内容。如果希望保存 planet,那么要非常小心地使用后代轴(descendant axis)(//)。如果使用 //,XSLT 处理器必须彻底检查整个树(子树)。在更大的一些文档中,这是一项代价很高的操作。更聪明的方法是编写更具体的表达式,显式指定查找节点的位置。比如,要获得购买方的地址,应该编写 /Invoice/BuyerParty/Party/Address,而不是编写 //BuyerParty//Address 甚至是 //Address。第一个表达式速度非常快,因为在计算期间只需要检查节点的一部分。这些针对性的表达式也很少会受到文档结构演变的影响,在文档结构发生演变时,具有相同名称不同含义的新元素可被添加到输入文档的不同上下文中。

在执行大量查找时使用的另一个技巧是使用 xsl:key 定义一个查找键,然后使用 key() 函数执行查找。

您还可以执行许多其他优化,但是它们所起的作用取决于您使用的 XSLT 处理器。

使用 XSLT 1.0 还是 2.0?

具体选择使用哪一个 XSLT 版本,这要取决于若干因素,但是通常来讲,我建议使用 XSLT 2.0。这是该语言的最新版本,其中包含有大量新指令和新函数,可以有效地简化众多任务 — 更加简短更加直观的代码通常更容易维护。而且,在 XSLT 2.0 中,您可以编写支持模式的样式表,使用模式验证输入和输出文档。支持模式的样式表可以使用模式中包含的信息自动检测样式表中某些类型的错误。

结束语

本文介绍了 XSLT 语言中一些比较难的内容。我希望您在学习完本文后能够更好地理解某些 XSLT 特性,并且能够编写更好的 XSLT 样式表。


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=XML
ArticleID=369767
ArticleTitle=避免常见的 XSLT 错误
publish-date=02162009