一个简单的搜索引擎
CGI(“通用网关接口”)标准最初旨在允许用户通过 Web 运行程序,这些程序原本只能在服务器上使用。因此,最早的 CGI 程序是 grep 和 finger 的简单接口,它们从 HTML 表单接收输入,并将 HTML 格式的输出发送到用户的浏览器。
CGI 程序以及一般的服务器端程序,自那时以来变得更加复杂。然而,有一个应用现在和过去一样有用:即搜索整个网站,查找包含特定单词或字符串的文档的能力。
虽然搜索站点(现在称为“门户”)使得浏览分布在多个服务器上的大量页面成为可能,但处理搜索的 CGI 程序的工作却更容易。它们只需要浏览本地服务器上的文件,生成与用户请求匹配的 URL 列表。
本月,我们将研究如何实现几种不同类型的搜索程序。虽然这些程序可能无法成功地与 ht://Dig 和 Webglimpse 竞争,但它们确实提供了一些关于这类程序如何工作以及程序员在编写此类软件时必须做出的权衡的见解。
长期以来,Perl 一直是我编写服务器端程序最喜欢的语言。这在很大程度上归功于其强大的文本处理能力。Perl 自带丰富的正则表达式语言,可以轻松地在一个文本中查找另一段文本。
例如,以下单行程序打印 test.txt 中包含单词 “foo” 的任何行
perl -ne 'print if m/foo/' test.txt
-n 开关告诉 Perl 默认情况下不打印行,-e 开关允许我们在单引号 (') 之间插入程序。我们指示 Perl 打印 m//(匹配)运算符找到搜索字符串的任何行。我们可以在程序内部完成相同的事情,如清单 1 所示。
当然,上面的程序在单个文件 (test.txt) 中搜索单个模式(字符串 “foo”)。我们可以通过使用空的 <> 而不是迭代 <FILE> 来更泛化该程序。空的 <> 迭代 @ARGV(包含命令行参数的数组)的每个元素,依次将每个元素分配给标量 $ARGV。如果没有命令行参数,则 <> 期望从用户接收输入。清单 2 是上述程序的修订版本,它在多个文件中搜索字符串 “foo”。请注意,此版本的程序同时打印文件名和匹配行。由于 $_ 已经包含换行符,因此我们无需在 print 语句末尾放置一个。清单 2 可以用单行 Perl 代码重写如下
perl -ne 'print "$ARGV: $_" if m/foo/;' *
最后,我们可以让我们的简单搜索稍微复杂一些,允许用户命名模式以及文件。清单 3 获取第一个命令行参数,将其从 @ARGV 中删除并放入 $pattern 中。为了告诉 Perl $pattern 不会改变,并且它应该只编译搜索模式一次,我们使用带有 /o 选项的 m//。
因此,要搜索模式 f.[aeiou] 在所有带有 “txt” 扩展名的文件中,我们将使用
./simple-search-3.pl "f.[aeiou]" *.txt
果然,每个包含 f,后跟任何字符,再后跟元音的行都会打印在屏幕上,前面是文件名。
如果网站上的所有文档都存储在单个目录中,那么以上将是我们基于 Web 的搜索的良好骨架。然而,通常情况恰恰相反:大多数网站将文件放在许多不同的目录中。一个好的搜索程序必须遍历整个 Web 层次结构,搜索每个目录中的每个文件。
虽然我们当然可以自己完成这项工作,但已经有人为我们做了。File::Find,一个 Perl 自带的包,使得使用 Perl 创建类似 find 的程序成为可能。File::Find 导出 find 子例程,它接受参数列表。第一个参数是为遇到的每个文件调用一次的子例程引用。其余参数应该是目录和文件名,File::Find 将按顺序读取它们直到结束。
例如,清单 4 是一个简短的程序,它使用 File::Find 打印特定目录中的所有文件名。正如您所见,File::Find 导出变量 $File::Find::name,其中包含当前文件名以及 find 子例程。当前目录存储在 $File::Find::dir 中。
清单 5 包含 simple-find-2.pl 的一个版本,它使用 File::Find 搜索给定目录树下的所有文件。与许多使用 File::Find 的程序一样,simple-find-2.pl 的大部分时间都花在了 find_matches 内部,这是一个为 @ARGV 中传递的目录下的每个遇到的文件调用一次的子例程。要在 /home 和 /development 下的目录中查找包含模式 “f.[aeiou]” 的所有文件,请键入
./simple-find-2.pl "f.[aeiou]" /home /development
simple-find-2.pl 的第 11 行尤其重要,因为它取消定义了 $/,即确定行尾字符的变量。通常,Perl 的 <> 运算符逐行迭代文件,到达末尾时返回 undef。但是,我们希望搜索整个文件,因为模式可能需要跨越多行。通过取消定义 $/,行
my $contents = (<FILE>);将文件句柄 FILE 的整个内容放入 $contents 中,而不仅仅是一行。
现在我们可以搜索特定目录下的所有文件的模式,让我们将此功能连接到 Web,搜索 HTTP 服务器文档层次结构下的所有文件。这样的程序只需要从用户那里接收一个模式,因为 Web 层次结构不会经常更改。
清单 6 是一个 HTML 表单,可用于提供此类输入。此 HTML 表单将其内容提交给清单 7 中的 CGI 程序 simple-cgi-find.pl。它的参数 pattern 包含一个 Perl 模式,用于与 Web 层次结构中每个文件的内容进行比较,simple-cgi-find.pl 将返回与用户模式匹配的文档列表。
不幸的是,Perl 自带的 File::Find 版本不适用于 -T 标志,该标志开启 Perl 的安全 tainting 模式。CGI 程序应始终使用 -T 运行,这确保了来自外部来源的数据不会以可能造成危害的方式使用。然而,在这种情况下,我们无法使用 -T 运行我们的程序。File::Find 依赖于 Cwd 模块中的 fastcwd 例程,该例程无法在使用 -T 的情况下成功运行。目前,我建议在不使用 -T 的情况下使用这些程序,但是当 Perl 的下一个版本发布时,我强烈建议升级以便在启用完全 tainting 的情况下运行 CGI 程序。
我们的搜索子例程 find_matches 已经稍作修改,使其结果对 Web 用户更相关。它做的第一件事是确保文件具有指示它包含 HTML 格式文本或纯文本的扩展名。这确保了搜索不会尝试查看图形文件,图形文件可以包含任何字符
return unless (m/\.html?$/i or m/\.te?xt$/i);
一些网站使用 .htm(或 .HTM)扩展名标记 HTML 文件,使用 .txt 或 .TXT 而不是 .text 标记文本文件。上述模式允许所有这些变体,使用 /i 开关忽略大小写,并使用 $ 元字符确保后缀出现在模式的末尾。
在检索当前文件的内容后,find_matches 检查是否可以在 $contents(包含文档的内容)中找到 $pattern。我们用 \b 字符包围 $pattern,以在单词边界上查找 $pattern。这确保了搜索 “foo” 不会匹配单词 “food”,即使前者是后者的子集。
如果找到匹配项,find_matches 会通过将 $search_root 替换为 $url_root 来创建 URL,这会向外部用户隐藏 HTML 文档层次结构。然后,它在指向该 URL 的超链接中打印文件名
if ($contents =~ m|\b$pattern\b|ios) { my $url = "$File::Find::dir/$filename"; $url =~ s/$search_root/$url_origin/; print qq{<li><a href="$url">$filename</a>\n} }
虽然 simple-cgi-find.pl 可以工作,但它确实存在一些问题。首先,它无法区分 HTML 标签和实际内容。搜索 “IMG” 不应匹配任何包含 <IMG> 标签的文档,而应匹配 HTML 标签之外的任何包含该字符串的内容。因此,我们将修改我们的程序以从输入文件中删除 HTML 标签。
Perl 初学者通常认为删除 HTML 标签的最佳方法是删除 < 和 > 之间的任何内容,如
$contents =~ s|<.+>||g;
由于 “.” 告诉 Perl 匹配任何字符,“+” 告诉 Perl 匹配一个或多个前面的字符,因此上面的语句看起来像是告诉 Perl 删除所有 HTML 标签。不幸的是,事实并非如此——该语句将删除文件中第一个 < 和最后一个 > 之间的一切内容。这是因为 Perl 的模式是“贪婪的”,并试图最大化它们匹配的字符数。
我们可以通过在 “+” 之后放置一个 ? 来使 “+” 变为非贪婪,并尝试仅匹配最少数量的字符。例如
$contents =~ s|<.+?>||g;
还有一个棘手的问题,即如果 $pattern 包含空格该怎么办。它应该被视为包含一个或多个空格字符的搜索短语吗?还是应该将其视为几个不同的单词,使用 “或” 或 “与” 搜索?
在这种特殊情况下,我们可以鱼和熊掌兼得。通过在 HTML 表单中添加一组单选按钮,我们可以允许用户选择搜索应该是字面的、需要找到所有搜索词,还是需要找到任何一个搜索词。
现在我们可以修改我们的程序来处理“短语”搜索(就像我们一直做的那样)、“与”搜索(其中必须出现所有单词)和“或”搜索(其中必须出现一个或多个单词)。
要实现“与”搜索,我们使用 Perl 的 “split” 运算符将短语的元素分开。然后我们计算我们必须找到的单词数,迭代每个单词并检查它们是否都存在于 $contents 中。如果 $counter 达到 0,我们可以确定所有单词都出现
elsif ($search_type eq "and") { my @words = split /\s+/, $pattern; my $count = scalar @words; foreach my $word (@words) { $count- if ($contents =~ m|\b$word\b|is); } unless ($count) { print qq{<li><a href="$url">$filename</a>\n}; $total_matches++; } }
“或”搜索甚至更容易实现:再次,我们将 $phrase 按空格分隔开。如果即使一个组成词匹配,我们可以立即打印文件名和超链接,并从 find_matches 返回
elsif ($search_type eq "or") { my @words = split /\s+/, $pattern; foreach my $word (@words) { if ($contents =~ m|\b$word\b|is) { print qq{<li><a href="$url">$filename</a>\n}; $total_matches++; return; } } }最后,我们应该有一种方法告诉用户有多少文档匹配。我们通过创建一个新变量 $total_matches 来做到这一点,每次文档匹配时都会递增该变量(如上面的 “与” 和 “或” 搜索的代码片段所示)。
这些改进已纳入名为 better-cgi-search.pl 的搜索程序中,如清单 9 所示,清单 9 未在此处打印,但包含在存档文件中(请参阅资源)。
我们现在有了一个功能相当齐全的搜索程序,可以处理人们想要做的大多数类型的搜索。问题是我们创建了一个程序,它可能 太 好以至于没有用。我的许多客户在信息准备发布之前将其放在他们的网站上。在没有任何指向这些目录和文档的链接的情况下,不太可能有人能够找到它们。但是,我们的搜索程序不依赖于超链接来查找文档。
一个常见的解决方案是让搜索程序忽略任何包含名为 .nosearch 的文件的目录。此文件不需要包含任何数据,因为它的存在意味着目录的内容将被跳过。
最简单的实现是检查当前探测的目录中是否存在 .nosearch 文件。但是,每次调用 find_matches 都检查文件会使我们程序已经很慢的性能变得更慢。如果程序查找 .nosearch 文件,然后将该信息存储在哈希中,以便在检查该目录中的未来文件时检索,那就更好了。
我们可以用两行代码解决这些问题。第一个放在 find_matches 的开头,如果已在当前目录中找到 .nosearch 文件,则立即返回
return if ($ignore_directory{$File::Find::dir});
如果我们到达第二行,则意味着此目录尚未找到 .nosearch 文件。然而,在某些情况下,即使没有找到 .nosearch 文件,也应该仍然有效:当我们检查 .nosearch 文件本身时,当 .nosearch 文件在目录中时,或者当 .nosearch 文件在父目录中时。毕竟,如果父目录不应被搜索,那么子目录也不应被搜索。以下是完成此操作的代码片段
# Mark the directory as ignorable ... $ignore_directory{$File::Find::dir} = 1 if (($_ eq ".nosearch") || (-e ".nosearch") || (-e "../.nosearch"));清单 10 包含 better-cgi-search.pl 的一个版本,其中包含这些添加项,可以在存档文件中找到(请参阅资源)。
如果您已经运行了这些程序,您很可能发现了上面概述的系统的主要问题:它非常慢。如果您的网站包含 100 个文件,此系统运行良好。但是,如果您的网站扩展到 1000 或 10,000 个文件,用户将在搜索过程中停止,因为它会花费太长时间。
因此,大多数严肃的搜索引擎采用不同的策略,将搜索分为两个不同的阶段。在第一阶段,索引程序将文件分解,跟踪它们可能在何处。然后,第二个程序作为搜索客户端运行,在预生成的索引中查找匹配项。
下个月,我们将研究创建此类索引的一些方法,以及如何浏览它们。也许我们的简单搜索程序将无法与 Glimpse 和 ht://Dig 竞争,但至少我们将大致了解它们的工作原理以及编写搜索程序时涉及的权衡。
