最佳实践系列(八):PHP 数据库统一处理 API —— PDO 扩展及其在 Laravel 底层的使用

PHP 支持多种数据库,如 MySQL、PostgreSQL、SQLite 和 Oracle 等,并且这些数据库都提供了用于 PHP 和相应数据库之间通信的扩展,如 mysqlisqlite3 等。这样造成的一个问题是如果项目中使用了多种数据库,需要安装并使用多种 PHP 数据库扩展和接口,增加了学习和维护的成本。为此,从 PHP 5.1 开始引入了一个新的扩展 —— PDO

PDO 扩展

PDO(PHP Data Objects)是一系列 PHP 类,抽象了不同数据库的具体实现,只通过同一套接口就可以与不同的数据库通信,极大地降低了学习成本,同时提高开发效率。

注:尽管 PDO 为不同数据库提供了统一接口,但是仍然必须自己编写 SQL 语句,这是 PDO 的劣势所在。这样存在的一个隐患是不同的数据库 SQL 语句语法可能略有出入,所以在切换数据系统的时候需要注意这一点,建议尽可能编写符合 ANSI/ISO 标准的 SQL 语句。

数据库连接和DSN

PDO 的构造函数中有一个字符串参数,用于指定 DSN(Data Source Name)来提供数据库连接的详细信息:

<?php
try {
    $pdo = new PDO(
        'mysql:host=127.0.0.1;dbname=test;port=3306;charset=utf8',
        'username',
        'password'
    );
} catch (PDOException $ex) {
    echo 'database connection failed';
    exit();
}

可见 DSN 主要包含信息包括:数据库主机名/IP地址、端口号、数据库名称、字符集。PDO构造函数的第二个参数是数据库用户名,第三个参数是该用户名对应密码。

Laravel 框架的数据库组件就是基于PDO实现的,底层使用工厂模式为不同的数据库创建相应的 PDO 实例,Laravel 默认使用 MySQL 数据库,对应的 PDO 实例通过 MySqlConnector 类的 connect() 方法创建,并且最终落地到 createPdoConnection 方法实现:

保证数据库凭证的安全

为了保证数据库凭证(用户名/密码)的安全,不能将其硬编码在代码中,尤其是可以公开访问的 PHP 文件。我们应该将其存放在一个位于文档根目录之外的配置文件中,然后在需要使用凭证的地方引入,正如 Laravel 所实现的那样(用户名密码信息位于 .env,隔离在版本控制之外):

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=homestead
DB_PASSWORD=secret

然后在 config/database.php 中引入:

'mysql' => [
    'driver' => 'mysql',
    'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_PORT', '3306'),
    'database' => env('DB_DATABASE', 'forge'),
    'username' => env('DB_USERNAME', 'forge'),
    'password' => env('DB_PASSWORD', ''),
    'unix_socket' => env('DB_SOCKET', ''),
    'charset' => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
    'strict' => true,
    'engine' => null,
],

预处理语句

现在我们建立了一个连接到数据库的 PDO 实例,通过这个实例就可以使用 SQL 语句从数据库读取数据,或者将数据写入数据库。

开发 PHP 应用时,我们经常需要从 HTTP 请求中获取动态值来定制 SQL 语句,如使用 /user?name=nonfu 来显示具体账户的资料信息,对应的 SQL 语句可能是:

SELECT * FROM user WHERE name = "nonfu";

初级 PHP 开发者可能会像这样构建这个 SQL 语句:

$sql = sprintf(
    'SELECT * FROM user WHERE name = "%s"', 
    filter_input(INPUT_GET, 'name')
);

这样做就会有 SQL 注入隐患。所以,在基于用户请求参数构建 SQL 语句时,一定要过滤用户输入参数值。幸运的是,在 PDO 中,我们可以通过预处理语句和参数绑定来实现用户输入过滤,从而避免 SQL 注入。

预处理语句是一个 PDOStatement 实例,我们可以通过 prepare() 方法来返回该实例:

<?php
$sql = 'SELECT * FROM user WHERE name = :name';
$statement = $pdo->prepare($sql);
$name = filter_input(INPUT_GET, 'name');
$statement->bindValue(':name', $name, PDO::PARAM_STR);

预处理语句会自动过滤 $name 的值,防止数据库遭受 SQL 注入攻击。

