创建一个基于网络的BBS,第 3 部分

作者:Reuven M. Lerner

在过去的两个月中,我们研究了一个可以合并到网站中的简单公告板系统。此 BBS 按主题或主题组消息,并将消息存储在关系数据库中。

正如我们在 ATF 的最后两期中所看到的,使用数据库进行信息存储和检索大大减少了实现此类系统所需的开发工作量。鉴于我能够轻松实现 BBS,我决定添加更多功能,以帮助用户浏览和使用 BBS。

本月,我们将研究如何实现其中几个功能。最重要的是全文搜索,它允许用户根据关键词查找有趣的帖子。这避免了他们不得不搜索主题,而主题可能没有适当或易于理解的标题。然后,我们将研究一种工具,该工具允许管理员删除不适当的帖子,而无需深入到数据库的内部。

以防您刚刚收看

如果您错过了“锻造”的最后两期,让我们快速了解一下 BBS 是如何实现的。我使用了 MySQL(请参阅资源),这是一种关系数据库,在 Web 程序员中非常受欢迎。关系数据库中的信息存储在表中,其中行表示记录,列表示字段。

我们使用 SQL 定义列,SQL 是一种众所周知的用于处理关系数据库的标准结构化查询语言。我们的 BBS 将包含两个独立的表,ATFThreads(用于跟踪单个主题,包括初始帖子)和 ATFMessages(用于跟踪单个消息)。SQL 从数据库客户端发送到数据库服务器;这可以是程序客户端(例如,CGI 程序)或交互式客户端(例如,MySQL 附带的 mysql 程序)。我通常使用交互式客户端进行表创建、维护和调试,但我们可以使用以下 SQL 创建我们的两个表

CREATE TABLE ATFThreads (
id SMALLINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    subject VARCHAR(255) NOT NULL,
    author VARCHAR(60) NOT NULL,
    email VARCHAR(60) NOT NULL,
    text TEXT NOT NULL,
    date DATETIME NOT NULL,
    UNIQUE(subject)
)
CREATE TABLE ATFMessages (
    id MEDIUMINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    thread SMALLINT UNSIGNED NOT NULL,
    subject VARCHAR(60) NOT NULL DEFAULT
    "No subject",
    date DATETIME NOT NULL,
    author VARCHAR(60) NOT NULL DEFAULT
    "Mr. Nobody",
    email VARCHAR(60) NOT NULL DEFAULT
    "atf@lerner.co.il",
    text TEXT NOT NULL
)

如果您将以上内容输入到交互式 mysql 程序中,您将需要在每个查询后放置一个分号 (;),以指示您希望 mysql 立即执行查询,而不是等待其他输入。

创建表后,我们将必须编写一些程序——在我们的例子中是 CGI 程序——来操作数据。我们不会直接使用 SQL 编写程序,而是在我们的 CGI 程序中创建它。一旦我们使用 CGI.pm(CGI 程序的标准 Perl 模块)和 DBI(Perl 的通用数据库接口),使用 Perl 进行此类 CGI 程序就特别容易了。

整个公告板系统由大约七个程序组成,每个程序处理系统的不同方面。您可以从 FTP 站点 ftp.linuxjournal.com/pub/lj/listings/issue57/3193.tgzftp.linuxjournal.com/pub/lj/listings/issue58/3252.tgz 下载这些程序。本文的列表将在 ftp.linuxjournal.com/pub/lj/listings/issue59/3296.tgz 中。

现在我们已经对基本功能进行了快速讨论,让我们开始为我们的 BBS 添加一些高级功能。

搜索

我们将添加到 BBS 的第一个新功能是全文搜索。长期以来,我一直是全文搜索的爱好者,无论是在 Web 上还是其他地方。根据 Jakob Nielsen(可能是最著名的 Web 可用性研究人员)的说法,许多用户是“搜索主导型”的。这意味着他们更喜欢在站点中搜索内容,而不是遍历超链接树。(有关 Nielsen 的更多信息,包括他的文章的 URL,请参阅资源。)

虽然我们 BBS 的主题结构使查找特定主题的帖子相对容易,但毫无疑问,有时主题行未能反映实际内容,或者讨论偏离了意想不到或不寻常的方向。允许用户搜索单词或短语可以让他们更容易找到他们想要的内容。最棒的是,由于搜索功能位于单独的程序中,因此只有当有人使用它时,系统才会变慢。如果从没有人搜索过 BBS,系统就不会变慢。

