三层设计

作者:Reuven M. Lerner

几个月前,我们深入研究了 Mason,这是一个结合了 mod_perl、Apache 和模板的 Web 开发环境。其中一个例子是新闻稿系统,Mason 组件从数据库中检索最新的新闻稿。本文演示的编程风格可以被描述为“两层”,其中 Mason 组件直接与数据库对话,使用 Perl 的 DBI 和 mod_perl 的 Apache::DBI。

但是,正如 Mason 电子邮件列表中的几个人指出的那样,这种方法——其中 SQL 语句直接位于 Mason 组件内部——通常是不明智的。修改数据库定义,甚至是我们使用的数据库服务器品牌,都会迫使我们更改组件本身。此外,非 Web 程序必须在其自己的程序中重新实现 SQL 调用,而不是重用常用的维护和测试过的库。

我们可以通过在数据库和 Mason 组件之间添加一个额外的软件层(有时称为“中间件”)来解决这两个问题。这种日益流行的架构被称为“三层”方法,因为它意味着我们必须使用三组不同的软件服务:数据库、“中间件”抽象层以及实现/表示层。

本月和下个月,我们将研究一个简单的基于 Web 的地址簿和约会日历,以演示这种三层方法。在此过程中,我希望您能了解这种方法的优点和缺点,并在创建网站时能够权衡它与其他方法。一旦我们研究了这种通用架构,我们将为研究带有 Jakarta-Tomcat 和应用服务器的 Java Server Pages 做好充分准备。我们将检查这种方法中涉及的陷阱,以及它如何在长期内使开发更容易和更具可扩展性。

数据库

第一层,也许也是最重要的一层,是关系数据库。我将在此示例中使用 PostgreSQL,但如果我们使用 Oracle 或 MySQL 之类的数据库,也不会有太大区别。

在设计数据库之前,我们必须有一个规范来描述我们想要做什么。毕竟,数据库的设计很大程度上取决于我们打算如何使用它。

就本文而言,我们将使规范相对简短且含糊不清:我想要一个地址簿,我可以通过 Web 查看、搜索和更新它。此外,我希望能够使用我的 Web 浏览器添加、查看和修改约会。

为了实现这一点,我们将至少需要两个表,一个包含人员,另一个包含约会。以下是人员表的初始定义(见列表 1)。

列表 1. 定义人员表

换句话说,我们将始终存储人员的名字、姓氏、国家和电子邮件地址。除此之外,我们可以存储他们的地址、城市、州、邮政编码以及一些关于他们的评论。这假设我们认识的每个人都有电子邮件地址——这个假设越来越成立,但如果您有来自计算机行业以外的朋友和业务联系人,则不一定是好的属性。

我们的人员表中的每个条目都将由 person_id 列唯一标识,该列由 PostgreSQL 自动递增。此外,我们通过检查其电子邮件地址的唯一性来确保我们只添加每个人一次。这允许我们有多个名为 John Smith 的朋友。这也意味着我们无法存储关于共享电子邮件地址的夫妇的单独信息。也没有很好的规定来处理拥有多个电子邮件地址的人。

向人员表中添加新人相对容易