Laravel 框架中相应的底层实现位于 MySqlConnetion 的父类 Connectionselect 方法中:

查询结果

有了预处理语句之后,就可以在数据库中执行 SQL 查询了,调用预处理语句的 execute() 方法后就会使用绑定的所有数据执行 SQL 语句,如果执行的是 INSERTUPDATEDELETE 语句,执行完 execute() 方法工作结束了,如果执行的是 SELECT 语句,我们还期望数据库能返回匹配的结果。我们可以使用以下方法获取查询结果:

  • fetch()
  • fetchAll()
  • fetchColumn()
  • fetchObject()

fetch() 方法用于获取结果集的下一行,我们可以使用这个方法迭代大型结果集:

$sql = 'SELECT * FROM user WHERE name = :name';
$statement = $pdo->prepare($sql);
$name = filter_input(INPUT_GET, 'name');
$statement->bindValue(':name', $name, PDO::PARAM_STR);
$statement->execute();

while (($result = $statement->fetch(PDO::FETCH_ASSOC)) !== FALSE) {
    echo $result['name'];
}

我们在调用 fetch() 方法时,传入了 PDO::FETCH_ASSOC 参数,该参数决定如何返回查询结果,该参数支持以下常量:

  • PDO::FETCH_ASSOC:返回关联数组,数组的键是数据表的列名
  • PDO::FETCH_NUM:返回键为数字的数组
  • PDO::FETCH_BOTH:顾名思义,返回一个既有键为列名又有键为数字的数组
  • PDO::FETCH_OBJ:返回一个对象,对象的属性是数据表的列名

如果处理的小型结果集合,可以使用 fetchAll() 方法获取所有查询结果,Laravel 框架底层的 select() 方法中就使用了该方法来获取返回结果集,所以在获取大量结果时不能使用该方法:

$sql = 'SELECT * FROM user WHERE name = :name';
$statement = $pdo->prepare($sql);
$name = filter_input(INPUT_GET, 'name');
$statement->bindValue(':name', $name, PDO::PARAM_STR);
$statement->execute();

$results = $statement->fetchAll(PDO::FETCH_ASSOC);
if ($results) {
    foreach ($results as $result) {
        echo $result['name'];
    }
}

如果只关心查询结果中的一列,可以使用 fetchColumn() 方法:

$sql = 'SELECT id, name FROM user WHERE name = :name';
$statement = $pdo->prepare($sql);
$name = filter_input(INPUT_GET, 'name');
$statement->bindValue(':name', $name, PDO::PARAM_STR);
$statement->execute();

while (($name = $statement->fetchColumn(1)) !== FALSE) {
    echo $name;
}

我们还可以使用 fetchObject() 方法获取查询结果中的行,这个方法把行当做对象:

$sql = 'SELECT id, name FROM user WHERE name = :name';
$statement = $pdo->prepare($sql);
$name = filter_input(INPUT_GET, 'name');
$statement->bindValue(':name', $name, PDO::PARAM_STR);
$statement->execute();

while (($result = $statement->fetchObject()) !== FALSE) {
    echo $result->name;
}

事务

事务是把一系列数据库操作当作一个逻辑单元执行,也就是说,事务中的一系列 SQL 语句要么都执行成功,要么都失败,事务的原子性能保证数据的一致性、安全性和持久性。

PDO 扩展中使用事务很容易,只需把想要执行的 SQL 语句放在 PDO 实例的 beginTransaction() 方法和 commit() 方法之间即可。

在 Laravel 框架中对事务操作进行了封装,我们可以这样调用:

DB::transaction(function () {
    DB::table('users')->update(['votes' => 1]);

    DB::table('posts')->delete();
}, 5);

相应的底层位于 ManagesTransactions trait 中:

还可以这样调用:

DB::beginTransaction();
try {
    DB::table('users')->update(['votes' => 1]);
    DB::table('posts')->delete();
    DB::commit();
} catch (PDOException $ex) {
    var_dump($ex);
    DB::rollback();
}

相应的底层实现同样位于 ManagesTransactions,可自行查看。

学院君 has written 716 articles

资深PHP工程师,Laravel学院院长

发表评论

标记为*的字段是必填项(邮箱地址不会被公开)

你可以使用这些HTML 标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>