消费者排名

作者:Reuven M. Lerner

在线书商改变了我决定购买哪些书的方式。他们不仅使我有可能在两到三天内获得大多数书籍,而且还为我提供了以前无法获得的比较购物手段。如果我正在考虑买一本书,我会立即查看其他人对它的评价。在大多数情况下,这些评价最终促成了交易,说服我购买某本书而不是它的竞争对手。

自从我最近搬到莫迪因(Modi'in),一个位于耶路撒冷和特拉维夫之间的新以色列城市以来,我一直在更多地思考这样的系统。就在我搬到莫迪因之前,我被要求接管一个为莫迪因居民设立的小型邮件列表。该列表让人们有机会分享当地公告和与莫迪因生活相关的想法。

在成为列表管理员后不久,我意识到订阅者经常询问建议,从医生到草坪服务再到课后活动。通常,看到如此频繁重复的电子邮件列表管理员会准备一个 FAQ,即常见问题及其答案列表。但是建议非常主观,一个人最喜欢的理发师可能是另一个人的噩梦。

本月,我们将研究一组我编写的 CGI 程序,以允许列表订阅者输入并对他们镇上最喜欢的产品和服务进行排名。由于我的网络空间提供商不提供 mod_perl,我不得不使用 CGI 标准来编写我的程序。

这个“排名”系统,我称之为,由三个用 Perl 编写的 CGI 程序组成,它们使用关系数据库进行后端存储。(由于空间考虑,这些列表未在此处打印,但可以从 LJ FTP 站点下载。请参阅“资源”部分。)我使用了 MySQL,但是没有理由不能用另一个关系数据库(例如 PostgreSQL 或 Oracle)来代替它。为了适应另一个数据库服务器,可能需要更改一些 SQL 语法,但大部分应该保持不变。

虽然本月介绍的排名系统不像 Amazon.com 使用的系统那样复杂,也不像 Epinions.com 使用的系统那样灵活,但它确实服务于一个简单的目的。此外,它演示了如何生成一个简单的排名系统,该系统可以轻松扩展以生成 Epinions.com 的粗略版本。

创建表

与往常一样,创建数据库/Web 应用程序的第一步是考虑我们希望如何存储信息,然后在我们的关系数据库中创建表。在这种特定情况下,我们将保持简单,将排名项目划分为类别,但没有任何表示类别的层次结构。因此,我们将能够将所有餐馆放在同一类别中,但意大利餐馆和法国餐馆之间没有任何区别。或者,我们可以为意大利和法国餐馆创建两个单独的类别,但这样系统会将它们视为与理发师和吸尘器推销员无关。

我们还将把姓名和电子邮件地址与每个排名关联起来。将用户名放在单独的表中并用数字键引用它们可能更优雅。但是,我们对跟踪用户不如对使查找有用的消费者信息成为可能更感兴趣。

考虑到所有这些,我决定使用三个表来实现排名系统:RankCategories、RankItems 和 Rankings。RankCategories,顾名思义,包含项目分类的类别,可以定义如下

CREATE TABLE RankCategories (
category_id MEDIUMINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
category_name VARCHAR(25) NOT NULL,
category_description TEXT NULL,
UNIQUE(category_name)
);

请注意每个类别将如何拥有唯一的 category_id,从而允许我们仅通过数字引用它。通过使用 AUTO_INCREMENT 功能,MySQL 可以自动为我们设置此数字,确保它是唯一的。对于小型网站,MEDIUMINT 足够大,范围从 0 到 16,777,215。大型站点最终可能有超过 1700 万个排名,在这种情况下,更大的尺寸,例如 INTBIGINT,可能是一个好主意。

我们还希望确保没有两个类别可以具有相同的名称,因此也向 category_name 列添加了 unique 约束。然后,每个类别都可以有一些与之关联的描述性文本,这些文本放在 category_description 中。

要排名的项目放在类似的表 RankItems 中

CREATE TABLE RankItems (
item_id MEDIUMINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
category_id MEDIUMINT UNSIGNED NOT NULL,
item_name VARCHAR(25) NOT NULL,
item_description TEXT NULL,
UNIQUE(item_name)
);

再一次,每个项目在 item_id 中都有一个唯一值,并且由于 UNIQUE 约束,项目名称保证是唯一的。但是,RankItems 添加了一个 category_id 列,用于标识此项目所属的类别。

RankItems 定义了可供排名的项目,但不存储等级。该角色分配给 Rankings 表,定义如下

CREATE TABLE Rankings (
ranking_id MEDIUMINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id MEDIUMINT UNSIGNED NOT NULL,
ranker_name VARCHAR(30) NOT NULL,
email VARCHAR(40) NOT NULL,
entry_datetime TIMESTAMP(8) NOT NULL,
comments TEXT NULL,
rank TINYINT UNSIGNED NOT NULL,
UNIQUE(item_id, email)
);

与其他表一样,Rankings 为每个排名提供一个唯一的主键 ranking_id。我们的应用程序均未使用 ranking_id,并且将其包含在表定义中可能会浪费一些磁盘和内存空间。但是,如果我们在排名系统上构建其他应用程序,这样的主键将使引用 Rankings 中的项目变得更容易。

Rankings 然后包含一个 item_id 列,该列引用回 RankItems 中同名的主键。由于每个项目都属于一个类别,并且 RankItems 包含一个 category_id 列,因此无需在 Rankings 中也命名类别。

我们要求每个排名用户提供其全名和电子邮件地址。此信息显示在排名的旁边,以使其更具人情味。此外,在小型用户社区中,有些人的意见比其他人的意见更重要,因此通过姓名识别意见是值得的。

排名由 0 到 10 之间的数字等级组成,存储在 TINYINT 中。它附带可选的 (NULL) 评论,存储在 TEXT 列中,用户可以在其中详细阐述自己的想法。

为了确保每个用户只能对每个产品进行一次排名,我们让 MySQL 要求项目 ID 和电子邮件地址的组合在 Rankings 表中是唯一的。通过以这种方式组合两者,数据库本身将拒绝任何尝试输入电子邮件地址和 item_id 的组合相同的两行。

添加类别

现在我们已经了解了数据的存储方式,我们将编写几个程序,允许用户将信息输入到表中。此处提供的界面可能看起来有点原始,但这无关紧要。由于信息存储在数据库中,我们始终可以编写新程序来改进或更改界面。

由于每个 RankItem 都必须放在一个类别中,因此我们必须首先编写一个程序,将新类别添加到系统中。列表 1,rank-category.pl(请参阅“资源”部分),是此类程序的简单示例。

当使用 GET 方法调用时,例如当用户在浏览器中输入 URL 时,rank-category.pl 会生成一个 HTML 表单,可以在其中输入有关站点的详细信息。该表单被提交给 rank-category.pl,后者处理使用 POST 提交的数据,从而向 RankCategories 表中添加新行。将新行添加到数据库后,rank-category.pl 再次显示 HTML 表单。这使得添加多个类别相对容易。

rank-category.pl 以及本月介绍的其他程序,都使用了 CGI.pm,这是用于处理 CGI 程序的标准 Perl 模块(由 Lincoln Stein 编写)。我们还将许多子例程导入到当前命名空间(与 :standard 标记分组在一起),从而更容易生成 HTML 输出。例如,p 函数用 <P> 和 </P> 标签包围文本。因此,以下代码

print p("Hello");

与以下代码相同

print "<P>Hello</P>";

但更具可读性。此外,CGI.pm 的 HTML 子例程可以处理多个字符串并且可以嵌套

print p("This will be in", b("bold"), "type");

CGI.pm 会自动在这些子例程的参数之间插入空格,因此无需在带引号的字符串的末尾或开头放置空格。

要设置 HTML 标签属性,请将哈希引用作为第一个参数传递给子例程。例如,以下代码来自 rank-category.pl(列表 1)

 print p("Now go ",
   a({-href=>'/cgi-bin/rank-something.pl'},
       "rank something"), "!");

上面的代码将文本“rank-something”放在指向 /cgi-bin/rank-something.pl 的超链接内,生成的 HTML 与以下代码相同,而无需担心引号或空格

print qq{<P>Now go
<a href="/cgi-bin/rank-something.pl">
rank something</a>!</P>
默认情况下,CGI.pm 不会在使用这些例程生成的 HTML 标签之间插入空格。这对于计算机来说可能更有效率,但使得检查和调试 HTML 输出变得困难。因此,我们使用 CGI::Pretty,它是 CGI.pm 的一个子类,它在标签之间插入适当的空格。(如果您的系统不支持 CGI::Pretty,请改用 CGI.pm。输出将不会格式化得那么好,但仍然可以工作。)

无论如何调用,rank-category.pl 始终生成一个 HTML 表单,其中包含两个文本字段,new_category_namenew_category_description。这些字段被提交回 rank-category.pl,后者将信息插入到数据库中。

程序与数据库的连接由 DBI 管理,DBI 是 CPAN(综合 Perl 存档网络,网址为 http://www.cpan.org/)提供的 Perl 数据库接口。DBI 与每种类型的数据库服务器的特定驱动程序 (DBD) 结合使用,为关系数据库提供通用 API。DBD 可用于大多数流行的数据库服务器品牌,并且可以最大限度地减少跨平台移植 Perl 程序的难度。

假设 new_category_namenew_category_description 包含有效数据,我们通过构建 SQL 查询将新行插入到 RankCategories 表中。以下是执行此任务的 Perl 代码

my $sql  = "INSERT INTO RankCategories ";
 $sql .= "(category_name, category_description) ";
 $sql .= "VALUES (?,?) ";
my $sth = $dbh->prepare($sql)
   || die "Cannot prepare: $DBI::errstr";
my $success =
   $sth“>execute($new_category_name,
       $new_category_description);

请注意 $sql 如何包含问号 (?) 而不是实际数据。这些值在 $sth->execute 中设置,第一个参数 ($new_category_name) 被分配给第一个占位符,第二个参数 ($new_category_description) 被分配给第二个占位符。

rank-category.pl 并未从占位符的使用中获得明显的优势,占位符加速了同一 SQL 查询的多次调用,但只有细微的变化。但是,使用占位符也可以避免在内插变量值包含 '" 字符时可能出现的问题。DBI 会自动处理并引用它们,从而使程序更具可读性,并消除这些类型错误的发生可能性。

创建排名表单

将一个或多个类别添加到 RankCategories 后,用户可以开始对单个项目进行排名。这是通过列表 2 rank-something.pl(请参阅“资源”部分)完成的,该列表生成一个 HTML 表单,以便用户可以对项目进行排名。用户可以选择对数据库中已有的内容进行排名,或者在现有类别中添加新项目。

表单必须由程序创建,而不是作为静态文档编写,因为它显示来自数据库表的目录和项目的弹出菜单。首次调用时,rank-something.pl 从 RankItems 和 RankCategories 中检索项目和类别,并将它们放入哈希中以便于检索。

DBI 通过其 fetchrow_hashref 方法可以相对容易地将表检索到哈希中。但是,文档明确指出 fetchrow_hashref 效率不高,因此我们使用 fetchrow_arrayref,将每一行检索为自己的数组引用,并将信息存储在哈希中

$sql  = "SELECT category_id, category_name ";
$sql .= "FROM RankCategories ";
$sth = $dbh->prepare($sql)
   || die "Cannot prepare: $DBI::errstr";
$result = $sth->execute
   || die "Cannot execute: $DBI::errstr";
my %categories = ();
while (my $row = $sth->fetchrow_arrayref)
   {
   my ($id, $name) = @$row;
   $categories{$id} = $name;
   }

表单的“现有项目”和“新项目”部分都使用了 CGI.pm 提供的 popup_menu 函数来创建 <select> 列表。弹出菜单具有名称和一组潜在值,每个值都可以选择性地与描述性文本关联。例如

<select name="number">
   <option value="1">one
   <option value="2" selected> two
   <option value="3">three
</select>
上面的 HTML 创建了一个名为“number”的三元素弹出菜单。此菜单可以将三个选项之一(123)传递给 CGI 程序。但是,用户永远看不到这些值;相反,菜单用与值对应的英文单词标记。虽然用户可以选择并提交菜单的任何值,但默认情况下会选择 two 元素。

鉴于现有项目放置在 %items 哈希中(正如上面的代码将现有类别放置在 %categories 哈希中一样),我们可以使用以下代码创建一个弹出菜单

popup_menu(-name => "existing_item_id",
   -values => [0, (sort {
   $items{$a} cmp $items{$b}} keys %items)],
      -labels => {0 => "Choose one",
      map {($_, $items{$_})} keys %items},
   -default => $existing_item_id,
   -override => 1),

popup_menu 返回一个文本字符串,适合传递给 print 或保存到磁盘(如果程序正在创建 HTML 格式的文本文件)。name 参数设置 name 属性,default 参数指示默认情况下将选择哪个属性。

values 参数将数组引用作为参数。在这种情况下,这些值应该是 %items 的键(即 RankItems 中的主键),但按每个键的 item_name 值的顺序排序。解决方案是通过值而不是键对 keys %items 进行排序。向 values 参数的前面添加一个 0 值元素意味着第一个选项将始终具有值 0。MySQL 不在 AUTO_INCREMENT 列中使用 0,Perl 将 0 视为 false — 因此将默认值设置为 0 永远不会与 item_id 的实际值冲突,并且在通过 POST 传递给我们的程序时可以轻松识别。

默认情况下,popup_menu 会将每个 <option> 的描述性文本设置为值本身。但是,labels 参数可以为某些或所有值分配自定义标签。labels 参数将哈希引用作为输入。由于列表上下文中的哈希会变成以逗号分隔的键和值列表,因此我们可以将 %items 放入哈希引用中,并在其前面加上从 0 到文本“选择一个”的映射。

不幸的是,HTML 不支持分层弹出菜单。如果我们能够使用 popup_menu 提供一个类别菜单,该菜单将引导到相关项目列表,这将对用户来说最容易和最好。考虑到需要在第二次程序调用之间进行选择(正如我们在下面描述的 view-ranking.pl 中所做的那样)和生成平面项目列表,我选择了后者。另一种方法是使用 Yahoo! 开创并被 Epinions 使用的方法,其中每个类别超链接后都带有指向最流行的单个项目的链接。实现这种方法将需要对 rank-something.pl(以及大概 view-ranking.pl)进行一些更改,但不会对底层数据库进行更改。

插入新排名

当使用 POST 方法调用 rank-something.pl 时,它会查找需要插入到 RankItems 中的新项目。实际插入非常简单,使用 DBI 的 prepareexecute 方法,正如我们之前看到的那样

$sql  = "INSERT INTO RankItems ";
$sql .= "(category_id, item_name, item_description) ";
$sql .= "VALUES (?,?,?) ";
$sth = $dbh->prepare($sql)
   || die "Cannot prepare: $DBI::errstr";
$success =
   $sth->execute($item_category, $item_name,
   $item_description);

如果执行不成功,$success 将设置为假值。我们可以通过这种方式捕获错误,生成如下错误消息

unless ($success)
 {
 print h1("Error inserting new item");
 print p(
 "There was an error inserting the item:".
 $DBI::errstr");
 print p(
 "Perhaps this item already exists in the".
 database?");
 exit;
 }
现在我们可以确定要排名的项目在 RankItems 中。如果该项目已在 RankItems 中,那么我们从 HTML 表单中的 <select> 列表中知道它的主键。但是,我们如何检索刚刚插入的新项目的主键呢?MySQL 的 DBI 驱动程序提供了一个名为 mysql_insertid 的属性,该属性返回最近插入行的主键。我们可以检索此值并将其存储在 $item_id 中,否则它将从弹出菜单中获取其值
$item_id = $dbh->{"mysql_insertid"};
正如我们之前看到的,每个排名都由介于 0 到 10 之间的整数(从最差到最佳)以及用户对该项目的评论组成。为了避免潜在的格式问题,我决定从评论中删除所有 HTML 标签。使用 Perl 的非贪婪正则表达式,这是一项简单的任务
$comments =~ s|<.*?>||g;
以上代码将所有出现的 <,后跟零个或多个字符,后跟 > 替换为空字符串。s||| 运算符末尾的 |g 修饰符全局执行此操作。

接下来,我们处理段落分隔符。我们希望人们能够输入多个段落,但不允许他们使用 <P> 和 </P> 标签来这样做。解决方案是将每次出现多个 \r(回车符)或 \n(换行符)字符都视为段落分隔符。UNIX 机器很少在文本输入中生成 \r 字符,但 DOS/Windows 系统以 \r\n 组合结束行,而 Macintosh 使用单个 \r。以下代码将任意两个(或多个)这些字符转换为 <br> 标签,后跟两个换行符

$comments =~ s|[\r\n]{2,}|<br>\n\n|g;

最后,我们将新排名插入到 Rankings 表中

$sql  = "INSERT INTO Rankings ";
$sql .= "(item_id, ranker_name, email, comments, rank) ";
$sql .= "VALUES (?,?,?,?,?) ";
$sth = $dbh->prepare($sql)
   || die "Cannot prepare: $DBI::errstr";
$success =
   $sth->execute($item_id, $ranker_name, $email,
   $comments, $rank);
再一次,我们使用问号 (?) 作为占位符,从而有可能加快多个查询的速度,并消除我们显式引用单个项目的需要。

如果 INSERT 成功,则会向用户显示一条简短消息

print p("Your ranking was successfully entered.");

在此消息之后,用户有机会通过单击超链接对数据库中的另一个项目进行排名或查看当前排名数据库。

检索数据

最后,一旦将数据输入到 Rankings 中,我们就可以编写列表 3 view-ranking.pl(请参阅“资源”部分),这是一个允许我们查看排名并阅读它们的程序。呈现信息的方式有很多种,我在该程序的此版本中采用了简单的方式,强制用户在看到特定项目的排名列表之前,先浏览两个菜单(一个是类别菜单,第二个是该类别中的项目菜单)。再一次,我们使用 GET/POST 技巧来编写一个既创建表单又接受其数据的程序。

如果使用 GET 调用 view-ranking.pl,它会创建一个简单的 HTML 表单,其中包含弹出菜单中的类别

print $query->start_html(-title =>
   "Choose a category");
print h1("Choose a category");
print startform(-method => "POST",
   -action => $query->url);
print p("Select a category to view:",
   popup_menu(-name => 'category_id',
   -values =>
   [sort {$categories{$a} cmp $categories{$b}}
          keys %categories],
   -override => 1,
   -labels => \%categories));
print submit(-value =>
   'View items in this category');
print endform;

如果使用 POST 调用 view-ranking.pl,它会检查是否设置了 category_id。如果是,则它假定调用表单是上述表单,并显示该类别中的项目列表

$sql  = "SELECT C.category_name, I.item_name, ";
$sql .= "       I.item_description, AVG(R.rank)";
$sql .= "FROM RankItems I, RankCategories C, ";
$sql .= "     Rankings R ";
$sql .= "WHERE I.category_id = C.category_id ";
$sql .= "AND   I.item_id = $item_id ";
$sql .= "AND   I.item_id = R.item_id ";
$sql .= "GROUP BY I.item_id = R.item_id ";
$sth = $dbh->prepare($sql)
   || die "Cannot prepare: $DBI::errstr";
$result = $sth->execute
   || die "Cannot execute: $DBI::errstr";
这个看似复杂的 SQL 查询从所有三个表中检索信息,从 RankCategories 中获取类别名称,并从 RankItems 中获取有关此项目的信息。AVG 函数返回所有返回行的平均值,从而可以了解项目应该真正排名的位置。当然,无法阻止恶意用户(或排名企业的拥有者)试图在特定方向上倾斜天平,因此用户阅读评论和个人排名与平均分同样重要。

一旦我们检索到有关该项目的一般信息,我们就会执行第二个 SELECT,请求按时间顺序排列的此项目的所有行

$sql  = "SELECT ranker_name, email, comments, rank ";
$sql .= "FROM Rankings ";
$sql .= "WHERE item_id = $item_id ";
$sql .= "ORDER BY entry_datetime ";

然后将此查询的结果打印给用户

while (my $row_ref = $sth->fetchrow_arrayref)
  {
  my ($name, $email, $comments, $rank) =
      @$row_ref;
print p(dt(a({href => "mailto:$email"},
  $name), "*" x $rank, " ($rank)"),
dd($comments));
}
HTML 的 <dt> 和 <dd> 标签非常适合格式化这些类型的评论,自动处理缩进。Perl 的 x 运算符(它乘以文本字符串)可以轻松生成与特定评论关联的正确星数。
结论

我本月介绍的排名软件仅处于早期阶段,并且无疑会在本期 Linux Journal 到达您手中时得到改进。

除了用户界面的改进和可能添加的分层类别系统之外,我还想添加几个相关的应用程序。一个将返回类别中排名最高的项目,允许用户找到最佳选择,而无需浏览数十个或数百个评论。此系统的核心用户可能只想阅读自上次访问该站点以来出现的评论。一些额外的个性化设置,包括使用 Cookie 来记住用户的姓名和电子邮件地址,将减少某人必须进行的键入量。随着系统的增长,系统管理员的编辑工具无疑将被证明是有用的。最后,提供搜索功能总是好的,以防万一意大利餐厅被错误地归类为咖啡馆。

我们本月检查的程序表明,生成简单的用户排名系统并非特别困难。如果我们利用关系数据库在存储和检索数据方面为我们提供的强大功能,尤其如此。最重要的是,我的电子邮件列表的订阅者现在可以将时间花在交易信息上,将建议留给一组 CGI 程序。

资源

Consumer Rankings
Reuven M. Lerner 是一位互联网和 Web 顾问,在 11 月与 Shira Friedman-Lerner 结婚后搬到了以色列的莫迪因。他的著作《Core Perl》将于春季由 Prentice-Hall 出版。可以通过 reuven@lerner.co.il 与 Reuven 联系。ATF 主页,包括档案、源代码和讨论论坛,位于 http://www.lerner.co.il/atf/
加载 Disqus 评论