面向 Perl 开发人员的 XML,第 1 部分: XML 加 Perl —— 简单的

来源:岁月联盟 编辑:zhu 时间:2009-03-05

  简介

  本文是关于 Perl 和 XML 的分三部分的系列文章的第一篇,主要关注 XML::Simple。对于 Perl 程序员,第一次使用 XML 往往是从配置文件接收参数。本文要讲解如何用两行代码读取这样的参数,第一行告诉 Perl 要使用 XML::Simple,第二行将一个变量设置为文件中的一个值。甚至不必提供配置文件的名称:XML::Simple 会进行智能化的猜测。

  作为一个更复杂的示例,我们要研究一个宠物商店应用程序。在那一节中,将学习如何简便地将 XML 文件读入一个层次化的 Perl 数据结构(匿名数组和散列的组合)。本文讲解 Perl 如何简便地转换和重组原 XML 文档中包含的信息,然后以各种形式将信息写回去。

  最后,讨论 XML::Simple 的一些限制。这会引出后两篇文章的主题:更高级的解析,使用高级工具对 XML 的形式进行转换,以及对 DOM 和其他内存中形式的 XML 进行串行化。

  本文主要针对非常熟悉 Perl 的 Perl 程序员,但是对 XML 专家也有用,可以帮助他们以更程序性的方式操纵 XML 文档。

  开始

  在开始之前,需要安装 Perl。如果还没有安装 Perl,请参见 参考资料 中的链接。

  接下来,需要 XML::Simple。如果使用 UNIX 或 Linux,那么最方便的方法是使用 cpan 从 CPAN 获得它们。首先,使用清单 1 中的命令在机器上安装 cpan。一般来说,应该作为根用户执行这个操作,从而让 Perl 模块可供所有用户使用。

  清单 1. 安装 cpan,获得 XML::Simple

$ perl -MCPAN -e shell
cpan> ...
cpan> install XML::Simple
cpan> quit

  首次运行此命令时,要经历很长的对话。这在 清单 1 中做了省略。某些用户会发现,编辑得到的配置文件(/etc/perl/CPAN/Config.pm)很方便。

  Windows 用户使用 PPM 执行相似的过程(如果您还没有 PPM,请参见 参考资料)。在这种情况下,安装模块的命令与清单 2 相似。

  清单 2. Windows:使用 PPM 获得 XML::Simple

$ ppm install XML::Simple

  cpan 和 ppm 都会在安装期间检查依赖项,并从存储库获取任何缺少的依赖项。如果将 cpan 的先决条件策略设置为 “follow”,那么这是自动的。在安装期间,模块一般会被编译,并产生几页消息。这会花些时间,这是正常的。

  另一个先决条件

  XML::Simple 将 XML 文档转换为对散列和散列数组的引用。这意味着需要充分了解引用、散列和数组在 Perl 中的交互作用。如果在这方面需要帮助,请参阅 参考资料 中精彩的 Perl 参考教程。

  XML::Simple

  Grant McLean 的 XML::Simple 基本上有两个功能;它将 XML 文本文档转换为 Perl 数据结构(匿名散列和数组的组合),以及将这种数据结构转换回 XML 文本文档。

  这些功能尽管有限,但是很有用,我们将在两个层次上说明这一点。首先,您将看到如何从 XML 形式的配置文件中导入数据。然后在更复杂的本地宠物商店示例中,学习如何将复杂的大型 XML 文件读入内存,以传统 XML 工具(比如 XSLT)不可能实现的方式对它进行转换,并将它写回磁盘。

  对于大多数情况,XML::Simple 提供了在 Perl 中处理 XML 所需的所有东西。

  XML 配置文件

  全世界的所有程序员都要面对一个问题:需要将适度复杂的配置信息传递给程序,但是如果用命令行参数传递这些信息,就太麻烦了。所以决定使用配置文件。因为 XML 是这种信息的标准格式,所以决定采用 XML 文件格式,形成的文件像清单 3 这样。我们将使用 XML::Simple 处理这个文件。

  清单 3. 配置文件 part1.xml

