使用 Mason 进行会话管理

作者:Reuven M. Lerner

在过去的两个月里,我们了解了基于 Perl 的 Web 开发框架 Mason。Mason 由 Jonathan Swartz 编写和维护,其核心思想是“组件”,这是一种灵活的模板,可以包含 HTML、Perl 代码或两者兼有。Mason 使在相对较短的时间内创建大型动态网站成为可能。此外,它还便于轻松且广泛的代码重用,从而消除了与网站相关的许多常见维护问题。

由于 Mason 传统上运行在 mod_perl 之上,这是一个 Apache 模块,它将完整的 Perl 二进制文件置于 Web 服务器内部,因此它可以利用为 mod_perl 开发的其他 Perl 模块。其中一个特别有用的模块示例是 Apache::Session,它使得绕过与 HTTP 无状态性相关的一些问题成为可能。

本月,我们将研究 Apache::Session,并使用它创建一个基于 Mason 的简单用户注册系统。该系统可以相对简单地创建一个个性化的网站,将关系数据库中的信息与特定用户连接起来。

会话管理

HTTP 被设计为一种轻量级协议,每次事务处理都花费最少的时间。因此,它相当简约,每次连接都包含一个请求-响应对。(现代版本的 HTTP 支持单个事务中的多个请求-响应对,但我的印象是单事务版本 1.0 仍然是常态。)

在这种模型中,HTTP 客户端连接到服务器,发送一个请求和一个可选参数,然后发送一个或多个描述浏览器功能的标头。然后,HTTP 服务器返回一个或多个描述响应的标头,然后是响应本身。响应可以是 HTML 格式的文本文档、图像或指示请求无法完成的错误消息。服务器发送此响应后,它会关闭连接。

由于每个 HTTP 事务都发生在真空中,没有任何来自其他事务的信息,因此很难跟踪用户的操作。与传统的计算机环境不同,Web 没有“登录”或“注销”的概念。无法知道五个 HTTP 请求是由同一台计算机上的五个不同用户发起的,还是由对五个不同 URL 感兴趣的一个用户发起的。

有两种主要技术可以解决这些问题。第一种称为“cookies”,允许服务器在用户的计算机上存储一个名称-值对。cookie 在服务器 HTTP 响应的开头以“Set-Cookie”标头设置。每次浏览器返回到此服务器域内的站点时,它都会在请求中发送一个“Cookie”标头,其中包含先前存储的名称-值对。Cookie 的长度有限,可以随时被浏览器删除,并且很容易被用户检查和修改。

另一种技术(我们本月不探讨)涉及使用 URL 的“path_info”段。例如,考虑 URL www.example.com/cgi-bin/foo.pl/abc/def。如果 /cgi-bin/foo.pl 存在于服务器上,则 /abc/def 作为附加参数传递,该参数与从客户端提交的任何名称-值对分开存在。

虽然 cookies 和 path_info 都不是解决 Web 上状态问题的完美解决方案,但它们对于大多数需求来说已经足够了。但是,这些解决方案仅解决了 HTTP 的问题;它们没有为我们的程序提供状态感。

Apache::Session 弥合了这一差距,使得将任意信息与用户关联成为可能。(我们很快就会发现事情并非如此简单,但总体原则是合理的。)Apache::Session 可从 CPAN 获取,它可以与 cookies 或 path_info 一起使用,并且可以使用从 ASCII 文件到关系数据库等机制存储信息。它被设计为与 mod_perl 一起使用,因此可以与 Mason 一起使用;文档表明 Apache::Session 也应该在 CGI 下工作,尽管我没有测试过这种说法。

由于其多功能性和速度,并且由于 Apache::Session 在与关系数据库中的附加信息关联时效果最佳,我们将使用 MySQL 作为我们的后端,在模块文档中称为“对象存储”。为了做到这一点,我们需要在我们的数据库中创建一个名为“sessions”的表,它看起来像这样

CREATE TABLE sessions (
    id CHAR(16),
    length INT(11),
    a_session TEXT
);

Apache::Session 要求表名为 sessions,并且包含三列:类型为 CHAR(16)id 列,类型为 INT(11)length 列,以及类型为 TEXT 或 BLOB 的 a_session 列,它可以包含任意数量的二进制数据。