MySQL 允许通过表进行两种类型的搜索,可以使用 SQL 正则表达式或 UNIX 风格的正则表达式。对于习惯使用 UNIX 的人来说,SQL 正则表达式可能看起来很傻,但它们保证可以在任何遵守 SQL 标准的数据库系统上工作。SQL 正则表达式有两个特殊字符:%(匹配零个或多个字符)和 _(匹配恰好一个字符)。要转义这些特殊字符,您可以插入前导反斜杠 (\)。要获得文字反斜杠,您可以插入两个反斜杠 (\\)。

要使用 SQL 正则表达式搜索匹配项,您可以在 SELECT 语句中使用 LIKE 运算符。这将返回正则表达式找到匹配项的所有行。例如

SELECT text FROM ATFMessages WHERE text LIKE "a%"

将检索所有以字母 a 开头,后跟零个或多个字符的消息文本。

如果您更喜欢 UNIX 风格的正则表达式,MySQL 允许您使用 REGEXP(或 RLIKE)运算符,如下所示

SELECT text FROM ATFMessages WHERE text RLIKE "a.*"

这将执行与上述相同的功能。

与其对所有用户强制使用一种系统,我决定允许使用文字文本和 UNIX 正则表达式。UNIX 正则表达式对于大多数人来说很难学习和理解,因此默认设置为允许文字文本搜索。我们通过使用 LIKE 运算符,并使用反斜杠转义两个 SQL 正则表达式元字符来执行文字文本搜索。

因此,搜索表单本身(search-form.shtml,有关扩展版本,请参阅存档文件中的列表 1)非常短

<P>Search for: <input type="text"
name="term"></P>
<input type="radio" name="regexp"
   checked value="no">Literal search
<input type="radio" name="regexp"
   value="yes">Use regular expressions
<input type="submit" value="Search!">

此表单被提交到 CGI 程序 search.pl(存档文件中的列表 2),该程序执行实际搜索。Search.pl 也相当简单,尽管 SQL 查询是我们在此项目中看到的最复杂的。这是因为我们必须搜索 ATFMessages 才能找到匹配项。我们还需要消息的主题 ID 号,以便创建指向 view-thread.pl 的超链接,该链接允许用户查看该主题。

创建搜索查询

我们执行所谓的两个表之间的“连接”,从 ATFMessages 中选择多个列,并从 ATFThreads 中选择一列。连接允许我们仅从两个或多个表中获取最有趣的列,仅抓取那些符合我们标准的列。始终记住在表之间建立关系,否则您将获得结果的“笛卡尔积”,其中表 A 中的每一行都与表 B 中的每一行匹配。因此,我们避免像这样的选择

SELECT M.id, M.thread, M.subject, M.author,
   T.subject
FROM ATFMessages M, ATFThreads T

这将产生笛卡尔积。相反,我们使用

SELECT M.id, M.thread, M.subject, M.author,
   T.subject
FROM ATFMessages M, ATFThreads T
AND M.thread = T.id
ORDER BY M.date desc
它限定了 ATFMessages(在此查询中给定的昵称“M”)和 ATFThreads(昵称 T)之间的关系,然后按降序日期顺序列出结果行。

我们还测试 $regexp 的值,该值设置为“regexp”单选按钮的值。如果 $regexp 为“yes”,我们在 SQL 查询中使用 REGEXP 运算符并执行正则表达式搜索。否则,我们转义 SQL 字符 % 和 _ 并使用 LIKE 运算符。使此查询成为可能的 Perl 代码如下所示

my $sql = "SELECT M.id, M.thread, M.subject, M.author, T.subject ";
$sql .= "FROM ATFMessages M, ATFThreads T ";
if ($regexp eq "yes")
{
    $sql .= "WHERE M.text REGEXP \"$term\" ";
}
else
{
    $term =~ s|%|\\\%|g;
    $term =~ s|_|\\\_|g;
    $sql .= "WHERE M.text LIKE \"%$term%\" ";
}
$sql .= "AND M.thread = T.id ";
$sql .= "ORDER BY M.date desc";

由于我们通过组合文本字符串来构建 SQL 查询,因此我们可以有条件地修改查询的各个部分,正如我们在上面看到的那样。

解析搜索结果