<config>
 <user>freddy</user>
 <passwd>longNails</passwd>
 <books>
  <book author="Steinbeck" title="Cannery Row"/>
  <book author="Faulkner" title="Soldier's Pay"/>
  <book author="Steinbeck" title="East of Eden"/>
 </books>
</config>

  除了构造器之外,XML::Simple 有两个子例程:XMLin() 和 XMLout()。如您所预料的,第一个子例程读取 XML 文件,返回一个引用。给出适当数据结构的引用,第二个子例程将它转换为 XML 文档,根据参数的不同,产生的 XML 文档采用字符串格式或文件形式。

  XML::Simple 有一些合理的默认设置,例如如果没有指定输入文件名,那么 Perl 程序 part1.pl(清单 4)将读取文件 part1.xml。

  清单 4. part1.pl

#!/usr/bin/perl -w
use strict;
use XML::Simple;
use Data::Dumper;
print Dumper (XML::Simple->new()->XMLin());

  执行 part1.pl 会产生清单 5 所示的输出。

  清单 5. part1.pl 的输出

$VAR1 = {
   'passwd' => 'longNails',
   'user' => 'freddy',
   'books' => {
       'book' => [
          {
         'title' => 'Cannery Row',
         'author' => 'Steinbeck'
          },
          {
         'title' => 'Soldier's Pay',
         'author' => 'Faulkner'
          },
          {
         'title' => 'East of Eden',
         'author' => 'Steinbeck'
          }
         ]
      }
  };

  XMLin() 返回一个对散列的引用。如果将这个引用赋值给变量 $config,就可以使用 $config->{user} 获得用户名,使用 $config->{passwd} 获得密码。关心简便性的读者会注意到,只用一行代码就可以读取配置文件并返回一个参数:XML::Simple->new->{user}。

  显然,在处理 XML::Simple 时要注意几个问题:

  首先,它丢弃了根元素的名称。

  第二,它将具有相同名称的元素合并成一个匿名数组引用。因此,第一本书的标题是 @{$config->{books}->{book}}[0]->{title},即 “Cannery Row”。

  第三,它以同样的方式对待属性和子元素。

  可以通过 XMLin() 的选项改变这些行为。

  一个更复杂的示例:宠物商店

  XML::Simple 不仅仅能够对配置文件进行简单的解析。实际上,它可以处理复杂的大型 XML 文件,并将它们转换为整齐的数据结构,这些结构常常更适合进行转换,这在 Perl 中是非常容易的,但是使用比较传统的 XML 转换工具(比如 XSLT)是很难完成的,甚至是不可能的。

  假设您在一家宠物商店工作,要在一个 XML 文件中记录关于宠物的信息。这个文档的一部分如清单 6 所示。经理希望做一些修改:

  为了节省空间,将所有子元素改为属性

  将价格提高 20%

  让所有价格显示为同样的形式,都显示两位小数

  对列表进行排序

  用年龄替换出生日期

  由于您对 Perl 有信心,而且意识到 XSLT 无法完成计算,所以决定用 XML::Simple 完成这个工作(见清单 6)。

  清单 6. pets.xml 文件的一部分

<?xml version='1.0'?>
<pets>
 <cat>
  <name>Madness</name>
  <dob>1 February 2004</dob>
  <price>150</price>
 </cat>
 <dog>Maggie</name>
  <dob>12 October 2005
  <name></dob>
  <price>75</price>
  <owner>Rosie</owner>
 </dog>
 <cat>
  <name>Little</name>
  <dob>23 June 2006</dob>
  <price>25</price>
 </cat>
</pets>

  最初的探索

  首先尝试按照清单 7 这样使用 XML::Simple。

  清单 7. 最初的尝试

#!/usr/bin/perl -w
use strict;
use XML::Simple;
use Data::Dumper;
my $simple = XML::Simple->new();
my $data  = $simple->XMLin('pets.xml');
# DEBUG
print Dumper($data) . "n";
# END

  谨慎起见,先使用 Data::Dumper 查看在内存中读入了什么内容,结果如清单 8 所示。

  清单 8. 获得的内容

