PHP 作为通用语言

作者:Marco Tabini

如果 PHP 是您在开发动态网站时选择的脚本语言,您可能已经爱上了它的即时性和强大功能。据估计,有一千万个网站至少使用了一些 PHP 脚本来生成他们的页面。

尽管大多数人主要将 PHP 用作 Web 开发脚本系统,但它具备适当的通用语言的所有特征,可以在各种其他环境中使用。在本文中,我将说明如何使用 PHP 的命令行版本来执行复杂的 shell 操作,例如操作数据文件、读取和解析远程 XML 文档以及通过 cron 调度重要任务。

本文的内容基于撰写本文时 PHP 的最新版本 4.3.0,该版本于 2002 年底发布。但是,您应该能够使用较旧版本的 PHP 4 而不会遇到太多问题。我将在必要时解释您可能遇到的差异。

PHP-CLI

随着 PHP 4.3 的发布,提供了一个名为命令行界面(或 PHP-CLI)的新版本解释器。PHP-CLI 不是 shell,正如其名称所示,而是一个旨在从 shell 运行的 PHP 版本。就软件开发而言,PHP-CLI 与其 CGI 或服务器 API (SAPI) counterparts 之间只有一些差异。首先,传统的 Apache 服务器变量不可用,因为 Apache 甚至不在其中,并且在执行脚本时不会输出 HTTP 标头。此外,引擎不使用输出缓冲,因为在非 Web 环境中它没有任何好处。

当您编译 PHP 版本时,默认情况下会创建 PHP-CLI,除非您在执行配置脚本时使用 --disable-cli 开关。但是,默认情况下不安装它。但是,您可以使用特殊命令强制 make 编译并安装它

make install-cli

要验证您的服务器上是否安装了 CLI 版本的 PHP,您只需执行此命令

php -v

结果版本信息应指定正在执行的是 CLI 版本还是 CGI 版本的 PHP。如果您只有 CGI 版本并且不想安装 CLI,您仍然可以使用 PHP 作为 shell 脚本语言。它们的差异主要是美观方面的,并且可以通过在调用解释器时使用正确的命令行开关来在一定程度上减轻其影响。

解析 RSS Feed

作为网络日志的爱好者,我经常访问网络上的许多博客。这是一个有点乏味的过程,因为我不喜欢新闻聚合器在我的机器上持续运行的想法,而且我认为没有必要为此付费。不过,RSS 聚合器似乎是展示如何使用 PHP 的一些强大功能(例如 fopen() wrappers 和内置 XML 解析引擎)来创建一个从命令行运行的脚本的好方法。

RSS feed 本质上是一个简单的 XML 文档,其中包含有关新闻来源(例如 Linux Journal)发布的项目的信息。它的格式由一个 channel 容器组成,该容器除了项目子容器集外,还包含几个可选元素,例如标题和描述。这些子容器中的每一个又包含一个标题、一个描述以及一个指向其代表的新闻报道的链接。

通常,新闻聚合器从任意数量的新闻 feed 加载信息,并将所有内容以给定的格式(例如 HTML)一起呈现。对于用户而言,新闻聚合器代表了一种方便的方式,可以为所有感兴趣的新闻来源创建一个单一的信息点。

我的基于 PHP 的新闻聚合器名为 Feeder,如列表 1 所示,它以纯文本电子邮件的形式呈现其结果,该电子邮件发送给执行脚本的用户。Feeder 从位于 ~/.feeder.rc 中的文件(列表 2)加载 RSS feed 列表。此文件的第一行还包含应将新闻 feed 数据发送到的电子邮件地址。配置文件的内容使用一个简单的技巧加载:反引号运算符,它执行与在 shell 中完全相同的功能,用于调用 cat 命令。然后使用 explode 函数将输出拆分为单个行的数组。

列表 1. Feeder,一个 RSS 聚合器

<?php

// Classes used internally to parse the XML
// data

class CItem
{
  var $title;
  var $description;
  var $url;
}