SQL SELECT 查询的结果始终以表格形式返回,其中列是查询中请求的行,行是那些符合查询标准的行。使用 DBI,从查询中读取结果通常意味着从 Perl while 循环中迭代行。

DBI 提供了许多用于检索 SELECT 返回值的方法,但可能最容易理解的方法是简单的 fetchrow_array 方法。此方法为 $sth 定义,“语句句柄”,我们通过它提交查询并检索其结果。

可以使用多种方法来检索 SELECT 的结果,但最容易理解的方法是 $sth->fetchrow_array,它从响应中返回一行。每次我们调用 $sth->fetchrow_array 时,都会返回响应表中的下一行。在 $sth->fetchrow_array 返回响应表的最后一行之后,它返回“false”。通过将 $sth->fetchrow_array 放在 while 循环中,我们可以迭代响应表中的每一行。

然后,这是来自 search.pl 的代码,它迭代结果表

while (my @row = $sth
{
  ($message_id, $thread_id, $subject, $author,
    $thread_name) = @row;
  print "<li><a href=\"/cgi-bin/view-thread.pl?";
  print "$thread_id#$message_id\">$subject</a>, ";
  print "by $author in ";
  print
"<a href=\"/cgi-bin/view-thread.pl?$thread_id\">";
  print "$thread_name</a>\n";
}

如您所见,我们为 @row 中的各个元素分配了许多标量。DBI 将 NULL 元素(即缺少值的元素,而不是 C/Perl 概念中“true”为非零)作为未定义返回,因此您可以使用 Perl 的内置 defined 函数测试值。一旦我们将 @row 的元素提取到许多易于识别的标量中,我们就可以使用它们将结果打印到用户的浏览器。

请注意,我们创建的每个超链接不仅仅指向主题,还指向消息。我们可以通过利用链接中的命名锚点来做到这一点,这允许我们强制用户的浏览器滚动到特定点。如果您不熟悉命名锚点,这里有一个快速课程:在链接 http://www.ssc.com/test.html#testing 中,“testing”是命名锚点,指向 test.html 中标记为 <a name="testing"> 的位置。如果不存在此类标记,则将命名锚点添加到 URL 无效。

由于我们的程序 view-thread.pl(上个月讨论过)在主题中每个消息标题的开头放置了这样一个命名锚点,因此我们可以将用户直接指向与他们的搜索字符串匹配的消息,而不是主题。

顺便说一句,如果您有兴趣从您的应用程序中获得尽可能快的速度,您可能需要考虑使用 $sth->fetchrow_arrayref 而不是 $sth->fetchrow_array。正如您可能从它们的名称中猜到的那样,不同之处在于前一种方法返回数组引用,而后一种方法返回数组。

传递引用总是比传递数组更快,因为它涉及更少的字节操作。我选择使用 $sth->fetchrow_array 部分原因是它简化了代码的其余部分,部分原因是我认为无论如何我们都会处理少量数据,并且速度差异不会太大。

通过在我们的服务器上安装这两个文件——search-form.html 和 search.pl,我们现在能够搜索任何消息的文本。通过从我们的主页添加一些指向搜索表单的新链接,此功能已集成到我们的系统中。

管理工具

每当我处理涉及数据库的网站时,我几乎总是包含一两个基于 Web 的管理工具。这些工具在许多方面都很有用,最明显的是它们对于那些想要在不学习 SQL 的情况下操作数据库的人来说非常有用。(它们还允许您保护数据库免受那些认为自己了解 SQL,却发现没有任何方法可以撤消 DROP TABLE 命令的人的侵害。)

哪些工具是必要的以及它们必须执行哪些功能将取决于您编写的 Web 应用程序,以及您的个人用户的需求。我们将研究一个名为 zap-thread.pl 的简单应用程序,它允许管理员删除一个或多个讨论主题。

为了确保只有授权用户才能删除主题,我们将包含一个密码字段,并将变量 $zap_password 添加到 ATFConstants.pm,该模块包含我们所有的全局变量。(请参阅存档文件中的列表 4。)

虽然有很多方法可以实现 zap-thread.pl,但我发现编写一个具有两种不同特性的单个程序是最容易的。当使用 GET 方法调用时,zap-thread.pl 生成一个 HTML 表单,该表单可用于删除主题,包括用户必须在其中输入密码的文本字段。

当使用 POST 调用时,zap-thread.pl 假定它是由其 GET 特性生成的表单调用的。它期望接收两种不同类型的 HTML 表单元素:password 表单元素中的密码,必须将其与 $zap_password 变量进行比较,以及一个或多个名为“thread-x”的复选框,其中 x 是数据库中主题的 ID 号。

在执行任何其他操作之前,我们的程序会将收到的密码与 $zap_password 变量进行比较。如果它们匹配,我们将继续而不发表评论。如果它们不匹配,我们将生成一条错误消息,告诉用户密码不匹配。

删除主题

我们可以通过多种方式迭代 thread-x 元素,但我发现最简单的方法是迭代每个元素,忽略任何未能匹配“thread-x”模式的元素。通过捕获括号内的数字

next unless ($element =~ m/^thread-(\d+)$/);

然后我们可以使用 $1 变量来抓取它,它检索上次匹配项中第一个括号集中的任何内容

my $thread_id = $1;
检索到主题 ID 号后,我们需要从 ATFThreads(其中包含主题的主列表)和 ATFMessages(其中包含消息本身)中删除匹配的行。如果我们仅从 ATFThreads 中删除行,我们将面临 MySQL 将主题 ID 号重用于新主题的巨大风险——这将有效地将我们所有本应删除的消息放入新主题中。

我们通过发送两个单独的 SQL 查询来删除它们,并在用户的浏览器上打印简短的状态消息

my $sql  = "DELETE FROM ATFThreads WHERE id = $thread_id ";
warn "SQL: \"$sql\"\n";
my $sth = $dbh->prepare ($sql);
my $result = $sth->execute;
die("Error deleting from ATFThreads: " .
    $sth->errstr) unless $result;
print "<P>Deleted the thread.</P>\n";
# Delete messages for this thread from ATFMessages
$sql  = "DELETE FROM ATFMessages WHERE thread = $thread_id ";
warn "SQL: \"$sql\"\n";
$sth = $dbh->prepare ($sql);
$result = $sth->execute;
die("Error deleting from ATFMessages: " .
    $sth->errstr) unless $result;
print "<P>Deleted messages in the thread.</P>\n";
    }