$VAR1 = {
   'cat' => {
      'Little' => {
          'dob' => '23 June 2006',
          'price' => '25'
         },
      'Madness' => {
        'dob' => '1 February 2004',
        'price' => '150'
         }
     },
   'dog' => {
      'owner' => 'Rosie',
      'dob' => '12 October 2005',
      'name' => 'Maggie',
      'price' => '75'
     }
  };

  结果是让人失望的。猫和狗的表示方式完全不一样:两只猫的信息存储在一个双重嵌套的散列中,以名称作为键;而关于狗的信息存储在一个简单散列中,它的名称只是属性之一。另外,根元素的名称消失了。所以您去阅读文档(参见 参考资料)并发现有一些选项,尤其是 ForceArray=>1 和 KeepRoot=>1。第一个选项使所有嵌套元素都表示为数组。在输入中第二个选项指示保留根元素的名称。正如在后面的输出中看到的,这意味着数据的内存表示会包含根元素的名称。使用这些选项之后,得到了清单 9 中的结果,这对于程序员来说处理起来容易多了,尽管它占用的内存要多一点儿。

  清单 9. 添加选项之后的 Data::Dumper 输出,整洁了些,可读性有所提高

$VAR1 = {
   'pets' => [
      {
       'cat' => [
          {
            'dob'  => [ '1 February 2004' ],
            'name' => [ 'Madness' ],
            'price' => [ '150' ]
          },
          {
            'dob'  => [ '23 June 2006' ],
            'name' => [ 'Little' ],
            'price' => [ '25' ]
          }
         ],
       'dog' => [
          {
            'owner' => [ 'Rosie' ],
            'dob'  => [ '12 October 2005' ],
            'name' => [ 'Maggie' ],
            'price' => [ '75' ]
          }
         ]
      }
     ]
  };

  对内存中的数据结构进行转换

  现在在内存中已经有了一个整齐的结构,非常容易通过程序处理它。为了实现您老板的第一个要求(将子元素转换为属性),需要替换对数组的引用,如清单 10 所示。

  清单 10. 对单元素数组的引用

'name' => [ 'Maggie' ]

  然后,必须替换简单值的引用,如清单 11 所示。

  清单 11. 简单值的引用

'name' => 'Maggie'

  经过这一修改,XML::Simple 将输出一个属性 —— 值对,而不是子元素。在需要输出一个类型的多个实例的情况下 —— 在这个示例中,有两只猫和一只狗 —— 需要以匿名散列的匿名数组的形式收集散列。清单 12 演示如何完成这个有点儿技巧性的任务。

  清单 12. 将数组转换为散列,从而将元素转换为属性

sub makeNewHash($) {
  my $hashRef = shift;
  my %oldHash = %$hashRef;
  my %newHash = ();
  while ( my ($key, $innerRef) = each %oldHash ) {
    $newHash[$key] = @$innerRef[0];
  }
  return %newHash;
}

  给出一个描述单个宠物的 XML 引用,这段代码将它转换为一个散列。如果该类型只有一只宠物,那么这样就可以了。可以将这个新散列的引用写回 $data。但是,如果该类型有多只宠物,要写回的就应该是对一个匿名数组的引用,这个数组包含对描述各个宠物的匿名散列的引用。可以查看完整解决方案(清单 16)中的 foldType(),了解这是如何实现的。

  其他需求:Perl 的出色之处

  老板的其他需求是对列表进行排序、将价格提高 20%、将价格写成两位小数以及用年龄替换出生日期。第一个需求无需处理,因为这是 XML::Simple 输出的默认设置。在 Perl 中,第二个和第三个需求只需一行代码就能够实现。Perl 具有很方便的多态性:在将价格提高 20% 时,价格是数字;但是,如果将它们作为字符串写回,它们会保持您所指定的格式。所以清单 13 同时完成这两项工作,它将字符串转换为数字,处理后再转换回字符串。

  清单 13. 提高价格并重新格式化