class CFeed
{
  var $title;
  var $url;

  var $items;

  var $currentitem;
}

// XML handlers

function ElementStarter($parser, $name, $attrs)
{
  global $currentelement;
  global $elements;

  $elements[$currentelement ++] = $name;
}

function ElementEnder($parser, $name)
{
  global $elements;
  global $currentelement;
  global $currentfeed;

  if ($name == 'ITEM')
  {
    $currentfeed->items[] =
           $currentfeed->currentitem;
    $currentfeed->currentitem = new CItem;
  }

  $currentelement--;
}

function DataHandler ($parser, $data)
{
  global $elements;
  global $currentelement;
  global $currentfeed;

  switch ($elements[$currentelement - 1])
  {
  case  'TITLE' :

      if ($elements[$currentelement - 2] == 'ITEM')
        $currentfeed->currentitem->title .= $data;
      else
        $currentfeed->title = $data;

    break;

  case  'LINK'  :

    if ($elements[$currentelement - 2] == 'ITEM')
      $currentfeed->currentitem->url .= $data;
    else
      $currentfeed->url .= $data;

    break;

  case 'DESCRIPTION'    :

    if ($elements[$currentelement - 2] == 'ITEM')
      $currentfeed->currentitem->description
                  .= $data;
    else
      $currentfeed->description .= $data;

    break;
  }
}

// Feed loading function

function get_feed ($location)
{
  global $elements;
  global $currentelement;
  global $currentfeed;

  $xml_parser = xml_parser_create();

  $elements = array();
  $currentelement = 0;
  $currentfeed = new CFeed;
  $currentfeed->currentitem = new CItem;

  xml_parser_set_option
    ($xml_parser, XML_OPTION_CASE_FOLDING, true);
  xml_set_element_handler
    ($xml_parser, "ElementStarter", "ElementEnder");
  xml_set_character_data_handler
    ($xml_parser, "DataHandler");

  if (!($fp = fopen($location, "r")))
    return 'Unable to open location';

  while ($data = fread($fp, 4096))
  {
    if (!xml_parse($xml_parser, $data, feof($fp)))
      return 'XML PARSE ERROR';
  }
  xml_parser_free($xml_parser);

  return $currentfeed;
}

// Feed formatting function

function format_feed ($feed, $url)
{

  if (!is_object ($feed))
  {
    $res = "Error loading feed at: $url.\n" .
           "$feed\n\n";
  }
  else
  {
    $res = "{$feed->title}\n[{$feed->url}]\n\n";

    foreach ($feed->items as $item)
    {
      $res .= "{$item->title}\n[{$item->url}]\n\n" .
        wordwrap ($item->description, 70) . "\n\n" .
        str_repeat ('-', 70) . "\n\n";
    }
  }

  return $res;
}

// Load up configuration file

$data = explode ("\n", trim (`cat ~/.feeder.rc`));

// The first line is the address, so skip it

$result = 0;

// Cycle through and get all the feeds

for ($i = 1; $i < count ($data); $i++)
  $result .= format_feed
    (get_feed ($data[$i]), $data[$i]);

// Mail them out to the user

mail ($data[0], 'Feeder update', $result);

?>


列表 2. Feeder 的配置文件


// Feed formatting function
function format_feed ($feed, $url)
{
   ob_start();

   if (!is_object ($feed)) {
   ?>
      <p>
      <b>Unable to load feed at
      <a href="<?= $url ?>"?>
      <?= htmlentities($url) ?></a></b></p>

      <?php

   } else {
   ?>

      <h1><a href="<?= $feed->url ?>">
      <?= $feed->title ?></a></h1>
      <p />

      <?php
      foreach ($feed->items as $item) {
      ?>

         <h2><a href="<?= $item->url ?>">
         <?= htmlentities ($item->title) ?></a></h2>
         <div width=500>
         <?= htmlentities ($item->description) ?>
         <hr></div>
       <?php
       }
   }

   $res = ob_get_contents();
   ob_clean();

   return $res;
}