INSERT INTO People
        (first_name, last_name, address1, address2, email,
        city, state, postal_code, country, comments)
    VALUES
        ('Shai', 'Re\'em', '10
Helmonit', 'Apt. 7',
        'shai@lerner.co.il',
'Modi\'in', NULL,
        71700, 'Israel', 'Six-year-old
nephew')
    ;

因为大多数列都是 NULL,所以我甚至可以通过输入最少的列来完成

INSERT INTO People
        (first_name, last_name, email, country)
VALUES
        ('Hadar', 'Re\'em',
'hadar@lerner.co.il',
        'Israel')
    ;
现在我们将创建一个约会表,在其中存储与人员表成员的约会
CREATE TABLE Appointments (
     person_id    INT4       NOT NULL   REFERENCES People,
     start_time   TIMESTAMP  NOT NULL,
     end_time     TIMESTAMP  NOT NULL,
     notes        TEXT       NULL       CHECK
                (notes <>''),
        UNIQUE(start_time)
    );
一旦我定义了约会表,我可以通过在约会中插入新行来添加新约会
INSERT INTO Appointments
        (person_id, start_time, end_time, notes)
    VALUES
        (1, 'November 22, 2000 19:00',
        'November 22, 2000 19:30', 'Read Dr. Seuss')
    ;
但是,由于 person_id 被定义为人员表的外键,因此我们只能与已经在人员表中的人会面时才能添加约会。这可能足以满足我们的目的,但更复杂(且规范良好)的系统可能会给我们更大的灵活性。当然,此数据库不允许我指示我一次与多人会面。
中间件

现在我们已经创建了初始数据库设计,我们将考虑中间件层的设计,该层将 Web 应用程序与数据库隔离。如果我们决定切换到另一个品牌的数据库服务器,甚至用平面 ASCII 文件或 DBM 文件替换数据库,则对象层将保持不变。

此外,非 Web 应用程序将能够使用此层来访问数据库,从而可以编写一组例程,将我们的约会日历导出到 XML,或从另一个程序导入它。

这个中间层通常被称为应用程序的“业务逻辑”。数据库使我们能够轻松安全地存储和检索信息,而 Mason 组件使我们能够轻松地为最终用户创建动态输出。中间件层将尝试强制数据库尽可能多地进行计算,使用内置函数、视图和存储过程。但是,确定我们应用程序功能的实际逻辑将驻留在中间层中。

在创建此层时,Perl 至少为我们提供了两个选项。一种可能性是创建一个基本的 Perl 模块,该模块提供子例程和变量,可以完成我们需要完成的任务。这种过程式接口相对容易编写,并且以与所有其他 Perl 子例程相同的速度执行。

但是 Perl 也为我们提供了创建对象模块的选项。虽然 Perl 对象稍微难以编写,并且它们的方法执行速度比直接子例程慢,但它们使概念化和编写程序更容易。

在我们创建中间件层之前,我们必须回答一些严肃的问题。我们将创建哪些类型的对象?我们可以创建一个处理我们所有查询的单个数据库对象,将其转换为适当的 SQL。但是我们偶尔会想要检索关于人员的信息,而无需考虑约会,这意味着我们应该至少有一个人员对象,以及一个单独的约会对象。由于我们的数据库表定义强制我们将每个约会与一个人关联,因此我们只能在人员对象之后定义我们的约会对象。

People.pm

列表 2 包含 People.pm 的列表,这是一个对象模块,它为我们之前创建的人员表执行一些基本任务。该对象不完整,并且有一些粗糙的边缘,但应该足以演示如何通过对象中间件层访问关系数据库。

列表 2. People.pm,一个与包 People 通信的 Perl 对象模块

基本思想是您创建一个人员的新实例,然后使用该对象操作您的约会簿中的人员。要检索数据库中所有人员的姓名,您可以使用 get_all_full_names 方法,如以下代码片段所示(另请参见列表 3)

use People;
# Create a People object
my $people = new People;
# Retrieve all of the full names
my @names = $people->get_all_full_names();
# Print the names
foreach my $name (@names)
{
    print "name\n";
}

列表 3. Retrieve-people.pl,一个使用 People.pm 从数据库检索信息的程序

要设置或检索关于特定人员的信息,您必须首先确定您正在谈论的人员。由于我们的中间件层旨在屏蔽用户,使其不必考虑或担心主键和其他数据库特定的 ID,我们将允许他们通过名字和姓氏或通过电子邮件地址来设置“当前人员”。

电子邮件地址保证在数据库层中是唯一的,因此使用 set_current_person_by_email 是最可靠的方法。然而,通常通过名字和姓氏来识别人员很有用,因此我们也提供了 set_current_person_by_name 方法。在当前实现中,使用名称来设置当前人员将匹配从数据库返回的第一个行,这可能不一定是您想要的。

一旦程序设置了当前人员,它可以使用 get_current_info 方法检索关于该人员的信息

# Set the current person by name
$people->set_current_person_by_name("Shai","Re'em")
|| die "Error retrieving person.";
# Print the information
foreach my $key (sort keys %{$info})
{
        if (defined $info->{$key})
        {
                print "$key => $info->{$key}\n";
        }
}

people 的每个实例将仅保留两个状态片段:当前选定人员的 ID ($self->{current_person}) 和将我们连接到数据库的数据库句柄 ($dbh) ($self->{dbh})。我们保留数据库句柄,因为连接到数据库是一个相对昂贵的操作。因此,我们可以通过在构造函数中连接到数据库,然后在每次我们在该对象上调用方法时使用该连接来节省一些时间。

当然,这意味着数据库连接必须在 Perl 对象消失时销毁——这是一个有点棘手的任务,因为 Perl 没有显式析构函数,因为它是一种垃圾收集语言。解决方案是创建一个名为 DESTROY 的方法,该方法在 Perl 销毁对象时调用。我们的 DESTROY 方法只是关闭我们与数据库的连接,从而允许删除对象,而不会潜在地导致数据库客户端或服务器中的内存泄漏

sub DESTROY
{
        # Get myself
        my $self = shift;
        # Get the database handle
        my $dbh = $self->{dbh};
        # Close the database handle
        $dbh->disconnect;
        return;
}

我们甚至可以创建一个新人,使用一组哈希键和值作为参数调用 new_person 方法。这些然后由中间件层转换为适当的 SQL 查询

# Now insert a new person
my $success = $people->new_person
        (first_name => "Reuven",
        last_name => "Lerner",
        country => "Israel"
        email => 'reuven@lerner.co.il',
        phone => '08-973-2225');
print "Inserted successfully" if $success;
由于 Perl 的未定义 (“undef”) 值会自动转换为 SQL “NULL” 值,因此可选列将填充 NULL,这应该是这种情况。
约会

现在我们有了一个处理数据库中人员的类,我们需要创建一个约会类。目前,我们只关注插入新约会和检索今天的约会。

Appointments.pm 的设计(见列表 4)通常类似于 People.pm,特别是在构造函数中打开数据库连接并在自动调用的 DESTROY 方法中关闭它的方式。然而,除此之外,约会不保留任何状态。它只是充当数据库的管道,允许我们创建新约会并找出我们今天与谁会面。

列表 4. Appointments.p

例如,列表 5 包含一个简短的程序,该程序使用 Appointments.pm 创建新约会。我们必须创建人员实例和一个约会实例。一旦我们有了这两个对象,我们可以将“当前人员”设置为我的侄女 (“Hadar Re'em”),如果 set_current_person_by_name 返回 undef(指示失败),则会因错误而终止。

列表 5. Insert-appointment.pl

一旦我们成功设置了当前人员,我们就可以与该人员创建约会。日期和时间的格式由 PostgreSQL 决定,它接受多种格式。

我们可以使用列表 6(print-appointments.pl)中的程序类似地检索今天的约会。该程序使用 get_today 方法,该方法返回哈希引用的列表。请注意,get_today 的实现使用 DBI 的 fetchrow_hashref 方法,已知该方法比 fetchrow_arrayref 慢得多。但是,它使生活更加方便,允许我们执行如列表 6 中所示的 print-appointments。

列表 6. Print-appointments.pl

最后,我们可以使用 get_today_with_person 方法列出今天与特定人员的所有约会。当然,这意味着我们必须创建人员实例并使用前面描述的方法之一选择当前人员。get_today_with_person 的实现期望接收人员实例作为其第一个用户传递的参数,允许我们在 SQL 查询中使用当前人员。列表 7 中的程序演示了我如何找到今天我与我的侄子 Shai 的所有约会。

列表 7. Print-appointments-with-shai.pl

对象设计

在中间件层中使用对象的主要优点之一是它们提供了抽象层。只要接口定义良好且保持稳定,实现就可以更改。

然而,与所有编程技术一样,设计好的对象可能很困难。Perl 提供了对对象内部结构的完全开放访问,这意味着在没有良好 API 的情况下,使用该对象的程序员可能会试图深入内部并直接使用实现。这可能意味着当实现发生变化时,软件将会崩溃——这正是使用对象应该防止的情况!

此外,我们希望我们的对象的实现彼此相对独立。在设计人员和约会对象期间,我非常想允许约会获取并使用当前人员的数字 ID。但当然,这样做会违反我使用对象创建的抽象屏障。解决方案是创建 get_current_person 方法,虽然它不如我希望的那么优雅。这允许约会检索当前用户,而无需知道它来自哪里。当然,最终,get_current_person 的返回值被放置在 SQL 语句中,并与 People.person_id 进行比较,从而在某种程度上破坏了抽象。

最后,请注意此处的每个对象都包含基本逻辑,但不存储任何状态。例如,对于我们的人员对象来说,检索人员表中的所有行,并使它们可以从 Perl 内部提供给调用对象将相对简单。实际上,这样的解决方案将大大减少访问数据库的开销,并允许我们在 Perl 中执行操作,而不是每次都求助于 SQL。

但是这种解决方案引起的问题比解决的问题更多。例如,如果我们创建两个人员实例会发生什么?现在我们有两个对象,每个对象都包含人员表的完整行集。如果一个对象修改其状态,则该修改永远不会反映在第二个对象中。更糟糕的是,如果两个对象在将这些更改存储在数据库中之前都修改了它们的状态,会发生什么情况?也许数据库被设计为解决此类锁定问题,但我们的 Perl 对象不是。此外,当我们的人员表中有 100,000 人时会发生什么?将如此多的数据读取到数据库客户端是内存的浪费,也是数据库服务器包含的高性能数据选择和操作例程的浪费。

因此,我们的对象是数据库的管道,使我们的 Web 应用程序能够与数据库对话,而无需包含任何 SQL 或表布局知识。通过提供标准 API,这些对象使得更改底层实现成为可能,而无需向外界宣布这些更改。

结论

本月,我们研究了三层架构背后的原因,并开始研究使用此架构的应用程序的基本实现。正如您所看到的,我们已经可以创建小型文本模式应用程序。下个月,我们将完成我们的实现,为我们提供使用 mod_perl/Mason 和 PostgreSQL 的简单约会簿的骨架。我们还将讨论关于三层解决方案的可扩展性问题,以及一些陷阱。

资源

Three-Tiered Design
Reuven M. Lerner 拥有并管理一家专门从事 Web 和 Internet 技术的小型咨询公司。当您阅读本文时,他应该(终于!)完成 Core Perl 的编写,该书将于今年晚些时候由 Prentice-Hall 出版。您可以通过 reuven@lerner.co.il 或 ATF 主页 http://www.lerner.co.il/atf/ 与他联系。
加载 Disqus 评论