PHP 之道

PHP 数据库

在大多数情况下,你的 PHP 代码都需要使用数据库来持久化存储信息。在连接和操作数据库时,你有几种不同的选择。在 PHP 5.1.0 之前,官方推荐的做法是使用原生驱动(Native drivers),例如 mysqlipgsqlmssql 等。

如果你的应用程序只使用一种数据库,原生驱动是非常棒的选择。但是,举个例子,如果你的项目大部分使用 MySQL,又用到了一点点 MSSQL,或者你中途需要连接到 Oracle 数据库,此时你就不能再使用相同的驱动代码了。你将不得不为每一种数据库去学习一套全新的 API——这显然是非常荒谬的。

1. MySQL 扩展 (历史遗留问题)

PHP 极其古老的 mysql 扩展已经被以下两个扩展彻底取代:

  • mysqli (MySQL Improved)
  • pdo (PHP Data Objects)

mysql 扩展不仅在很久以前就停止了开发,而且已在 PHP 7.0 中被官方正式彻底移除

为了免去翻找 php.ini 配置文件来确认你正在使用哪个模块的麻烦,一个简单的方法是在你常用的代码编辑器中全局搜索 mysql_*。如果代码中出现了诸如 mysql_connect()mysql_query() 这样的函数,说明你的项目仍在危险地使用已废弃的 mysql 扩展。

即使你目前使用的还不是 PHP 7.x 或更高的版本,如果不尽早将升级提上日程,当 PHP 版本最终不得不更新时,你将面临巨大的痛苦。最好的做法是:在日常开发计划中,逐步将旧有的 mysql 用法替换为 mysqliPDO,这样日后就不会手忙脚乱了。

重要警告: 如果你正准备从 mysql 升级到 mysqli,请千万警惕那些告诉你“只需简单地把 mysql_* 查找替换为 mysqli_* 即可”的懒人教程。这不仅是对升级过程的严重过度简化,而且会让你完全错失 mysqli 带来的核心优势,比如参数绑定 (parameter binding)(PDO 同样提供此功能)。

延伸阅读:

2. PDO 扩展 (PHP Data Objects)

PDO 是一个数据库连接抽象库——自 PHP 5.1.0 起内置于 PHP 中——它为你提供了一个通用的接口,用来与多种不同的数据库进行通信。

例如,你可以使用基本上完全相同的代码来连接并查询 MySQL 或 SQLite:

<?php
// PDO + MySQL 示例
$pdo = new PDO('mysql:host=example.com;dbname=database', 'user', 'password');
$statement = $pdo->query("SELECT some_field FROM some_table");
$row = $statement->fetch(PDO::FETCH_ASSOC);
echo htmlentities($row['some_field']);

// PDO + SQLite 示例
$pdo = new PDO('sqlite:/path/db/foo.sqlite');
$statement = $pdo->query("SELECT some_field FROM some_table");
$row = $statement->fetch(PDO::FETCH_ASSOC);
echo htmlentities($row['some_field']);

需要注意的是,PDO 不会帮你自动翻译 SQL 查询语句,也不会模拟某个数据库缺失的功能;它纯粹只是为了让你能用一套相同的 API 去连接多种类型的数据库。

2.1 防御 SQL 注入攻击

更重要的一点是,PDO 允许你安全地将外部输入(例如 ID)注入到你的 SQL 查询中,而完全不必担心数据库受到 SQL 注入攻击。 这是通过 PDO 语句和绑定参数(bound parameters)来实现的。

假设一个 PHP 脚本通过 URL 查询参数接收到了一个数字 ID,并用这个 ID 从数据库中获取用户记录。下面是极其错误的做法:

<?php
$pdo = new PDO('sqlite:/path/db/users.db');
// 错误示范:绝对不要这么做!
$pdo->query("SELECT name FROM users WHERE id = " . $_GET['id']);

这是一段糟糕透顶的代码。你直接把未经处理的原始查询参数插入到了 SQL 查询中。这会让你分分钟被黑客利用一种称为 SQL 注入 (SQL Injection) 的手段攻破。想象一下,如果黑客构造了一个巧妙的 id 参数,访问这样一个 URL:http://domain.com/?id=1%3BDELETE+FROM+users。这会将 $_GET['id'] 变量设置为 1;DELETE FROM users,直接把你的所有用户数据删光!

相反,你应该使用 PDO 的绑定参数来清理(转义)ID 输入:

<?php
$pdo = new PDO('sqlite:/path/db/users.db');

// 1. 准备一条带有占位符 (:id) 的 SQL 语句
$stmt = $pdo->prepare('SELECT name FROM users WHERE id = :id');

// 2. 首先过滤你的数据(这对于 INSERT、UPDATE 等操作尤为重要)
$id = filter_input(INPUT_GET, 'id', FILTER_SANITIZE_NUMBER_INT); 

// 3. 将变量绑定到占位符上(PDO 会自动针对 SQL 语法进行安全清理和转义)
$stmt->bindParam(':id', $id, PDO::PARAM_INT); 