sprintf "%6.2f", $amt * (1 $change)

  将出生日期转换为年龄有点儿困难。但是,研究一下 CPAN 就会发现,Date::Calc 提供了所需的所有特性(还有许多其他特性)。Decode_Date_EU 将 ‘European’ 格式的日期(比如 13 January 2006)转换为三个元素的数组(YMD),这是这个包使用的标准日期格式。给出两个这样的日期,Delta_YMD($earlier, $later) 会产生相同格式的时间差,这样就可以得到年龄。但糟糕的是,Delta_YMD 有点儿错误:有时候,天或月份会是负数!但是,在 google 上很容易搜索到修复方法。完整解决方案(见 清单 16)中的 deltaYMD 演示了如何处理这个问题。

  对猫和狗进行分派

  为了使代码更容易扩展,要使用清单 14 所示的分派表。Jason Dominus 的精彩著作 Higher Order Perl 中详细讨论了分派表(参见 参考资料 中的链接)。

  清单 14. 分派表

my $DISPATCHER = {
  'cat'  => sub { foldType(shift); },
  'dog'  => sub { foldType(shift); },
  'hippo' => &hippoFunc,
};

  分派表可以包含用来处理特定元素的实际代码(匿名子例程),也可以包含对别处定义的命名子例程的引用。可以使用这种结构实现其他语言中 switch-case 结构的效果。

  在这个示例中,只有两种元素类型,猫和狗。在真实的 XML 文档中,可能有许多元素类型,而且处于不同的层次上。尽管可以在 Perl 中使用 if ... elsif ... elsif 结构,但是使用一个或多个分派表要清晰得多,而且更容易维护。

  将 XML 写回磁盘

  XML::Simple 的默认输出通常是很合理的。如果没有为 XMLout() 指定选项,它就会产生一个字符串。如果希望将输出写到文件中,就要加上 OutputFile 选项。如果没有另外指定的话,它将使用 <opt> 作为根元素。如果内存中的数据结构具有根元素名称,那么添加 KeepRoot 选项,将这个选项设置为 true 或者 1(在 Perl 中 1 表示真)。清单 15 演示了具体做法。

  清单 15. 输出到 XML 文件

$simple->XMLout($data,
      KeepRoot  => 1,
      OutputFile => 'pets.fixed.xml',
      XMLDecl  => "<?xml version='1.0'?>",
    );

  完整的解决方案

  清单 16 中的 112 行代码就可以完成老板的要求。XML::Simple 的简便性确实让人印象深刻。有 8 行代码用来读写 XML。其他代码的一小半儿用来转换 XML 的结构。

  清单 16. 代码的最终版本

