Apache 2.0 中的过滤器

不久前,尽管免费资源相对较少,但我认为我对 mod_perl 2.0 的研究已经拖延得太久——是时候真正开始测试并尝试所有新功能了。我发现新的 mod_perl API 充满了有趣的功能,同时发现和使用它们既繁琐又令人沮丧,同时也启发性和有趣。希望我可以通过先自己体验痛苦,然后通过一系列文章分享一些经验,来帮助减轻你可能会遇到的成长痛苦。请将本文和未来的文章视为我们共同探索充满挑战但令人兴奋的新 mod_perl 前沿的旅程。

Apache 2.0 重设计工作中出现的一个更有趣且实用的功能是输出过滤器。虽然 Apache 2.0 中有各种过滤器,包括输入和连接过滤器,但对我来说最有趣的是输出过滤器——主要是因为 2.0 讨论中明确指出,在 Apache 1.3 中过滤输出内容是不可能的(好吧,实际上是非常困难的),尽管 mod_perl 用户多年来已经能够(在一定程度上)过滤内容。因此,当我开始研究 mod_perl 2.0 时,我的第一个任务似乎是逻辑上的,那就是将教育性且实用的Apache::Clean(一个用于 mod_perl 1.0 的内容过滤器)移植到新的架构中。

我们将在此处检查的是使用 mod_perl 2.0 API 的 Apache::Clean 的初步实现。因为 mod_perl 2.0 仍在每天调整,如果你想在你的机器上跟踪,那么你需要从 CVS 获取当前的 mod_perl 版本,或者获取最近的 快照——Linux 发行版(如 RedHat)中提供的最新版本,或者 CPAN(1.99_08)上的最新版本,对于我们将要做的来说都过于陈旧了。最新的 Apache 2.0Perl 5.8.0 版本也将很有帮助。请记住,mod_perl 2.0 中的许多有趣功能尚未完全稳定,所以如果你发现六个月后的东西工作得有点不同,请不要感到惊讶。

输出过滤器究竟是什么?

好吧,承认吧。在某个时刻,你编写了一个生成带有嵌入式服务器端包含标签的 HTML 的 CGI 脚本。这个想法背后的动力很简单:你希望这些嵌入的 SSI 标签能让你免于添加到你的动态页面的底部以外的额外工作,比如添加一个预设的页脚。听起来合理,对吧?看到那些未处理的 SSI 标签出现在结果页面中肯定令人震惊。

实际上,无论你是否知道,在 Apache 术语中,你试图过滤你的内容,或者将一个进程(CGI 脚本)的输出传递给另一个进程(Apache 的 SSI 引擎)以进行后续处理。内容过滤是一个简单而自然的想法,作为程序员,我们对此感到很自然。毕竟,Apache 应该是模块化的,并且将模块化组件一起——在 Unix 命令行上,我们经常做的是 cat yachts.txt | wc -l。在我们的首选 Web 服务器中希望有相同的功能似乎不仅合乎逻辑,而且在高效应用程序开发中几乎是必需的。

虽然这个想法确实很合理,但上述实验揭示了Apache 1.3服务器本身的局限性,即按照设计,对于特定请求不能有超过一个内容处理器——您可以使用mod_cgi处理CGI脚本,或者使用mod_include解析SSI文档,但不能两者同时使用。

在Apache 2.0中,引入了输出过滤器的概念,它提供了一种官方方式来截取和操作数据在从内容处理器到浏览器传输过程中的数据。在我们的SSI示例中,mod_include在Apache 2.0中实现为一个输出过滤器,使其能够后处理静态文件(由默认的Apache内容处理器提供)或动态生成的脚本(如由mod_cgi、mod_perl或mod_php生成的脚本)。mod_perl忠实于将其整个Apache API暴露给Perl的目标,允许您通过Perl插件到Apache过滤器API,并在Perl中创建自己的输出过滤器,这就是我们将要使用Apache::Clean来做的。

HTML::CleanApache::Clean

在深入探讨Apache::Clean之前,我们先来看看HTML::Clean,它基本上是一个mod_perl包装器,它将HTML::Clean转换成一个输出过滤器。HTML::Clean是一个小巧的模块,它使用多种不同的但简单的技术来减少HTML页面的尺寸,例如删除不必要的空白、将较长的HTML标签替换为较短的等效标签等。结果是页面虽然仍然是有效的HTML,并且可以很容易地由浏览器渲染,但相对紧凑。如果您所在的环境中带宽减少很重要,那么使用HTML::Clean在离线状态下整理静态页面是一种快速简单的方法来节省一些字节。