每个唯一的会话都由一个唯一的 16 字符字符串标识,存储在 id 列中。实际的会话数据存储在 a_session 列中,采用 Storable 模块定义的“nfreeze”格式。(Storable 也可从 CPAN 获取。)

Apache::Storable 和 Mason

每次用户的浏览器向 Web 服务器发送 HTTP 请求时,它都会发送该域存储的任何 cookies。因此,如果 cookie 是由 cnn.com 设置的,那么当我再次访问 http://www.cnn.com/ 时,我的浏览器只会返回该 cookie——毕竟,它只是一个名称-值对。

Apache::Storable 的 cookie 版本通过在 cookie 中存储唯一标识符来利用这一点。此唯一标识符对应于 sessions 表中的 id 列。这允许我们检索存储在 a_session 中的任何数据。由于 a_session 被定义为无限长,因此我们可以存储的数据量仅受我们的数据库和文件系统的限制。

Apache::Session 存储在表 sessions 中的数据可以通过全局 %session 哈希访问。%session 是为每个传入的 HTTP 请求重新创建的,并且仅引用存储在 a_session 中的数据。将某些内容存储在 %session 中会将其放置在 a_session 列中,而从 %session 中检索某些内容则会从 a_session 中获取值。假设变量 $first_name$last_name$email 包含适当的信息片段,我们可以使用以下 Perl 代码可靠地存储它们

$session{first_name} = $first_name;
$session{last_name} = $last_name;
$session{email} = $email;

由于每个用户(实际上,每个会话)都存储在数据库的单独行中,因此我们无需担心用户之间发生冲突。

为了使会话工作,我们必须在 Apache::Session::DBI 模块和磁盘上相应的 sessions 表之间建立连接。此连接必须考虑三种不同的可能性:(a)用户向我们发送有效的 ID cookie,(b)用户向我们发送无效的 ID cookie,以及(c)用户根本没有向我们发送 ID cookie。

第一种情况最简单;程序只需要使用 Perl 的“tie”机制重新建立 %session 和 sessions 中相应行之间的连接。在第二种情况下,如果程序无法重新建立先前的会话,则必须创建一个新的会话。如果用户根本没有发送 cookie,那么我们必须在 sessions 中创建一个新行,为其附加一个唯一的 ID,并将该唯一 ID 以 cookie 的形式发送给用户的浏览器。

在使用 Mason 时,我们将所有这些都放在我们的启动文件中。Mason 文档将此文件称为 handler.pl(但我更喜欢称之为 mason.pl),它定义了 Mason 的所有主要行为,并允许我们定义系统其他元素将需要的全局变量。在 mason.pl 中定义 %session 还确保它在所有 Mason 组件中都可用。请参阅清单 1,了解想要包含会话的站点的 mason.pl 的简单示例。(清单 1 的大部分内容直接来自 Mason 文档。)

清单 1

此文件中最重要的部分是对 Perl 的 eval 命令的调用。eval 有两种形式,其中一种将代码块作为参数,另一种形式作为错误检查的原始形式。在我们的代码块内部,我们尝试使用 Perl 的 tie 命令将哈希 %HTML::Mason::Commands::session 连接到 Apache::Session::DBI 模块。将这两者绑定在一起意味着与哈希关联的默认存储和检索机制不再适用于 %session——当我们检索或修改其值时,Apache::Session::DBI 中的一个或多个方法将接管

eval {
    tie %HTML::Mason::Commands::session, 'Apache::Session::DBI',
        ($cookies{'AF_SID'} ? $cookies{'AF_SID'}->value() : undef),
        {
         DataSource => $dbsource,
         UserName => $dbuser,
         Password => $dbpass
        };
};

如果此 eval 不成功,则变量 $@ 将包含错误消息。在这里,我们测试以查看对象是否存在于数据存储中。如果存在,则我们为用户分配一个新的会话