XML feed 的解析分两个阶段进行。首先,get_feed 函数使用 fopen() wrappers 以 4KB 的块下载 feed。然后将这些块传递给内置 PHP XML 解析器的实例,该实例继续解释其内容并根据需要调用 ElementStarter()、ElementEnder() 和 DataHandler()。这三个函数反过来解析 XML 文件的内容,并创建一个 CFeed 和 CItem 实例的结构,该结构表示 feed 本身。然后,脚本调用 format_feed 函数,该函数扫描 feed 对象并生成其内容的文本版本。一旦所有 feed 都被解析和格式化,生成的邮件就会通过电子邮件发送给预期的收件人。

作为安全注意事项,format_feed() 使用 wordwrap 函数来格式化新闻项目的描述,使其宽度不超过 70 列。这有助于通过向用户呈现更紧凑的外观来提高新闻 feed 的可读性。在 PHP 4.3.0 之前,wordwrap() 的源代码包含一个未检查的数据缓冲区,理论上可以利用该缓冲区执行任意代码,从而构成安全问题。如果您没有使用最新版本的 PHP,您可能应该避免使用 wordwrap() 或将其替换为您自己编写的版本。

执行脚本

从 shell 执行脚本的最简单方法是显式调用 PHP 解释器

marcot ~# php feeder.php

如果您有 CGI 版本的 PHP,您可能需要使用 -q 开关,这会导致解释器忽略通常在 Web 事务期间需要的任何 HTTP 标头。

但是,如果您希望用户方便地访问您编写的脚本,则这种显式方法不是很实用。更好的解决方案是使脚本可执行,以便可以显式调用它们,就好像它们是自主程序一样。为此,首先确定 PHP 可执行文件的确切位置

marcot ~# which php
/usr/local/bin/php

下一步是创建 shebang—一个初始命令,指示 shell 解释器通过特定应用程序(在本例中为 PHP 引擎)管道传输可执行文件的其余部分。shebang 必须是脚本的第一行—它之前不能有任何空格。它以字符 # 和字符 ! 开头,后跟必须通过其管道传输文件其余部分的可执行文件的名称。例如,如果您使用的是 CLI 版本的 PHP,则您的 shebang 可能如下所示

#!/usr/local/bin/php

如果您使用的是 CGI 版本的 PHP 解释器,您也可以向其传递其他选项,以使其保持静默并防止打印出通常的 HTTP 标头

#!/usr/local/bin/php -q

最后一步是使您的脚本可执行

marcot ~# chmod a+x feeder.php

在此阶段,您可以运行脚本而无需显式调用 PHP 解释器;shell 将为您处理。

您可能已经注意到,我没有重命名脚本以删除 .php 扩展名。即使从 shell 运行脚本时扩展名本身不是必需的,但它的存在使 vim 等文本编辑器可以轻松识别它并突出显示源代码的语法

marcot ~# ./feeder.php
通过 Cron 运行 PHP 脚本

每次想要阅读新闻页面时都必须显式调用的新闻聚合器不是很有用。因此,您可能希望系统在特定计划上自动运行它。cron 守护程序通常用于此目的。cron 是一个简单的守护程序,它在后台运行,并以固定的时间间隔读取一个特殊文件,称为 crontab,其中包含服务器上每个用户的计划规范。根据 crontab 文件中包含的信息,cron 执行任意数量的 shell 命令,并可选择将它们的结果的电子邮件通知发送给用户。crontab 文件包含以下格式的条目

minute hour
day month
weekday command

前五个字段指示必须执行命令的时间或时间。例如

5 9 13 9 1 /usr/bin/feeder.php

意味着在 9 月 13 日上午 9:05,将执行命令 /usr/bin/feeder.php,但前提是 9 月 13 日是星期一(工作日 1)。这听起来可能很复杂,但这是一个极端的例子。最有可能的是,您希望在更简单的计划上执行命令,例如每小时的开始。这可以通过使用 * 通配符来完成,这意味着任何。因此,对于每小时一次,在整点,您将输入