以下是HTML::Clean的一个简单示例。

use HTML::Clean ();

use strict;

my $dirty = q!<strong>&quot;helm's alee&quot;</strong>!;

my $h = HTML::Clean->new(\$dirty);

$h->strip({ shortertags => 1, entities => 1 });

print ${$h->data};

如您所见,HTML::Clean的接口是面向对象的,相当直接。一切从调用new()构造函数开始,以创建一个HTML::Clean对象。new()接受要清理的文件名或包含一些HTML的字符串引用。确定要整理的HTML的具体方面的方法有两种:或者使用level()方法设置优化级别,或者通过传递一组丰富的选项中的任何数量的选项给strip()方法。在任何情况下,strip()都用于实际清理HTML。之后,调用data()方法返回一个包含经过抛光的HTML的字符串引用,就像Perl风格的白色。在我们的示例代码中,原始HTML已经改变为

<b>"helm's alee"</b>

这是原始字符串大小的一半,但浏览器显示的方式相同。

根据您网站的大小,使用HTML::Clean可能会导致通过电线发送的字节数显著减少——例如,当前mod_perl项目主页的首页在用$h->level(9)整理后,其大小变为原始大小的70%。然而,虽然花时间整理静态HTML可能是有意义的,但任何给定网站上的静态页面数量似乎每天都在减少。那么动态生成的HTML怎么办呢?

处理动态HTML的一种方法是将HTML::Clean程序添加到应用程序的每个动态组件中,这个过程实际上既不可扩展也不易维护。更好的解决方案是将HTML::Clean处理直接注入到我们想要的地方的服务器响应中,以创建一个可配置的模块,我们可以配置它来后处理到任何给定URI的请求。这就是Apache::Clean的用武之地。

Apache::Clean 提供了一个基本的接口进入 HTML::Clean,但它作为一个输出过滤器工作。如前所述,Apache::Clean 已经在 mod_perl 1.0 中存在,但在 Apache 1.3 中它的功能有限,只能对由 mod_perl 生成的响应进行后处理,而且这需要足够的魔法。我们不会深入探讨在 mod_perl 1.0 中它是如何工作的——有关详细信息,请参阅 《mod_perl 开发者手册》 中的第 15.4 节或原始的 Apache::Clean manpage。随着 Apache 2.0 的推出和输出过滤器的引入,我们现在可以将 Apache::Clean 编码为 Apache 请求处理的真实部分,这样我们就可以在内容生成者完全独立的情况下对浏览器上的响应进行清理。

新指令

以下是 Apache 2.0 的一个可能配置,它将 CGI 脚本的输出,对 SSI 标签进行后处理,然后用我们的 Apache::Clean 输出过滤器清理。

Alias /cgi-bin /usr/local/apache2/cgi-bin
<Location /cgi-bin>
  SetHandler cgi-script

  SetOutputFilter INCLUDES
  PerlOutputFilterHandler Apache::Clean

  PerlSetVar CleanOption shortertags
  PerlAddVar CleanOption whitespace

  Options +ExecCGI +Includes
</Location>

与 Apache 1.3 一样,mod_cgi 的启用方式也相同——在我们的情况下是通过 SetHandler cgi-script 指令,尽管这并不是唯一的方式,熟悉的 ScriptAlias 指令仍然被支持。在这段 httpd.conf 片段中,不同的地方是 SSI 引擎(mod_include)的配置。如前所述,mod_include 在 Apache 2.0 中被实现为一个输出过滤器,输出过滤器带来了一个新的指令。SetOutputFilter 指令激活了 SSI 引擎——在我们的容器中激活了 INCLUDES 过滤器。这意味着无论谁处理实际的内容生成,对 cgi-bin/ 的请求都将由 mod_include 进行解析。有关其他可能的 SSI 配置和选项,请参阅 mod_include 文档

在解决了 Apache 的通用问题之后,我们可以继续到 mod_perl 部分,这部分并不复杂。虽然 PerlSetVarPerlAddVar 指令与 mod_perl 1.0 中完全相同,但 mod_perl 2.0 引入了一个新的指令——PerlOutputFilterHandler,它指定了请求的 Perl 输出过滤器。在我们的示例 httpd.conf 中,Apache::Clean 输出过滤器将在 mod_include 之后添加,它将 SSI 处理插入到 mod_cgi 之后。过滤器真正酷的地方在于,所有事情都无需任何技巧或魔法——让所有这些独立的模块在创建服务器响应时和谐地工作是完全正常的,这是一个巨大的改进,超过了 Apache 1.3。