if ( $@ )
{
    if ( $@ =~ m#^Object does not exist in the data store# )
    {
        tie %HTML::Mason::Commands::session,
'Apache::Session::DBI',
            undef,
            {
             DataSource => $dbsource,
             UserName => $dbuser,
             Password => $dbpass
            };
        undef $cookies{'AF_SID'};
       }
}
最后,如果用户根本没有向我们传递任何标识 AF_SID cookie,我们会创建一个新的 cookie,并告诉 mod_perl 将其与其余传出标头一起发送
if ( !$cookies{'AF_SID'} )
{
    my $cookie =
       new CGI::Cookie(-name => 'AF_SID',
                       -value =>
                       $HTML::Mason::Commands::session{_session_id},
                       -path => '/',);
    $r->header_out('Set-Cookie', => $cookie);
}
一旦这些就位,任何 Mason 组件都可以在 %session 中存储和检索信息。Apache::Session 对 Storable 模块的使用意味着引用和复杂的数据结构(例如数组的数组和哈希的哈希)可以存储在 %session 中,而我们无需担心丢失数据。
我们存储什么?

仅仅因为我们 可以%session 中存储任何内容,并不意味着我们一定应该这样做。例如,想要跟踪用户姓名和电子邮件地址的站点可能会将此信息存储在 %session 中。虽然这样做使得可以从 Mason 组件中轻松获得信息,但它会产生其他问题。例如,很难检索“sessions”的行并使用它们来创建向订阅者电子邮件地址的群发邮件。

因此,我通常使用 Apache::Session 仅存储一个值,即与 Users 表中用户行关联的主键。(还有其他方法可以完成相同的任务,例如在 Users 表中包含用户的唯一 16 字符 ID 字段,并在其上添加“UNIQUE”约束。)如果 $session{user_id} 存在,那么我们可以假设用户之前已经注册,并使用该值从 Users 中检索其他信息。如果 $session{user_id} 不存在,那么我们假设用户是我们的系统的新用户。

以下是 Users 表的一种可能的定义,我们可以在这种方式中使用它

CREATE TABLE Users (
    user_id MEDIUMINT AUTO_INCREMENT,
    username VARCHAR(30) NOT NULL,
    email VARCHAR(50) NOT NULL,
    password VARCHAR(20) NOT NULL,
    password_hint VARCHAR(60) NOT NULL,
    PRIMARY KEY(user_id),
    UNIQUE(username),
    UNIQUE(email)
);

我们将此数据库中的所有列定义为 NOT NULL,这意味着它们是必填字段。除了用户的唯一 ID(由 MySQL 自动生成)、用户名和电子邮件地址外,我们还需要密码和密码提示。正如我们将看到的,这些将允许我们创建一个完整的登录系统,并处理与 HTTP cookies 相关的一些问题。

注册组件

现在我们已经定义了 Users 表,是时候定义一些 Mason 组件了。其中一些组件将类似于子例程,而另一些组件将类似于 HTML 片段。正如我们上个月看到的,两者都是可以接受的(并且受欢迎的)Mason 组件类型。我通常在用户可见的顶级组件上使用 .html 后缀,而在其他组件上使用 .comp 后缀——但您可能希望设置自己的约定。

在我们做任何其他事情之前,我们将需要一个允许我们连接到数据库并检索数据库句柄(传统上称为 $dbh)的组件。由于 Mason 通常在 mod_perl 下运行,我们将利用 Apache::DBI 模块,即使在 HTTP 请求已服务之后,该模块也保持数据库连接打开。以这种方式重用数据库连接可以显着提高我们应用程序的速度,因为登录数据库可能相对较慢。

清单 2 包含一个简单的 Mason 组件,它连接到数据库并返回有效的 $dbh。通过将此功能放在一个组件中,我们避免了在站点上的每个其他组件中都包含该代码。此外,这意味着如果我们必须修改数据源名称(Perl 术语中的“DSN”),我们可以通过更改一个文件来完成。

清单 2

请注意,database-connect 仅由 <%perl><%once> 部分组成,没有任何 HTML。这是一个纯粹充当 Perl 子例程的组件示例,它向其调用者返回值。相比之下,清单 3 包含 register-form.html,这是一个顶级组件,仅包含几行 Perl 代码。register-form.html 的大部分是直接 HTML,可以由图形设计师而不是程序员编写。

清单 3

注册是一个相对简单的过程。输入到 register-form.html 中的信息被发送到 register.html(参见清单 4)。后者从表单中检索名称-值对,并使用 Mason <%args> 部分将它们放入标量变量中。如果缺少一个或多个元素,register.html 会向用户显示一条错误消息,指示需要更新信息。

清单 4

如果用户的注册信息看起来是完整的,register.html 会执行快速 SELECT 以确保用户名确实是唯一的。没错,我们已经定义了表,使得用户名必须是唯一的,但是我们宁愿为用户生成一个美观的错误消息,而不是显示来自数据库的错误消息。

请注意,此代码创建了一个竞争条件;可能有两个用户同时尝试注册相同的用户名。两者都会被告知用户名可用,但只有一个人会被允许插入请求的用户名。支持事务的数据库(例如 PostgreSQL)可以通过将 SELECT 和随后的 INSERT 包装到单个事务中来避免此问题,如果发生错误,则可以回滚该事务。

清单 5

register-form.html 试图提供一些帮助,提醒用户他们是否已经登录。(毕竟,如果您已经登录,通常没有任何理由注册。)它使用组件 get-user-info.comp(参见清单 5),该组件接受一个参数(用户 ID)并返回一个描述具有该 ID 的用户的哈希引用。由于用户 ID 使用 user_id 键存储在 %session 中,因此我们可以按如下方式检索带有用户信息的哈希引用

my $user_info = $m->comp("get-user-info.comp",
                          user_id => $session{user_id});

如果 $session{user_id} 未定义——也就是说,如果用户没有会话——那么 get-user-info.comp 返回 undef。否则,程序可以使用哈希引用的键检索用户的信息。实际上,register-form.html 的顶部演示了这一点

% if ($user_info) {
<P>You are currently logged in as <b><% $user_info->{username} %></b>. Do
you really want to register?</P>
% } else {
<P>You are not logged in. Go ahead and register!</P>
% }
登录和注销

register.html 会自动登录用户。通过这种方式,我们的意思是它将 $session{user_id} 的值设置为 Users 表的有效主键。当 $session{user_id} 设置时,用户被认为是已登录;当它未定义时,用户未登录。

清单 6

因此,注销用户就像取消定义值 $session{user_id} 一样简单。我们在清单 6 logout.html 中完全这样做。一旦用户访问此页面,他或她就不再登录。请注意,行

undef $session{user_id};

不会从 %session 中删除 user_id 键。相反,它将未定义的值分配给 $session{user_id}。

如果用户未能注销,则只要会话 cookie 存在,会话将保持活动状态。Cookies 通常在创建时被分配一个过期日期,指示它们应该传输到服务器的最晚日期。如果未提及过期日期,则 cookie 应在用户退出浏览器时消失。会话 cookies 通常设置为后一种过期日期,强制它们在用户退出浏览器时消失。

但是,这并不意味着用户可以忽略“注销”按钮。相反,未能注销的人实际上是在说,源自特定计算机的任何 HTTP 请求都应归因于他或她的用户名。在典型的办公室中,每个人都有自己的计算机,这可能不是一个严重的问题。但是,在我最近教的一个班级中的一位学生告诉我,她能够在网吧阅读别人的电子邮件,因为 Yahoo! Mail 未能注销之前的用户。

如果信息特别敏感,您可能希望强制用户每 15 或 30 分钟重新注册一次。只需将会话 cookie 的过期日期和时间设置为非常近的将来,cookie 就会自动过期。

登录稍微复杂一些,因为我们必须要求用户提供用户名和密码。这些信息从 login-form.html(清单 7)提供,并传递给 login.html 组件(清单 8)。login.html 执行两项任务:它向数据库提交一个 SELECT 查询,请求提交的用户名和密码的 user_id 列。如果不存在这样的行,$sth->fetchrow_array 返回 undef,因此我们知道用户不存在。如果存在,那么我们将关于此用户的所有相关信息检索到哈希引用中,并将 $session{user_id} 设置为新重新发现的用户 ID。这将将会话信息恢复到用户的浏览器,浏览器将其设置在 cookie(或 path_info,视情况而定)中。

清单 7

清单 8

虽然这里没有空间讨论它,但创建一个“password-remind.html”组件显然不会很困难,该组件允许用户使用他们在初始注册表单中输入的提示来检索他们的密码。

当然,如果个性化站点仅存储用户的姓名和电子邮件地址,则会非常无趣。如果站点跟踪用户的兴趣、生日和股票投资组合,事情会变得更加有趣。但是,一旦我们有了代表此用户的唯一 ID——Users 表中的 user_id 列——我们就可以创建任意数量的表,用它们的主键标识每个用户。

结论

当在 Web 上工作时,会话管理可能是一个棘手的主题,因为它意味着使用无状态连接来完成它从未打算做的事情。借助 Mason 和 Apache::Session,开发一个个性化站点并不困难,该站点可以跟踪用户的兴趣并相应地自定义站点的输出。

资源

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