通过将 DELETE 命令放在 foreach 循环中,我们可以删除多个主题。如果用户指示应删除三个主题,我们将进入循环三次,依次删除每个主题。

zap-thread.pl 的此实现至少存在一个问题,那就是缺少 撤消 功能。如果您删除了错误的主题会发生什么?实现此类取消删除功能的最简单方法可能是更改底层表,向 ATFThreads 和 ATFMessages 添加新的“active”列。此列将包含单个真/假值,可能使用 TINYINT 或 ENUM 类型实现。

有了这样的表定义,删除主题将涉及 UPDATE 查询(而不是当前的 DELETE 查询)来修改该列中的值。这样的更改还需要对 list-threads.pl 和 view-thread.pl 进行一些调整,以便它们仅选择那些 WHERE active="true" 或类似条件的行。

zap-thread.pl 的另一个潜在问题是它基于每个主题进行操作。毫无疑问,有时我们会想要删除单个消息,而不是整个主题。创建这样的程序将比 zap-thread.pl 稍微困难一些,但真正的挑战在于用户界面。对于 100 个主题,使用 zap-thread.pl 将足够困难;尝试找到适合删除单个消息的界面会更加困难。最好的方法可能是将程序分成两个较小的程序,一个用于选择主题,另一个用于选择消息。

与大多数软件项目一样,此 BBS 具有几乎无限的改进和扩展潜力。在许多方面仍然可以改进此软件——允许子主题,提供审核讨论,当添加新帖子时向主题中的某些或所有参与者发送电子邮件,允许用户编辑自己的帖子,甚至集成拼写检查器。

最后,请注意我们的 Web BBS 应用程序如何使用单独的 CGI 程序,而不是一个大型程序。使用一组相关的程序不是设计此类应用程序的唯一方法,但我发现它是创建此类功能的最简单方法。它不仅允许将问题分解为单独的切片,而且还允许增量实现——当客户催着您想立即看到结果时,这会派上用场。

资源

Creating a Web-Based BBS, Part 3
Reuven M. Lerner 顾问,居住在以色列海法,自 1993 年初开始使用 Web。他的著作《Core Perl》将于春季由 Prentice-Hall 出版。可以通过 reuven@lerner.co.il 联系 Reuven。ATF 主页,包括档案和讨论论坛,位于 http://www.lerner.co.il/atf/。
加载 Disqus 评论