PHP-数据对象学习手册(全)

PHP 数据对象学习手册(全)

原文:zh.annas-archive.org/md5/33ff31751d56930c46ef1daf9ca0ebcb

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书将向您介绍 PHP 5.0 版本开始提供的最重要的扩展之一——PHP 数据对象,通常称为 PDO。

PHP 由于其简单性和易用性而成为非常流行的 Web 编程语言。这种不断增长的成功的关键因素之一是内置的访问许多流行的关系数据库管理系统(RDBMS)的可能性,比如 MySQL、PostgreSQL 和 SQLite 等。今天,大多数现有的和新创建的 Web 应用程序都与这些数据库相互连接,以生成动态的、数据驱动的网站。

虽然大多数支持 PHP 的 Web 服务器仍在运行 PHP 5.0 之前的版本,但这个新版本引入的增强功能和性能改进将在未来几年内导致 PHP 5 在各个层面得到广泛接受。这就需要我们开始熟悉今天在这个版本中可用的所有高级功能。

本书涵盖内容

第一章概述了 PDO 以及一些功能,比如创建连接的单一接口、连接字符串、统一的语句方法以及异常的使用和单一的错误代码系统。

第二章帮助您开始使用 PDO,通过创建一个示例数据库,然后创建一个连接对象。它还介绍了 PDOStatement 类。

第三章涉及各种错误处理过程及其用途。

第四章介绍了准备好的语句。它涉及在不绑定值的情况下使用准备好的语句,绑定变量以及将参数绑定到准备好的语句。我们还看一下如何使用流处理 BLOB,以便我们不会出现查询失败的风险。

第五章帮助我们确定返回结果集中的行数。此外,我们还遇到了一个新概念——可滚动的游标,它允许我们从结果集中获取子集行。

第六章讨论了 PDO 的高级用法,包括设置连接参数、事务以及PDOPDOStatement类的方法。

第七章给出了一个例子,讨论了 MVC 应用程序的方法部分的创建。

附录 A 解释了面向对象的特性,比如继承、封装、多态和异常处理。

本书的目标读者

本书面向考虑迁移到 PHP 5 并使用新的数据库连接抽象库 PHP 数据对象的 PHP 程序员。虽然 PDO 是完全面向对象的,但需要熟悉这种编程范式。不熟悉 PHP 5 面向对象特性的初学者可能会考虑先阅读附录 A,以便能够跟随本书中的代码示例。

我们假设读者熟悉 SQL,能够创建表并进行简单的 SELECT 查询和更新。我们的示例基于 MySQL 和 SQLite 数据库,因为它们是最常用的选项,也是大多数廉价托管提供商提供的唯一选项。

本书末尾将呈现一个更高级的例子,可能会引起对 SQL 和编程概念有更深入了解的专业程序员的兴趣。

约定

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是一些样式的示例,以及它们的含义解释。

代码有三种样式。文本中的代码单词显示如下:"PostgreSQL 用户可能已经使用了pg_prepare()pg_execute()对。"

代码块将设置如下:

// Assume we also want to filter by make
$sql = 'SELECT * FROM cars WHERE make=?';
$stmt = $conn->prepare($sql);
$stmt->execute(array($_REQUEST['make']));

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将被加粗:

// Assume we also want to filter by make
$sql = 'SELECT * FROM cars WHERE make=?';
**$stmt = $conn->prepare($sql);**
$stmt->execute(array($_REQUEST['make']));

新术语重要单词以粗体字体介绍。您在屏幕上看到的单词,比如菜单或对话框中的单词,在我们的文本中会出现如下:"您可以在浏览器中的图书列表页面上简单点击作者链接"。

注意

重要提示会以这样的框出现。

注意

提示和技巧会出现在这样的形式中。

第一章:介绍

PHP 数据对象PDO)是一个 PHP5 扩展,定义了一个轻量级的数据库连接抽象库(有时称为数据访问抽象库)。对于像 PDO 这样的工具的需求是由 PHP 支持的大量数据库系统所决定的。这些数据库系统中的每一个都需要一个单独的扩展,为执行相同的任务定义自己的 API,从建立连接到准备语句和错误处理等高级功能。

这些 API 不统一的事实使得在底层数据库之间的转换痛苦,通常导致许多代码行的重写,进而导致需要时间来跟踪、调试和纠正新的编程错误。另一方面,缺乏像 Java 的 JDBC 那样的统一库,使得 PHP 在编程语言世界中落后于大型玩家。现在有了这样的库,PHP 正在重新夺回其地位,并成为数百万程序员的首选平台。

然而,值得注意的是,存在一些用 PHP 编写的库,用于与 PDO 具有相同的目的。最流行的是 ADOdb 库和 PEAR DB 包。它们与 PDO 之间的关键区别在于速度。PDO 是用编译语言(C/C++)编写的 PHP 扩展,而 PHP 库是用解释语言编写的。此外,一旦启用 PDO,它就不需要您在脚本中包含源文件并将其与应用程序一起重新分发。这使得安装您的应用程序更容易,因为最终用户不需要关心第三方软件。

注意

在这里,我们既不比较这些库与 PDO,也不主张使用 PDO 取代这些库。我们只是展示这个扩展的优缺点。例如,PEAR 包 MDB2 具有更丰富的功能,是一个高级的数据库抽象库,而 PDO 没有。

PDO 作为一个 PECL 扩展,本身依赖于特定于数据库的驱动程序和其他 PECL 扩展。这些驱动程序也必须安装才能使用 PDO(您只需要用于您正在使用的数据库的驱动程序)。由于安装 PDO 和特定于数据库的驱动程序的描述超出了本书的范围,您可以参考 PHP 手册www.php.net/pdo获取有关安装和升级问题的技术信息。

注意

PECL 是 PHP 扩展社区库,一个用 C 语言编写的 PHP 扩展库。这些扩展提供了在 PHP 中无法实现的功能,以及一些出于性能原因存在的扩展,因为 C 代码比 PHP 快得多。PECL 的主页位于pecl.php.net

使用 PDO

正如前一节中所指出的,PDO 是一个连接或数据访问抽象库。这意味着 PDO 定义了一个统一的接口,用于创建和维护数据库连接,发出查询,引用参数,遍历结果集,处理准备好的语句和错误处理。

我们将在这里简要概述这些主题,并在接下来的章节中更详细地讨论它们。

连接到数据库

让我们考虑一下著名的 MySQL 连接场景:

mysql_connect($host, $user, $password);
mysql_select_db($db);

在这里,我们建立一个连接,然后选择连接的默认数据库。(我们忽略可能出现的错误。)

例如,在 SQLite 中,我们会写出以下内容:

$dbh = sqlite_open($db, 0666);

在这里我们再次忽略错误(稍后我们将更多地涵盖这一点)。为了完整起见,让我们看看如何连接到 PostgreSQL:

pg_connect("host=\(host dbname=\)db user=\(user password=\)password");

正如您所看到的,所有三个数据库都需要完全不同的方式来打开连接。虽然现在这不是问题,但如果您总是使用相同的数据库管理系统,以防需要迁移,您将不得不重写您的脚本。

现在,让我们看看 PDO 提供了什么。由于 PDO 是完全面向对象的,我们将处理连接对象,与数据库的进一步交互将涉及调用这些对象的各种方法。上面的示例暗示了需要类似于这些连接对象的东西——调用mysql_connectpg_connect返回链接标识符和特殊类型的 PHP 变量:resource。然而,我们当时没有使用连接对象,因为这两个数据库 API 不要求我们在脚本中只有一个连接时显式使用它们。然而,SQLite 始终需要一个链接标识符。

使用 PDO,我们将始终必须显式使用连接对象,因为没有其他调用其方法的方式。(不熟悉面向对象编程的人应参考附录 A)。

上述三个连接可以以以下方式建立:

// For MySQL:
$conn = new PDO("mysql:host=$host;dbname=$db", $user, $pass);
// For SQLite:
$conn = new PDO("sqlite:$db");
// And for PostgreSQL:
$conn = new PDO("pgsql:host=$host dbname=$db", $user, $pass);

正如你所看到的,这里唯一变化的部分是传递给 PDO 构造函数的第一个参数。对于 SQLite,不使用用户名和密码,第二和第三个参数可以省略。

注意

SQLite 不是一个数据库服务器,而是一个嵌入式 SQL 数据库库,它在本地文件上运行。有关 SQLite 的更多信息可以在www.sqlite.org找到,有关使用 SQLite 与 PHP 的更多信息可以在www.php.net/sqlite找到。有关使用 PDO 与 SQLite 的信息可以从www.php.net/manual/en/ref.pdo-sqlite.php获取。

连接字符串

正如你在前面的示例中看到的,PDO 使用所谓的连接字符串(或数据源名称,缩写为 DSN),允许 PDO 构造函数选择适当的驱动程序并将后续方法调用传递给它。这些连接字符串或 DSN 对于每个数据库管理系统都是不同的,是你唯一需要更改的东西。

如果你正在设计一个能够与不同数据库一起工作的大型应用程序,那么这个连接字符串(连同连接用户名和密码)可以在配置文件中定义,并以后以以下方式使用(假设你的配置文件类似于php.ini)。

$config = parse_ini_file($pathToConfigFile);
$conn = new PDO($config['db.conn'], $config['db.user'],
$config['db.pass']);

然后你的配置文件可能如下所示:

db.conn="mysql:host=localhost;dbname=test"
db.user="johns"
db.pass="mypassphrase"

我们将在第二章中更详细地介绍连接字符串;在这里,我们给出了一个快速示例,以便你可以看到使用 PDO 连接到不同数据库系统有多么容易。

发出 SQL 查询、引用参数和处理结果集

如果 PDO 没有超越创建数据库连接的单一接口,那么它就不值得写一本书。在前面的示例中介绍的 PDO 对象具有统一执行查询所需的所有方法,而不管使用的是哪种数据库。

让我们考虑一个简单的查询,它将从一个虚构的二手车停车场所使用的数据库中选择所有汽车的make属性。查询就像下面的 SQL 命令一样简单:

SELECT DISTINCT make FROM cars ORDER BY make;

以前,我们必须调用不同的函数,这取决于数据库:

// Let's keep our SQL in a single variable
$sql = 'SELECT DISTINCT make FROM cars ORDER BY make';
// Now, assuming MySQL:
mysql_connect('localhost', 'boss', 'password');
mysql_select_db('cars');
$q = mysql_query($sql);
// For SQLite we would do:
$dbh = sqlite_open('/path/to/cars.ldb', 0666);
$q = sqlite_query($sql, $dbh);
// And for PostgreSQL:
pg_connect("host=localhost dbname=cars user=boss
password=password");
$q = pg_query($sql);

现在我们使用 PDO,可以这样做:

// assume the $connStr variable holds a valid connection string
// as discussed in previous point
$sql = 'SELECT DISTINCT make FROM cars ORDER BY make';
$conn = new PDO($connStr, 'boss', 'password');
$q = $conn->query($sql);

如你所见,用 PDO 的方式并不太不同于发出查询的传统方法。此外,这里应该强调的是,对$conn->query()的调用返回PDOStatement类的另一个对象,而不像对mysql_query()sqlite_query()pg_query()的调用,它们返回 PHP 变量的resource类型。

现在,让我们将我们简单的 SQL 查询变得更加复杂,以便它选择我们想象中汽车停车场上所有福特车的总价值。查询看起来会像这样:

SELECT sum(price) FROM cars WHERE make='Ford'

为了使我们的示例更加有趣,让我们假设汽车制造商的名称保存在一个变量($make)中,这样我们必须在传递给数据库之前对其进行引用。我们的非 PDO 查询现在看起来像这样:

$make = 'Ford';
// MySQL:
$m = mysql_real_escape_string($make);
$q = mysql_query("SELECT sum(price) FROM cars WHERE make='$m'");
// SQLite:
$m = sqlite_escape_string($make);
$q = sqlite_query("SELECT sum(price) FROM cars WHERE make='$m'",
$dbh);
// and PostgreSQL:
$m = pg_escape_string($make);
$q = pg_query("SELECT sum(price) FROM cars WHERE make='$m'");

PDO 类定义了一个用于引用字符串的方法,以便它们可以安全地用于查询。我们将在第三章中讨论诸如 SQL 注入之类的安全问题。这个方法做了一个很好的事情;如果有必要,它会自动在值周围添加引号:

$m = $conn->quote($make);
$q = $conn->query("SELECT sum(price) FROM cars WHERE make=$m");

再次,您可以看到 PDO 允许您使用与以前相同的模式,但所有方法的名称都是统一的。

现在我们已经发出了查询,我们将想要查看其结果。由于上一个示例中的查询总是只返回一行,我们将想要更多行。同样,这三个数据库将要求我们在从mysql_query(), sqlite_query()pg_query()中返回的$q变量上调用不同的函数。因此,我们获取所有汽车的代码将类似于这样:

// assume the query is in the $sql variable
$sql = "SELECT DISTINCT make FROM cars ORDER BY make";
// For MySQL:
$q = mysql_query($sql);
while($r = mysql_fetch_assoc($q))
{
echo $r['make'], "\n";
}
// For SQLite:
$q = sqlite_query($dbh, $sql);
while($r = sqlite_fetch_array($q, SQLITE_ASSOC))
{
echo $r['make'], "\n";
}
// and, finally, PostgreSQL:
$q = pg_query($sql);
while($r = pg_fetch_assoc($q))
{
echo $r['make'], "\n";
}

正如你所看到的,想法是一样的,但我们必须使用不同的函数名。另外,需要注意的是,如果我们想以与 MySQL 和 PostgreSQL 相同的方式获取行,SQLite 需要一个额外的参数(当然,这可以省略,但返回的行将包含列名索引和数字索引的元素。)

正如您可能已经猜到的那样,当涉及到 PDO 时,事情变得非常简单:我们不关心底层数据库是什么,获取行的方法在所有数据库中都是相同的。因此,上面的代码可以以以下方式重写为 PDO:

$q = $conn->query("SELECT DISTINCT make FROM cars ORDER BY make");
while($r = $q->fetch(PDO::FETCH_ASSOC))
{
echo $r['make'], "\n";
}

之前发生的事情与以往无异。这里需要注意的一点是,我们在这里明确指定了PDO::FETCH_ASSOC的获取样式常量,因为 PDO 的默认行为是将结果行作为数组索引,既按列名又按数字索引。(这种行为类似于mysql_fetch_array(), sqlite_fetch_array()没有第二个参数,或者pg_fetch_array()。)我们将在第二章中讨论 PDO 提供的获取样式。

注意

最后一个示例并不是用于呈现 HTML 页面,因为它使用换行符来分隔输出行。要在真实的网页中使用它,您需要将echo $r['make'], "\n";更改为echo $r['make'], "<br>\n";

错误处理

当然,上面的示例没有提供任何错误检查,因此对于实际应用程序来说并不是非常有用。

在与数据库一起工作时,我们应该在打开数据库连接时,选择数据库时以及发出每个查询后检查错误。然而,大多数 Web 应用程序只需要在出现问题时显示错误消息(而不需要详细的错误信息,这可能会泄露一些敏感信息)。但是,在调试错误时,您(作为开发人员)需要尽可能详细的错误信息,以便您可以在最短的时间内调试错误。

一个简单的情景是中止脚本并呈现错误消息(尽管这可能不是你想要做的)。根据数据库的不同,我们的代码可能如下所示:

// For SQLite:
$dbh = sqlite_open('/path/to/cars.ldb', 0666) or die
('Error opening SQLite database: ' .
sqlite_error_string(sqlite_last_error($dbh)));
$q = sqlite_query("SELECT DISTINCT make FROM cars ORDER BY make",
$dbh) or die('Could not execute query because: ' .
sqlite_error_string(sqlite_last_error($dbh)));
// and, finally, for PostgreSQL:
pg_connect("host=localhost dbname=cars user=boss
password=password") or die('Could not connect to
PostgreSQL: . pg_last_error());
$q = pg_query("SELECT DISTINCT make FROM cars ORDER BY make")
or die('Could not execute query because: ' . pg_last_error());

如您所见,与 MySQL 和 PostgreSQL 相比,对于 SQLite 来说,错误处理开始有点不同。(请注意调用sqlite_error_string (sqlite_last_error($dbh)).))

在我们看如何使用 PDO 实现相同的错误处理策略之前,我们应该注意,这将是 PDO 中三种可能的错误处理策略之一。我们将在本书的后面详细介绍它们。在这里,我们将只使用最简单的一个:

// PDO error handling
// Assume the connection string is one of the following:
// $connStr = 'mysql:host=localhost;dbname=cars'
// $connStr = 'sqlite:/path/to/cars.ldb';
// $connStr = 'pgsql:host=localhost dbname=cars';
try
{
$conn = new PDO($connStr, 'boss', 'password');
}
catch(PDOException $pe)
{
die('Could not connect to the database because: ' .
$pe->getMessage();
}
$q = $conn->query("SELECT DISTINCT make FROM cars ORDER BY make");
if(!$q)
{
$ei = $conn->errorInfo();
die('Could not execute query because: ' . $ei[2]);
}

这个例子表明,PDO 会强制我们使用与传统错误处理方案略有不同的方案。我们将对 PDO 构造函数的调用包装在trycatch块中。(那些对 PHP5 的面向对象特性不熟悉的人应该参考附录 A。)这是因为虽然 PDO 可以被指示不使用异常(事实上,PDO 的默认行为是不使用异常),但是在这里你无法避免异常。如果构造函数调用失败,异常将总是被抛出。

捕获这个异常是一个非常好的主意,因为默认情况下,PHP 会中止脚本执行,并显示这样的错误消息:

致命错误:未捕获的异常'PDOException',消息为'SQLSTATE[28000] [1045] 用户'bosss'@'localhost'被拒绝访问(使用密码:YES)',位于/var/www/html/pdo.php5:3 堆栈跟踪:#0 c:\www\hosts\localhost\pdo.php5(3):PDO->__construct('mysql:host=loca...', 'bosss', 'password', Array) #1 {main} 在/var/www/html/pdo.php5 的第 3 行抛出

我们通过在对 PDO 构造函数的调用中提供错误的用户名bosss来制造了这个异常。正如你从这个输出中看到的,它包含了一些我们不希望其他人看到的细节:像文件名和脚本路径,正在使用的数据库类型,最重要的是用户名和密码。假设这个异常发生在我们提供了正确的用户名并且数据库服务器出了问题的情况下。那么屏幕输出将包含真实的用户名和密码。

如果我们正确捕获异常,错误输出可能会像这样:

SQLSTATE[28000] [1045] 用户'bosss'@'localhost'被拒绝访问(使用密码:YES)

这个错误消息包含了更少的敏感信息。(事实上,这个输出与我们的非 PDO 示例中产生的错误输出非常相似。)但我们再次警告您,最好的策略是只显示一些中立的错误消息,比如:“抱歉,服务暂时不可用。请稍后再试。”当然,您还应该记录所有错误,以便以后找出是否发生了任何不好的事情。

预处理语句

这是一个相当高级的话题,但你应该熟悉它。如果你是一个使用 PHP 与 MySQL 或 SQLite 的用户,那么你可能甚至没有听说过预处理语句,因为 PHP 的 MySQL 和 SQLite 扩展不提供这个功能。PostgreSQL 用户可能已经使用了pg_prepare()pg_execute()。MySQLi(改进的 MySQL 扩展)也提供了预处理语句功能,但方式有些别扭(尽管可能是面向对象的风格)。

对于那些不熟悉预处理语句的人,我们现在将给出一个简短的解释。

当开发基于数据库的交互式动态应用程序时,您迟早会需要接受用户输入(可能来自表单),并将其作为查询的一部分传递给数据库。例如,给定我们的汽车数据库,您可能设计一个功能,将输出在任意两年之间制造的汽车列表。如果允许用户在表单中输入这些年份,代码将看起来像这样:

// Suppose the years come in the startYear and endYear
// request variables:
$sy = (int)$_REQUEST['startYear'];
$ey = (int)$_REQUEST['endYear'];
if($ey < $sy)
{
// ensure $sy is less than $ey
$tmp = $ey;
$ey = $sy;
$sy = $tmp;
}
$sql = "SELECT * FROM cars WHERE year >= $sy AND year <= $ey";
// send the query in $sql…

在这个简单的例子中,查询依赖于两个变量,这些变量是结果 SQL 的一部分。在 PDO 中,相应的预处理语句看起来像这样:

$sql = 'SELECT * FROM cars WHERE year >= ? AND year <= ?';

正如你所看到的,我们在查询体中用占位符替换了$sy$ey变量。现在我们可以操作这个查询来创建预处理语句并执行它:

// Assuming we have already connected and prepared
// the $sy and $ey variables
$sql = 'SELECT * FROM cars WHERE year >= ? AND year <= ?';
$stmt = $conn->prepare($sql);
$stmt->execute(array($sy, $ey));

这三行代码告诉我们,预处理语句是对象(具有类PDOStatement)。它们是使用调用PDO::prepare()方法创建的,该方法接受带有占位符的 SQL 语句作为其参数。

然后必须执行准备好的语句,以通过调用PDOStatement::execute()方法获取查询结果。正如示例所示,我们使用一个包含占位符值的数组来调用这个方法。请注意,该数组中变量的顺序与$sql变量中占位符的顺序相匹配。显然,数组中的元素数量必须与查询中占位符的数量相同。

您可能已经注意到,我们没有将对PDOStatement::execute()方法的调用结果保存在任何变量中。这是因为语句对象本身用于访问查询结果,这样我们就可以将我们的示例完善成这样:

// Suppose the years come in the startYear and endYear
// request variables:
$sy = (int)$_REQUEST['startYear'];
$ey = (int)$_REQUEST['endYear'];
if($ey < $sy)
{
// ensure $sy is less than $ey
$tmp = $ey;
$ey = $sy;
$sy = $tmp;
}
$sql = 'SELECT * FROM cars WHERE year >= ? AND year <= ?';
$stmt = $conn->prepare($sql);
$stmt->execute(array($sy, $ey));
// now iterate over the result as if we obtained
// the $stmt in a call to PDO::query()
while($r = $stmt->fetch(PDO::FETCH_ASSOC))
{
echo "$r[make] $r[model] $r[year]\n";
}

正如这个完整的示例所示,我们调用PDOStatement::fetch()方法,直到它返回 false 值为止,此时循环退出——就像我们在讨论结果集遍历时的先前示例中所做的那样。

当然,用实际值替换问号占位符并不是准备好的语句唯一能做的事情。它们的强大之处在于可以根据需要执行多次。这意味着我们可以调用PDOStatement::execute()方法多次,每次都可以为占位符提供不同的值。例如,我们可以这样做:

$sql = 'SELECT * FROM cars WHERE year >= ? AND year <= ?';
$stmt = $conn->prepare($sql);
// Fetch the 'new' cars:
$stmt->execute(array(2005, 2007));
$newCars = $stmt->fetchAll(PDO::FETCH_ASSOC);
// now, 'older' cars:
$stmt->execute(array(2000, 2004));
$olderCars = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Show them
echo 'We have ', count($newCars), ' cars dated 2005-2007';
print_r($newCars);
echo 'Also we have ', count($olderCars), ' cars dated 2000-2004';
print_r($olderCars);

准备好的语句执行起来比调用PDO::query()方法要快,因为数据库驱动程序只会在调用PDO::prepare()方法时对它们进行优化一次。使用准备好的语句的另一个优点是,您不必引用在调用PDOStatement::execute()时传递的参数。

在我们的示例中,我们将请求参数显式转换为整数变量,但我们也可以这样做:

// Assume we also want to filter by make
$sql = 'SELECT * FROM cars WHERE make=?';
$stmt = $conn->prepare($sql);
$stmt->execute(array($_REQUEST['make']));

这里的准备好的语句将负责在执行查询之前进行适当的引用。

最重要的一点是,PDO 为每个支持的数据库模拟了准备好的语句。这意味着您可以在任何数据库中使用准备好的语句;即使它们不知道这是什么。

对 PDO 的适当理解

如果我们不提到这一点,我们的介绍就不完整了。PDO 是一个数据库连接抽象库,因此不能保证您的代码对其支持的每个数据库都有效。只有当您的 SQL 代码是可移植的时,才会发生这种情况。例如,MySQL 使用以下形式的插入扩展了 SQL 语法:

INSERT INTO mytable SET x=1, y='two';

这种 SQL 代码是不可移植的,因为其他数据库不理解这种插入方式。为了确保您的插入在各个数据库中都能正常工作,您应该用以下代码替换上面的代码:

INSERT INTO mytable(x, y) VALUES(1, 'two');

这只是使用 PDO 时可能出现的不兼容性的一个例子。只有通过使数据库架构和 SQL 可移植,才能确保您的代码与其他数据库兼容。然而,确保这种可移植性超出了本文的范围。

总结

这个介绍性的章节向您展示了在使用 PHP5 语言开发动态、数据库驱动应用程序时使用 PDO 的基础知识。我们还看到了 PDO 如何有效地消除了不同传统数据库访问 API 之间的差异,并产生了更清晰、更可移植的代码。

在接下来的章节中,我们将更详细地讨论本章讨论的每个功能,以便您完全掌握 PHP 数据对象扩展。

第二章:使用 PHP 数据对象:第一步

在上一章中,我们简要概述了 PDO 是什么,如何使用 PDO 连接到您喜欢的数据库,如何发出简单的查询以及如何处理错误。现在您已经确信 PDO 是一个好东西,并且正在考虑积极使用它,我们将深入了解它所提供的所有功能。

在本章中,我们将更仔细地研究使用 PDO 和连接字符串(数据源名称)创建数据库连接,PDOStatement类以及如何遍历结果集。我们还将创建一个小型的图书管理应用程序,它将允许我们管理您家中图书的收藏。该应用程序将能够列出书籍和作者,并添加和编辑它们。

我们将首先看一下连接字符串,因为没有它们,我们将无法连接到任何数据库。然后我们将创建一个示例数据库,本书中的所有示例都将基于此数据库。

我们将离开简单的、想象中的汽车数据库,并创建一个真正的工作数据库,其中包含几个表。但是,现在我们将处理书籍和作者的经典示例。我们选择这个例子是因为这样的实体更常见。关系模型将相对简单,这样您就可以轻松地跟随示例,如果您已经在其他地方遇到过这样的数据库。

连接字符串

连接字符串或数据源名称(在 PDO 文档中称为 DSN)是 PHP 字符串,其中包含数据库管理系统的名称和数据库本身的名称,以及其他连接参数。

它们相对于使用传统的方法创建数据库连接的优势在于,如果更改数据库管理系统,您无需修改代码。连接字符串可以在配置文件中定义,并且该文件由您的应用程序处理。如果您的数据库(数据源)更改,您只需编辑该配置文件,其余代码将保持完整。

由于不同的数据库管理系统的存在,PDO 中使用的连接字符串不同。但是,它们始终具有一个共同的前缀,表示底层数据库驱动程序。请记住第一章中的 MySQL、SQLite 和 PostgreSQL 示例。三个连接字符串看起来像下面这样:

mysql:host=localhost;dbname=cars
sqlite:/path/to/cars.db
pgsql:host=localhost dbname=cars

如我们所见,前缀(第一个分号之前的子字符串)始终保留 PDO 驱动程序的名称。由于我们不必使用不同的函数来创建与 PDO 的连接,这个前缀告诉我们应该使用哪个内部驱动程序。字符串的其余部分由该驱动程序解析以进一步初始化连接。在这些情况下,我们提供了数据库名称;对于 MySQL 和 PostgreSQL;我们还提供了服务器运行的主机名。(由于 SQLite 是一个本地数据库引擎,这样的参数是没有意义的。)

如果您想指定其他参数,您应该查阅您的数据库手册(www.php.net/pdo始终是一个很好的起点)。例如,MySQL PDO 驱动程序理解以下参数:

  • host - 服务器运行的主机名(在我们的示例中为localhost

  • port - 数据库服务器正在侦听的端口号(默认为3306

  • dbname - 数据库的名称(在我们的示例中为cars

  • unix_socket - MySQL 的 UNIX 套接字(而不是主机和/或端口)。

注意

SQLite:前缀表示连接到 SQLite 3 数据库。要连接到 SQLite 2 数据库,您必须使用SQLite2:前缀。有关详细信息,请参阅www.php.net/manual/en/ref.pdo-sqlite.connection.php

正如您可能已经注意到的,不同的驱动程序使用不同的字符来分隔参数——例如 MySQL 中的分号和 PostgreSQL 中的空格。

创建示例数据库

假设您在家里有一个很好的图书馆,您希望计算机帮助您管理它。您决定使用 PHP 和当然 PDO 创建一个基于 Web 的数据库。从现在开始,示例将是针对 MySQL 和 SQLite 数据库的。

数据模型

由于我们的数据库非常简单,因此我们只会在其中有两个实体:作者和书籍。因此,我们将创建两个同名的表。现在,让我们考虑每个实体将具有哪些属性。

作者将有他们的名字、姓氏和简短的传记。表格将需要一个我们称之为id的主键。我们将使用它来从books表中引用作者。

书是由作者写的。(有时它们是由多位作者写的,但我们将在这里只考虑由一位作者写的书。)因此,我们将需要一个字段来存储作者的 ID,以及书的标题、ISBN 号、出版商名称和出版年份。此外,我们将包括书的简短摘要。

我们需要一个单独的作者表,因为一个作者可能写了多本书。否则,我们的示例将非常简单!因此,我们选择了一个两表数据库结构。如果我们考虑由多位作者编写的书籍,我们将需要三个表,这将使示例变得非常复杂。

创建 MySQL 数据库

当您启动了 MySQL 命令行客户端后,您将看到mysql>提示符,您可以在其中发出命令来创建数据库和其中的表:

mysql> create database pdo;
Query OK, 1 row affected (0.05 sec)
mysql> use pdo;
Database changed
mysql> create table books(
-> id int primary key not null auto_increment,
-> author int not null,
-> title varchar(70) not null,
-> isbn varchar(20),
-> publisher varchar(30) not null,
-> year int(4) not null,
-> summary text(2048));
Query OK, 0 rows affected (0.17 sec)
mysql> create table authors(
-> id int primary key not null auto_increment,
-> firstName varchar(30) not null,
-> lastName varchar(40) not null,
-> bio text(2048));
Query OK, 0 rows affected (0.00 sec)

如您所见,我们已经创建了一个名为pdo的数据库。我们还创建了两个表:books 和 authors,就像我们计划的那样。现在让我们看看如何在 SQLite 中做到这一点。由于我们无法在 SQLite 命令行客户端内创建数据库,我们会这样启动它:

> sqlite3 pdo.db
sqlite> create table books(
...> id integer primary key,
...> author integer(11) not null,
...> title varchar(70) not null,
...> isbn varchar(20),
...> publisher varchar(30) not null,
...> year integer(4) not null,
...> summary text(2048));
sqlite> create table authors(
...> id integer(11) primary key,
...> firstName varchar(30) not null,
...> lastName varchar(40) not null,
...> bio text(2048));

如您所见,SQLite 的 SQL 略有不同——主键声明时没有NOT NULLauto_increment选项。在 SQLite 中,声明为INTEGER PRIMARY KEY的列会自动递增。现在让我们向数据库插入一些值。MySQL 和 SQLite 的语法将是相同的,所以这里我们只呈现 MySQL 命令行客户端的示例。我们将从作者开始,因为我们需要他们的主键值来插入到书籍表中:

mysql> insert into authors(firstName, lastName, bio) values(
-> 'Marc', 'Delisle', 'Marc Delisle is a member of the MySQL
Developers Guide');
Query OK, 1 row affected (0.14 sec)
mysql> insert into authors(firstName, lastName, bio) values(
-> 'Sohail', 'Salehi', 'In recent years, Sohail has contributed
to over 20 books, mainly in programming and computer graphics');
Query OK, 1 row affected (0.00 sec)
mysql> insert into authors(firstName, lastName, bio) values(
-> 'Cameron', 'Cooper', 'J. Cameron Cooper has been playing
around on the web since there was not much of a web with which to
play around');
Query OK, 1 row affected (0.00 sec)

现在我们已经插入了三位作者,让我们添加一些书籍。但在这样做之前,我们应该知道哪个作者有哪个id。一个简单的SELECT查询将帮助我们:

mysql> select id, firstName, lastName from authors;
+----+-----------+----------+
| id | firstName | lastName |
+----+-----------+----------+
| 1 | Marc | Delisle |
| 2 | Sohail | Salehi |
| 3 | Cameron | Cooper |
+----+-----------+----------+
3 rows in set (0.03 sec)

现在我们终于可以使用这些信息添加三本书,每本书都是由这些作者中的一位写的:

mysql> insert into books(author, title, isbn, publisher, year, summary) values(
-> 1, 'Creating your MySQL Database: Practical Design Tips and
Techniques', '1904811302', 'Packt Publishing Ltd', '2006',
-> 'A short guide for everyone on how to structure your data and
set-up your MySQL database tables efficiently and easily.');
Query OK, 1 row affected (0.00 sec)
mysql> insert into books(author, title, isbn, publisher, year, summary) values(
-> 2, 'ImageMagick Tricks', '1904811868', 'Packt Publishing
Ltd', '2006',
-> 'Unleash the power of ImageMagick with this fast, friendly
tutorial, and tips guide');
Query OK, 1 row affected (0.02 sec)
mysql> insert into books(author, title, isbn, publisher, year,
summary) values(
-> 3, 'Building Websites with Plone', '1904811027', 'Packt
Publishing Ltd', '2004',
-> 'An in-depth and comprehensive guide to the Plone content
management system');
Query OK, 1 row affected (0.00 sec)

现在我们已经填充了authorsbooks表,我们可以开始创建我们小型图书馆管理网络应用的第一个页面。

注意

所使用的数据基于由 Packt Publishing Ltd 出版的真实书籍(这是为您带来正在阅读的这本书的出版商)。要了解更多信息,请访问他们的网站www.packtpub.com

设计我们的代码

良好的应用架构是应用的另一个关键因素,除了正确的数据模型。由于我们将在本章开发的应用程序相对较小,因此这项任务并不是很复杂。首先,我们将创建两个页面,分别列出书籍和作者。首先,我们应该考虑这些页面的外观。为了使我们的简单示例小巧紧凑,我们将在所有页面上呈现一个标题,其中包含指向书籍列表和作者列表的链接。稍后,我们将添加另外两个页面,允许我们添加作者和书籍。

当然,我们应该创建一个通用的包含文件,用于定义共同的函数,如标题和页脚显示以及与数据库的连接。我们的示例非常小,因此我们将不使用任何模板系统甚至面向对象的语法。(事实上,这些主题超出了本书的范围。)因此,总结一下:

  • 所有通用函数(包括创建 PDO 连接对象的代码)将保存在一个包含文件中(称为common.inc.php)。

  • 每个页面将保存在一个单独的文件中,其中包括common.inc.php文件。

  • 每个页面将处理数据并显示数据(因此我们没有数据处理和数据呈现的分离,这是人们从设计为模型-视图-控制器模式的应用程序所期望的)。

现在我们有了这个小计划,我们可以开始编写我们的common.inc.php文件。正如我们刚刚讨论的,目前,它将包含显示页眉和页脚的函数,以及创建连接对象的代码。让我们将 PDO 对象保存在一个名为$conn的全局变量中,并调用我们的页眉函数showHeader(),页脚函数showFooter()。此外,我们将在这个包含文件中保留数据库连接字符串、用户名和密码:

<?php
/**
* This is a common include file
* PDO Library Management example application
* @author Dennis Popel
*/
// DB connection string and username/password
$connStr = 'mysql:host=localhost;dbname=pdo';
$user = 'root';
$pass = 'root';
/**
* This function will render the header on every page,
* including the opening html tag,
* the head section and the opening body tag.
* It should be called before any output of the
* page itself.
* @param string $title the page title
*/
function showHeader($title)
{
?>
<html>
<head><title><?=htmlspecialchars($title)?></title></head>
<body>
<h1><?=htmlspecialchars($title)?></h1>
<a href="books.php">Books</a>
<a href="authors.php">Authors</a>
<hr>
<?php
}
/**
* This function will 'close' the body and html
* tags opened by the showHeader() function
*/
function showFooter()
{
?>
</body>
</html>
<?php
}
// Create the connection object
$conn = new PDO($connStr, $user, $pass);

正如你所看到的,这个文件非常简单,你只需要更改$user$pass变量的值(第 9 行和第 10 行)以匹配你的设置。对于 SQLite 数据库,你还需要更改第 8 行,使其包含一个适当的连接字符串,例如:

$connStr = 'sqlite:/www/hosts/localhost/pdo.db';

当然,你应该根据你创建 SQLite 数据库的路径进行更改。此外,showHeader()函数只是呈现 HTML 代码,并通过htmlspecialchars()函数传递$title变量的值,以便任何非法字符(如小于号)都能得到适当的转义。

将文件保存到您的 Web 根目录。这取决于您的 Web 服务器设置。例如,它可以是C:\Apache\htdocs/var/www/html

现在,让我们创建一个列出书籍的页面。我们将发出查询,然后遍历结果,以呈现每本书的单独行。稍后,我们将创建一个页面,列出我们之前创建的数据库中的所有作者。完成这项任务后,我们将查看结果集遍历。

让我们称我们的文件为books.php并创建代码:

<?php
/**
* This page lists all the books we have
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Issue the query
$q = $conn->query("SELECT * FROM books ORDER BY title");
// Display the header
showHeader('Books');
// now create the table
?>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
</tr>
<?php
// Now iterate over every row and display it
while($r = $q->fetch(PDO::FETCH_ASSOC))
{
?>
<tr>
<td><?=htmlspecialchars($r['title'])?></td>
<td><?=htmlspecialchars($r['isbn'])?></td>
<td><?=htmlspecialchars($r['publisher'])?></td>
<td><?=htmlspecialchars($r['year'])?></td>
<td><?=htmlspecialchars($r['summary'])?></td>
</tr>
<?php
}
?>
</table>
<?php
// Display footer
showFooter();

这个文件应该保存在common.inc.php文件所在的目录中。正如你所看到的,代码中有更多的注释和 HTML,但这里没有什么非常复杂的东西。正如我们之前决定的,代码包括common.inc.php文件,然后呈现页面页眉,在第 10 行发出查询,呈现表头,最后遍历结果集中的每一行,输出每本书的详细信息。

就像在第一章中一样,我们使用PDOStatement对象的fetch()方法(保存在$q变量中)在while行中遍历结果集。我们指示该方法返回由表列名称索引的数组行(通过指定PDO::FETCH_ASSOC参数)。

在循环内,我们呈现每一行的 HTML,插入表中的列。循环结束后,我们关闭表并显示页脚。

现在是测试我们第一个 PDO 驱动的应用程序的时候了。打开你的浏览器,转到http://localhost/books.php。如果你做得正确(这样你的 Web 服务器和数据库都正确设置),你应该看到一个类似下面截图的表格(尽管你的页面可能看起来更宽,我们在截图之前调整了窗口大小,以便它适合打印页面):

设计我们的代码

一旦我们确保我们的应用程序可以与 MySQL 一起工作,让我们看看它如何与 SQLite 一起工作。为此,我们必须编辑common.inc.php文件中的第 8 行,使其包含 SQLite DSN:

$connStr = 'sqlite:/www/hosts/localhost/pdo.db';

如果你做得正确,那么刷新你的浏览器后,你应该看到相同的屏幕。正如我们之前讨论过的——当你开始使用另一个数据库系统时,只需要更改一个配置选项。

现在,让我们为列出作者的页面创建代码。创建一个名为authors.php的文件,并将其放在您保存前两个文件的目录中。代码几乎与书籍列表页面相同:

<?php
/**
* This page lists all the authors we have
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Issue the query
$q = $conn->query("SELECT * FROM authors ORDER BY lastName,
firstName");
// Display the header
showHeader('Authors');
// now create the table
?>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>First Name</td>
<td>Last Name</td>
<td>Bio</td>
</tr>
<?php
// Now iterate over every row and display it
while($r = $q->fetch(PDO::FETCH_ASSOC))
{
?>
<tr>
<td><?=htmlspecialchars($r['firstName'])?></td>
<td><?=htmlspecialchars($r['lastName'])?></td>
<td><?=htmlspecialchars($r['bio'])?></td>
</tr>
<?php
}
?>
</table>
<?php
// Display footer
showFooter();

这个文件遵循相同的逻辑:包含common.inc.php文件,然后发出查询并遍历结果集。如果你做的一切都正确,那么你只需在浏览器中点击位于书籍列表页面上的作者链接,就可以得到以下页面:

设计我们的代码

正如您所看到的,页面正确地呈现了我们在本章开头添加的三位作者。如果您想要使用 SQLite 进行测试,请将第 10 行更改为包含 SQLite 连接字符串。刷新浏览器后,您应该看到相同的页面,但现在基于 SQLite 数据库内容。

现在我们已经创建了这两个页面,并且看到使用 PDO 并不复杂,让我们在扩展应用程序之前先看一些理论。

PDO 语句和结果集

我们的示例使用了 PHP 数据对象中的两个主要类:PDO类,用于创建连接和发出查询,以及PDOStatement类,我们用它来循环遍历结果集。我们将在后面的章节中查看这两个类中的第一个。在这里,我们将检查PDOStatement类,看看它提供了哪些其他遍历结果集的方式。

正如我们已经知道的那样,从对PDO::query()方法的调用中返回PDOStatement类的实例。这个类的主要目的是提供一个接口来访问结果集。事实上,我们已经使用了它最重要的方法来遍历结果集。我们只看了一个获取样式(或返回行的模式),但 PDO 提供了几种样式。这个类还可以提供有关结果集的其他信息,比如行数和列数,并将整个结果集获取到一个二维数组中。

让我们首先看一些不同的获取样式。我们已经知道PDO::FETCH_ASSOC模式,它返回一个由列名索引的数组。PDOStatement对象的默认操作是返回一个由整数索引和列名索引的数组,即PDO::FETCH_BOTH获取模式。我们还可以使用PDO::FETCH_NUM获取样式来请求只有整数索引的数组。PDO 还支持使用PDO::FETCH_OBJ模式将行作为对象获取。在这种情况下,对PDO::fetch()method的调用将返回一个stdClass内部类的实例,其属性填充了行的值。这在以下代码中发生:

$q = $conn->query('SELECT * FROM authors ORDER BY lastName,
firstName');
$r = $q->fetch(PDO::FETCH_OBJ);
var_dump($r);
//would print:
object(stdClass)#4 (4)
{
["id"]=>
string(1) "3"
["firstName"]=>
string(7) "Cameron"
["lastName"]=>
string(6) "Cooper"
["bio"]=>
string(112) "J. Cameron Cooper has been playing around on the web
since there was not much of a web with which to play around"
}

PDOStatement类还允许您为所有后续对其fetch()方法的调用设置获取模式。这是通过PDOStatement::setFetchMode()方法完成的,该方法接受PDO::FETCH_ASSOC, PDO::FETCH_BOTH, PDO::FETCH_NUMPDO::FETCH_OBJ常量中的任何一个。有了这个想法,我们可以将authors.php文件的第 23 和 24 行重写为以下形式:

// Now iterate over every row and display it
$q->setFetchMode(PDO::FETCH_ASSOC);
while($r = $q->fetch())
{

您可以在authors.php文件的副本上尝试并刷新浏览器,看看它是否有效。

您可能已经注意到,SQLite、MySQL 和 pgSQL PHP 扩展都提供了类似的功能。事实上,我们可以使用mysql_fetch_row()、mysql_fetch_assoc()、mysql_fetch_array()mysql_fetch_object()函数来实现相同的效果。这就是为什么 PDO 更进一步,使我们能够使用三种额外的获取模式。这三种模式只能通过PDOStatement::setFetchMode()调用来设置,它们分别是:

  • PDO::FETCH_COLUMN允许您指示PDOStatement对象返回每行的指定列。在这种情况下,PDO::fetch()将返回一个标量值。列从 0 开始编号。这在以下代码片段中发生:
$q = $conn->query('SELECT * FROM authors ORDER BY lastName,
firstName');
$q->setFetchMode(PDO::FETCH_COLUMN, 1);
while($r = $q->fetch())
{
var_dump($r);
}
//would print:
string(7) "Cameron"
string(4) "Marc"
string(6) "Sohail"

这揭示了对$q->fetch()的调用确实返回标量值(而不是数组)。请注意,索引为 1 的列应该是作者的姓,而不是他们的名,如果您只是查看作者列表页面。然而,我们的查询看起来像是SELECT * FROM authors,所以它也检索了作者的 ID,这些 ID 存储在第 0 列中。您应该意识到这一点,因为您可能会花费数小时来寻找这样一个逻辑错误的源头。

  • PDO::FETCH_INTO可以用来修改对象的实例。让我们将上面的示例重写如下:
$q = $conn->query('SELECT * FROM authors ORDER BY lastName,
firstName');
$r = new stdClass();
$q->setFetchMode(PDO::FETCH_INTO, $r);
while($q->fetch())
{
var_dump($r);
}
//would print something like:
object(stdClass)#3 (4)
{
["id"]=>
string(1) "3"
["firstName"]=>
string(7) "Cameron"
["lastName"]=>
string(6) "Cooper"
["bio"]=>
string(112) "J. Cameron Cooper has been playing around on the
web since there was not much of a web with which to play around"
}
object(stdClass)#3 (4)
{
["id"]=>
string(1) "1"
["firstName"]=>
string(4) "Marc"
["lastName"]=>
string(7) "Delisle"
["bio"]=>
string(54) "Marc Delisle is a member of the MySQL Developer Guide"
}
object(stdClass)#3 (4)
{
["id"]=>
string(1) "2"
["firstName"]=>
string(6) "Sohail"
["lastName"]=>
string(6) "Salehi"
["bio"]=>
string(101) "In recent years, Sohail has contributed to over 20
books, mainly in programming and computer graphics"
}

注意

while循环中,我们没有分配$r变量,这是$q->fetch()的返回值。在循环之前,通过调用$q->setFetchMode()$r绑定到这个方法。

  • PDO::FETCH_CLASS可以用来返回指定类的对象。对于每一行,将创建这个类的一个实例,并将结果集列的值命名和赋值给这些属性。请注意,该类不一定要声明这些属性,因为 PHP 允许在运行时创建对象属性。例如:
$q = $conn->query('SELECT * FROM authors ORDER BY lastName,
firstName');
$q->setFetchMode(PDO::FETCH_CLASS, stdClass);
while($r = $q->fetch())
{
var_dump($r);
}

这将打印类似于上一个示例的输出。此外,这种获取模式允许您通过将参数数组传递给它们的构造函数来创建实例:

$q->setFetchMode(PDO::FETCH_CLASS, SomeClass, array(1, 2, 3));

(这只有在SomeClass类已经被定义的情况下才会起作用。)

我们建议使用PDOStatement::setFetchMode(),因为它更方便,更容易维护(当然,功能也更多)。

描述所有这些获取模式可能看起来有些多余,但在某些情况下,它们每一个都是有用的。实际上,您可能已经注意到书籍列表有些不完整。它不包含作者的名字。我们将添加这个缺失的列,并且为了使我们的示例更加棘手,我们将使作者的名字可点击,并将其链接到作者的个人资料页面(我们将创建)。这个个人资料页面需要作者的 ID,以便我们可以在 URL 中传递它。它将显示我们关于作者的所有信息,以及他们所有书籍的列表。让我们从这个作者的个人资料页面开始:

<?php
/**
* This page shows an author's profile
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Get the author
$id = (int)$_REQUEST['id'];
$q = $conn->query("SELECT * FROM authors WHERE id=$id");
$author = $q->fetch(PDO::FETCH_ASSOC);
$q->closeCursor();
// Now see if the author is valid - if it's not,
// we have an invalid ID
if(!$author) {
showHeader('Error');
echo "Invalid Author ID supplied";
showFooter();
exit;
}
// Display the header - we have no error
showHeader("Author: $author[firstName] $author[lastName]");
// Now fetch all his books
$q = $conn->query("SELECT * FROM books WHERE author=$id ORDER BY title");
$q->setFetchMode(PDO::FETCH_ASSOC);
// now display everything
?>
<h2>Author</h2>
<table width="60%" border="1" cellpadding="3">
<tr>
<td><b>First Name</b></td>
<td><?=htmlspecialchars($author['firstName'])?></td>
</tr>
<tr>
<td><b>Last Name</b></td>
<td><?=htmlspecialchars($author['lastName'])?></td>
</tr>
<tr>
<td><b>Bio</b></td>
<td><?=htmlspecialchars($author['bio'])?></td>
</tr>
</table>
<h2>Books</h2>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
</tr>
<?php
// Now iterate over every book and display it
while($r = $q->fetch())
{
?>
<tr>
<td><?=htmlspecialchars($r['title'])?></td>
<td><?=htmlspecialchars($r['isbn'])?></td>
<td><?=htmlspecialchars($r['publisher'])?></td>
<td><?=htmlspecialchars($r['year'])?></td>
<td><?=htmlspecialchars($r['summary'])?></td>
</tr>
<?php
}
?>
</table>
<?php
// Display footer
showFooter();

将此文件命名为author.php并将其保存到其他文件所在的目录中。

以下是有关代码的一些评论:

  • 我们通过将作者的 ID(第 13 行)显式转换为整数来处理它,以防止可能的安全漏洞。我们稍后将$id变量传递给查询文本,而不用引号引用,因为对于数字值来说这样做是可以的。

  • 我们将在接下来的章节中讨论第 13 行中对$q->closeCursor(); $q = null的调用。在这里我们只想指出,调用这个方法是一个好主意,可以在同一个连接对象上执行查询之间调用它,然后将其设置为 null。我们的示例如果没有它将无法工作。还要注意的是,在最后一个查询之后我们不需要这样做。

  • 我们在这里也进行了简单的错误处理:我们检查作者 ID 是否无效。如果无效,我们会显示错误消息,然后退出。(见第 22 至 27 行。)

  • 在第 25 和 27 行,我们使用作者的 ID 创建查询,并将获取模式设置为PDO::FETCH_ASSOC。然后我们继续显示数据:首先我们呈现作者的详细信息,然后是他的所有书籍。

现在您可以返回浏览器,将其指向 URL:http://localhost/author.php?id=1

以下屏幕应该出现:

PDO 语句和结果集

正如您所看到的,页面上的一切都是正确的:我们首先填写的作者详细信息(id=1),以及这位作者的唯一一本书。现在让我们看看我们的应用程序如何对提交的无效 ID 做出反应。我们知道我们只有三位作者,所以除了 1、2 或 3 之外的任何数字都是无效的。此外,非数字参数将计算为 0,这是无效的。如果我们将地址栏中的 URL 更改为http://localhost/author.php?id=zzz。我们将得到以下结果:

PDO 语句和结果集

您还应该在common.inc.php中切换到 SQLite,并查看此页面是否也适用于此数据库。

现在,让我们修改现有的books.php文件,以添加一个带有指向作者个人资料页面的链接的作者列。我们将不得不连接两个表,其中书的作者字段等于作者的 ID 字段,并选择作者的 ID、名和姓。因此,我们的查询将如下所示:

SELECT authors.id, authors.firstName, authors.lastName, books.* FROM authors, books WHERE author=authors.id ORDER BY title;

在继续更改之前,让我们在命令行客户端中运行此查询。我们还将修改此查询以适应客户端,因为其窗口无法容纳整行:

mysql> SELECT authors.id, firstName, lastName, books.id, title FROM
authors, books WHERE books.author=authors.id;
+----+-----------+----------+----+------------------------------+
| id | firstName | lastName | id | title |
+----+-----------+----------+----+------------------------------+
| 1 | Marc | Delisle | 1 | Creating your MySQL... |
| 2 | Sohail | Salehi | 2 | ImageMagick Tricks | | 3 | Cameron | Cooper | 3 | Building Websites with Plone |
+----+-----------+----------+----+------------------------------+
3 rows in set (0.00 sec)

正如您所看到的,查询返回了两列名为id。这意味着我们将无法使用PDO::FETCH_ASSOC模式,因为只能有一个id数组索引。我们有两个选择:要么使用PDO::FETCH_NUM模式,要么使用别名检索 ID 字段。

让我们看看如何使用PDO::FETCH_NUM编写页面:

<?php
/**
* This page lists all the books we have
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Issue the query
**$q = $conn->query("SELECT authors.id, firstName, lastName, books.*
FROM authors, books WHERE author=authors.id ORDER
BY title");
$q->setFetchMode(PDO::FETCH_NUM);**
// Display the header
showHeader('Books');
// now create the table
?>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
**<td>Author</td>**
<td>Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
</tr>
<?php
// Now iterate over every row and display it
**while($r = $q->fetch())**
{
?>
<tr>
**<td><a href="author.php?id=<?=$r[0]?>">
<?=htmlspecialchars("$r[1] $r[2]")?></a></td>
<td><?=htmlspecialchars($r[5])?></td>
<td><?=htmlspecialchars($r[6])?></td>
<td><?=htmlspecialchars($r[7])?></td>
<td><?=htmlspecialchars($r[8])?></td>
<td><?=htmlspecialchars($r[9])?></td>**
</tr>
<?php
}
?>
</table>
<?php
// Display footer
showFooter();

请注意高亮显示的行-它们包含更改;文件的其余部分相同。正如您所看到的,我们添加了对$q->setFetchMode()的调用,并更改了循环以使用数字列索引。

如果我们导航回http://localhost/books.php,我们将看到与此截图中类似的列表:

PDO 语句和结果集

我们可以点击每个作者以进入其个人资料页面。当然,在common.inc.php中切换回 SQLite 也应该起作用。

另一个(更好的)选择是在 SQL 代码中为列名使用别名。如果我们这样做,我们就不必关心数字索引,并且每次从我们的表中添加或删除列时都要更改代码。我们只需将 SQL 更改为以下内容:

SELECT authors.id AS authorId, firstName, lastName, books.* FROM
authors, books WHERE author=authors.id ORDER BY title;

books.php的最终版本将如下所示:

<?php
/**
* This page lists all the books we have
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Issue the query
**$q = $conn->query("SELECT authors.id AS authorId, firstName,
lastName, books.* FROM authors, books WHERE
author=authors.id
ORDER BY title");
$q->setFetchMode(PDO::FETCH_ASSOC);**
// Display the header
showHeader('Books');
// now create the table
?>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Author</td>
<td>Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
</tr>
<?php
// Now iterate over every row and display it
while($r = $q->fetch())
{
?>
<tr>
**<td><a href="author.php?id=<?=$r['authorId']?>">
<?=htmlspecialchars("$r[firstName] $r[lastName]")?></a></td>
<td><?=htmlspecialchars($r['title'])?></td>
<td><?=htmlspecialchars($r['isbn'])?></td>
<td><?=htmlspecialchars($r['publisher'])?></td>
<td><?=htmlspecialchars($r['year'])?></td>
<td><?=htmlspecialchars($r['summary'])?></td>**
</tr>
<?php
}
?>
</table>
<?php
// Display footer
showFooter();

请注意,我们将提取模式更改回PDO::FETCH_ASSOC。此外,我们在第 34 行使用$r['authorId']访问作者的 ID,因为我们在查询中使用authorId对该列进行了别名。

PDO 还允许我们将所有结果提取到数组中。我们可能需要这个用于进一步处理或传递给某个函数。但是,这应该仅用于小型结果集。这在我们这样的应用程序中是非常不鼓励的,因为我们只是显示书籍或作者的列表。将大型结果集提取到数组中将需要为整个结果分配内存,而在我们的情况下,我们逐行显示结果,因此只需要一行的内存。

这个方法被称为PDOStatement::fetchAll()。结果数组可以是一个二维数组,也可以是对象列表-这取决于提取模式。这个方法接受所有PDO::FETCH_xxxx常量,就像PDOStatement::fetch()一样。例如,我们可以以以下方式重写我们的books.php文件以达到相同的结果。以下是books.php第 9 到 46 行的相关部分:

// Issue the query
$q = $conn->query("SELECT authors.id AS authorId, firstName,
lastName, books.* FROM authors, books WHERE
author=authors.id ORDER BY title");
**$books = $q->fetchAll(PDO::FETCH_ASSOC);**
// Display the header
showHeader('Books');
// now create the table
?>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Author</td>
<td>Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
</tr>
<?php
// Now iterate over every row and display it
**foreach($books as $r)
{**
?>
<tr>
<td><a href="author.php?id=<?=$r['authorId']?>">
<?=htmlspecialchars("$r[firstName] $r[lastName]")?></a></td>
<td><?=htmlspecialchars($r['title'])?></td>
<td><?=htmlspecialchars($r['isbn'])?></td>
<td><?=htmlspecialchars($r['publisher'])?></td>
<td><?=htmlspecialchars($r['year'])?></td>
<td><?=htmlspecialchars($r['summary'])?></td>
</tr>
<?php
}
?>
</table>

请注意这里的高亮显示的行-我们在第 5 行将整个结果提取到$books数组中,然后在第 21 行使用foreach循环对其进行迭代。如果运行修改后的页面,您将看到我们收到相同的结果。如果在common.inc.php文件中切换到 SQLite 数据库,这也将起作用。

PDOStatement::fetchAll()方法还允许我们使用PDO::FETCH_COLUMN模式选择单个列的值。如果我们想使用上一个示例中的查询提取整个书名,我们可以这样做(注意列的数量和顺序):

$q = $conn->query("SELECT authors.id AS authorId, firstName,
lastName, books.* FROM authors, books WHERE
author=authors.id ORDER BY title");
$books = $q->fetchAll(PDO::FETCH_COLUMN, 5);
var_dump($books);

这将产生以下输出:

array(3)
{
[0]=>
string(28) "Building Websites with Plone"
[1]=>
string(66) "Creating your MySQL Database: Practical Design Tips and
Techniques"
[2]=>
string(18) "ImageMagick Tricks"
}

正如您所看到的,当请求单个列时,此方法返回一维数组。

检索结果集元数据

正如我们在前一节中所看到的,PDOStatement类允许我们检索有关结果集中包含的数据的一些信息。这些信息称为元数据,您可能已经以某种方式使用过其中的一些。

结果集最重要的元数据当然是它包含的行数。我们可以使用行数来增强用户体验,例如对长结果集进行分页。我们的示例库应用目前还很小,只有三本书,但随着数据库的增长,我们肯定需要一些工具来获取每个表的总行数,并对其进行分页以便浏览。

传统上,您会使用mysql_num_rows(), sqlite_num_rows()函数或pg_num_rows()函数(取决于您的数据库)来获取查询返回的总行数。在 PDO 中,负责检索行数的方法称为PDOStatement::rowCount()。但是,如果你想用以下代码测试它:

$q = $conn->query("SELECT * FROM books ORDER BY title");
$q->setFetchMode(PDO::FETCH_ASSOC);
var_dump($q->rowCount());

你会发现 PDO 对 MySQL 和 SQLite 都返回 0。这是因为 PDO 的操作方式与传统的数据库扩展不同。文档中说:“如果与关联的PDOStatement类执行的最后一个 SQL 语句是SELECT语句,则某些数据库可能返回该语句返回的行数。但是,并不是所有数据库都保证这种行为。”

可移植应用程序不应依赖于这种方法。" MySQL 和 SQLite 驱动程序都不支持此功能,这就是为什么该方法的返回值为 0。我们将在第五章中看到如何使用 PDO 计算返回的行数(因此这是一个真正可移植的方法)。

注意

RDBMS不知道查询将返回多少行,直到检索到最后一行。这是出于性能考虑。在大多数情况下,带有WHERE子句的查询只返回表中存储的部分行,数据库服务器会尽力确保这样的查询尽快执行。这意味着他们在发现与WHERE子句匹配的行时就开始返回行——这比到达最后一行要早得多。这就是为什么他们真的不知道事先将返回多少行。mysql_num_rows(), sqlite_num_rows()函数或pg_num_rows()函数操作的是已经预取到内存中的结果集(缓冲查询)。PDO 的默认行为是使用非缓冲查询。我们将在第六章中讨论 MySQL 缓冲查询。

另一个可能感兴趣的方法是PDOStatement::columnCount()方法,它返回结果集中的列数。当我们执行任意查询时,这很方便。(例如,像phpMyAdmin这样的数据库管理应用程序可以充分利用这种方法,因为它允许用户输入任意 SQL 查询。)我们可以这样使用它:

$q = $conn->query("SELECT authors.id AS authorId, firstName,
lastName, books.* FROM authors, books WHERE
author=authors.id ORDER BY title");
var_dump($q->columnCount());

这将揭示我们的查询返回了一个包含 10 列的结果集(books表的七列和authors表的三列)。

不幸的是,PDO 目前不允许您从结果集中检索表的名称或特定列的名称。如果您的应用程序使用了连接两个或多个表的查询,这个功能就很有用。在这种情况下,可以根据其数字索引(从 0 开始)获取每列的表名。但是,正确使用列别名可以消除使用这种功能的需要。例如,当我们修改书籍列表页面以显示作者的姓名时,我们为作者的 ID 列设置了别名以避免名称冲突。该别名清楚地标识了该列属于authors表。

总结

在本章中,我们初步使用了 PDO,甚至创建了一个可以在两个不同数据库上运行的小型数据库驱动动态应用程序。现在你应该能够连接到任何支持的数据库,使用构建连接字符串的规则。然后你应该能够对其运行查询,并遍历和显示结果集。

在下一章中,我们将处理任何数据库驱动应用程序的一个非常重要的方面——错误处理。我们还将通过为其添加和编辑书籍和作者的功能来扩展我们的示例应用程序,从而使其更加真实和有用。

第三章:错误处理

现在我们已经构建了使用 PDO 的第一个应用程序,我们将更仔细地研究用户友好的 Web 应用程序的一个重要方面——错误处理。它不仅通知用户有错误发生,而且在错误发生时没有被检测到时,它还限制了损害。

大多数 Web 应用程序都有相当简单的错误处理策略。当发生错误时,脚本终止并显示错误页面。错误应该记录在错误日志中,开发人员或维护人员应该定期检查日志。数据库驱动的 Web 应用程序中最常见的错误来源如下:

  • 服务器软件故障或过载,比如著名的“连接过多”错误

  • 应用程序配置不当,当我们使用不正确的连接字符串时可能会发生,这在将应用程序从一个主机移动到另一个主机时是一个相当常见的错误

  • 用户输入验证不当,可能导致 SQL 格式不正确,从而导致查询失败

  • 插入具有重复主键或唯一索引值的记录,这可能是应用程序业务逻辑错误导致的,也可能发生在受控情况下

  • SQL 语句中的语法错误

在本章中,我们将扩展我们的应用程序,以便我们可以编辑现有记录以及添加新记录。由于我们将处理通过 Web 表单提供的用户输入,我们必须对其进行验证。此外,我们可能会添加错误处理,以便我们可以对非标准情况做出反应,并向用户呈现友好的消息。

在我们继续之前,让我们简要地检查上面提到的错误来源,并看看在每种情况下应该应用什么错误处理策略。我们的错误处理策略将使用异常,所以你应该熟悉它们。如果你不熟悉,你可以参考附录 A,它将向你介绍 PHP5 的新面向对象特性。

我们有意选择使用异常,即使 PDO 可以被指示不使用它们,因为有一种情况是它们无法避免的。当数据库对象无法创建时,PDO 构造函数总是抛出异常,所以我们可能会将异常作为我们在整个代码中的主要错误捕获方法。

错误来源

要创建一个错误处理策略,我们首先应该分析错误可能发生的地方。错误可能发生在对数据库的每次调用上,尽管这可能不太可能,我们将研究这种情况。但在这样做之前,让我们检查每个可能的错误来源,并为处理它们定义一个策略。

服务器软件故障或过载

这可能发生在一个非常繁忙的服务器上,无法处理更多的传入连接。例如,后台可能正在运行一个漫长的更新。结果是我们无法从数据库中获取任何数据,所以我们应该做以下事情。

如果 PDO 构造函数失败,我们会显示一个页面,上面显示一条消息,说明用户的请求目前无法满足,他们应该稍后再试。当然,我们也应该记录这个错误,因为它可能需要立即处理。(一个好主意是通过电子邮件通知数据库管理员有关这个错误。)

这个错误的问题在于,虽然它通常在与数据库建立连接之前就显现出来(在调用 PDO 构造函数时),但有一点风险,它可能在连接建立之后发生(在调用PDOPDOStatement对象的方法时,数据库服务器正在关闭)。在这种情况下,我们的反应将是一样的——向用户呈现一个错误消息,要求他们稍后再试。

应用程序配置不当

这个错误只会在我们将应用程序从数据库访问细节不同的服务器上移动时发生;这可能是当我们从开发服务器上传到生产服务器时,数据库设置不同。这不是在应用程序正常执行期间可能发生的错误,但在上传时应该注意,因为这可能会中断网站的运行。

如果发生此错误,我们可以显示另一个错误消息,如:“该网站正在维护中”。在这种情况下,网站维护者应立即做出反应,因为如果不纠正连接字符串,应用程序就无法正常运行。

用户输入验证不正确

这是一个与 SQL 注入漏洞密切相关的错误。每个数据库驱动应用程序的开发人员都必须采取适当的措施来验证和过滤所有用户输入。这个错误可能导致两个主要后果:要么查询由于 SQL 格式不正确而失败(因此不会发生特别糟糕的事情),要么可能发生 SQL 注入并且应用程序安全可能会受到损害。虽然后果不同,但这两个问题可以以相同的方式防止。

让我们考虑以下情景。我们从表单中接受一些数值,并将其插入数据库。为了使我们的例子简单,假设我们想要更新一本书的出版年份。为了实现这一点,我们可以创建一个包含书的 ID 的隐藏字段和一个输入年份的文本字段的表单。我们将在这里跳过实现细节,并看看使用一个设计不良的脚本来处理这个表单可能会导致错误并将整个系统置于风险之中。

表单处理脚本将检查两个请求变量:$_REQUEST['book'],其中包含书的 ID 和$_REQUEST['year'],其中包含出版年份。如果没有对这些值进行验证,最终的代码将类似于这样:

$book = $_REQUEST['book'];
$year = $_REQUEST['year'];
$sql = "UPDATE books SET year=$year WHERE id=$book";
$conn->query($sql);

让我们看看如果用户将book字段留空会发生什么。最终的 SQL 将如下所示:

UPDATE books SET year= WHERE id=1;

这个 SQL 是格式不正确的,会导致语法错误。因此,我们应该确保这两个变量都包含数值。如果它们不包含数值,我们应该重新显示表单并显示错误消息。

现在,让我们看看攻击者如何利用这一点来删除整个表的内容。为了实现这一点,他们可以在year字段中输入以下内容:

2007; DELETE FROM books;

这将一个查询变成了三个查询:

UPDATE books SET year=2007; DELETE FROM books; WHERE book=1;

当然,第三个查询是格式不正确的,但第一个和第二个将执行,并且数据库服务器将报告一个错误。为了解决这个问题,我们可以使用简单的验证来确保year字段包含四位数字。然而,如果我们有可能包含任意字符的文本字段,字段的值在创建 SQL 之前必须进行转义。

插入具有重复主键或唯一索引值的记录

当应用程序插入具有主键或唯一索引的重复值的记录时,可能会出现这个问题。例如,在我们的作者和书籍数据库中,我们可能希望防止用户因错误而两次输入相同的书。为此,我们可以在books表的 ISBN 列上创建一个唯一索引。由于每本书都有一个唯一的 ISBN,任何尝试插入相同的 ISBN 都会生成一个错误。我们可以捕获这个错误,并通过显示一个错误消息要求用户纠正 ISBN 或取消其添加来做出相应反应。

SQL 语句中的语法错误

如果我们没有正确测试应用程序,可能会发生此错误。一个好的应用程序不应包含这些错误,开发团队有责任测试每种可能的情况,并检查每个 SQL 语句是否执行时没有语法错误。

如果发生这种错误,我们会使用异常来捕获它,并显示一个致命错误消息。开发人员必须立即纠正这种情况。

现在我们已经了解了可能的错误来源,让我们来看看 PDO 如何处理错误。

PDO 中的错误处理类型

默认情况下,PDO 使用静默错误处理模式。这意味着调用PDOPDOStatement类的方法时发生的任何错误都不会被报告。在这种模式下,每次发生错误时,都必须调用PDO::errorInfo()PDO::errorCode()PDOStatement::errorInfo()PDOStatement::errorCode()来查看是否真的发生了错误。请注意,这种模式类似于传统的数据库访问——通常,在调用可能引起错误的函数之后,代码会调用mysql_errno()mysql_error()(或其他数据库系统的等效函数),在连接到数据库之后和发出查询之后。

另一种模式是警告模式。在这里,PDO将与传统的数据库访问行为相同。与数据库通信期间发生的任何错误都会引发一个E_WARNING错误。根据配置,可能会显示错误消息或将其记录到文件中。

最后,PDO 引入了一种处理数据库连接错误的现代方式——使用异常。对PDOPDOStatement方法的任何失败调用都会引发异常。

正如我们之前注意到的,PDO 默认使用静默模式。要切换到所需的错误处理模式,我们必须通过调用PDO::setAttribute()方法来指定它。每个错误处理模式由 PDO 类中定义的以下常量指定:

  • PDO::ERRMODE_SILENT - 静默策略。

  • PDO::ERRMODE_WARNING - 警告策略。

  • PDO::ERRMODE_EXCEPTION - 使用异常

要设置所需的错误处理模式,我们必须以以下方式设置PDO::ATTR_ERRMODE属性:

$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

要查看 PDO 如何抛出异常,请在common.inc.php文件中编辑,在第 46 行后添加上述语句。如果您想测试当 PDO 抛出异常时会发生什么,请更改连接字符串以指定不存在的数据库。现在将浏览器指向图书列表页面。

您应该看到类似于以下的输出:

PDO 中的错误处理类型

这是 PHP 对未捕获异常的默认反应——它们被视为致命错误,程序执行停止。错误消息显示了异常的类PDOException、错误描述和一些调试信息,包括抛出异常的语句的名称和行号。请注意,如果要测试 SQLite,指定不存在的数据库可能不起作用,因为如果数据库不存在,它将被创建。要查看它是否适用于 SQLite,请更改第 10 行的$connStr变量,以便数据库名称中有一个非法字符:

$connStr = 'sqlite:/path/to/pdo*.db';

刷新您的浏览器,您应该看到类似于这样的内容:

PDO 中的错误处理类型

如您所见,显示了类似于上一个示例的消息,指定了错误的原因和源代码中的位置。

定义错误处理函数

如果我们知道某个语句或代码块可能会抛出异常,我们应该将该代码包装在try...catch块中,以防止显示默认错误消息并呈现用户友好的错误页面。但在我们继续之前,让我们创建一个函数,用于呈现错误消息并退出应用程序。由于我们将从不同的脚本文件中调用它,所以最好的地方就是common.inc.php文件。

我们的函数,名为showError(),将执行以下操作:

  • 呈现一个标题,写着“错误”。

  • 呈现错误消息。我们将使用htmlspecialchars()函数转义文本,并使用nl2br()函数处理它,以便我们可以显示多行消息。(此函数将所有换行字符转换为<br>标签。)

  • 调用showFooter()函数来关闭打开的<html><body>标签。该函数将假定应用程序已经调用了showHeader()函数。(否则,我们将得到破损的 HTML。)

我们还必须修改在common.inc.php中创建连接对象的块,以捕获可能的异常。通过所有这些更改,common.inc.php的新版本将如下所示:

<?php
/**
* This is a common include file
* PDO Library Management example application
* @author Dennis Popel
*/
// DB connection string and username/password
$connStr = 'mysql:host=localhost;dbname=pdo';
$user = 'root';
$pass = 'root';
/**
* This function will render the header on every page,
* including the opening html tag,
* the head section and the opening body tag.
* It should be called before any output of the
* page itself.
* @param string $title the page title
*/
function showHeader($title)
{
?>
<html>
<head><title><?=htmlspecialchars($title)?></title></head>
<body>
<h1><?=htmlspecialchars($title)?></h1>
<a href="books.php">Books</a>
<a href="authors.php">Authors</a>
<hr>
<?php
}
/**
* This function will 'close' the body and html
* tags opened by the showHeader() function
*/
function showFooter()
{
?>
</body>
</html>
<?php
}
**/**
* This function will display an error message, call the
* showFooter() function and terminate the application
* @param string $message the error message
*/
function showError($message)
{
echo "<h2>Error</h2>";
echo nl2br(htmlspecialchars($message));
showFooter();
exit();
}
// Create the connection object
try
{
$conn = new PDO($connStr, $user, $pass);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
}
catch(PDOException $e)
{
showHeader('Error');
showError("Sorry, an error has occurred. Please try your request
later\n" . $e->getMessage());
}**

正如您所看到的,新创建的函数非常简单。更有趣的部分是我们用来捕获异常的try…catch块。现在通过这些修改,我们可以测试真正的异常将如何被处理。为此,请确保您的连接字符串是错误的(这样它就为 MySQL 指定了错误的数据库名称,或者包含了 SQLite 的无效文件名)。将浏览器指向books.php,您应该会看到以下窗口:

定义错误处理函数

创建编辑书籍页面

正如我们之前讨论的,我们希望扩展我们的应用程序,以便我们可以添加和编辑书籍和作者。此外,我们的系统应该能够通过在书籍表的ISBN列上强制执行唯一索引来防止我们输入相同的书籍两次。

在继续进行代码之前,我们将创建索引。启动您的命令行客户端,并输入以下命令(对于 MySQL 和 SQLite 是相同的):

CREATE UNIQUE INDEX idx_isbn ON books(isbn);

我们还将使我们的编辑书籍页面同时具有两个目的——添加新书和编辑现有书籍。脚本将通过书籍 ID 的存在来区分要采取的操作,无论是在 URL 中还是在隐藏的表单字段中。我们将从books.php中链接到这个新页面,这样我们就可以通过在书籍列表页面上点击链接来编辑每一本书。

这个页面比上一章描述的页面更复杂,所以我会先给你代码,然后再讨论它。让我们称这个页面为 edit Book.php:

<?php
/**
* This page allows to add or edit a book
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// See if we have the book ID passed in the request
$id = (int)$_REQUEST['book'];
if($id) {
// We have the ID, get the book details from the table
$q = $conn->query("SELECT * FROM books WHERE id=$id");
$book = $q->fetch(PDO::FETCH_ASSOC);
$q->closeCursor();
$q = null;
}
else {
// We are creating a new book
$book = array();
}
// Now get the list of all authors' first and last names
// We will need it to create the dropdown box for author
$authors = array();
$q = $conn->query("SELECT id, lastName, firstName FROM authors ORDER
BY lastName, firstName");
$q->setFetchMode(PDO::FETCH_ASSOC);
while($a = $q->fetch())
{
$authors[$a['id']] = "$a[lastName], $a[firstName]";
}
// Now see if the form was submitted
if($_POST['submit']) {
// Validate every field
$warnings = array();
// Title should be non-empty
if(!$_POST['title'])
{
$warnings[] = 'Please enter book title';
}
// Author should be a key in the $authors array
if(!array_key_exists($_POST['author'], $authors))
{
$warnings[] = 'Please select author for the book';
}
// ISBN should be a 10-digit number
if(!preg_match('~^\d{10}$~', $_POST['isbn'])) {
$warnings[] = 'ISBN should be 10 digits';
}
// Published should be non-empty
if(!$_POST['publisher']) {
$warnings[] = 'Please enter publisher';
}
// Year should be 4 digits
if(!preg_match('~^\d{4}$~', $_POST['year'])) {
$warnings[] = 'Year should be 4 digits';
}
// Sumary should be non-empty
if(!$_POST['summary']) {
$warnings[] = 'Please enter summary';
}
// If there are no errors, we can update the database
// If there was book ID passed, update that book
if(count($warnings) == 0) {
if(@$book['id']) {
$sql = "UPDATE books SET title=" . $conn>quote($_POST['title']) .
', author=' . $conn->quote($_POST['author']) .
', isbn=' . $conn->quote($_POST['isbn']) .
', publisher=' . $conn->quote($_POST['publisher']) .
', year=' . $conn->quote($_POST['year']) .
', summary=' . $conn->quote($_POST['summary']) .
" WHERE id=$book[id]";
}
else {
$sql = "INSERT INTO books(title, author, isbn, publisher,
year,summary) VALUES(" .
$conn->quote($_POST['title']) .
', ' . $conn->quote($_POST['author']) .
', ' . $conn->quote($_POST['isbn']) .
', ' . $conn->quote($_POST['publisher']) .
', ' . $conn->quote($_POST['year']) .
', ' . $conn->quote($_POST['summary']) .
')';
}
// Now we are updating the DB.
// We wrap this into a try/catch block
// as an exception can get thrown if
// the ISBN is already in the table
try
{
$conn->query($sql);
// If we are here that means that no error
// We can return back to books listing
header("Location: books.php");
exit;
}
catch(PDOException $e)
{
$warnings[] = 'Duplicate ISBN entered. Please correct';
}
}
}
else {
// Form was not submitted.
// Populate the $_POST array with the book's details
$_POST = $book;
}
// Display the header
showHeader('Edit Book');
// If we have any warnings, display them now
if(count($warnings)) {
echo "<b>Please correct these errors:</b><br>";
foreach($warnings as $w)
{
echo "- ", htmlspecialchars($w), "<br>";
}
}
// Now display the form
?>
<form action="editBook.php" method="post">
<table border="1" cellpadding="3">
<tr>
<td>Title</td>
<td>
<input type="text" name="title"
value="<?=htmlspecialchars($_POST['title'])?>">
</td>
</tr>
<tr>
<td>Author</td>
<td>
<select name="author">
<option value="">Please select...</option>
<?php foreach($authors as $id=>$author) { ?>
<option value="<?=$id?>"
<?= $id == $_POST['author'] ? 'selected' : ''?>>
<?=htmlspecialchars($author)?>
</option>
<?php } ?>
</select>
</td>
</tr>
<tr>
<td>ISBN</td>
<td>
<input type="text" name="isbn"
value="<?=htmlspecialchars($_POST['isbn'])?>">
</td>
</tr>
<tr>
<td>Publisher</td>
<td>
<input type="text" name="publisher"
value="<?=htmlspecialchars($_POST['publisher'])?>">
</td>
</tr>
<tr>
<td>Year</td>
<td>
<input type="text" name="year"
value="<?=htmlspecialchars($_POST['year'])?>">
</td>
</tr>
<tr>
<td>Summary</td>
<td>
<textarea name="summary"><?=htmlspecialchars( $_POST['summary'])?></textarea>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<input type="submit" name="submit" value="Save">
</td>
</tr>
</table>
<?php if(@$book['id']) { ?>
<input type="hidden" name="book" value="<?=$book['id']?>">
<?php } ?>
</form>
<?php
// Display footer
showFooter();

代码相当自我解释,但让我们简要地浏览一下它的主要部分。12 到 23 行处理如果页面使用书籍 ID 请求,则会获取要编辑的书籍详情。这些细节存储在$book变量中。请注意,我们明确将请求参数book转换为整数,以便不会发生 SQL 注入(第 13 行)。如果没有提供书籍 ID,则将其设置为空数组。请注意我们如何调用closeCursor()函数,然后将$q变量赋值为 null。这是必要的,因为我们将重用连接对象。

26 到 33 行准备作者列表。由于我们的系统每本书只允许一个作者,我们将创建一个选择框字段列出所有作者。

35 行检查是否提交了表单。如果测试成功,脚本将验证每个字段(37 到 68 行)。每个失败的验证都会附加到警告列表中。($warnings变量初始化为空数组。)我们将使用此列表来查看验证是否成功,并在验证失败时存储错误消息。

69 到 94 行构建实际的更新 SQL。最终的 SQL 取决于我们是在更新书籍(当$book数组包含id键时),还是添加新书。请注意,在查询执行之前如何引用每个列值。

95 到 112 行尝试执行查询。如果用户输入了重复的 ISBN,查询可能会失败,因此我们将代码包装在try…catch块中。如果确实抛出了异常,catch块将向$warnings数组附加相应的警告。如果一切正常且没有错误,脚本将重定向到书籍列表页面,您应该能看到更改。

113 到 118 行在表单没有提交时执行。在这里,$_POST数组被$books变量的内容填充。我们这样做是因为我们将使用$_POST数组在代码后面显示表单字段的值。

请注意我们如何在 122 到 129 行显示错误消息(如果有的话),以及在 141 到 154 行显示选择框。 (我们正在浏览所有作者,如果作者的 ID 与此书作者的 ID 匹配,则将该作者标记为选定的选项。)此外,其他表单字段是使用htmlspecialchars()函数应用于$_POST数组的项目来呈现的。 189 到 191 行将向表单添加一个包含当前编辑的书籍的 ID 的隐藏字段(如果有的话)。

现代 Web 应用程序除了对用户提供的数据进行服务器端验证外,还采用了客户端验证。虽然这不在本书的范围内,但您可能会考虑在项目中使用基于浏览器的验证,以增加响应性并可能减少 Web 服务器的负载。

现在,我们应该从books.php页面链接到新创建的页面。我们将为每个列出的书籍提供一个编辑此书链接,以及在表格下方提供一个添加书籍链接。我不会在这里重复整个books.php源代码,只是应该更改的行。因此,应该将 32 到 48 行替换为以下内容:

<?php
// Now iterate over every row and display it
while($r = $q->fetch())
{
?>
<tr>
<td><ahref="author.php?id=<?=$r['authorId']?>">
<?=htmlspecialchars("$r[firstName] $r[lastName]")?></a></td>
<td><?=htmlspecialchars($r['title'])?></td>
<td><?=htmlspecialchars($r['isbn'])?></td>
<td><?=htmlspecialchars($r['publisher'])?></td>
<td><?=htmlspecialchars($r['year'])?></td>
<td><?=htmlspecialchars($r['summary'])?></td>
**<td>
<a href="editBook.php?book=<?=$r['id']?>">Edit</a>
</td>**
</tr>
<?php
}
?>

应该在调用showFooter()函数之前添加以下内容,以便这四行看起来像这样:

<a href="editBook.php">Add book...</a>
<?php
// Display footer
showFooter();

现在,如果您再次导航到books.php页面,您应该看到以下窗口:

创建编辑书籍页面

要查看我们的编辑书籍页面的外观,请单击表格最后一列中的任何编辑链接。您应该看到以下表单:

创建编辑书籍页面

让我们看看我们的表单是如何工作的。它正在验证发送到数据库的每个表单字段。如果有任何验证错误,表单将不会更新数据库,并提示用户更正提交。例如,尝试将作者选择框更改为默认选项(标有请选择...),并将 ISBN 编辑为 5 位数。

如果单击保存按钮,您应该看到表单显示以下错误消息:

创建编辑书籍页面

现在纠正错误,并尝试将 ISBN 更改为 1904811027。这个 ISBN 已经在我们的数据库中被另一本书使用,所以表单将再次显示错误。您还可以通过添加一本书来进一步测试表单。您可能还想测试它在 SQLite 中的工作方式。

创建编辑作者页面

我们的应用程序仍然缺少添加/编辑作者功能。这个页面将比编辑书籍页面简单一些,因为它不会有作者的选择框和唯一索引。(您可能希望在作者的名字和姓氏列上创建唯一索引,以防止那里也出现重复,但我们将把这个问题留给您。)

让我们称这个页面为editAuthor.php。以下是它的源代码:

<?php
/**
* This page allows to add or edit an author
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// See if we have the author ID passed in the request
$id = (int)$_REQUEST['author'];
if($id) {
// We have the ID, get the author details from the table
$q = $conn->query("SELECT * FROM authors WHERE id=$id");
$author = $q->fetch(PDO::FETCH_ASSOC);
$q->closeCursor();
$q = null;
}
else {
// We are creating a new book
$author = array();
}
// Now see if the form was submitted
if($_POST['submit']) {
// Validate every field
$warnings = array();
// First name should be non-empty
if(!$_POST['firstName']) {
$warnings[] = 'Please enter first name';
}
// Last name should be non-empty
if(!$_POST['lastName']) {
$warnings[] = 'Please enter last name';
}
// Bio should be non-empty
if(!$_POST['bio']) {
$warnings[] = 'Please enter bio';
}
// If there are no errors, we can update the database
// If there was book ID passed, update that book
if(count($warnings) == 0) {
if(@$author['id']) {
$sql = "UPDATE authors SET firstName=" .
$co>quote($_POST['firstName']) .
', lastName=' . $conn->quote($_POST['lastName']) .
', bio=' . $conn->quote($_POST['bio']) .
" WHERE id=$author[id]";
}
else {
$sql = "INSERT INTO authors(firstName, lastName, bio) VALUES(" .
$conn->quote($_POST['firstName']) .
', ' . $conn->quote($_POST['lastName']) .
', ' . $conn->quote($_POST['bio']) .
')';
}
$conn->query($sql);
header("Location: authors.php");
exit;
}
}
else {
// Form was not submitted.
// Populate the $_POST array with the author's details
$_POST = $author;
}
// Display the header
showHeader('Edit Author');
// If we have any warnings, display them now
if(count($warnings)) {
echo "<b>Please correct these errors:</b><br>";
foreach($warnings as $w)
{
echo "- ", htmlspecialchars($w), "<br>";
}
}
// Now display the form
?>
<form action="editAuthor.php" method="post">
<table border="1" cellpadding="3">
<tr>
<td>First name</td>
<td>
<input type="text" name="firstName"
value="<?=htmlspecialchars($_POST['firstName'])?>">
</td>
</tr>
<tr>
<td>Last name</td>
<td>
<input type="text" name="lastName"
value="<?=htmlspecialchars($_POST['lastName'])?>">
</td>
</tr>
<tr>
<td>Bio</td>
<td>
<textarea name="bio"><?=htmlspecialchars($_POST['bio'])?>
</textarea>
</td>
</tr>
<tr>
<td colspan="2" align="center">
<input type="submit" name="submit" value="Save">
</td>
</tr>
</table>
<?php if(@$author['id']) { ?>
<input type="hidden" name="author" value="<?=$author['id']?>">
<?php } ?>
</form>
<?php
// Display footer
showFooter();

此源代码与editBook.php页面以相同的方式构建,因此您应该能够轻松地跟随它。

我们将以与我们从books.php页面链接到editBook.php页面相同的方式链接到editAuthors.php页面。编辑authors.php文件,并将 30-41 行更改为以下内容:

while($r = $q->fetch(PDO::FETCH_ASSOC))
{
?>
<tr>
<td><?=htmlspecialchars($r['firstName'])?></td>
<td><?=htmlspecialchars($r['lastName'])?></td>
<td><?=htmlspecialchars($r['bio'])?></td>
**<td>
<a href="editAuthor.php?author=<?=$r['id']?>">Edit</a>
</td>**
</tr>
<?php
}

在最后一个 PHP 块之前添加以下行:

<a href="editAuthor.php">Add Author...</a>

现在,如果您刷新authors.php页面,您将看到以下内容:

创建编辑作者页面

您可以单击右侧列中的编辑链接来编辑每位作者的详细信息。您可以尝试使用空值提交表单,以查看无效提交将被拒绝。此外,您可以尝试向系统添加新作者。成功完成后,您可能希望返回到书籍列表并编辑一些书籍。您将看到新创建的作者可用于作者选择框。

防止未捕获异常

正如我们之前所看到的,我们在可能引发异常的代码周围放置了try...catch块。然而,在非常罕见的情况下,可能会出现一些意外的异常。我们可以通过修改其中一个查询来模拟这样的异常,使其包含一些格式不正确的 SQL。例如,让我们编辑authors.php,将第 16 行修改为以下内容:

$q = $conn->query("SELECT * FROM authors ORDER BY lastName, firstName");

现在尝试使用浏览器导航到authors.php,看看是否发生了未捕获的异常。为了正确处理这种情况,我们要么创建一个异常处理程序,要么将调用PDOPDOStatement类方法的每个代码块包装在try...catch块中。

让我们看看如何创建异常处理程序。这是一种更简单的方法,因为它不需要改变大量的代码。然而,对于大型应用程序来说,这可能是一个不好的做法,因为在发生异常的地方处理异常可能更安全,并且可以应用更好的恢复逻辑。

然而,对于我们简单的应用程序,我们可以使用全局异常处理程序。它将只是使用showError()函数来表示网站正在维护中。

/**
* This is the default exception handler
* @param Exception $e the uncaught exception
*/
function exceptionHandler($e)
{
showError("Sorry, the site is under maintenance\n" .
$e->getMessage());
}
// Set the global excpetion handler
set_exception_handler('exceptionHandler');

将这段代码放入common.inc.php中,就在连接创建代码块之前。如果现在刷新authors.php页面,你会看到处理程序被调用了。

拥有默认的异常处理程序总是一个好主意。正如你已经注意到的,未处理的异常会暴露太多敏感信息,包括数据库连接详细信息。此外,在真实世界的应用程序中,错误页面不应显示有关错误类型的任何信息。(请注意,我们的示例应用程序是这样的。)默认处理程序应该写入错误日志,并通知网站维护人员有关错误的信息。

总结

在本章中,我们研究了PDO如何处理错误,并介绍了异常。此外,我们调查了错误的来源,并看到了如何对抗它们。

我们的示例应用程序已经扩展了一些真实世界的管理功能,使用了数据验证,并且受到了 SQL 注入攻击的保护。当然,他们还应该只允许基于登录名和密码的特定用户对数据库进行修改。然而,这超出了本书的范围。

在下一章中,我们将看到 PDO 和数据库编程中另一个非常重要的方面——使用预处理语句。我们将看到如何借助它们来简化我们的管理页面,从而减少代码量并提高维护性。

第四章:准备好的语句

在前几章中,我们已经了解了 PDO 的基础知识,您可能已经注意到它的大部分功能类似于用于连接数据库的传统扩展。唯一的新功能是异常,但即使这一点也可以类似于传统的错误处理。

在本章中,我们将看到 PHP 中 PDO 之前不存在的一个新概念:准备好的语句。我们将看到它们如何进一步简化我们的代码,甚至提高性能。我们还将看看 PDO 如何以数据库无关的方式处理 BLOBs。

关于我们的图书馆管理应用程序,我们将重写前一章中添加的编辑/更新功能,以便支持准备好的语句,并添加对书籍封面图片的支持,我们将保存在数据库中。

准备好的语句

准备好的语句是针对数据库执行一个或多个 SQL 查询的模板。准备好的语句的理念是,对于使用相同语法但不同值的查询,预处理语法一次,然后使用不同参数多次执行会更快。考虑以下任务。我们必须将几位新作者的姓名插入到我们的数据库中。当然,我们可以使用命令行客户端或我们最近创建的add author页面,但我们决定使用一个 PHP 脚本。

假设要添加的作者保存在一个 PHP 数组中:

$authors = array(
array(
'firstName' => 'Alexander',
'lastName' => 'Dumas',
'bio' => 'Alexandre Dumas was a French writer, best known for his
numerous historical novels of high adventure which have
made him one of the most widely read French authors in
the world.'),
array(
'firstName' => 'Ivan',
'lastName' => 'Franko',
'bio' => 'Ivan Franko was a Ukrainian poet, writer, social and
literary critic, and journalist. In addition to his own
literary work, he translated the works of William
Shakespeare, Lord Byron, Dante, Victor Hugo, Goethe and
Schiller into the Ukrainian language.'));

这是一个二维数组,我们将使用foreach循环来迭代,以便将两位作者的详细信息插入到数据库中。

foreach($authors as $author)
{
$conn->query(
'INSERT INTO authors(firstName, lastName, bio) VALUES(' .
$conn->quote($author['firstName']) .
',' . $conn->quote($author['lastName']) .
',' . $conn->quote($author['bio'])')' .
);
}

正如您所看到的,我们在每次迭代中为每个作者创建一个 SQL 语句,并引用所有参数。

使用准备好的语句,我们可以只构建一次查询,然后通过传递不同的值任意次数执行它。我们的代码将如下所示:

$stmt = $conn->prepare('INSERT INTO authors(firstName, lastName, bio)
VALUES(?, ?, ?)');
foreach($authors as $author)
{
$stmt->execute(
array($author['firstName'], $author['lastName'],
$author['bio']));
}

从上面的代码片段中,您可以看到准备好的语句首先通过调用PDO::prepare()方法准备。该方法接受一个包含 SQL 命令的字符串,其中变化的值被问号字符替换。调用返回一个PDOStatement类的对象。然后在循环中,我们调用语句的execute()方法,而不是PDO::query()方法。

PDOStatement::execute()方法接受一个值数组,这些值将被插入到 SQL 查询中,取代问号。该数组中的元素数量和顺序必须与传递给PDO::prepare()的查询模板中问号的数量和顺序相同。

您一定注意到我们在代码中没有使用PDO::quote()——PDO 会正确引用传入的值。

位置和命名占位符

前面的例子使用问号来指定准备好的语句中数值的位置。这就是为什么这些问号被称为位置占位符。当使用它们时,您必须注意传递给PDOStatement::execute()方法的数组中元素的正确顺序。虽然它们写起来很快,但当您更改查询列时,它们可能成为难以跟踪错误的源泉。为了保护自己免受这种影响,您可以使用所谓的命名占位符,它们由冒号前面的描述性名称组成,而不是问号。

使用命名占位符,我们可以以以下方式重写代码来插入这两位作者:

$stmt = $conn->prepare(
'INSERT INTO authors(firstName, lastName, bio) ' .
'VALUES(:first, :last, :bio)');
foreach($authors as $author)
{
$stmt->execute(
array(
':first' => $author['firstName'],
':last' => $author['lastName'],
':bio' => $author['bio'])
);
}

正如您所看到的,我们用命名占位符替换了三个问号,然后在调用PDOStatement::execute()时,我们提供了一个键值对数组,其中键是相应的命名占位符,值是我们要插入数据库的数据。

使用命名占位符时,数组中元素的顺序并不重要,只有关联才重要。例如,我们可以将循环重写如下:

foreach($authors as $author)
{
$stmt->execute(
array(
':bio' => $author['bio'],
':last' => $author['lastName'],
':first' => $author['firstName'])
);
}

然而,对于位置占位符,只要我们确保其元素的顺序与占位符的顺序匹配,就可以将$author数组的值传递给PDOStatement::execute()方法:

$stmt = $conn->prepare(
'INSERT INTO authors(firstName, lastName, bio) VALUES(?, ?, ?)');
foreach($authors as $author)
{
$stmt->execute(array_values($author));
}

请注意我们如何使用array_values()函数来摆脱字符串键并将关联数组转换为列表。

如果我们向PDOStatement::execute()提供的值数组与查询中的占位符数量不匹配,或者我们向使用位置占位符的语句传递了一个关联数组(或向使用命名占位符的语句传递了一个列表),这将被视为错误,并且将抛出异常(前提是之前在调用PDO::setAttribute()方法中启用了异常)。

关于占位符的使用有一件重要的事情需要注意。它们不能作为您传递给数据库的值的一部分。这最好通过一个无效使用示例来演示:

$stmt = $conn->prepare("SELECT * FROM authors WHERE lastName
LIKE '%?%'");
$stmt->execute(array($_GET['name']));

这必须重写为:

$stmt = $conn->prepare("SELECT * FROM authors WHERE lastName
LIKE ?");
$stmt->execute(array('%' . $_GET['name'] . '%'));

这里的想法是,不要将占位符放在 SQL 模板中的字符串中——这必须在调用PDOStatement::execute()方法中完成。

准备语句和绑定值

上面的示例使用了所谓的未绑定语句。这意味着我们在传递给PDOStatement::execute()方法的数组中提供了查询的值。PDO 还支持绑定语句,其中您可以将立即值或变量显式绑定到命名或位置占位符。

要将立即值绑定到语句,使用PDOStatement::bindValue()方法。此方法接受占位符标识符和一个值。占位符标识符是查询中位置占位符的问号的基于 1 的索引,或命名占位符的名称。例如,我们可以将使用位置占位符的示例重写为以下方式使用绑定值:

$stmt = $conn->prepare(
'INSERT INTO authors(firstName, lastName, bio) VALUES(?, ?, ?)');
foreach($authors as $author)
{
$stmt->bindValue(1, $author['firstName']);
$stmt->bindValue(2, $author['lastName']);
$stmt->bindValue(3, $author['bio']);
$stmt->execute();
}

如果您喜欢使用命名占位符,可以编写:

$stmt = $conn->prepare(
'INSERT INTO authors(firstName, lastName, bio) ' .
'VALUES(:last, :first, :bio)');
foreach($authors as $author)
{
$stmt->bindValue(':first', $author['firstName']);
$stmt->bindValue(':last', $author['lastName']);
$stmt->bindValue(':bio', $author['bio']);
$stmt->execute();
}

如您所见,在这两种情况下,我们在调用PDOStatement::execute()时不提供任何内容。同样,与未绑定语句一样,如果您没有为每个占位符绑定值,调用PDOStatement::execute()将失败,导致异常。

PDO 也可以将结果集列绑定到 PHP 变量以用于 SELECT 查询。这些变量将在每次调用PDOStatement::fetch()时被相应列的值修改。这是在第二章中讨论的将结果集行作为数组或对象获取的替代方法。考虑以下示例:

$stmt = $conn->prepare('SELECT firstName, lastName FROM authors');
$stmt->execute();
$stmt->bindColumn(1, $first);
$stmt->bindColumn(2, $last);
while($stmt->fetch(PDO::FETCH_BOUND))
{
echo "$last, $first <br>";
}

这将呈现表中的所有作者。变量在调用PDOStatement::bindColumn()方法时绑定,该方法期望第一个参数是结果集中的列的基于 1 的索引或从数据库返回的列名,第二个参数是要更新的变量。

请注意,当使用绑定列时,应使用PDO::FETCH_BOUND模式调用PDOStatement::fetch()方法,或者应该在调用PDOStatement::setFetchMode(PDO::FETCH_BOUND)之前进行预设。此外,必须在调用PDOStatement::execute()方法之后调用PDOStatement::bindColumn()方法,以便 PDO 知道结果集中有多少列。

现在让我们回到我们的图书馆应用程序,并增强它以使用一些预处理语句。由于仅依赖用户提供的值的页面是添加/编辑书籍添加/编辑作者,我们将重写两个相应的脚本,editBook.phpeditAuthor.php

当然,我们只会重写更新数据库的代码部分。对于editBook.php,这些是第 65 到 102 行。我将在这里为您方便起见呈现这些行:

if(@$book['id']) {
$sql = "UPDATE books SET title=" . $conn->quote($_POST['title']) .
', author=' . $conn->quote($_POST['author']) .
', isbn=' . $conn->quote($_POST['isbn']) .
', publisher=' . $conn->quote($_POST['publisher']) .
', year=' . $conn->quote($_POST['year']) .
', summary=' . $conn->quote($_POST['summary']) .
" WHERE id=$book[id]";
}
else {
$sql = "INSERT INTO books(title, author, isbn, publisher, year,
summary) VALUES(" . $conn->quote($_POST['title']) .
', ' . $conn->quote($_POST['author']) .
', ' . $conn->quote($_POST['isbn']) .
', ' . $conn->quote($_POST['publisher']) .
', ' . $conn->quote($_POST['year']) .
', ' . $conn->quote($_POST['summary']) .
')';
}
// Now we are updating the DB.
// We wrap this into a try/catch block
// as an exception can get thrown if
// the ISBN is already in the table.
try
{
$conn->query($sql);
// If we are here, then there is no error.
// We can return back to books listing
header("Location: books.php");
exit;
}
catch(PDOException $e)
{
$warnings[] = 'Duplicate ISBN entered. Please correct';
}

正如我们所看到的,构造查询的部分非常长。使用预处理语句,可以将此代码片段重写如下:

if(@$book['id']) {
$sql = "UPDATE books SET title=?, author=?, isbn=?, publisher=?
year=?, summary=? WHERE id=$book[id]";
}
else {
$sql = "INSERT INTO books(title, author, isbn, publisher, year,
summary) VALUES(?, ?, ?, ?, ?, ?)";
}
$stmt = $conn->prepare($sql);
// Now we are updating the DB.
// We wrap this into a try/catch block
// as an exception can get thrown if
// the ISBN is already in the table.
try
{
$stmt->execute(array($_POST['title'], $_POST['author'],
$_POST['isbn'], $_POST['publisher'], $_POST['year'],
$_POST['summary']));
// If we are here, then there is no error.
// We can return back to books listing.
header("Location: books.php");
exit;
}
catch(PDOException $e)
{
$warnings[] = 'Duplicate ISBN entered. Please correct';
}

我们遵循相同的逻辑 - 如果我们正在编辑现有书籍,我们构建一个UPDATE查询。如果我们要添加新书,那么我们必须使用INSERT查询。$sql变量将保存适当的语句模板。在这两种情况下,语句都有六个位置占位符,我故意将书籍 ID 硬编码到UPDATE查询中,以便我们可以创建并执行语句,而不管所需的操作是什么。

在我们实例化语句之后,我们将其execute()方法的调用包装在try…catch块中,因为如果 ISBN 已经存在于数据库中,可能会抛出异常。在语句成功执行后,我们将浏览器重定向到书籍列表页面。如果调用失败,我们会用一个提示通知用户 ISBN 不正确(或者书籍已经存在于数据库中)。

您可以看到我们的代码现在要短得多。此外,我们不需要引用值,因为准备好的语句已经为我们做了这个。现在您可以稍微玩弄一下,并在common.inc.php中将数据库更改为 MySQL 和 SQLite,以查看准备好的语句是否适用于它们两个。您可能还想重写此代码,以使用命名占位符而不是位置占位符。如果这样做,请记住在传递给PDOStatement::execute()方法的数组中提供占位符名称。

现在让我们看看editAuthor.php中的相应代码块(第 42 至 59 行):

if(@$author['id']) {
$sql = "UPDATE authors SET firstName=" .
$conn->quote($_POST['firstName']) .
', lastName=' . $conn->quote($_POST['lastName']) .
', bio=' . $conn->quote($_POST['bio']) .
" WHERE id=$author[id]";
}
else {
$sql = "INSERT INTO authors(firstName, lastName, bio) VALUES(" .
$conn->quote($_POST['firstName']) .
', ' . $conn->quote($_POST['lastName']) .
', ' . $conn->quote($_POST['bio']) .
')';
}
$conn->query($sql);
header("Location: authors.php");
exit;

由于我们不希望在这里出现异常,所以代码更短。现在让我们重写它以使用准备好的语句:

if(@$author['id']) {
$sql = "UPDATE authors SET firstName=?, lastName=?, bio=?
WHERE id=$author[id]";
}
else {
$sql = "INSERT INTO authors(firstName, lastName, bio)
VALUES(?, ?, ?)";
}
$stmt = $conn->prepare($sql);
$stmt->execute(array($_POST['firstName'], $_POST['lastName'],
$_POST['bio']));
header("Location: authors.php");
exit;

再次取决于所需的操作,我们创建 SQL 模板并将其分配给$sql变量。然后我们实例化PDOStatement对象,并使用作者的详细信息调用其execute方法。由于我们的查询不应该失败(除非出现意外的数据库故障),我们不希望在这里出现异常,并重定向到作者列表页面。

确保您使用 MySQL 和 SQLite 测试此代码。

使用 BLOBs

现在让我们扩展我们的应用程序,以便我们可以上传书籍的封面图片并显示它们。与传统的数据库访问一样,我们将在书籍表中使用BLOB 字段,以及一个varchar 字段来存储图像的 MIME 类型,我们需要将其与图像数据一起提供给浏览器。此外,我们还需要另一个脚本,它将从表中获取图像数据并将其传递给浏览器。(我们将从<img>标签中引用此脚本。)

传统上,我们不会在对mysql_query()sqlite_query()的调用中插入 BLOB 列 - 我们只需确保它们被正确引用。但是,使用 PDO,情况就不同了。PDO 通过流和准备好的语句处理 BLOB 列。

让我们看看以下示例:

$blob = fopen('/path/to/file.jpg', 'rb');
$stmt = $conn->prepare("INSERT INTO images(data) VALUES(?)");
$stmt->bindParam(1, $blob, PDO::PARAM_LOB);
$stmt->execute();

正如您所看到的,我们使用fopen()函数以二进制模式打开要插入的文件(这样我们就不会在不同平台上遇到换行符的问题),然后在调用PDOStatement::bindParam()方法时将文件句柄绑定到语句,并指定PDO::PARAM_LOB标志(以便 PDO 了解我们绑定的是文件句柄而不是立即值)。

在对PDOStatement::execute()方法的调用中,PDO 将从文件中读取数据并将其传递给数据库。

注意

如果您想知道为什么 PDO 以这种方式工作,简短的解释是,如果您的 BLOB 非常大,查询可能会失败。通常数据库服务器有一个限制通信数据包大小的设置。(您可以将其与post_max_sizePHP 设置进行比较)。如果您在 SQL INSERTUPDATE语句中传递相对较大的字符串,它可能会超过数据包大小,导致查询失败。使用流,PDO 确保数据以较小的数据包发送,以便查询成功执行。

BLOBs 也应该用流来读取。因此,要检索上面示例中插入的 BLOB 列,可以使用以下代码:

$id = (int)$_GET['id'];
$stmt = $db->prepare("SELECT data FROM images WHERE id=$id");
$stmt->execute();
$stmt->bindColumn(1, $blob, PDO::PARAM_LOB);
$stmt->fetch(PDO::FETCH_BOUND);
$data = stream_get_contents($blob);

在这种情况下,$blob变量将是一个可以使用流处理函数读取的流资源。在这里,我们使用了stream_get_contents()函数将所有数据读入$data变量中。如果我们想直接将数据返回给浏览器(就像我们在应用程序中将要做的那样),我们可以使用fpassthru()函数。

截至目前(PHP 版本 5.2.3),返回的 blob 列不是流,而是列中包含的实际数据(字符串)。有关详细信息,请参阅 PHP bug#40913 bugs.php.net/bug.php?id=40913。因此,上述代码片段中的最后一行是不需要的,$blob变量将保存实际数据。下面 showCover.php 文件的源代码将返回的数据视为字符串而不是 blob,因此代码可以在当前 PHP 版本中运行。

所以,让我们开始修改我们的数据库,并向其中添加新的列:

mysql> alter table books add column coverMime varchar(20);
Query OK, 3 rows affected (0.02 sec)
Records: 3 Duplicates: 0 Warnings: 0
mysql> alter table books add column coverImage blob(24000);
Query OK, 3 rows affected (0.02 sec)
Records: 3 Duplicates: 0 Warnings: 0

您还可以在 SQLite 命令行客户端中执行这些查询,无需修改。现在,让我们修改editBook.php文件。我们将在现有表单中添加另一个字段。这行将允许用户上传封面图片,并增强表单验证以检查用户是否真的上传了一张图片(通过检查上传文件的 MIME 类型)。

我们还将允许用户在不重新提交封面图片文件的情况下修改书籍的详细信息。为此,我们将仅在成功上传文件时更新封面列。因此,我们的脚本逻辑将使用两个查询。第一个将更新或创建书籍记录,第二个将更新coverMimecoverImage列。

考虑到这一点,editBook.php文件将如下所示:

<?php
/**
* This page allows adding or editing a book
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// See if we have the book ID passed in the request
$id = (int)$_REQUEST['book'];
if($id) {
// we have the ID, get the book details from the table
$q = $conn->query("SELECT * FROM books WHERE id=$id");
$book = $q->fetch(PDO::FETCH_ASSOC);
$q->closeCursor();
$q = null;
}
else {
// we are creating a new book
$book = array();
}
// Now get the list of all authors' first and last names
// we will need it to create the dropdown box for author
$authors = array();
$q = $conn->query("SELECT id, lastName, firstName FROM authors ORDER
BY lastName, firstName");
$q->setFetchMode(PDO::FETCH_ASSOC);
while($a = $q->fetch())
{
$authors[$a['id']] = "$a[lastName], $a[firstName]";
}
// Now see if the form was submitted
if($_POST['submit']) {
// Validate every field
$warnings = array();
// Title should be non-empty
if(!$_POST['title']) {
$warnings[] = 'Please enter book title';
}
// Author should be a key in the $authors array
if(!array_key_exists($_POST['author'], $authors)) {
$warnings[] = 'Please select author for the book';
}
// ISBN should be a 10-digit number
if(!preg_match('~^\d{10}$~', $_POST['isbn'])) {
$warnings[] = 'ISBN should be 10 digits';
}
// Published should be non-empty
if(!$_POST['publisher']) {
$warnings[] = 'Please enter publisher';
}
// Year should be 4 digits
if(!preg_match('~^\d{4}$~', $_POST['year'])) {
$warnings[] = 'Year should be 4 digits';
}
// Summary should be non-empty
if(!$_POST['summary']) {
$warnings[] = 'Please enter summary';
}
**// Now validate the file upload
$uploadSuccess = false;
if(is_uploaded_file($_FILES['cover']['tmp_name'])) {
// See if the file is an image
if(!preg_match('~image/.+~', $_FILES['cover']['type'])
|| filesize($_FILES['cover']['tmp_name']) > 24000) {
$warnings[] = 'Please upload an image file less than 24K
in size';
}
else {
// Set a flag that upload is successful
$uploadSuccess = true;
}
}**
// If there are no errors, we can update the database
// If there was book ID passed, update that book
if(count($warnings) == 0) {
if(@$book['id']) {
$sql = "UPDATE books SET title=?, author=?, isbn=?,
publisher=?, year=?, summary=? WHERE
id=$book[id]";
}
else {
$sql = "INSERT INTO books(title, author, isbn, publisher,
year, summary) VALUES(?, ?, ?, ?, ?, ?)";
}
$stmt = $conn->prepare($sql);
// Now we are updating the DB.
// we wrap this into a try/catch block
// as an exception can get thrown if
// the ISBN is already in the table
try
{
$stmt->execute(array($_POST['title'], $_POST['author'],
$_POST['isbn'], $_POST['publisher'], $_POST['year'],
$_POST['summary']));
// If we are here that means that no error
**// Now we can update the cover columns
// But first we have to get the ID of the newly inserted book
if(!@$book['id']) {
$book['id'] = $conn->lastInsertId();
}
// Now see if there was an successful upload and
// update cover image
if($uploadSuccess) {
$stmt = $conn->prepare("UPDATE books SET coverMime=?,
coverImage=? WHERE id=$book[id]");
$cover = fopen($_FILES['cover']['tmp_name'], 'rb');
$stmt->bindValue(1, $_FILES['cover']['type']);
$stmt->bindParam(2, $cover, PDO::PARAM_LOB);
$stmt->execute();
}**
// We can return back to books listing
header("Location: books.php");
exit;
}
catch(PDOException $e)
{
$warnings[] = 'Duplicate ISBN entered. Please correct';
}
}
}
else {
// Form was not submitted.
// populate the $_POST array with the book's details
$_POST = $book;
}
// Display the header
showHeader('Edit Book');
// If we have any warnings, display them now
if(count($warnings)) {
echo "<b>Please correct these errors:</b><br>";
foreach($warnings as $w)
{
echo "- ", htmlspecialchars($w), "<br>";
}
}
// Now display the form
?>
**<form action="editBook.php" method="post"
enctype="multipart/form-data">**
<table border="1" cellpadding="3">
<tr>
<td>Title</td>
<td>
<input type="text" name="title"
value="<?=htmlspecialchars($_POST['title'])?>">
</td>
</tr>
<tr>
<td>Author</td>
<td>
<select name="author">
<option value="">Please select...</option>
<?php foreach($authors as $id=>$author)
{ ?>
<option value="<?=$id?>"
<?= $id == $_POST['author'] ? 'selected' : ''?>>
<?=htmlspecialchars($author)?>
</option>
<?php } ?>
</select>
</td>
</tr>
<tr>
<td>ISBN</td>
<td>
<input type="text" name="isbn"
value="<?=htmlspecialchars($_POST['isbn'])?>">
</td>
</tr>
<tr>
<td>Publisher</td>
<td>
<input type="text" name="publisher"
value="<?=htmlspecialchars($_POST['publisher'])?>">
</td>
</tr>
<tr>
<td>Year</td>
<td>
<input type="text" name="year"
value="<?=htmlspecialchars($_POST['year'])?>">
</td>
</tr>
<tr>
<td>Summary</td>
<td>
<textareaname="summary"><?=htmlspecialchars($_POST['summary'])?>
</textarea>
</td>
</tr>
**<tr>
<td>Cover Image</td>
<td><input type="file" name="cover"></td>
</tr>
<?php if(@$book['coverMime'])
{ ?>
<tr>
<td>Current Cover</td>
<td><img src="showCover.php?book=<?=$book['id']?>"></td>
</tr>
<? } ?>**
<tr>
<td colspan="2" align="center">
<input type="submit" name="submit" value="Save">
</td>
</tr>
</table>
<?php if(@$book['id']) { ?>
<input type="hidden" name="book" value="<?=$book['id']?>">
<?php } ?>
</form>
<?php
// Display footer
showFooter();

突出显示的部分是我们添加或更改的部分。现在,我们需要验证我们的表单和上传的文件(第 60 到 73 行)。如果上传成功,$uploadSuccess布尔变量将设置为true,我们稍后将使用这个值来查看是否需要更新封面列。由于我们也允许新书进行上传,我们使用PDO::lastInsertId()方法值(在第 100 行)来获取新创建书籍的 ID(否则我们只使用$books['id']值)。如果上传失败,我们将向$warnings数组添加相应的警告,并让现有的错误逻辑执行其工作。

实际的封面图片更新发生在 105 到 110 行,使用了准备好的语句和流。在我们的表单中,看到我们如何在第 140 行的表单标签上添加了multipart/form-data属性。这是文件上传所必需的。此外,表单现在有一个新的输入字段(第 182-185 行),允许我们选择并上传文件。接下来的行将显示当前的封面图片(如果有的话)。请注意,<img>标签引用了一个新文件showCover.php,我们现在需要创建它:

<?php
/**
* This script will render a book's cover image
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// See if we have the book ID passed in the request
$id = (int)$_REQUEST['book'];
$stmt = $conn->prepare("SELECT coverMime, coverImage FROM books
WHERE id=$id");
$stmt->execute();
$stmt->bindColumn(1, $mime);
$stmt->bindColumn(2, $image, PDO::PARAM_LOB);
$stmt->fetch(PDO::FETCH_BOUND);
header("Content-Type: $mime");
echo $image;

现在,对于一本新书,表单看起来像这样:

使用 BLOBs

如您所见,有一个新字段允许我们上传封面图片。由于新创建的书没有任何封面图片,因此没有当前的封面图片。对于有封面图片的书,页面将如下所示:

使用 BLOBs

您现在可以使用应用程序来查看表单在不上传图片的情况下的工作方式。(如果有的话,它应该保留旧图片。)您还可以看到它如何处理过大或非图片文件。(它应该在表单上方显示警告。)确保在不同数据库之间切换,以便我们是数据库无关的。

作为封面图片的最后一步,我们可以重新格式化书籍列表页面books.php,以便在那里也显示封面图片。我将在这里呈现新代码,并突出显示更改的部分:

<?php
/**
* This page lists all the books we have
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Display the header
showHeader('Books');
// Issue the query
$q = $conn->query("SELECT authors.id AS authorId, firstName,
lastName, books.* FROM authors, books WHERE
author=authors.id ORDER BY title");
$q->setFetchMode(PDO::FETCH_ASSOC);
// now create the table
?>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Cover</td>
<td>Author and Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
<td>Edit</td>
</tr>
<?php
// Now iterate over every row and display it
while($r = $q->fetch())
{
?>
**<tr>
<td>
<?php if($r['coverMime']) { ?>
<img src="showCover.php?book=<?=$r['id']?>">
<?php }
else
{ ?>
n/a
<? } ?>
</td>
<td>
<a href="author.php?id=<?=$r['authorId']?>">
<?=htmlspecialchars("$r[firstName] $r[lastName]")?></a><br/>
<b><?=htmlspecialchars($r['title'])?></b>
</td>**
<td><?=htmlspecialchars($r['isbn'])?></td>
<td><?=htmlspecialchars($r['publisher'])?></td>
<td><?=htmlspecialchars($r['year'])?></td>
<td><?=htmlspecialchars($r['summary'])?></td>
<td>
<a href="editBook.php?book=<?=$r['id']?>">Edit</a>
</td>
</tr>
<?php
}
?>
</table>
<a href="editBook.php">Add book...</a>
<?php
// Display footer
showFooter();

第一个单元格将包含图片(如果有的话)。现在作者和标题都在同一个单元格中呈现,以节省表格宽度。现在图书列表应该看起来像这样:

使用 BLOBs

摘要

本章向我们介绍了一个新概念:准备语句。我们已经看到它们如何简化我们的查询,并进一步保护我们免受 SQL 语法错误和代码漏洞的影响。我们还看了如何使用流处理 BLOBs,以便我们不会出现查询失败的风险。我们的应用现在可以用于上传和显示数据库中书籍的封面图片。

在下一章中,我们将看到如何确定结果集中的行数,这对于对长列表进行分页是必要的。(最常见的例子是搜索引擎将结果列表分成每页 10 个结果。)此外,我们将熟悉一个新概念:可滚动的游标,它将允许我们从指定位置开始获取结果集的子集行。

第五章:处理行集

现实生活中的动态数据驱动的 Web 应用程序彼此非常不同,因为它们的复杂性由它们服务的目的决定。然而,几乎所有这些应用程序都具有一些共同的特征。其中之一是对长结果列表进行分页以方便使用和更快的页面加载时间。

正确的分页需要计算从数据库返回的总行数、页面大小(可配置选项)和当前页面的数量。根据这些数据,很容易计算结果集的起始偏移量,以仅显示一部分行。

在本章中,我们将研究:

  • 如何检索 PDO 返回的结果集中的行数

  • 如何从指定的行号开始获取结果

检索结果集中的行数

正如我们在第二章中已经讨论的,PDOStatement::rowCount()方法不会返回查询中的正确行数。(对于 MySQL 和 SQLite 都返回零。)这种行为的原因是数据库管理系统实际上直到返回查询的最后一行才知道这个数字。mysql_num_rows()函数(以及其他数据库的类似函数)返回行数的原因是,当您发出查询时,它会将整个结果集预加载到内存中。

虽然这种行为可能看起来很方便,但并不推荐。如果查询返回 20 行,那么脚本可以承受内存使用。但是如果查询返回数十万行呢?它们都将保留在内存中,因此在高流量站点上,服务器可能会耗尽资源。

唯一的逻辑措施(也是 PDO 可用的唯一选项)是指示数据库自己计算行数。无论查询有多复杂,都可以重写以使用 SQL 的COUNT()函数,仅返回满足主查询的行数。

让我们看一下我们应用程序中使用的查询。(我们只会检查返回多行的查询。)

  • books.php中,我们有一个查询,它连接两个表以呈现书籍列表以及它们的作者:
SELECT authors.id AS authorId, firstName, lastName, books.*
FROM authors, books WHERE author=authors.id ORDER BY title;

要获取此查询返回的行数,我们应该将其重写为以下内容:

SELECT COUNT(*) FROM authors, books WHERE author=authors.id;

请注意,这里不需要ORDER BY子句,因为顺序对行数并不重要。

  • authors.php中,我们只是按照他们的姓和名的顺序选择所有作者:
SELECT * FROM authors ORDER BY lastName, firstName;

这简单地重写为以下内容:

SELECT COUNT(*) FROM authors;

  • 另一个返回多行的查询在author.php中——它检索特定作者撰写的所有书籍:
SELECT * FROM books WHERE author=$id ORDER BY title;

这翻译为以下内容:

SELECT COUNT(*) FROM books WHERE author=$id;

正如您所看到的,我们以类似的方式重写了所有这些查询——通过用COUNT(*)替换列的列表并修剪ORDER BY子句。有了这个想法,我们可以创建一个函数,它将接受一个包含要执行的 SQL 的字符串,并返回查询将返回的行数。这个函数将必须执行这些简单的转换:

  • 在传递的字符串中,用COUNT(*)替换SELECTFROM之间的所有内容。

  • 删除ORDER BY及其后的所有文本。

实现这种转换的最佳方法是使用正则表达式。与前几章一样,我们将使用 PCRE 扩展。我们将把该函数放入common.inc.php中,因为我们将从各个地方调用它:

/**
* This function will return the number of rows a query will return
* @param string $sql the SQL query
* @return int the number of rows the query specified will return
* @throws PDOException if the query cannot be executed
*/
function getRowCount($sql)
{
global $conn;
$sql = trim($sql);
$sql = preg_replace('~^SELECT\s.*\sFROM~s', 'SELECT COUNT(*) FROM',
$sql);
$sql = preg_replace('~ORDER\s+BY.*?$~sD', '', $sql);
$stmt = $conn->query($sql);
$r = $stmt->fetchColumn(0);
$stmt->closeCursor();
return $r;
}

让我们运行一下这个函数,看看它做了什么:

  1. 它将 PDO 连接对象($conn)导入到本地函数范围内。

  2. 它修剪了 SQL 查询开头和结尾的可能空格。

  3. 两次对preg_replace()的调用完成了转换查询的主要任务。

注意我们如何使用模式修饰符——s修饰符指示 PCRE 用点匹配换行符,D修饰符强制$匹配整个字符串的结尾(不仅仅是在第一个换行符之前)。我们使用这些修饰符来确保函数能够正确处理多行查询。

我们现在将修改这三个脚本,以显示它们返回的每个表中的行数。让我们从books.php开始:

<?php
/**
* This page lists all the books we have
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Display the header
showHeader('Books');
**// Get the count of books and issue the query
$sql = "SELECT authors.id AS authorId, firstName, lastName, books.*
FROM authors, books WHERE author=authors.id ORDER BY title";
$totalBooks = getRowCount($sql);
$q = $conn->query($sql);**
$q->setFetchMode(PDO::FETCH_ASSOC);
// now create the table
?>
**Total books: <?=$totalBooks?>**
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Cover</td>
<td>Author and Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
<td>Edit</td>
</tr>
<?php
// Now iterate over every row and display it
while($r = $q->fetch())
{
?>
<tr>
<td>
<?php if($r['coverMime']) { ?>
<img src="showCover.php?book=<?=$r['id']?>">
<?php } else { ?>
n/a
<? } ?>
</td>
<td>
<a href="author.php?id=<?=$r['authorId']?>"><?=htmlspecialchars
("$r[firstName] $r[lastName]")?></a><br/>
<b><?=htmlspecialchars($r['title'])?></b>
</td>
<td><?=htmlspecialchars($r['isbn'])?></td>
<td><?=htmlspecialchars($r['publisher'])?></td>
<td><?=htmlspecialchars($r['year'])?></td>
<td><?=htmlspecialchars($r['summary'])?></td>
<td>
<a href="editBook.php?book=<?=$r['id']?>">Edit</a>
</td>
</tr>
<?php
}
?>
</table>
<a href="editBook.php">Add book...</a>
<?php
// Display footer
showFooter();

正如你所看到的,修改非常简单——我们使用$sql变量来保存查询,并将其传递给getRowCount()函数和$conn->query()方法。我们还在表格上方显示一条消息,告诉我们数据库中有多少本书。

现在,如果你刷新books.php页面,你会看到以下内容:

检索结果集中的行数

authors.php的更改类似:

<?php
/**
* This page lists all the authors we have
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Display the header
showHeader('Authors');
**// Get the number of authors and issue the query
$sql = "SELECT * FROM authors ORDER BY lastName, firstName";
$totalAuthors = getRowCount($sql);**
$q = $conn->query($sql);
// now create the table
?>
**Total authors: <?=$totalAuthors?>**
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>First Name</td>
<td>Last Name</td>
<td>Bio</td>
<td>Edit</td>
</tr>
<?php
// Now iterate over every row and display it
while($r = $q->fetch(PDO::FETCH_ASSOC))
{
?>
<tr>
<td><?=htmlspecialchars($r['firstName'])?></td>
<td><?=htmlspecialchars($r['lastName'])?></td>
<td><?=htmlspecialchars($r['bio'])?></td>
<td>
<a href="editAuthor.php?author=<?=$r['id']?>">Edit</a>
</td>
</tr>
<?php
}
?>
</table>
<a href="editAuthor.php">Add Author...</a>
<?php
// Display footer
showFooter();

authors.php现在应该显示以下内容:

检索结果集中的行数

最后,author.php将如下所示:

<?php
/**
* This page shows an author's profile
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Get the author
$id = (int)$_REQUEST['id'];
$q = $conn->query("SELECT * FROM authors WHERE id=$id");
$author = $q->fetch(PDO::FETCH_ASSOC);
$q->closeCursor();
$q = null;
// Now see if the author is valid - if it's not,
// we have an invalid ID
if(!$author) {
showHeader('Error');
echo "Invalid Author ID supplied";
showFooter();
exit;
}
// Display the header - we have no error
showHeader("Author: $author[firstName] $author[lastName]");
**// Now get the number and fetch all the books
$sql = "SELECT * FROM books WHERE author=$id ORDER BY title";
$totalBooks = getRowCount($sql);
$q = $conn->query($sql);
$q->setFetchMode(PDO::FETCH_ASSOC);**
// now display everything
?>
<h2>Author</h2>
<table width="60%" border="1" cellpadding="3">
<tr>
<td><b>First Name</b></td>
<td><?=htmlspecialchars($author['firstName'])?></td>
</tr>
<tr>
<td><b>Last Name</b></td>
<td><?=htmlspecialchars($author['lastName'])?></td>
</tr>
<tr>
<td><b>Bio</b></td>
<td><?=htmlspecialchars($author['bio'])?></td>
</tr>
**<tr>
<td><b>Total books</td>
<td><?=$totalBooks?></td>
</tr>**
</table>
<a href="editAuthor.php?author=<?=$author['id']?>">Edit author...</a>
<h2>Books</h2>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
</tr>
<?php
// Now iterate over every book and display it
while($r = $q->fetch()) {
?>
<tr>
<td><?=htmlspecialchars($r['title'])?></td>
<td><?=htmlspecialchars($r['isbn'])?></td>
<td><?=htmlspecialchars($r['publisher'])?></td>
<td><?=htmlspecialchars($r['year'])?></td>
<td><?=htmlspecialchars($r['summary'])?></td>
</tr>
<?php
}
?>
</table>
<?php
// Display footer
showFooter();

输出应该如下所示。(我把页面向下滚动了一点以节省空间):

检索结果集中的行数

你应该在common.inc.php中在 MySQL 和 SQLite 之间切换,以确保两个数据库都能工作。

注意

这种方法可能适用于许多情况,但并不适用于所有查询。一个这样的例子是使用GROUP BY子句的查询。如果你用getRowCount()函数重写这样的查询,你将得到不正确的结果,因为分组将被应用,查询将返回多行。(行数将等于你正在分组的列中不同值的数量。)

限制返回的行数

现在,我们知道如何计算结果集中的行数,让我们看看如何只获取前 N 行。这里我们有两个选项:

  • 我们可以在 SQL 查询中使用特定于数据库的功能。

  • 我们可以自己处理结果集,并在获取所需数量的行后停止。

使用特定于数据库的 SQL

如果你主要使用 MySQL,那么你会熟悉LIMIT x,y子句。例如,如果我们想按姓氏排序获取前五位作者,可以发出以下查询:

SELECT * FROM authors ORDER BY lastName LIMIT 0, 5;

同样的事情也可以用以下查询完成:

SELECT * FROM authors ORDER BY lastName LIMIT 5 OFFSET 0;

第一个查询适用于 MySQL 和 SQLite,而第二个查询也适用于 PostgreSQL。然而,像 Oracle 或 MS SQL Server 这样的数据库不使用这样的语法,所以这些查询对它们来说将失败。

仅处理前 N 行

正如你所看到的,特定于数据库的 SQL 不允许我们以数据库无关的方式解决执行分页的任务。然而,我们可以像对待所有行一样发出查询,而不使用LIMIT....OFFSET子句。在获取每一行后,我们可以增加计数器变量,这样当我们处理了所需数量的行时,我们就可以中断循环。以下代码片段可以实现这一目的:

$q = $conn->query("SELECT * FROM authors ORDER BY lastName,
firstName");
$q->setFetchMode(PDO::FETCH_ASSOC);
$count = 1; **while(($r = $q->fetch()) && $count <= 5)**
{
echo $r['lastName'], '<br>';
$count++;
} **$q->closeCursor();
$q = null;**

注意循环条件——它检查计数器变量是否小于或等于 5。(当然,你可以在那里放任何数字),以及它验证是否还有行要获取,因为重要的是如果没有更多行要获取,我们就中断循环。(例如,如果表只有 3 行,我们想显示其中的 5 行,我们应该在最后一行后中断,而不是在计数器达到 5 后中断。)请注意,使用特定于数据库的 SQL 将为我们处理这样的情况。

另一个重要的事情是调用PDOStatement::closeCursor()(如前一个代码片段中倒数第二行)。有必要告诉数据库我们不需要更多的行。如果我们不这样做,那么在同一个 PDO 对象上发出的后续查询将引发异常,因为数据库管理系统无法在仍在发送上一个查询的行时处理新查询。这就是为什么我们在author.php中必须调用这个方法。

注意

目前(对于 PHP 版本 5.2.1),可能需要将语句对象取消分配为 null(如author.php,第 17 行)。另一方面,至少在 2007 年 4 月 1 日左右发布的一个 CVS 快照根本不需要关闭游标。但是,在完成游标后调用PDOStatement::closeCursor()仍然是一个好习惯。

从任意偏移开始

现在我们知道如何处理指定数量的行,我们可以使用相同的技术来跳过一定数量的行。假设我们想显示第 6 到第 10 位作者(就像我们在每页允许每页 5 位作者时显示第 2 页):

$q = $conn->query("SELECT * FROM authors ORDER BY lastName,
firstName");
$q->setFetchMode(PDO::FETCH_ASSOC);
$count = 1;
while(($r = $q->fetch()) && $count <= 5)
{
$count++;
}
$count = 1;
while(($r = $q->fetch()) && $count <= 5)
{
echo $r['lastName'], '<br>';
$count++;
}
$q->closeCursor();
$q = null;

在这里,第一个循环用于跳过必要的起始行,第二个循环显示请求的行的子集。

注意

这种方法对小表可能效果很好,但性能不佳。您应该始终使用特定于数据库的 SQL 来返回结果行的子集。如果您需要数据库独立性,应该检查底层数据库软件并发出特定于数据库的查询。原因是数据库可以对查询执行某些优化,使用更少的内存,从而在服务器和客户端之间交换的数据量更少。

不幸的是,PDO 没有提供数据库独立的方法来有效地获取结果行的子集,因为 PDO 是连接抽象,而不是数据库抽象工具。如果您需要编写可移植的代码,应该探索 MDB2 等工具。

这种方法可能比使用PDOStatement::fetchAll()方法更复杂。事实上,我们可以将上一个代码重写如下:

$stmt = $conn->query("SELECT * FROM authors ORDER BY lastName,
firstName");
$page = $stmt->fetchAll(PDO::FETCH_ASSOC);
$page = array_slice($page, 5, 5);
foreach($page as $r)
{
echo $r['lastName'], '<br>';
}

尽管这段代码要短得多,但它有一个主要缺点:它指示 PDO 返回表中的所有行,然后取其中的一部分。使用我们的方法,不必要的行将被丢弃,并且循环指示数据库在返回足够的行后停止发送行。但是,在这两种情况下,数据库都必须向我们发送当前页面之前的行。

总结

在本章中,我们已经看到如何处理无缓冲查询并获取结果集的行数。我们还看了一个应用程序,其中无法避免使用特定于数据库的 SQL,因为这将需要一个可能不合适的解决方法。但是,这一章对于开发使用数据库的复杂 Web 应用程序的人应该是有帮助的。

在下一章中,我们将讨论 PDO 的高级功能,包括持久连接和其他特定于驱动程序的选项。我们还将讨论事务并检查PDOPDOStatement类的更多方法。

第六章:PDO 的高级用法

现在我们已经熟悉了 PDO 的基本特性,并用它们来构建了数据驱动的 Web 应用程序,让我们来看一些高级功能。在这一章中,我们将看到如何获取和设置连接属性(比如列名、大小写转换以及底层 PDO 驱动的名称),以及通过指定连接配置文件名或在php.ini文件中的选项来连接数据库。我们还将讨论事务。

我们将修改我们的图书馆应用程序,以在每个页面的页脚显示数据库驱动程序的名称。除了这个简单的改变,我们还将扩展应用程序,以跟踪我们拥有的单本书的副本数量,并跟踪那些借阅了书的人。我们将使用事务来实现这个功能。

设置和获取连接属性

我们在第三章中简要介绍了设置连接属性,当我们看到如何使用异常作为错误报告的手段时。连接属性允许我们控制连接的某些方面,以及查询诸如驱动程序名称和版本之类的东西。

  • 一种方法是在 PDO 构造函数中指定属性名称/值对的数组。

  • 另一种方法是调用PDO::setAttribute()方法,它接受两个参数:

  • 属性的名称

  • 属性的值

在 PDO 中,属性及其值被定义为PDO类中的常量,就像在common.inc.php文件中的以下调用一样:

$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

它包括两个这样的常量——PDO::ATTR_ERRMODEPDO::ERRMODE_EXCEPTION

要获取属性的值,有PDO::getAttribute()方法。它接受一个参数,属性名称,并返回属性的值。例如,下面的代码将打印Exception:

if($conn->getAttribute(PDO::ATTR_ERRMODE) == PDO::ERRMODE_EXCEPTION) {
echo 'Exception';
}

现在,让我们看看 PDO 中有哪些连接属性。

  • PDO::ATTR_CASE。这个属性控制了PDOStatement::fetch()方法返回的列名的大小写。如果获取模式是PDO::FETCH_ASSOCPDO::FETCH_BOTH(当行以包含按名称索引的列的数组返回时),这将非常有用。这个属性可以有以下三个值:PDO::CASE_LOWER, PDO::CASE_NATURALPDO::CASE_UPPER。根据这个值,列名将分别是小写、不改变、或大写,就像下面的代码片段一样:
$conn->setAttribute(PDO::ATTR_CASE, PDO::CASE_UPPER);
$stmt = $conn->query("SELECT * FROM authors LIMIT 1");
$r = $stmt->fetch(PDO::FETCH_ASSOC);
$stmt->closeCursor();
var_dump($r);

将会打印:

array(4)
{
["ID"]=>
string(1) "1"
["FIRSTNAME"]=>
string(4) "Marc"
["LASTNAME"]=>
string(7) "Delisle"
["BIO"]=>
string(54) "Marc Delisle is a member of the MySQL Developers
Guild"
}

默认行为是不改变列名的大小写,即PDO::CASE_NATURAL

  • PDO::ATTR_ORACLE_NULLS: 这个属性,尽管名字是这样,但是对所有数据库都有效,不仅仅是 Oracle。它控制了NULL值和空字符串在 PHP 中的传递。可能的取值有PDO::NULL_NATURAL(表示不进行任何转换),PDO::NULL_EMPTY_STRING(表示空字符串将被替换为 PHP 的 null 值),以及PDO::NULL_TO_STRING(表示 SQL 的 NULL 值在 PHP 中被转换为空字符串)。

你可以看到这个属性是如何工作的,下面是代码:

$conn->setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_TO_STRING);
$stmt = $conn->query("SELECT * FROM books WHERE coverImage IS
NULL LIMIT 1");
$r = $stmt->fetch(PDO::FETCH_ASSOC);
$stmt->closeCursor();
var_dump($r);

将会产生:

array(9)
{
["id"]=>
string(1) "2"
["author"]=>
string(1) "2"
["title"]=>
string(18) "ImageMagick Tricks"
["isbn"]=>
string(10) "1904811868"
["publisher"]=>
string(20) "Packt Publishing Ltd"
["year"]=>
string(4) "2006"
["summary"]=>
string(81) "Unleash the power of ImageMagick
with this fast,friendly tutorial and tips guide"
["coverMime"]=>
string(0) ""
["coverImage"]=>
string(0) ""
}

正如你所看到的,高亮显示的字段被报告为字符串,而不是 NULL(如果我们没有设置PDO::ATTR_ORACLE_NULLS属性的话)。

  • PDO::ATTR_ERRMODE。这个属性设置了连接的错误报告模式。它接受三个值:

  • PDO::ERRMODE_SILENT: 不采取任何行动,错误代码可以通过PDO::errorCode()PDO::errorInfo()方法(或它们在PDOStatement类中的等价物)获得。这是默认值。

  • PDO::ERRMODE_WARNING: 与以前一样,不采取任何行动,但会引发一个E_WARNING级别的错误。

  • PDO::ERRMODE_EXCEPTION将设置错误代码(与PDO::ERRMODE_SILENT一样),并且将抛出一个PDOException类的异常。

还有特定于驱动程序的属性,我们在这里不会涉及。有关更多信息,请参阅www.php.net/pdo。但是,有一个值得我们关注的特定于驱动程序的属性:PDO::ATTR_PERSISTENT。您可以使用它来指定 MySQL 驱动程序应该使用持久连接,这样可以获得更好的性能(您可以将其视为mysql_pconnect()函数的对应物)。此属性应该在 PDO 构造函数中设置,而不是通过 PDO::setAttribute()调用:

$conn = new PDO($connStr, $user, $pass,
array(PDO::ATTR_PERSISTENT => true);

上述三个属性是读/写属性,这意味着它们可以被读取和写入。还有只能通过PDO::getAttribute()方法获得的只读属性。这些属性可能返回字符串值(而不是在 PDO 类中定义的常量)。

  • PDO::ATTR_DRIVER_NAME: 这将返回底层数据库驱动程序的名称:
echo $conn->getAttribute(PDO::ATTR_DRIVER_NAME);

这将打印出 MySQL 或 SQLite,具体取决于您使用的驱动程序。

  • PDO::ATTR_CLIENT_VERSION: 这将返回底层数据库客户端库版本的名称。例如,对于 MySQL,这可能是类似于 5.0.37 的东西。

  • PDO::ATTR_SERVER_VERSION: 这将返回您正在连接的数据库服务器的版本。对于 MySQL,这可以是一个字符串,比如"4.1.8-nt"

现在让我们回到我们的应用程序,并修改它以在每个页面的页脚中显示数据库驱动程序。为了实现这一点,我们将修改common.inc.php中的showFooter()函数:

function showFooter()
{
global $conn;
if($conn instanceof PDO) {
$driverName = $conn->getAttribute(PDO::ATTR_DRIVER_NAME);
echo "<br/><br/>";
echo "<small>Connecting using $driverName driver</small>";
}
?>
</body>
</html>
<?php
}

在此函数中,我们从全局命名空间导入了$conn变量。如果此变量是PDO类的对象,那么我们将调用上面讨论的getAttribute()方法。我们必须进行此检查,因为在某些情况下,$conn变量可能未设置。例如,如果PDO构造函数失败并抛出异常,我们将无法调用$conn变量上的任何方法(这将导致致命错误——在非对象上调用成员函数是致命错误)。

由于我们应用程序中的所有页面都调用showFooter()方法函数,这个改变将在所有地方都可见:

设置和获取连接属性设置和获取连接属性

MySQL 缓冲查询

如果您只使用 MySQL 数据库,那么您可能希望使用 MySQL 的 PDO 驱动程序缓冲查询模式。当连接设置为缓冲查询模式时,每个 SELECT 查询的整个结果集都会在返回到应用程序之前预先获取到内存中。这给我们带来了一个好处——我们可以使用PDOStatement::rowCount()方法来检查结果集包含多少行。在第二章中,我们讨论了这个方法,并展示了它对 MySQL 和 SQLite 数据库返回 0 的情况。现在,当 PDO 被指示使用缓冲查询时,这个方法将返回有意义的值。

要强制 PDO 进入 MySQL 缓冲查询模式,您必须指定PDO::MYSQL_ATTR_USE_BUFFERED_QUERY连接属性。考虑以下示例:

$conn = new PDO($connStr, $user, $pass);
$conn->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); **$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, 1);**
$q = $conn->query("SELECT * FROM books");
echo $q->rowCount();

这将打印返回的行数。

请注意,此属性仅适用于 MySQL,并且在数据库之间不可移植。如果您的应用程序只使用 MySQL,应该使用它。此外,请记住,返回大型结果集的缓冲查询在资源方面非常昂贵,应该避免使用。如果要使用缓冲查询,请确保在发出此类昂贵的查询之前禁用它们。可以通过关闭此属性来实现:

$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, 0);

您可以通过调用查询 MySQL 缓冲查询是否当前启用

$conn->getAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY);

我已经为每个截图切换了数据库(并且在第一个截图中,页面向下滚动到底部以节省空间)。

使用连接配置文件和 php.ini 设置连接

当我们讨论连接字符串(或 PDO 的数据源名称)时,我们看到连接字符串以驱动程序名称开头,后跟一个分号。PDO 还支持配置文件 - 包含连接字符串的文件。例如,我们可以在应用程序文件所在的目录中创建一个名为pdo.dsn的文件,并在其中放置连接字符串:

mysql:host=localhost;dbname=pdo
or
sqlite:/www/hosts/localhost/pdo.db

或者,我们可以创建两个文件,mysql.dsnsqlite.dsn,分别包含第一个和第二个连接字符串。

然后在 PDO 构造函数中,我们可以指定配置文件路径或 URL,而不仅仅是连接字符串:

uri:./pdo.dsn

PDO 将读取文件并使用其中指定的连接字符串。使用此方法的优势在于,您不仅可以指定本地文件,还可以指定任何 URL,以便包含远程文件(前提是系统为诸如 HTTP 或 FTP 之类的协议注册了合适的流处理程序)。另一方面,如果文件未受到所有用户的网络访问保护,则可能会向第三方泄露安全信息,因此在使用此方法指定连接字符串时应谨慎。

还有另一种指定连接字符串的方法:在php.ini文件中。例如,您可以在php.ini文件中定义以下指令:

pdo.dsn.mysql= mysql:host=localhost;dbname=pdo
pdo.dsn.sqlite=sqlite:/www/hosts/localhost/pdo.db

然后可以分别将'mysql'或'sqlite'字符串传递给PDO构造函数,而不是整个 mysql 和 sqlite 的连接字符串:

$conn = new PDO('mysql', $user, $pass);
$conn = new PDO('sqlite', $user, $pass);

如您所见,此处的连接字符串应与php.ini文件中的相应选项匹配,带有'pdo.dsn'前缀。

获取可用驱动程序列表

PDO 允许您以编程方式获取所有已安装驱动程序的列表。可以调用PDO::getAvailableDrivers()方法返回一个包含可以使用的数据库驱动程序名称的数组。例如,此代码将打印类似以下内容的内容:

var_dump(PDO::getAvailableDrivers());
array(3)
{
[0]=>
string(5) "mysql"
[1]=>
string(6) "sqlite"
[2]=>
string(7) "sqlite2"
}

此数组中包含的驱动程序名称是连接字符串的前缀。同时,相同的名称作为PDO::ATTR_DRIVER_NAME属性的值返回。

注意

PDO::getAvailableDrivers()方法返回在php.ini文件中注册到 PDO 系统的驱动程序的名称。您可能无法在本地机器上使用所有这些驱动程序 - 例如,如果 MySQL 服务器未运行,则返回的数组中存在 MySQL 项目并不意味着您可以连接到本地 MySQL 服务器,如果某个数据库服务器在本地机器上运行,但其驱动程序未注册到 PDO,则您将无法连接到该数据库服务器。

交易

PDO API 还标准化了事务处理方法。默认情况下,在成功创建 PDO 连接后,它被设置为autocommit模式。这意味着对于每个支持事务的数据库,每个查询都包装在一个隐式事务中。对于那些不支持事务的数据库,每个查询都会按原样执行。

通常,事务处理策略是这样的:

  1. 开始交易。

  2. 将与数据库相关的代码放在try...catch块中。

  3. 与数据库相关的代码(在try块中)应在所有更新完成后提交更改。

  4. catch块应回滚事务。

当然,只有更新数据库的代码和可能破坏数据完整性的代码应该在事务中处理。交易的一个经典例子是资金转移:

  1. 开始交易。

  2. 如果付款人的帐户上有足够的钱:

  • 从付款人的帐户中扣除金额。

  • 向受益人的帐户中添加金额。

  1. 提交交易。

如果在交易中间发生了任何不好的事情,数据库不会得到更新,数据完整性得到保留。此外,通过将帐户余额检查包装到交易中,我们确保并发更新不会破坏数据完整性。

PDO 只提供了三种处理事务的方法:PDO::beginTransaction()用于启动事务,PDO::commit()用于提交自从调用PDO::beginTransaction()以来所做的更改,PDO::rollBack()用于回滚自从启动事务以来的任何更改。

PDO::beginTransaction()方法不接受任何参数,并根据事务启动的成功与否返回一个布尔值。如果调用此方法失败,PDO 将抛出一个异常(例如,如果您已经处于事务中,PDO 会告诉您)。同样,如果没有活动事务,PDO::rollBack()方法将抛出一个异常,如果在调用PDO::beginTransaction()之前调用PDO::commit()方法,也会发生相同的情况。(当然,您的错误处理模式必须设置为PDO::ERRMODE_EXCEPTION才能抛出异常。)

还应该注意,如果您正在使用 PDO 进行任务控制,不应该使用直接查询来控制事务。我们的意思是,您不应该使用诸如BEGIN TRANSATION,COMMITROLLBACK等查询来使用PDO::query()方法。否则,这三种方法的行为将是不一致的。此外,PDO 目前不支持保存点。

现在让我们回到我们的图书馆应用程序。为了看看事务是如何实际工作的,我们将修改它,使其能够跟踪我们有多少本特定书籍的副本,并实现一个函数来跟踪我们借出书籍的人。

这个修改将包括以下更改:

  • 我们将不得不通过向书籍表添加一个新列来修改书籍表,以保留每本书的副本数量。editBook.php页面将需要修改以更改这个值。

  • 我们将创建一个表来跟踪所有借阅者,但为了简化示例,我们不会创建一个借阅者表(就像我们为真实的图书馆应用程序所做的那样)。我们只会将借阅者的姓名与我们借给他们的书籍的书籍 ID 关联起来。

  • 我们将创建一个页面,用于借出书籍。这个页面将要求借阅者的姓名,然后将记录插入到借阅者表中,并减少书籍表中的副本数量。

  • 我们还需要一个页面,用于列出所有借阅者,以及另一个脚本,允许他们归还书籍。这个脚本将从借阅者表中删除一条记录,并增加书籍表中的副本数量。

我们只在同时更新两个表时使用事务(就像上面列表中的最后两点)。

在进行编码之前,我们将修改书籍表:

mysql> alter table books add column copies tinyint not null default 1;
Query OK, 3 rows affected (0.50 sec)
Records: 3 Duplicates: 0 Warnings: 0

对于 SQLite,应该执行相同的命令。

现在,让我们稍微修改books.php,以显示每本书的副本数量,并提供一个链接。以下是需要更改的代码行(第 20 至 58 行):

<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Cover</td>
<td>Author and Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
**<td>Copies</td>
<td>Lend</td>**
<td>Edit</td>
</tr>
<?php
// Now iterate over every row and display it
while($r = $q->fetch())
{
?>
<tr>
<td>
<?php if($r['coverMime']) { ?>
<img src="showCover.php?book=<?=$r['id']?>">
<?php } else { ?>
n/a
<? } ?>
</td>
<td>
<a href="author.php?id=<?=$r['authorId']?>"><?=htmlspecialchars
("$r[firstName] $r[lastName]")?></a><br/>
<b><?=htmlspecialchars($r['title'])?></b>
</td>
<td><?=htmlspecialchars($r['isbn'])?></td>
<td><?=htmlspecialchars($r['publisher'])?></td>
<td><?=htmlspecialchars($r['year'])?></td>
<td><?=htmlspecialchars($r['summary'])?></td>
**<td><?=$r['copies']?></td>
<td>
<a href="lendBook.php?book=<?=$r['id']?>">Lend</a>
</td>**
<td>
<a href="editBook.php?book=<?=$r['id']?>">Edit</a>
</td>
</tr>
<?php
}
?>

现在,对于 MySQL 和 SQLite,您应该看到一个页面,就像以下的屏幕截图一样(我们已经向下滚动并向右滚动,以便它适合页面):

Transactionsdriver listgetting, getAvailableDrivers() method used

现在,让我们创建借阅者表。正如我们之前讨论过的,该表将包含一个 ID 字段,书籍的 ID 字段,借阅者的姓名和一个时间戳列。我们需要在这个表上有一个 ID(主键),以防止可能的数据损坏;例如,如果同一个借阅者两次借同一本书。如果我们只通过姓名和书籍 ID 跟踪借阅者,那么在该表中可能会有重复的记录,而归还一本书可能会删除该表中的多行,这将导致数据损坏:

mysql> create table borrowers(
-> id int primary key not null auto_increment,
-> book int not null,
-> name varchar(40),
-> dt int);
Query OK, 0 rows affected (0.13 sec)

对于 SQLite,语法会有些不同:

sqlite> create table borrowers(
...> id integer primary key,
...> book int not null,
...> name varchar(40),
...> dt int);

借出图书的页面(lendBook.php)可能是最困难的部分。这个页面将包括一个表单,您可以在其中输入借阅者的姓名。提交成功后,脚本将启动事务,检查图书是否至少有一本可用,向借阅者表插入一条记录并减少图书表中的副本列,提交事务,并重定向到books.php页面。

<?php
/**
* This page allows lending a book
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// First see if the request contains the book ID
// Return back to books.php if not
$id = (int)$_REQUEST['book'];
if(!$id) {
header("Location: books.php");
exit;
}
// Now see if the form was submitted
$warnings = array();
if($_POST['submit']) {
// Require that the borrower's name is entered
if(!$_POST['name']) {
$warnings[] = 'Please enter borrower\'s name';
}
else {
// Form is OK, "lend" the book
$conn->beginTransaction();
try
{
$stmt = $conn->query("SELECT copies FROM books WHERE id=$id");
$copies = $stmt->fetchColumn();
$stmt->closeCursor();
if($copies > 0) {
// If we can lend it
$conn->query("UPDATE books SET copies=copies-1
WHEREid=$id");
$stmt = $conn->prepare("INSERT INTO borrowers(book, name, dt)
VALUES(?, ?, ?)");
$stmt->execute(array($id, $_POST['name'], time()));
}
else {
// Else show warning
$warnings[] = 'There are no more copies of this book
available';
}
$conn->commit();
}
catch(PDOException $e)
{
// Something bad happened
// Roll back and rethrow the exception
$conn->rollBack();
throw $e;
}
}
// Now, if we don't have errors,
// redirect back to books.php
if(count($warnings) == 0) {
header("Location: books.php");
exit;
}
// otherwise, the warnings will be displayed
}
// Display the header
showHeader('Lend Book');
// If we have any warnings, display them now
if(count($warnings)) {
echo "<b>Please correct these errors:</b><br>";
foreach($warnings as $w)
{
echo "- ", htmlspecialchars($w), "<br>";
}
}
// Now display the form
?>
<form action="lendBook.php" method="post">
<input type="hidden" name="book" value="<?=$id?>">
<b>Please enter borrower's name:<br></b>
<input type="text" name="name"value="<?=htmlspecialchars
($_POST['name'])?>">
<input type="submit" name="submit" value=" Lend book ">
</form>
<?php
// Display footer
showFooter();

现在让我们来看看代码。我们首先检查图书的 ID 是否通过 URL 或表单传递给脚本。(我们将 ID 保存在表单的隐藏字段中。)然后,如果有表单提交(按下提交按钮),我们检查姓名字段是否填写正确。如果测试成功,我们继续进行事务,在其中计算剩余的副本数量,并检查这个数字是否大于零,我们减少副本列,并使用准备好的语句将一条记录插入到borrowers表中。如果副本少于一本,我们向$warnings数组添加一条消息,以便在页面上显示警告。

如果事务中出现故障,将执行catch块。事务将被回滚,并且异常将再次被抛出。我们这样做是为了让我们的默认错误处理程序发挥作用。

现在,如果您将上面的代码列表保存在lendBook.php中,并点击图书列表页面上的一个借出链接,您应该会到达以下页面:

Transactionsdriver listgetting, getAvailableDrivers() method used

当然,你应该在数据库之间切换,以查看代码是否与 MySQL 和 SQLite 一起工作。

注意

为了增强页面,我们还应该显示图书的标题和作者,但这部分留给你。另外,如果你想知道为什么我们在表单提交后才警告用户没有更多的副本,这是因为我们只能在事务中决定这一点。如果我们在事务中检测到有副本可用,那么我们才能确保没有并发更新会改变这一点。当然,从用户的角度来看,另一个补充可能是在图书详情旁边显示一个警告。然而,事务中也需要进行检查。

现在,如果您借出一本书,您会看到图书列表页面上的副本列已经减少。现在,让我们创建一个页面,列出所有借阅者和借给他们的图书。让我们称之为borrowers.php。虽然这个页面不处理任何用户输入,但它包含一个查询,连接了三个表(借阅者、图书和作者):

<?php
/**
* This page lists all borrowed books
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Display the header
showHeader('Lended Books');
// Get all lended books count and list
$sql = "SELECT borrowers.*, books.title, authors.firstName,
authors.lastName
FROM borrowers, books, authors
WHERE borrowers.book=books.id AND books.author=authors.id
ORDER BY borrowers.dt";
$totalBooks = getRowCount($sql);
$q = $conn->query($sql);
$q->setFetchMode(PDO::FETCH_ASSOC);
// now create the table
?>
Total borrowed books: <?=$totalBooks?>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Title</td>
<td>Author</td>
<td>Borrowed by</td>
<td>Borrowed on</td>
<td>Return</td>
</tr>
<?php
// Now iterate over every row and display it
while($r = $q->fetch())
{
?>
<tr>
<td><?=htmlspecialchars($r['title'])?></td>
<td><?=htmlspecialchars("$r[firstName] $r[lastName]")?></td>
<td><?=htmlspecialchars($r['name'])?></td>
<td><?=date('d M Y', $r['dt'])?></td>
<td>
<a href="returnBook.php?borrower=<?=$r['id']?>">Return</a>
</td>
</tr>
<?php
}
?>
</table>
<?php
// Display footer
showFooter();

代码很容易理解;它遵循与books.phpauthors.php相同的逻辑。但是,由于这个页面没有从任何地方链接过来,我们应该在网站页眉(common.inc.php中的showHeader()函数)中添加一个链接:

function showHeader($title)
{
?>
<html>
<head><title><?=htmlspecialchars($title)?></title></head>
<body>
<h1><?=htmlspecialchars($title)?></h1>
<a href="books.php">Books</a>
<a href="authors.php">Authors</a>
**<a href="borrowers.php">Borrowers</a>**
<hr>
<?php
}

现在,如果您导航到borrowers.php,您应该看到类似于这个屏幕截图的东西:

Transactionsdriver listgetting, getAvailableDrivers() method used

正如我们所看到的,这个页面包含指向returnBook.php页面的链接,但这个页面还不存在。这个脚本将从借阅者表中删除相关记录,并增加图书表中的副本列。这个操作也将被包装在一个事务中。此外,returnBook.php接受借阅者表的 ID 字段(与lendBook.php接受图书的 ID 相反)。因此,我们还应该从借阅者表中获取图书的 ID:

<?php
/**
* This page "returns" a book back to the library
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// First see if the request contains the borrowers ID
// Return back to books.php if not
$id = (int)$_REQUEST['borrower'];
if(!$id) {
header("Location: books.php");
exit;
}
// Now start the transaction
$conn->beginTransaction();
try
{
$q = $conn->query("SELECT book FROM borrowers WHERE id=$id");
$book = (int)$q->fetchColumn();
$q->closeCursor();
$conn->query("DELETE FROM borrowers WHERE id=$id");
$conn->query("UPDATE books SET copies=copies+1 WHERE id=$book");
$conn->commit();
header("Location: books.php");
}
catch(PDOException $e)
{
$conn->rollBack();
throw $e;
}

代码应该是相当自解释的。首先,我们检查请求是否包含借阅者的 ID,然后更新两个表。成功完成后,我们将被重定向到图书列表页面,否则,错误处理程序将显示相关消息。

现在,最后的一步:editBook.php页面,可以用来编辑我们拥有的书籍副本数量。我们将把这个任务留给你,但这里有一些考虑。跟踪已借出的书籍的建议方式对于实际的图书馆应用来说并不是很好。我们应该保留图书馆中总副本数的一个列,以及已借出的副本数的另一个列,而不是保留可用副本数。这样做是因为编辑可用书籍的数量可能会导致数据损坏。归还一本书将增加图书表中的副本列。如果同时有其他人在编辑可用副本数,他们可能不知道借阅者正在归还一本书,因此可能输入一个不正确的数字。

另一方面,如果有两个独立的列,那么更新总副本数将完全独立于借出和归还书籍所引起的更新。然而,在这种情况下,借书的脚本应该检查已借出的副本数是否小于总副本数。只有在满足这个条件的情况下,事务才能继续。

总结

在本章中,我们看了一些 PDO 提供的扩展功能,特别是事务。我们修改了应用示例,提供了依赖事务的额外功能。我们还看了事务感知代码的组织。

然而,正如你可能已经注意到的,我们在一个文件中混合了更新数据库、处理用户输入和呈现页面的代码。虽然我们试图将输入处理和呈现分开放在一个文件的不同部分(首先是数据处理,然后是页面呈现),但我们无法分开数据处理。

在下一章中,我们将看到如何分离数据模型和应用逻辑,以便数据不仅可以从我们的应用程序中访问和操作,还可以从其他地方访问和操作。我们将开发一个数据模型类,封装我们的图书馆应用程序数据处理方法。然后这个类可以被其他应用程序使用。

第七章:一个高级示例

到目前为止,您应该能够使用 PDO 开发 Web 应用程序。但是,当应用程序保持相当小且功能有限时,我们的示例应用程序是可管理的。很快您将意识到,在一个文件中混合所有的数据访问、用户输入和显示逻辑可能会变得难以管理。

为了编写更易管理的代码,并允许多个开发人员共同开发项目,数据访问用户输入处理和页面呈现应该分开。您可能已经听说过广泛用于大型 Web 应用程序的模型-视图-控制器编程范式(MVC)。其思想是将数据访问和修改模块(即模型)与数据呈现(即视图)分开。视图可能非常复杂,因此通常使用模板引擎。最后,控制器是一个接收用户输入、访问模型并准备视图的 PHP 脚本。

除了使代码库更易管理外,这种划分还允许我们从其他应用程序(使用在应用程序自己的服务器上运行的维护脚本或在其他服务器上运行的脚本,通过 RPC 或 SOAP 调用访问)访问模型的功能。

由于 PDO 是面向对象的,并且可以从对PDOStatement::fetch()方法的调用中返回类的实例,因此我们将使用面向对象编程来模拟我们的数据实体(书籍、作者和借书记录)。

设计模型

模型通常由一个静态类组成(其方法被静态调用),以及模拟数据实体的几个类。对该模型类的方法的调用要么返回其他模型类的实例,要么返回PDOStatement实例,后者在调用fetch()方法时返回模型类的实例。

对于我们的应用程序,类将是Model,Book,AuthorBorrower。这些类反映了我们示例数据库中的表,并允许我们对底层数据执行简单的操作。(主要思想是将 SQL 从控制器脚本中隔离到相关的模型类中。)例如,Book类可能有一个方法来返回一个代表该书的作者的Author类实例。另一方面,Author类可能有一个方法来返回一个由该作者撰写的每本书的Book类实例的列表。

在本章中,我们将开发我们自己的静态Model类以及Book,AuthorBorrower类。在开始之前,我们应该清楚地定义每个类将具有的方法(功能)。让我们定义模型的功能。

Model类应包含静态方法,这些方法将充当数据库中存储的数据的入口点。这些方法应该执行以下操作:

  • 获取所有的书籍。

  • 获取所有的作者。

  • 获取所有的书籍借阅者。

  • 获取书籍的数量。

  • 获取作者的数量。

  • 获取书籍借阅者的数量。

  • 按 ID 获取一本书。

  • 按 ID 获取作者。

  • 按 ID 获取借书人。

另一方面,Model类将不包含在书籍或作者上执行的方法。要借出一本书,我们将使用Book类中定义的方法,要归还一本书,我们将使用Borrower类中的方法。

现在让我们计划Book类的方法:

  • 获取作者。

  • 获取书籍的借书人列表。

  • 借出一本书。

对于我们的示例应用程序,Author类甚至更简单:

  • 获取所有的书籍。

  • 获取该作者的书籍数量。

最后,还有代表借书人表中记录的Borrower类:

  • 获取书籍。

  • 返回书籍。

每个数据实体的属性将作为相关类的实例变量可访问。此外,这些类中的方法将包含我们已经在books.php和其他文件中编写的 PDO 调用。我们将这些方法移动到相关的类中,这些文件将只作为处理用户输入的控制器。表单验证仍然是控制器脚本的任务。但是,我们不打算将显示逻辑与业务逻辑分开,因为我们的应用程序非常简单,没有必要使用任何模板引擎,甚至将页面渲染代码移动到单独的include文件中。

除此之外,我们将不再使用全局变量$connModel类将有一个同名的私有静态变量和一个检索连接对象的方法。这个方法将遵循单例模式,并在需要时创建对象,如果尚未创建,则简单地返回它(有关单例模式的更多信息以及在 PHP5 中的示例实现,您可以访问en.wikipedia.org/wiki/Singleton_pattern)。

我们将把所有类都放在一个名为classes.inc.php的单独文件中,然后从common.inc.php中包含它。

让我们从中心的Model类开始:

/**
* This is the central Model class. Use its static methods
* To retrieve a book, author, borrower by ID
* Or all the books, authors and borrowers
*/
class Model
{
/**
* This is the connection object returned by
* Model::getConn()
* @var PDO
*/
private static $conn = null;
/**
* This method returns the connection object.
* If it has not been yet created, this method
* instantiates it based on the $connStr, $user and $pass
* global variables defined in common.inc.php
* @return PDO the connection object
*/
static function getConn()
{
if(!self::$conn) {
global $connStr, $user, $pass;
try
{
self::$conn = new PDO($connStr, $user, $pass);
self::$conn->setAttribute(PDO::ATTR_ERRMODE,
PDO::ERRMODE_EXCEPTION);
}
catch(PDOException $e)
{
showHeader('Error');
showError("Sorry, an error has occurred. Please
try your request later\n" . $e->getMessage());
}
}
return self::$conn;
}
/**
* This method returns the list of all books
* @return PDOStatement
*/
static function getBooks()
{
$sql = "SELECT * FROM books ORDER BY title";
$q = self::getConn()->query($sql);
$q->setFetchMode(PDO::FETCH_CLASS, 'Book', array());
return $q;
}
/**
* This method returns the number of books in the database
* @return int
*/
static function getBookCount()
{
$sql = "SELECT COUNT(*) FROM books";
$q = self::getConn()->query($sql);
$rv = $q->fetchColumn();
$q->closeCursor();
return $rv;
}
/**
*This method returns a book with given ID
* @param int $id
* @return Book
*/
static function getBook($id)
{
$id = (int)$id;
$sql = "SELECT * FROM books WHERE id=$id";
$q = self::getConn()->query($sql);
$rv = $q->fetchObject('Book');
$q->closeCursor();
return $rv;
}
/**
* This method returns the list of all authors
* @return PDOStatement
*/
static function getAuthors()
{
$sql = "SELECT * FROM authors ORDER BY lastName, firstName";
$q = self::getConn()->query($sql);
$q->setFetchMode(PDO::FETCH_CLASS, 'Author', array());
return $q;
}
/**
* This method returns the number of authors in the database
* @return int
*/
static function getAuthorCount()
{
$sql = "SELECT COUNT(*) FROM authors";
$q = self::getConn()->query($sql);
$rv = $q->fetchColumn();
$q->closeCursor();
return $rv;
}
/**
*This method returns an author with given ID
* @param int $id
* @return Author
*/
static function getAuthor($id)
{
$id = (int)$id;
$sql = "SELECT * FROM authors WHERE id=$id";
$q = Model::getConn()->query($sql);
$rv = $q->fetchObject('Author');
$q->closeCursor();
return $rv;
}
/**
* This method returns the list of all borrowers
* @return PDOStatement
*/
static function getBorrowers()
{
$sql = "SELECT * FROM borrowers ORDER BY dt";
$q = self::getConn()->query($sql);
$q->setFetchMode(PDO::FETCH_CLASS, 'Borrower', array());
return $q;
}
/**
* This method returns the number of borrowers in the database
* @return int
*/
static function getBorrowerCount()
{
$sql = "SELECT COUNT(*) FROM borrowers";
$q = self::getConn()->query($sql);
$rv = $q->fetchColumn();
$q->closeCursor();
return $rv;
}
/**
*This method returns a borrower with given ID
* @param int $id
* @return BorrowedBook
*/
static function getBorrower($id)
{
$id = (int)$id;
$sql = "SELECT * FROM borrowers WHERE id=$id";
$q = Model::getConn()->query($sql);
$rv = $q->fetchObject('Borrower');
$q->closeCursor();
return $rv;
}
}

正如您所见,这个类定义了getConn()方法,用于检索 PDO 连接对象,以及另外九个方法——每个数据实体(书籍、作者和借阅者)三个方法。获取所有数据实体的方法(getBooks()getAuthors()getBorrowers())返回一个预配置为获取相关类实例的PDOStatement。返回每个数据实体的数量的方法获取一个整数,而返回单个数据实体的方法获取数据实体模型类的实例。请注意这些方法中如何关闭游标——这个功能已经从控制器文件中转移过来。

现在让我们来看看这三个模型类。

/**
* This class represents a single book
*/
class Book
{
/**
* Return the author object for this book
* @return Author
*/
function getAuthor()
{
return Model::getAuthor($this->author);
}
/**
* This method is used to lend this book to the person
* specified by $name. It returns the Borrower class
* instance in case of success, or null in case when we cannot
* lend this book due to insufficient copies left
* @param string $name
* @return Borrower
*/
function lend($name)
{
$conn = Model::getConn();
$conn->beginTransaction();
try
{
$stmt = $conn->query("SELECT copies FROM books
WHERE id=$this->id");
$copies = $stmt->fetchColumn();
$stmt->closeCursor();
if($copies > 0) {
// If we can lend it
$conn->query("UPDATE books SET copies=copies-1
WHERE id=$this->id");
$stmt = $conn->prepare("INSERT INTO borrowers(book, name, dt)
VALUES(?, ?, ?)");
$stmt->execute(array($this->id, $name, time()));
// Success, get the newly created
// borrower ID
$bid = $conn->lastInsertId();
$rv = Model::getBorrower($bid);
}
else {
$rv = null;
}
$conn->commit();
}
catch(PDOException $e)
{
// Something bad happened
// Roll back and rethrow the exception
$conn->rollBack();
throw $e;
}
return $rv;
}
}

这里我们只有两个方法。一个用于获取书籍的作者(请注意我们在这里重用了Model::getAuthor()方法)。另一个方法提供了借书功能。请注意我们是从数据库中重新读取了副本列的值,而不是依赖于$this->copies变量。正如我们在上一章中看到的,这是为了确保数据完整性。$this->copies变量在事务开始之前就被赋值了,当调用Book::lend()方法时,数据库中的实际副本数量可能已经发生了变化。

这就是为什么我们在事务中再次读取该值。此外,如果操作失败,此方法将返回 null,如果操作成功,将返回Borrower类的实例。如果发生错误,将抛出一个异常,由common.inc.php中定义的异常处理程序处理(就像以前一样)。

另一个model类是Author。它非常简单:

/**
* This class represents a single author
*/
class Author
{
/**
* This method returns the list of books
* written by this author
* @return PDOStatement
*/
function getBooks()
{
$sql = "SELECT * FROM books WHERE author=$this->id
ORDER BY title";
$q = Model::getConn()->query($sql);
$q->setFetchMode(PDO::FETCH_CLASS, 'Book', array());
return $q;
}
/**
* This method returns the number of books
* written by this author
* @return int
*/
function getBookCount()
{
$sql = "SELECT COUNT(*) FROM books WHERE author=$this->id";
$q = Model::getConn()->query($sql);
$rv = $q->fetchColumn();
$q->closeCursor();
return $rv;
}
}

这两个方法只是返回该作者写的书籍列表和此列表中的书籍数量。

最后,Borrower类表示借阅者表中的一条记录:

/**
* This class represents a single borrower
* (i.e., a record in the borrowers table)
*/
class Borrower
{
/**
* Return the book associated with this borrower
* @return Book
*/
function getBook()
{
return Model::getBook($this->book);
}
/**
* This method "returns" a book.
* After this method call, this object
* is unusable as it does not represent
* a data entity any more
*/
function returnBook()
{
$conn = Model::getConn();
$conn->beginTransaction();
try
{
$book = $this->getBook();
$conn->query("DELETE FROM borrowers WHERE id=$this->id");
$conn->query("UPDATE books SET copies=copies+1
WHERE id=$book->id");
$conn->commit();
}
catch(PDOException $e)
{
$conn->rollBack();
throw $e;
}
}
}

实质上,returnBook()方法的主体是从returnBook.php文件中转移过来的(就像Book::lend()方法是从lendBook.php文件中稍作修改后转移过来的一样)。

修改前端以使用模型

现在我们已经从生成前端页面的文件中删除了数据访问逻辑,让我们看看应该如何修改它们。让我们从books.php文件开始:

<?php
/**
* This page lists all the books we have
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Display the header
showHeader('Books');
**// Get the books list
$books = Model::getBooks();**
// now create the table
?>
**Total books: <?=Model::getBookCount()?>**
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Cover</td>
<td>Author and Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
<td>Copies</td>
<td>Lend</td>
<td>Edit</td>
</tr>
<?php
// Now iterate over every row and display it
**while($b = $books->fetch())
{
$a = $b->getAuthor();**
?>
<tr>
<td>
<?php if($b->coverMime) { ?>
**<img src="showCover.php?book=<?=$b->id?>">**
<?php } else { ?>
n/a
<? } ?>
</td>
<td>
**<a href="author.php?id=<?=$a->id?>"><?=htmlspecialchars("$a >firstName $a->lastName")?></a><br/>
<b><?=htmlspecialchars($b->title)?></b>
</td>
<td><?=htmlspecialchars($b->isbn)?></td>
<td><?=htmlspecialchars($b->publisher)?></td>
<td><?=htmlspecialchars($b->year)?></td>
<td><?=htmlspecialchars($b->summary)?></td>
<td><?=$b->copies?></td>
<td>
<a href="lendBook.php?book=<?=$b->id?>">Lend</a>
</td>
<td>
<a href="editBook.php?book=<?=$b->id?>">Edit</a>
</td>**
</tr>
<?php
}
?>
</table>
<a href="editBook.php">Add book...</a>
<?php
// Display footer
showFooter();

如您所见,我们已经删除了 SQL 命令和对 PDO 类实例方法的调用,并用Model类的方法调用替换了它们(请注意突出显示的行)。

另一个重要的变化是,在while循环中返回的Book类的实例(从第 30 行开始)没有作者的名字或姓氏的变量。为了获取这些变量,我们为我们显示的每一本书调用Book::getAuthor()方法。然后,在循环的后面,我们引用$b变量来访问书的属性,或者引用$a变量来访问作者的详细信息。请注意,我们在这里访问这些细节时,是作为对象变量而不是数组元素。

这是因为Model::getBooks()方法不再使用表连接,所以Book类的实例不会包含作者的详细信息。相反,Book类定义了一个方法来获取该书的Author对象。这意味着,对于我们显示的每一本书,我们将执行额外的 SQL 查询来获取作者的详细信息。

乍一看,这可能在性能上显得过于昂贵。但另一方面,在实际应用中,我们可能只会显示表中的一页(比如说,20 本书),而表中可能有数千条记录。在这种情况下,一个在books表上没有JOINSELECT语句,选择要在当前页面显示的行,然后对每一行进行一些简单的查询,可能更有效率。

然而,如果这种方法不合适,那么Model类可以扩展另一个方法,例如Model::getBooksWithAuthors(),它将返回Book类的实例,其中lastNamefirstName变量将存在。这个方法可能看起来像下面这样:

/**
* This method returns the list of all books with
* author's first and last names
* @return PDOStatement
*/
static function getBooksWithAuthors()
{
$sql = "SELECT books.*, authors.lastName, authors.firstName
FROM books, authors
WHERE books.author=authors.id
ORDER BY title";
$q = self::getConn()->query($sql);
$q->setFetchMode(PDO::FETCH_CLASS, 'Book', array());
return $q;
}

开发模型部分可能会在灵活性方面对我们施加限制,但这是为了代码可管理性而付出的代价。然而,这可以通过模型类中的其他方法或者如果真的有必要的话,通过与 PDO 的直接通信来克服。上述方法是可能的,因为 PDO 不关心类中定义了哪些变量;它只是动态地为查询返回的每一列创建变量。

当谨慎使用时,这是一个非常强大的功能。如果不小心使用,可能会导致难以跟踪的逻辑错误。例如,如果在上述方法中从作者表中选择了ID列,那么它的值将覆盖从书表中选择的ID列的值。Book类中的其他方法依赖于id字段中的值是正确的,如果这个值不正确,可能会导致严重的数据损坏。

我们现在应该修改的另一个文件是authors.php:

<?php
/**
* This page lists all the authors we have
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Display the header
showHeader('Authors');
// Get number of authors and issue the query
**$authors = Model::getAuthors();**
// now create the table
?>
**Total authors: <?=Model::getAuthorCount()?>**
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>First Name</td>
<td>Last Name</td>
<td>Bio</td>
<td>Edit</td>
</tr>
<?php
// Now iterate over every row and display it
**while($a = $authors->fetch())
{**
?>
<tr>
**<td><?=htmlspecialchars($a->firstName)?></td>
<td><?=htmlspecialchars($a->lastName)?></td>
<td><?=htmlspecialchars($a->bio)?></td>
<td>
<a href="editAuthor.php?author=<?=$a->id?>">Edit</a>
</td>**
</tr>
<?php
}
?>
</table>
<a href="editAuthor.php">Add Author...</a>
<?php
// Display footer
showFooter();

在这里,我们只是用对Model类的调用替换了与 PDO 的直接通信,并重写了循环以使用对象变量而不是数组元素。

对应用程序所做的更改还允许我们从author.php:中删除与 SQL 相关的代码片段。

<?php
/**
* This page shows an author's profile
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Get the author
$id = (int)$_REQUEST['id'];
**$author = Model::getAuthor($id);**
// Now see if the author is valid - if it's not,
// we have an invalid ID
if(!$author) {
showHeader('Error');
echo "Invalid Author ID supplied";
showFooter();
exit;
}
// Display the header - we have no error
**showHeader("Author: $author->firstName $author->lastName");**
// Now get the number and fetch all his books
**$books = $author->getBooks();
$totalBooks = $author->getBookCount();**
// now display everything
?>
<h2>Author</h2>
<table width="60%" border="1" cellpadding="3">
<tr>
<td><b>First Name</b></td>
**<td><?=htmlspecialchars($author->firstName)?></td>**
</tr>
<tr>
<td><b>Last Name</b></td>
**<td><?=htmlspecialchars($author->lastName)?></td>**
</tr>
<tr>
<td><b>Bio</b></td>
**<td><?=htmlspecialchars($author->bio)?></td>**
</tr>
<tr>
<td><b>Total books</td>
<td><?=$totalBooks?></td>
</tr>
</table>
**<a href="editAuthor.php?author=<?=$author->id?>">Edit author...</a>**
<h2>Books</h2>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Title</td>
<td>ISBN</td>
<td>Publisher</td>
<td>Year</td>
<td>Summary</td>
</tr>
<?php
// Now iterate over every book and display it
**while($b = $books->fetch())
{**
?>
<tr>
**<td><?=htmlspecialchars($b->title)?></td>
<td><?=htmlspecialchars($b->isbn)?></td>
<td><?=htmlspecialchars($b->publisher)?></td>
<td><?=htmlspecialchars($b->year)?></td>
<td><?=htmlspecialchars($b->summary)?></td>**
</tr>
<?php
}
?>
</table>
<?php
// Display footer
showFooter();

这里的变化相当表面,它只是删除了与 PDO 的直接通信,并将高亮显示的行上的数组语法更改为对象语法。

最后,显示borrowers.php中的列表的最后一个页面:

<?php
/**
* This page lists all borrowed books
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Display the header
showHeader('Lended Books');
// Get all lended books list
**$brs = Model::getBorrowers();
$totalBooks = Model::getBorrowerCount();**
// now create the table
?>
Total borrowed books: <?=$totalBooks?>
<table width="100%" border="1" cellpadding="3">
<tr style="font-weight: bold">
<td>Title</td>
<td>Author</td>
<td>Borrowed by</td>
<td>Borrowed on</td>
<td>Return</td>
</tr>
<?php
// Now iterate over every row and display it
**while($br = $brs->fetch())
{
$b = $br->getBook();
$a = $b->getAuthor();**
?>
<tr>
**<td><?=htmlspecialchars($b->title)?></td>
<td><?=htmlspecialchars("$a->firstName $a->lastName")?></td>
<td><?=htmlspecialchars($br->name)?></td>
<td><?=date('d M Y', $br->dt)?></td>
<td>
<a href="returnBook.php?borrower=<?=$br->id?>">Return</a>
</td>**
</tr>
<?php
}
?>
</table>
<?php
// Display footer
showFooter();

在这个文件中,我们遇到了与books.php页面相同的问题——Model类返回的Borrower类实例没有书名和作者名,而我们希望在这个页面上显示。因此,我们在每次迭代中为每个Borrower类实例获取Book类实例,然后使用该对象获取作者的详细信息。

最后,我们将修改另外两个页面,以利用我们新创建的数据模型。这两个页面是lendBook.phpreturnBook.php。它们可能包含了与 PDO 交互的最长的代码段。从lendBook.php中,我们移除了事务内部的所有代码:

<?php
/**
* This page allows you to lend a book
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// First see if the request contains the book ID
// Return to books.php if the ID invalid
$id = (int)$_REQUEST['book'];
**$book = Model::getBook($id);**
if(!$book) {
header("Location: books.php");
exit;
}
// Now see if the form was submitted
$warnings = array();
if($_POST['submit']) {
// Require that the borrower's name is entered
if(!$_POST['name']) {
$warnings[] = 'Please enter borrower\'s name';
}
else {
**// Form is OK, "lend" the book
if(!$book->lend($_POST['name'])) {
// Failure, show error message
$warnings[] = 'There are no more copies of
this book available';
}**
}
// Now, if we don't have errors,
// redirect back to books.php
if(count($warnings) == 0) {
header("Location: books.php");
exit;
}
// Otherwise, the warnings will be displayed
}
// Display the header
showHeader('Lend Book');
// If we have any warnings, display them now
if(count($warnings)) {
echo "<b>Please correct these errors:</b><br>";
foreach($warnings as $w)
{
echo "- ", htmlspecialchars($w), "<br>";
}
}
// Now display the form
?>
<form action="lendBook.php" method="post">
<input type="hidden" name="book" value="<?=$id?>">
<b>Please enter borrower's name:<br></b>
<input type="text" name="name" value="<?=htmlspecialchars($_
POST['name'])?>">
<input type="submit" name="submit" value=" Lend book ">
</form>
<?php
// Display footer
showFooter();

注意我们如何改变了借出图书的部分——Bool::lend()方法在失败的情况下返回null,因此我们将显示没有更多可借的书的消息。如果操作成功,那么Book::lend()方法将返回Borrower类实例(在if语句中求值为true),页面将重定向到books.php

类似地,我们从returnBook.php中删除了与 PDO 相关的代码,并用相应的调用Borrower::returnBook()方法替换:

<?php
/**
* This page "returns" a book back to the library
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// First see if the request contains the borrowers ID
// Return to books.php if not
$id = (int)$_REQUEST['borrower'];
**$borrower = Model::getBorrower($id);**
if(!$borrower) {
header("Location: books.php");
exit;
}
// Return the book and redirect to books.php
// If anything happens, the exception will be
// handled automatically
**$borrower->returnBook();**
header("Location: books.php");

分离模型的优势

到目前为止,几乎所有生成前端页面的文件都不包含数据访问逻辑,更容易管理。另一方面,模型类可以从我们的应用程序外部使用,并且可以快速创建额外的页面来以其他格式(如 XML)表示数据库中的信息。

例如,考虑以下页面(我们将其称为books.xml.php):

<?php
/**
* This page lists all the books we have as an XML data structure
* PDO Library Management example application
* @author Dennis Popel
*/
// Don't forget the include
include('common.inc.php');
// Set the content type to be XML
header('Content-Type: application/xml');
// Get the books list
$books = Model::getBooksWithAuthors();
// Echo XML declaration and open root element
echo '<?xml version="1.0"?>', "\n";
echo "<books>\n";
// Now iterate over every book and display it
while($b = $books->fetch())
{
?>
<book id="<?=$b->id?>">
<isbn><?=$b->isbn?></isbn>
<title><?=htmlspecialchars($b->title)?></title>
<publisher><?=htmlspecialchars($b->publisher)?></publisher>
<summary><?=htmlspecialchars($b->summary)?></summary>
<author>
<id><?=$b->author?></id>
<lastName><?=$b->lastName?></lastName>
<firstName><?=$b->firstName?></firstName>
</author>
</book>
<?
}
echo '</books>';

这个文件允许我们以 XML 格式导出书籍列表,供另一个应用程序使用。正如你所看到的,对原始的books.php文件的更改只在显示逻辑中。如果你现在导航到该页面,你应该会看到以下内容:

分离模型的优势

通过轻微修改,我们能够创建数据的新表示(第二和第三本书已经折叠以适应截图)。

定义model类的另一个优势是,这些类成为数据访问和操作的中心点。例如,如果你改变了用于表示来自多个表的数据的 SQL(使用连接)或找到了优化查询的方法,你只需要更新相关的模型类,使用该查询的脚本(控制器)就不需要更新。这是一个重要的可管理性优势。

你可以扩展抽象模型类,以模拟通用数据模型中真实子类的扩展功能。例如,在内容管理系统中,你可以创建一个名为Item的抽象基类,它将为所有子类(项目类型)具有共同的属性,如作者、关键词和创建日期。然后模型可以对所有可能的子类执行一些操作,而无需进一步编码,以便广泛重用现有代码。

有一种叫做对象关系映射器ORMs)的工具,它们利用了本章描述的思想。ORMs 用于创建功能强大的面向对象应用程序,在这些应用程序中,你的模型中几乎没有 SQL 代码。(事实上,这些工具在一些配置后扮演了你应用程序中的模型的角色。)你可以在en.wikipedia.org/wiki/Object-relational_mapping了解更多关于 ORMs 的信息。Propel (propel.phpdb.org/)是 PHP5 的一种流行的 ORM 工具。

进一步思考

本章开发的模型在至少两个领域需要一些改进,如果你想在实际应用中使用它的话。我们没有在模型中创建能够提供editBook.phpeditAuthor.php文件功能的方法。然而,现在你应该准备自己添加这些功能。我们将为你提供一些提示:

  • 创建Book::update()Author::update()方法。这些方法应该接受反映每个对象属性的参数(对于Author类,这应该是名字、姓氏和传记)。

  • 这些方法应该使用预处理语句来更新数据库中相应的记录(基于$this->id的值)。

  • Model类应该扩展两个方法,Model::createBook()Model::createAuthor()。这些方法应该接受与Book::update()Author::update()相同的参数列表。两者都应该根据传递的参数插入一行到相关表中。可以使用以下代码完成这个操作:

$conn = self::getConn();
$conn->beginTransaction();
try
{
$conn->query("INSERT INTO authors(bio) VALUES('')");
$aid = $conn->lastInsertId();
$author = self::getAuthor($aid);
$author->update($firstName, $lastName, $bio);
$conn->commit();
}
catch(Exception $e)
{
$conn->rollBack();
}

  • 这里的想法是将实体更新集中在一个地方,即Author::update()。我们在这里使用事务来确保,如果发生任何事情,空行不会存储在数据库中。

  • 表单处理代码应该检测它是在编辑现有实体还是创建新实体,并在已经存在的实例上适当地调用Model::createAuthor()Author::update()

另一个问题是,模型类的方法不验证接受的参数。如果要将数据模型暴露给第三方应用程序,它们应该对传递到数据库的每个参数进行验证。如果通过 Web 浏览器访问,我们的数据模型受到表单验证代码的保护。然而,直接访问模型类并不那么安全。

建议在模型方法中接受用户提供的参数时,如果验证失败,抛出异常。此外,Web 表单验证和方法参数验证应该使用通用代码。(例如,您可以开发一个Validation类,无论值来自何处,都可以用来验证。)这段代码应该从表单验证代码和模型方法中使用。通过这样做,您将确保代码重用和验证规则的单一位置。

收尾工作

PHP 数据对象是一种很棒且易于使用的技术。然而,它仍处于起步阶段,许多改进和其他变化尚未到来。一定要及时了解来自 PHP 开发人员和大量 PHP 粉丝和用户的最新消息。

只有对安全威胁有深刻的理解并知道如何防范,才能有效地使用 PDO 和 PHP。使用 PDO 的预处理语句可以减少 SQL 注入攻击的风险,但作为开发人员,您仍然负责保护您的应用程序。确保您及时了解安全领域的最新发展。

愉快的 PHP 编程!

附录 A. PHP5 中面向对象编程的介绍

在本书中,我们主要使用过程化代码来构建示例应用程序。然而,PDO API 是完全面向对象的,在最后一章中,我们通过使用类来模拟数据库中的真实实体。这个附录是为那些不熟悉 PHP5 面向对象扩展的程序员准备的。我们将向您介绍面向对象编程的基础知识,因为许多来自较早 PHP 版本的开发人员没有这种编程经验。然而,这只是一个简短的介绍;如果您想掌握面向对象编程,您应该参考一些专门讨论这个主题的书籍。

什么是面向对象编程?

面向对象编程(OOP)是一个相对较新的概念,尽管其根源可以追溯到 20 世纪 60 年代。在 OOP 中,软件与模拟真实实体的对象一起工作(例如第七章中的书籍和作者)。而过程式编程涉及一系列指令,OOP 中的应用涉及一组相互交互的对象。

声明对象的语法

一个对象可以被视为多个变量的容器,称为属性,以及对这些变量进行操作的函数。这些函数称为方法。每个对象都属于一个类。在 PHP 中,每个对象只能属于一个类(尽管一些其他面向对象编程语言允许多重继承),但可以有许多对象或实例属于同一个类。类是一种语法结构,允许您描述属于这个类的对象将具有什么属性和方法。

有一个类似物种和生物体的类比——例如,狗(一种物种,或者一个类)是所有活着的狗的概括。一个概括的狗有诸如体重和年龄的属性,以及像叫的方法,而现实生活中的狗,比如莱西,属于狗这个物种,可以被描述为Dog类的一个实例。

让我们看看在 PHP5 中如何建模这个:

class Dog
{
public $weight;
public $age;
function bark()
{
print "woof!";
}
}
$lessie = new Dog();
$lessie->weight = 15;
$lessie->age = 3;
$lessie->bark();

在这段小代码片段中,我们定义了一个叫做Dog的类。在 PHP5 中,类的定义以保留字class开头,后面跟着类的名称(类的名称可以包含与函数名称相同的字符)。所有的类的属性和方法,统称为成员,都定义在{...}块内。

正如你所看到的,当我们声明属性和方法时,我们使用关键字public。在 PHP4 中,我们将使用var关键字,但是这个关键字在 PHP5 中已经被弃用。除了public关键字,我们还可以使用protected关键字或private关键字,但稍后会详细介绍。

正如你在代码的第二部分中所看到的,我们使用new关键字创建对象。

$lessie = new Dog();

这一行创建了一个属于Dog类的新对象,并将其分配给$lessie变量。这是一个非常重要的步骤,因为这是创建对象的唯一方法。在 PHP 处理完它之后,$lessie变量变得初始化,我们可以访问Dog类中声明的属性和方法,以便对名为Lessie的对象进行操作。我们现在想在我们的应用程序中有两只狗,第二只将被称为K9。为了实现这一点,我们需要写类似这样的代码:

$k9 = new Dog();
$k9->age = 5;
$k9->weight = 18;

现在,我们可以访问$k9$lessie变量,如果我们想要与我们的每只狗进行交互。

换句话说,在我们可以与一个实例通信之前,它必须首先用new关键字创建。

变量初始化后,我们可以访问它的属性和方法。正如你在代码中看到的那样,这是通过->构造实现的,该构造用于属性和方法。请注意,当访问类的属性时,我们不必在->后面写美元符号(但在类定义内声明属性时必须使用)。

方法是用function关键字声明的,后面跟着方法的名称和参数列表。事实上,类的方法的声明方式与普通函数的声明方式类似,但有一个主要区别。在方法的声明中,总是存在一个隐式变量,称为$this,它允许你访问对象的属性。让我们看看如何创建一个getInfo()方法来返回有关我们的狗的一些额外信息:

<?php
class Dog
{
public $weight;
public $age;
function bark()
{
print "woof!";
}
**function getInfo()
{
return 'Weight: ' . $this->weight . ' kg, age: ' . $this->age .
' years';
}**
}
$lessie = new Dog();
$lessie->weight = 15;
$lessie->age = 3;
$k9 = new Dog();
$k9->age = 5;
$k9->weight = 18;
echo 'Lessie: ', $lessie->getInfo(), "\n";
echo 'K9: ', $k9->getInfo(), "\n";

这段代码将显示以下输出:

Lessie: Weight: 15 kg, age: 3 years
K9: Weight: 18 kg, age: 5 years

构造函数

每个类还有一个特殊的函数(可能是隐式的或显式声明的),称为构造函数。构造函数总是在 PHP 遇到new关键字时调用,它的目的是执行一些初始化任务。让我们扩展Dog类,使其具有$name属性。我们还将更改代码,以便在构造函数中初始化name, weightage属性,而不是在主应用程序中:

<?php
class Dog
{
public $weight;
public $age;
**public $name;
function __construct($name, $age, $weight)
{
$this->name = $name;
$this->weight = $weight;
$this->age = $age;
}**
function bark()
{
print "woof!";
}
**function getInfo()
{
return
'Name: ' . $this->name .
', weight: ' . $this->weight .
' kg, age: ' . $this->age .
' years';
}
}
$lessie = new Dog('Lessie', 3, 15);
$k9 = new Dog('K9', 5, 18);
echo $lessie->getInfo(), "\n";
echo $k9->getInfo(), "\n";**

这个应用程序将显示以下内容:

Name: Lessie, weight: 15 kg, age: 3 years
Name: K9, weight: 18 kg, age: 5 years

这是我们所做的简要总结。我们首先声明了$name属性,然后是我们Dog类的构造函数。构造函数被声明为一个特殊名称为__construct的函数(constructor一词前面加上两个下划线('_'))。我们的构造函数接受三个参数——nameageweight,它们的值被分配给对象的属性。我们分配值给属性的顺序并不重要。请注意,我们必须始终使用$this变量来表示对象的属性。通过这样做,我们可以区分构造函数中的局部变量$name, $age$weight(作为参数传递)和对象自己的属性,它们在构造函数中具有相同的名称。

我们还改变了getInfo()方法,使其也返回狗的名字。现在我们可以通过将名字、年龄和体重传递给构造函数来实例化对象。由于这些属性在构造函数中被赋值,我们不必在代码的主要部分中这样做。

还应该注意的是,你可以在类定义中为属性分配默认值。这将确保该类的每个对象都自动分配默认值。例如,我们可以这样做:

class Dog
{
public $weight;
public $age;
public $name;
public $hasCollar = true;
function __construct($name, $age, $weight)
{
$this->name = $name;
$this->weight = $weight;
$this->age = $age;
}
function bark()
{
print "woof!";
}
**function getInfo()
{
return
'Name: ' . $this->name .
', weight: ' . $this->weight .
' kg, age: ' . $this->age .
' years, has collar: ' . ($this->hasCollar ? 'yes' : 'no');
}**
}

如果你使用这个Dog类定义运行应用程序,你将看到以下输出:

Name: Lessie, weight: 15 kg, age: 3 years, has collar: yes
Name: K9, weight: 18 kg, age: 5 years, has collar: yes

正如你现在所看到的,hasCollar的默认属性值已传播到每个新创建的实例(当然,它可以稍后为每个对象更改)。

析构函数

有一个与构造函数相对的概念,叫做析构函数。顾名思义,析构函数用于执行清理任务(这些任务的经典示例是删除临时文件,关闭数据库连接等)。在 PHP5 中,当对象没有更多的引用时(例如,通过将持有对象引用的变量设置为null或应用程序终止),对象上的析构函数将被调用。

析构函数是一个方法:__destruct()。如果你把这个方法添加到类中,那么当对象被释放时它将被调用。让我们把析构函数添加到Dog类中:

class Dog {
public $weight;
public $age;
public $name;
public $hasCollar = true;
function __construct($name, $age, $weight) {
$this->name = $name;
$this->weight = $weight;
$this->age = $age;
}
function bark() {
print "woof!";
}
function getInfo() {
return
'Name: ' . $this->name .
', weight: ' . $this->weight .
' kg, age: ' . $this->age .
' years, has collar: ’ . ($this->hasCollar ? 'yes’ : 'no’);
}
function __destruct() {
print "Freeing $this->name\n";
}
}

现在,如果你再次运行代码,它将给出以下输出:

Name: Lessie, weight: 15 kg, age: 3 years, has collar: yes
Name: K9, weight: 18 kg, age: 5 years, has collar: yes
Freeing K9
Freeing Lessie

请注意,PHP5 调用析构函数的顺序是不确定的。此外,在析构函数中,代码可能无法访问其他对象,除非它们被释放的对象引用。换句话说,析构函数只应清理由该对象创建的资源。

面向对象编程的优势

面向对象编程的力量在于它的三个主要特征:继承、封装和多态。

继承

面向对象编程中的继承允许你创建新的类,这些类继承了现有类的行为(方法)和属性(属性)。让我们考虑下面的例子。假设我们有一个名为Fruit的类。它是不同水果的通用类型类,它的共同属性是颜色和重量。在面向对象编程中,我们可以对Fruit进行子类化,创建新的类AppleBanana。这两个类(作为Fruit的子类)将具有相同的属性:weightcolor。(请注意,我们谈论的是属性本身,而不是它们的值)。一个苹果可以是绿色的,而一个Banana可以是黄色的。但是,与AppleBanana类实例交互的任何代码都不需要知道它正在与哪种水果进行通信。

让我们把这个例子写成代码:

class Fruit
{
public $color;
public $weight;
}
class Apple extends Fruit
{
function __construct()
{
$this->color = 'green';
$this->weight = 200;
}
}
class Banana extends Fruit
{
function __construct()
{
$this->color = 'yellow';
$this->weight = 250;
}
}
$a[] = new Apple();
$a[] = new Banana();
foreach($a as $f)
{
echo $f->color, "\t", $f->weight, "\n";
}

正如你所看到的,在这个小应用程序中,我们有一个Apple对象和一个Banana对象。我们在循环中对它们进行迭代,但是无论它们的类型如何,我们都可以访问它们的属性,因为这两个类使用相同的属性名称。但是这些属性对于每种水果来说都有不同的值。

继承还允许扩展或完全重写父类的行为。假设我们的Fruit类有一个额外的特征——每公斤的价格。它还有一个新的方法——getPrice(),它只是将重量(以克为单位)乘以价格:

class Fruit
{
public $color;
public $weight;
public $price;
function getPrice()
{
return $this->weight / 1000 * $this->price;
}
}

现在我们可以在子类中使用这种方法:

class Apple extends Fruit
{
function __construct()
{
$this->color = 'green';
$this->weight = 200;
$this->price = 2;
}
}
class Banana extends Fruit
{
function __construct()
{
$this->color = 'yellow';
$this->weight = 250;
$this->price = 3;
}
}
$a[] = new Apple();
$a[] = new Banana();
foreach($a as $f)
{
echo $f->getPrice(), "\n";
}

接下来,我们假设Banana类有另一个方法来计算价格,以便应用折扣:

class Banana extends Fruit
{
function __construct()
{
$this->color = 'yellow';
$this->weight = 250;
$this->price = 3;
}
function getPrice()
{
return $this->weight / 1000 * $this->price * 0.9;
}
}

正如你所看到的,我们改变了Banana类中的方法,以便调用Banana类的getPrice()方法的代码将获得折扣价格,而Apple类的getPrice()方法返回全价。

另一方面,我们可以在Banana类中重用Fruit类的getPrice()方法的实现(这样我们就不必在基类中重复包含的代码):

function getPrice()
{
return parent::getPrice() * 0.9;
}

封装

封装(有时称为信息隐藏)是一个更为理论的概念。它涉及以一种方式在类中定义方法,以便我们可以隐藏实现细节,使客户端代码无法访问。当我们在Banana类中重新定义价格计算时,我们已经看到了这一点。从应用程序的角度来看,什么都没有改变:我们仍然调用getPrice()方法,但我们不知道这个计算是如何进行的。

换句话说,类是通过它们的方法访问的,这些方法具有相同的名称,因此,即使这些名称背后的代码发生了变化,名称本身也不会改变。这确保了现有的代码不需要更改以适应方法的新版本。

我们可以做更多的工作来隐藏客户端代码的实现细节,PHP5 和其他面向对象的语言一样,支持方法和属性的可见性修饰符。例如,我们可以在Banana类中添加一个私有属性,它将对应用程序的其余部分隐藏起来:

class Banana extends Fruit
{
**private $mySecretProperty;**
function __construct()
{
$this->color = 'yellow';
$this->weight = 250;
$this->price = 3;
}
function getPrice()
{
return parent::getPrice() * 0.9;
}
}

$mySecretProperty属性只能在Banana类中访问(或可见);试图从Banana类的方法之外访问它将触发运行时错误。(在编译语言中,这将导致编译错误。)

在 PHP5 中,还存在两个修饰符:public(我们已经使用过)和protected。公共方法或属性可以从整个应用程序中访问,而受保护的方法或属性只能在类及其子类中访问。

多态性

多态性是面向对象编程的一个特性,它允许我们编写能够处理属于不同类的对象的代码,只要这些类有相同的基类。我们在上面的例子中已经看到了多态性的作用,当我们使用它们的名称访问不同对象的属性和方法时,返回不同的值并执行不同的操作。

子类实现了基类的所有属性和方法,并且基类的所有未来子类都保证实现这些属性和方法,以便现有代码甚至可以与尚不存在的子类一起工作。

PHP5 支持接口。接口是一种描述不同类和类层次结构中某些行为的构造。例如,让我们考虑一个Tradeable接口,其中有一个方法isImported()

interface Tradeable
{
public isImported();
}

现在,我们可以在Fruit类的定义中声明它实现了 Tradeable 接口:

class Fruit **implements Tradeable**
{
public $color;
public $weight;
public $price;
function getPrice()
{
return $this->weight / 1000 * $this->price;
}
function isImported()
{
return false;
}
}

我们已经创建了Fruit对象及其子类(AppleBanans)的所有对象默认为非进口。现在我们可以将香蕉设为进口,同时将苹果保留为国产:

class Banana extends Fruit
{
function __construct()
{
$this->color = 'yellow';
$this->weight = 250;
$this->price = 3;
}
function getPrice()
{
return parent::getPrice() * 0.9;
}
function isImported()
{
return true;
}
}

接下来,我们将创建一个虚构的Car类,实现Tradeable接口:

class Car implements Tradeable
{
public $year;
public $make;
public $model;
function isImported()
{
return true;
}
}

请注意,Car没有扩展Fruit,但它仍然具有isImported()方法。现在我们可以从应用程序中调用这个方法:

$a[] = new Apple();
$a[] = new Banana();
$a[] = new Car();
foreach($a as $item)
{
echo $item->isImported();
}

这个小例子展示了如何通过给它们一个共同的接口,以相同的方式处理来自不同类层次结构的对象。通过这样做,通常具有完全不同含义的对象可以以多态的方式进行操作。

静态属性、方法和类常量

在本附录中的所有示例中,我们都在使用模拟现实生活实体的类的实例(对象)。但是,在 PHP5 中,可以使用静态属性和方法。静态属性是对给定类的所有实例都通用的变量,因此,如果更改静态属性,它将对属于该类的所有对象进行更改。

静态属性的声明方式与常规属性相同,但有一个特殊的static关键字:

class DataModel
{
**public static $conn = null;**
}

可以在不创建类的实例的情况下访问静态属性:

if(!DataModel::$conn) {
echo 'Connection not established!';
}

访问静态属性的语法如下:类名,然后是双冒号,然后是属性名。请注意,对于静态属性(与常规属性不同),必须存在美元符号$

静态方法和静态属性一样,可以在不实例化对象的情况下访问。它们的声明和访问方式如下:

class DataModel
{
public static $conn = null;
**static function getConn()**
{
if(!DataModel::$conn) {
DataModel::$conn = new PDO('sqlite:./my.db', 'user', 'pass');
}
return DataModel::$conn;
}
}
**$conn = DataModel::getConn();**

静态方法的声明具有static关键字,后面跟着常规方法声明。可以通过类名后跟双冒号,然后是方法名来访问该方法。

可以在类声明内部使用快捷关键字self访问静态属性和方法:

class DataModel
{
public static $conn = null;
static function getConn()
{
**if(!self::$conn) {
self::$conn = new PDO('sqlite:./my.db', 'user', 'pass');**
}
**return self::$conn;**
}
}
**$conn = DataModel::getConn();**

静态方法的定义也有一个主要区别。您不能使用$this变量(因为没有对象可以引用$this变量)。

类的另一个“静态”特性是类常量。类常量的作用类似于静态属性,但其值不能被更改。类常量必须始终在类声明部分分配其值,并且它们之前没有美元符号(因此它们的命名方式就像常规的 PHP 常量)。类常量主要用于保持全局命名空间的清洁(这也是静态方法的用途之一):

class DataModel
{
public static $conn = null;
**const ORDER_AZ = 1;
const ORDER_ZA = 2;**
static function getConn()
{
if(!self::$conn) {
self::$conn = new PDO('sqlite:./my.db', 'user', 'pass');
}
return self::$conn;
}
static function getItems($sortMode)
{
if($sortMode == self::ORDER_AZ) {
$sql = // SQL for ascending
}
else {
$sql = // SQL for descending
}
}
}
$items = DataModel::getItems(DataModel::ORDER_ZA);

在代码中尝试为类常量分配一个值将导致解析错误。

异常

正如我们所见,异常是 PHP5 的一个非常重要的补充。异常是一种特殊类型的对象,当实例化并“抛出”时,会打破正常的执行流程并跳转到所谓的catch块。

异常用于报告错误条件。传统上,如果函数失败,函数会返回错误代码。应用程序必须在继续下一个函数调用之前检查每个函数调用。记住您用于连接到 MySQL 数据库的代码片段:

$dbh = mysql_connect($host, $user, $pass);
if(!$dbh) {
die('Could not connect to the DB!');
}
if(!mysql_select_db('mydb')) {
die('Could not select the DB');
}
$q = mysql_query('SELECT * FROM test');
if(!$q) {
die('Could not execute query');
}
while($r = mysql_fetch_row($q))
{
...
}

如果mysql_xxx函数可能会抛出异常,那么这段代码可以简化为这样:

try
exception handlingexception, throwing{
mysql_connect($host, $user, $pass);
mysql_select_db('mydb');
$q = mysql_query('SELECT * FROM test');
while($r = mysql_fetch_row($q))
{
...
}
}
catch(Exception $e)
{
die(e->getMessage());
}

当然,这段代码不会起作用,因为这些函数并不是设计来抛出异常的。您将需要使用 PDO,在第三章中我们看到了如何处理 PDO 异常。

异常允许您延迟错误检查并保持更清晰的代码。导致异常抛出的函数(或方法)将被终止,并且catch关键字指定的块中的代码将被执行。任何可能抛出异常的代码都被包装在try块中:

try
{
// do something exceptional
}
catch(Exception $e)
{
// display warnings etc
// $e->getMessage() contains error message
}

异常的真正威力在于能够将它们升级到调用堆栈。这意味着,如果您设计了一个可能抛出异常的函数或类方法,那么该函数或方法不必捕获该异常。事实上,许多应用程序库都是设计成不处理异常,而是让它们传递给调用代码。

例如,本书中遇到的PDOPDOStatement类的许多方法都可能抛出异常,您有责任捕获并适当处理它们。

仔细看上面代码片段中的catch块。它后面跟着Exception(这是 PHP 中所有异常的基类的名称)和变量标识符$e。我们可以在catch块中使用$e变量来检查错误消息和其他调试信息。Exception类定义了以下方法:

  • getMessage()返回错误消息。

  • getCode()返回错误代码。

  • getFile()返回异常发生的文件名。

  • getLine()返回异常发生的行号。

  • getTrace()getTraceAsString()返回回溯(调用堆栈),用于调试。

当然,错误消息和错误代码会根据异常发生的位置而变化,因此它们取决于您使用的应用程序库(如 PDO)。

我们在catch关键字后面指定了Exception类名,因为这个类,像其他类一样,可以被扩展以创建子类。例如,从 PDO 方法抛出的所有异常都是PDOException类的实例。

异常处理机制允许我们为不同类别的异常创建不同的处理程序。例如,我们可以这样做:

try
{
$conn = new PDO('sqlite:./mydb', '', '');
$q = $conn->query('SELECT * FROM test');
while($r = $q->fetch())
{
...
}
**}
catch(PDOException $pdoe)
{**
die('Database error: ' . $pdoe->getMessage());
**}
catch(Exception $e)
{**
die('Unexpected error: ' . $e->getMessage());
}

这段代码为所有 PDO 错误定义了两个错误处理程序:一个用于数据库错误,另一个用于所有其他错误,我们将其标识为意外错误。当然,在实际应用中,错误处理策略会更加复杂,但这个例子展示了异常如何被分类。

总结

在这个附录中,我们看到 PHP5 具有一些新的面向对象编程扩展,这些扩展与现代编程语言的扩展相当。它们允许我们编写非常庞大的应用程序,同时保持代码重用和整洁。面向对象编程是大型项目(如内容管理系统或涉及 PDO 的数据库库)的自然解决方案。现在,为 PHP5 编写的库都考虑了面向对象编程。

然而,这个附录只是简要介绍了面向对象编程背后的主要概念,以便您可以跟随本书中的代码示例。如果您想完全掌握面向对象编程,您应该参考一些能够介绍并指导您掌握这一挑战性主题的书籍。

posted @ 2024-05-05 12:10  绝不原创的飞龙  阅读(11)  评论(0编辑  收藏  举报