出于安全考虑,您应该注意我们的示例配置中不包含 entities 选项。因为我们正在清理动态内容,减少实体标签(例如,将 &quot; 改为 ")会意外地移除生成脚本引入的针对跨站脚本的任何保护。有关跨站脚本和如何保护它的更多信息,请参阅 这篇 perl.com 文章

介绍 mod_perl 2.0

实际上,mod_perl 为编码 Perl 输出过滤器提供了两种不同的 API。我们将使用更简单的、流式 API,它稍微隐藏了原始的 Apache API。当然,如果你愿意大胆尝试并直接操作 Apache 的桶式搬运,我们非常欢迎,但这是一个更复杂的过程,所以我们在这里不会讨论它。相反,这里是我们的新 Apache::Clean 处理程序,它使用流式过滤器 API 转移到 mod_perl 2.0。

package Apache::Clean;

use 5.008;

use Apache::Filter ();      # $f
use Apache::RequestRec ();  # $r
use Apache::RequestUtil (); # $r->dir_config()
use Apache::Log ();         # $log->info()
use APR::Table ();          # dir_config->get() and headers_out->get()

use Apache::Const -compile => qw(OK DECLINED);

use HTML::Clean ();

use strict;

sub handler {

  my $f   = shift;

  my $r   = $f->r;

  my $log = $r->server->log;

  # we only process HTML documents
  unless ($r->content_type =~ m!text/html!i) {
    $log->info('skipping request to ', $r->uri, ' (not an HTML document)');

    return Apache::DECLINED;
  }

  my $context;

  unless ($f->ctx) {
    # these are things we only want to do once no matter how
    # many times our filter is invoked per request

    # parse the configuration options
    my $level = $r->dir_config->get('CleanLevel') || 1;

    my %options = map { $_ => 1 } $r->dir_config->get('CleanOption');

    # store the configuration
    $context = { level   => $level,
                 options => \%options,
                 extra   => undef };

    # output filters that alter content are responsible for removing
    # the Content-Length header, but we only need to do this once.
    $r->headers_out->unset('Content-Length');
  }

  # retrieve the filter context, which was set up on the first invocation
  $context ||= $f->ctx;

  # now, filter the content
  while ($f->read(my $buffer, 1024)) {

    # prepend any tags leftover from the last buffer or invocation
    $buffer = $context->{extra} . $buffer if $context->{extra};

    # if our buffer ends in a split tag ('<strong' for example)
    # save processing the tag for later
    if (($context->{extra}) = $buffer =~ m/(<[^>]*)$/) {
      $buffer = substr($buffer, 0, - length($context->{extra}));
    }

    my $h = HTML::Clean->new(\$buffer);

    $h->level($context->{level});

    $h->strip($context->{options});

    $f->print(${$h->data});
  }

  if ($f->seen_eos) {
    # we've seen the end of the data stream

    # print any leftover data
    $f->print($context->{extra}) if $context->{extra};
  }
  else {
    # there's more data to come

    # store the filter context, including any leftover data
    # in the 'extra' key
    $f->ctx($context);
  }

  return Apache::OK;
}

1;

如果您暂时忽略mod_perl特定的部分,您将看到处理器中嵌入的HTML::Clean逻辑,这与我们用来单独说明HTML::Clean的独立代码没有太大区别。然而,我们需要做的一件事是确定传递给HTML::Cleanlevel()options()方法的选项。在这里,我们使用$r->dir_config()来收集通过我们的PerlSetVarPerlAddVar配置指定的所有httpd.conf选项。

my $level = $r->dir_config->get('CleanLevel') || 1;

my %options = map { $_ => 1 } $r->dir_config->get('CleanOption');

这种使用dir_config()的方式实际上与我们在mod_perl 1.0中编写的代码没有区别。同样,之后的方法,如r->content_type()$r->server->log->info()$r->uri(),也像在mod_perl 1.0中一样表现相同,这应该提供了一定程度的安慰。例如,以下代码块

unless ($r->content_type =~ m!text/html!i) {
  $log->info('skipping request to ', $r->uri, ' (not an HTML document)');

  return Apache::DECLINED;
}

看起来几乎与mod_perl 1.0中的样子一样,只是使用了Apache::DECLINED。新的Apache::Const类提供了访问您在处理器中需要使用到的所有常量,尽管与之前相比,接口略有不同——当使用-compile选项时,常量被导入到Apache::命名空间。如果您希望常量在您的自己的命名空间中,模仿古老的OK,您只需使用use Apache::Const即可,不需要使用-compile选项。

您将会注意到的一些其他细微差别是在处理器顶部添加了大量的use语句。与mod_perl 1.0相比,当时几乎每个类都在需要时神奇地出现,而在mod_perl 2.0中,您需要非常具体地说明在处理器中将使用哪些类,而且几乎没有默认可用。

总的来说,最重要的类是Apache::RequestRec,它提供了访问Apache C request_rec结构中所有元素的方法。起源于请求对象的方法,如$r,但不在实际的request_rec槽中操作,例如$r->dir_config(),是在Apache::RequestUtil中定义的。这是一种很好的分离,可以帮助您更多地从访问底层Apache内部结构的角度来思考mod_perl,而不仅仅是黑魔法。

如果您还记得1.0版本,$r->dir_config()返回一个Apache::Table对象,它对应于一个Apache表,允许像头部一样以不区分大小写、多键的方式存储事物。在2.0中,Apache表通过APR(Apache Portable Runtime)层访问,因此需要访问表的任何API都需要use APR::Table。这包括用于像headers_outdir_config这样的表上的get()set()方法。

除了Apache::RequestRecApache::RequestUtilAPR::Table之外,我们的处理器还需要访问Apache::LogApache::Filter类。Apache::Log的表现与在mod_perl 1.0中一样,而Apache::Filter是完全新的,将在稍后讨论。

根据经验,我可以告诉您,确定需要使用哪个模块来访问所需的功能是令人沮丧的。在旧时代(就在撰写本文的几周前),开发者需要浏览mod_perl测试套件中的代码示例,以确定他们需要的模块。但现在不再是这样了。最近引入了ModPerl::MethodLookup包,其中包含lookup_method()函数——只需传递您正在寻找的方法的名称,您就会得到一个可能满足您需求的模块列表。有关详细信息,请参阅MethodLookup文档

在基础维护工作完成之后,我们可以专注于输出过滤器核心和 Apache::Filter 流式API。您会注意到传递给 handler() 子例程的第一个参数是一个 Apache::Filter 对象,而不是您可能期望的典型 $r。为了使编写过滤器更加直观,mod_perl 2.0 适当地提高了它的DWIM(Do What I Mean)因子。实际上,您可以在不访问 $r 的情况下编写输出过滤器,因此 mod_perl 为您提供了您将主要需要的功能。为了访问 $r,我们调用名为 r() 的方法,然后在 $f 上使用 $r 作为访问请求属性的网关,例如响应的MIME类型。请注意,我们的过滤器确实会拒绝处理非HTML内容。没有花哨的操作,只是按照应有的方式处理。

在典型的处理器初始化之外,事情才真正开始变得陌生,首先是过滤器上下文的概念,或 $f->ctx()。与mod_perl 1.0不同,其中每个处理器在每个请求中只被调用一次,输出过滤器可以(并且通常是这样)在每个请求中多次调用。这可能有几个原因,但对我们来说,理解我们需要调整正常的处理器逻辑并补偿由于多次调用而产生的一些微妙行为就足够了。

因此,我们首先做的是隔离请求中只需要在每次请求中发生一次的部分。$f->ctx(),它存储过滤器上下文,在第一次请求时将返回 undef,因此我们可以将其用作我们过滤器初始调用的指示器。由于我们实际上只需要解析一次 httpd.conf 配置,我们使用初始调用来获取我们的 PerlSetVar 选项,并将它们存储在哈希中供以后使用——因为 $f->ctx() 可以为我们存储标量,我们将我们的哈希作为引用存储在 $context 中。我们还为 extra 元素预留了空间,这将在以后变得很重要。

我们还需要在每个请求中只做一次的事情是从响应中删除 Content-Length 标头。Apache 2.0 已经采取了巨大的步骤来确保所有请求尽可能地符合RFC标准,同时试图使开发者的生活更轻松。实际上,这其中包括添加了内容长度过滤器,它计算响应的长度,并在Apache认为合适的情况下添加 Content-Length 标头。如果您正在编写一个会改变内容到响应长度不同的处理器(这可能在大多数情况下是正确的),您负责从输出头表中删除 Content-Length 标头。只需要调用 $r->headers_out->unset() 就可以完成这项任务,这与mod_perl 1.0中一样简单。而且不用担心,如果缺少 Content-Length,Apache 会采取其他步骤,例如使用分块传输编码,以确保请求符合HTTP标准。

这就是所有应该在每次请求中只发生一次的处理。如果您不喜欢在每次非HTML过滤器调用时看到 "skipping..." 信息,您也可以在那里添加测试 $f->ctx() 的逻辑。

一旦我们处理完了一次性处理,我们就可以继续处理我们的输出过滤器核心。实际的 Apache::Filter 流式API相当直观。在很大程度上,我们只是调用 $f->read() 来分块读取传入数据,在我们的情况下是每次 1K。将处理过的数据发送下去只需要我们调用 $f->print()。总的来说,流式API的基础非常简单。它开始变得复杂的部分源于我们特定过滤器的性质。

《HTML::Clean》背后的想法是它可以在一定程度上使HTML更加紧凑。然而,由于HTML基于标签,并且这些标签通常是成对出现的,我们需要采取特殊措施来确保在《Apache::Clean》运行之后我们的标签仍然保持平衡。因为我们在分块读取和处理数据,所以有可能一个标签会落在两个块之间。例如,如果HTML看起来像

[1019 bytes of stuff] <strong>Bold!</strong> [more stuff]

《Apache::Clean》首先看到的数据块是

[1019 bytes of stuff] <str

由于《<str》不是一个有效的HTML标签,《HTML::Clean》保留其原样。当从过滤器中读取下一块数据时,它会表现为

ong>Bold!</strong> [more stuff]

并且《HTML::Clean》再次未处理未识别的《ong>》。然而,它确实捕捉到了关闭标签《</strong>》。最终结果,正如你可能已经看到的,将是

[1020 bytes of stuff] <strong>Bold!</b> [more stuff]

这显然是不希望的。我们的匹配正则表达式和《extra》操作确保任何悬空标签都添加到下一个缓冲区的开头,从而保护免受这种特定问题的影响。当然,这种逻辑并非所有过滤器都需要。只需记住,在实现自己的过滤器时,要考虑到操作数据块带来的复杂性。

一旦我们处理完当前过滤器调用中的所有数据,我们就来到了一个关键节点——确定我们是否已经看到了实际响应的所有数据。如果我们的过滤器不会再次调用此请求,则《$f->seen_eos()`》将返回true,表示我们已经到达数据流的末尾。因为我们可能还在《$context->{extra}`》中存储了剩余的标签片段,在退出过滤器之前我们需要将这些发送出去。另一方面,如果有更多的数据要来,那么我们需要存储当前的过滤器上下文,以便在下一次调用中使用。

瞧!

所以,这就是使用mod_perl 2.0使输出过滤变得简单的方法。总的来说,这与你可能习惯的mod_perl 1.0略有不同,但一旦你掌握了它,就不会那么困难。它确实允许做一些很酷的事情。例如,我们现在不仅可以使用Perl编写像《Apache::Clean》这样的有趣处理程序,而且整体过滤器机制使得可以使用Perl来操纵来自服务器的《任何和所有》内容——只需在服务器的《httpd.conf》级别(例如在《DocumentRoot`》指令旁边)添加这样一行

PerlOutputFilterHandler My::Cool::Stuff

就可以对每个请求进行后处理。很酷。

当然,这并不是故事的结束,剩下的既有积极的一面也有消极的一面。我们在这里看到的是过滤器的表面现象:还有输入和连接过滤器要讨论,以及通过bucket brigades以原始方式访问Apache过滤器API。缺点是,由于(当前的)mod_perl 2.0过滤器API的不完整性,构建正确处理条件《GET`》请求的过滤器并不是很好。然而,把这些放在一边,我们在这里所取得的成就已经相当令人印象深刻,我希望它能够激发你的创造力,并开始在这个非常酷的mod_perl 2.0新世界中尝试。

如果您想尝试这篇文章中的代码,它可以从我的网站作为发行版获取。其他信息来源包括(不断增长的)mod_perl 2.0文档,特别是关于过滤器的部分,最近已经(非常)更新了最新信息。

敬请期待……

我如何知道我这里的代码实际上运行正常并且做了我期望它做的事呢?我为 Clean.pm 编写了一个测试套件,使用了新的 Apache::Test 框架,并将我的代码与每个我能想到的实时 Apache 服务器的情况进行了对比。Apache::Test 可能是 mod_perl 2.0 重新设计努力中最好的成果之一,我将在下次分享给你。

谢谢

非常感谢 Stas Bekman,尽管这意味着他必须处理我的问题、建议和 API 抱怨。

标签

反馈

这篇文章有什么问题吗?请在 GitHub 上打开一个 issue 或 pull request 来帮助我们。