#!/usr/bin/perl -w
use strict;
use XML::Simple;
use Date::Calc qw(Add_Delta_YM Decode_Date_EU Delta_Days Delta_YMD);
use Data::Dumper;
my $simple = XML::Simple->new (ForceArray => 1, KeepRoot => 1);
my $data  = $simple->XMLin('pets.xml');
my @now = (localtime(time))[5, 4, 3];
$now[0] = 1900; # Perl years start in 1900
$now[1] ;    # months are zero-based
sub fixPrice($$) {
  my ($amt, $change) = @_;
  return sprintf "%6.2f", $amt * (1 $change);
}
sub deltaYMD($$) {
  my ($earlier, $later) = @_;  # refs to YMD arrays
  my @delta = Delta_YMD (@$earlier, @$later);
  while ( $delta[1] < 0 or $delta[2] < 0 ) {
    if ( $delta[1] < 0 ) { # negative month
      $delta[0]--;
      $delta[1] = 12;
    }
    if ( $delta[2] < 0 ) { # negative day
      $delta[1]--;
      $delta[2] = Delta_Days(
          Add_Delta_YM (@$earlier, @delta[0,1]), @$later);
    }
  }
  return @delta;
}
sub dob2age($) {
  my $strDOB = shift;
  my @dob = Decode_Date_EU($strDOB);
  my $ageRef = deltaYMD( @dob, @now );
  my ($ageYears, $ageMonths, $ageDays) = @$ageRef;
  my $age;
  if ( $ageYears > 1 ) {
    $age = "$ageYears years";
  } elsif ($ageYears == 1) {
    $age = '1 year' . ( $ageMonths > 0 ?
      ( ", $ageMonths month" . ($ageMonths > 1 ? 's' : '') )
      : '');
  } elsif ($ageMonths > 1) {
    $age = "$ageMonths months";
  } elsif ($ageMonths == 1) {
    $age = '1 month' . ( $ageDays > 0 ?
      ( ", $ageDays day" . ($ageDays > 1 ? 's' : '') ) : '');
  } else {
    $age = "$ageDays day" . ($ageDays != 1 ? 's' : '');
  }
  return $age;
}
sub makeNewHash($) {
  my $hashRef = shift;
  my %oldHash = %$hashRef;
  my %newHash = ();
  while ( my ($key, $innerRef) = each %oldHash ) {
    my $value = @$innerRef[0];
    if ($key eq 'dob') {
      $newHash{'age'} = dob2age($value);
    } else {
      if ($key eq 'price') {
        $value = fixPrice($value, 0.20);
      }
      $newHash{$key} = $value;
    }
  }
  return %newHash;
}
sub foldType ($) {
  my $arrayRef = shift;
  # if single element in array, return simple hash
  if (@$arrayRef == 1) {
    return makeNewHash(@$arrayRef[0]);
  }
  # if multiple elements, return array of simple hashes
  else {
    my @outArray = ();
    foreach my $hashRef (@$arrayRef) {
      push @outArray, makeNewHash($hashRef);
    }
    return @outArray;
  }
}
my $dispatcher = {
  'cat' => sub { foldType(shift); },
  'dog' => sub { foldType(shift); },
};
my @base = @{$data->{pets}};
my %types = %{$base[0]};
my %newTypes = ();
while ( my ($petType, $arrayRef) = each %types ) {
  my @petArray = @$arrayRef;
  print "type $petType has " . @petArray . " representatives n";
  my $refReturned = &{$dispatcher->{$petType}}( $arrayRef );
  $newTypes{$petType} = $refReturned;
}
$data->{pets} = %newTypes;       # overwrite existing data
$simple->XMLout($data,
      KeepRoot  => 1,
      OutputFile => 'pets.fixed.xml',
      XMLDecl  => "<?xml version='1.0'?>",
    );

 

  尽管还能让这段 Perl 代码更简洁,但是它已经足以说明在 Perl 中处理 XML 是多么容易。尤其是,通过使用分派表,可以按照非常清晰且可维护的方式处理许多不同结构的元素类型。

  限制

  不幸的是,有些操作无法用 XML::Simple 完成。我将在第 2 部分和第 3 部分中详细讨论这个问题,但是 XML::Simple 有两个主要限制。首先,在输入方面,它将完整的 XML 文件读入内存,所以如果文件非常大或者需要处理 XML 数据流,就不能使用这个模块。第二,它无法处理 XML 混合内容,也就是在一个元素体中同时存在文本和子元素的情况,如清单 17 所示。

  清单 17. 混合内容

<example>of <mixed/> content</example>

  如何判断文件是否太大了,XML::Simple 处理不了?经验规则是,XML 被读入内存时它会扩大 10 倍。这意味着,如果您的工作站上有几百 MB 的空闲内存,那么 XML::Simple 能够处理的 XML 文件大小最多为几十 MB。

  结束语

  XML 在计算环境中已经无处不在了,而且越来越深地植入了现代应用程序和操作系统中。Perl 程序员迫切需要掌握使用 XML 的方法。XML::Simple 这样的工具能够轻松地将 XML 文档转换为容易理解的 Perl 数据结构,以及将这些数据结构转换回 XML。这些操作一般只需一行代码。

  另一方面,XML 专家也会惊喜地发现 Perl 在转换和响应 XML 内容方面是多么有帮助。

  第 2 部分将讲解如何在 Perl 中进行两种主要的 XML 解析:树解析和事件驱动的解析。