0 * * * * /usr/bin/feeder.php

对于每天一次,在午夜,输入

0 0 * * * /usr/bin/feeder.php

时间字段允许更复杂的规范。例如,您可以通过用逗号分隔来创建特定时间的列表

0,30 * * * * /usr/bin/feeder.php

此 crontab 规范导致命令 /usr/bin/feeder.php 从整点开始每 30 分钟运行一次。同样,您可以通过用破折号分隔它们来指定包含的时间列表。例如,以下 crontab 命令

0 0 * * 1-3 /usr/bin/feeder.php

导致脚本在午夜、星期一至星期三执行。

为了更改 crontab 文件的内容,您需要使用 crontab 实用程序,该实用程序还会自动编辑正确的文件并通知守护程序您的计划已更改。只要 PHP 脚本不期望用户输入任何内容,就没有运行 PHP 脚本作为 cron 作业的特殊要求。

操作 HTML 代码

即使您的 PHP-CLI 脚本没有通过 Web 服务器输出 HTML,您仍然可以使用它们来操作和生成 HTML 代码。由于脚本是以模块化方式编写的,因此将脚本的输出转换为 HTML 格式仅涉及更改 format_feed 函数并修改对 mail() 的调用。这样做是为了使用户的电子邮件应用程序可以将电子邮件消息识别为有效的 HTML 文档。

使用 PHP 编写 Web 页面脚本的最大优势之一是能够将动态语句直接与静态 HTML 代码混合使用。从列表 3 可以看出,其中显示了 format_feed 的更新版本,即使脚本没有输出到 Web 页面,此概念仍然可以完美地工作。

列表 3. 生成 HTML 的 format_feed 函数的版本

// Feed formatting function

function format_feed ($feed, $url)
{

  ob_start();

  if (!is_object ($feed))
  {
  ?>
    <p>
    <b>Unable to load feed at
    <a href="<?= $url ?>"?>
    <?= htmlentities($url) ?></a></b></p>
  <?php
  }
  else
  {
    ?>

    <h1><a href="<?= $feed->url ?>">
    <?= $feed->title ?></a></h1>
    <p />

    <?php

    foreach ($feed->items as $item)
    {
    ?>
        <h2><a href="<?= $item->url ?>">
        <?= htmlentities ($item->title) ?></a></h2>
        <div width=500>
        <?= htmlentities ($item->description) ?>
        <hr>
        </div>
    <?php
    }
  }

  $res = ob_get_contents();
  ob_clean();

  return $res;
}


使得可以在变量中捕获 PHP 输出的技巧本质上包括通过调用 ob_start() 来启用解释器的输出缓冲区(默认情况下禁用)。一旦输出了适当的信息,脚本就会检索缓冲区的内容,然后擦除它并通过调用 ob_end() 关闭输出缓冲。

后续步骤

尽管我在本文中介绍的新闻聚合器脚本执行了一组相当复杂的功能——从从 Web 上抓取内容到解析 XML 并将其格式化为 HTML——但它只需要大约 200 行代码,包括所有注释和空行。可以使用 Perl 甚至 shell 脚本编写相同的脚本,并在 wget、expat 和 sendmail 等一些外部应用程序的帮助下完成。在我看来,后一种方法会导致代码库复杂,并且有很多犯错的机会。

PHP-CLI 很少默认安装在运行 Linux 的机器上,尽管您可以指望 Perl 随时可用。因此,如果您可以控制运行脚本的服务器的组成,并且您对 PHP 感到满意,那么您没有理由需要学习另一种语言来编写大多数 shell 应用程序。另一方面,如果您正在编写代码以在您无法控制的单独机器上运行,您可能会发现 PHP 是一个稍微成问题的选择。

Marco Tabini 是居住在加拿大多伦多的作家和软件顾问。他的公司 Marco Tabini & Associates, Inc. 专门从事在企业环境中引入开源软件。您可以通过他的博客 blogs.phparch.com 联系 Marco。

加载 Disqus 评论