// 4. 执行查询
$stmt->execute();

这才是正确的代码。它在 PDO 语句中使用了绑定参数。在外部输入(ID)被引入数据库之前,它对其进行了安全的转义,彻底杜绝了潜在的 SQL 注入攻击。

请注意:对于写入操作(比如 INSERT 或 UPDATE),在传入 PDO 之前,你仍然需要先过滤你的数据,并针对其他场景进行清理(比如移除恶意的 HTML 标签、JavaScript 等防范 XSS 攻击)。因为 PDO 的清理仅仅针对 SQL 安全,并不负责你应用层面的安全逻辑。

延伸阅读: 学习 PDO

2.2 关闭数据库连接

你还需要意识到,数据库连接会占用系统资源。如果在用完后没有隐式地关闭连接,导致资源耗尽的情况并非闻所未闻(尽管这在其他编程语言中更常见)。

使用 PDO,你可以通过销毁对象来隐式关闭连接。具体做法是确保所有指向该 PDO 对象的引用都被删除,即将其设置为 NULL。如果你没有显式地这样做,PHP 也会在脚本执行结束时自动为你关闭连接 —— 当然,除非你使用的是持久化连接 (persistent connections)。

延伸阅读: 学习 PDO 连接管理

3. 代码架构:与数据库交互的正确姿势

当开发者刚开始学习 PHP 时,他们经常会把数据库交互代码和页面展示逻辑混杂在一起,写出来的代码往往像这样:

<ul>
<?php
foreach ($db->query('SELECT * FROM table') as $row) {
    echo "<li>".$row['field1']." - ".$row['field1']."</li>";
}
?>
</ul>

这种做法有很多弊端,最主要的是它极难调试、极难测试、可读性极差;而且如果你忘了在 SQL 语句里加上 LIMIT,它可能会一下子输出成千上万行数据导致页面崩溃。

无论你倾向于面向对象编程 (OOP) 还是函数式编程,解决这个问题的方案有很多,但核心原则只有一个:必须要有分离 (Separation)

考虑最基础的改进步骤(函数分离):

<?php
// 第一步改进:将数据获取逻辑封装到函数中
function getAllFoos($db) {
    return $db->query('SELECT * FROM table');
}

$results = getAllFoos($db);
foreach ($results as $row) {
    echo "<li>".$row['field1']." - ".$row['field1']."</li>"; // 仍然不够好!混合了展示逻辑
}

这已经是一个好的开始了。接下来,把这两部分内容拆分到两个不同的文件中,你就实现了清晰的职责分离。

为那个查询方法创建一个专门的类,你就拥有了一个 “模型 (Model)”。创建一个简单的 .php 文件专门放置 HTML 展示逻辑,你就拥有了一个 “视图 (View)”。这已经非常接近 MVC (Model-View-Controller) 了——这是现代 PHP 框架最普遍采用的 OOP 架构设计。

控制器:foo.php

<?php
$db = new PDO('mysql:host=localhost;dbname=testdb;charset=utf8mb4', 'username', 'password');

// 引入你的模型类
include 'models/FooModel.php';

// 创建模型实例
$fooModel = new FooModel($db);

// 获取 Foo 的数据列表
$fooList = $fooModel->getAllFoos();

// 引入视图进行页面展示
include 'views/foo-list.php';

模型:models/FooModel.php

<?php
class FooModel
{
    // 利用 PHP 8 的构造器属性提升语法注入 PDO 实例
    public function __construct(protected PDO $db)
    {
    }

    public function getAllFoos() {
        return $this->db->query('SELECT * FROM table');
    }
}

视图:views/foo-list.php

<?php foreach ($fooList as $row): ?>
    <li><?= $row['field1'] ?> - <?= $row['field1'] ?></li>
<?php endforeach ?>

这本质上就是大多数现代框架在做的事情,尽管手动实现显得稍微繁琐了一些。你可能不需要每次都做这么全面,但是,如果你未来有对应用程序进行单元测试 (unit-test) 的计划,将页面展示逻辑和数据库交互过度混合绝对会成为一个巨大的灾难。

4. 数据库抽象层 (Abstraction Layers)

许多 PHP 框架都提供了自己的数据库抽象层,它们有些是构建在 PDO 之上的,有些则不是。

这些抽象层通常会在 PHP 方法中包装你的查询语句,从而为一个数据库系统“模拟”出它本身缺失、但在另一个数据库系统中存在的功能。这为你提供了真正的数据库抽象(例如 ORM 或 DBAL),而不仅仅是 PDO 提供的“连接层抽象”。

当然,这会增加一点点性能开销。但是,如果你正在构建一个需要跨平台运行、必须兼容 MySQL、PostgreSQL 和 SQLite 的通用应用程序,为了代码的整洁度和可移植性,付出一点点性能开销是完全值得的。

社区中也有一些遵循 PSR-0PSR-4 命名空间标准的优秀独立抽象层库,你可以通过 Composer 将它们轻松安装到任何你喜欢的应用程序中: