在PHP项目中,数据库操作几乎无处不在。PDO虽然功能强大,但如果在每个业务文件里都写一遍new PDO、拼接SQL、处理异常,代码会迅速变得混乱且充满安全隐患。合理的封装能把数据库访问收敛到统一的入口,让业务代码只关心SQL本身和数据处理。本文将系统讲解PDO封装的设计思路和具体实现。

为什么需要封装PDO:直接使用的问题分析
先看一段常见的反面代码。很多初学者会这样写数据库查询:
<?php // 反面示例:直接在业务代码中使用PDO $sql = "SELECT * FROM users WHERE name = '" . $_GET['name'] . "'"; $rows = $pdo->query($sql)->fetchAll();
这段代码存在三个严重问题。第一,字符串拼接SQL造成了典型的SQL注入漏洞,攻击者可以通过构造name参数执行任意SQL语句。第二,错误处理完全缺失,一旦查询失败程序会直接抛出未捕获的异常或者静默出错。第三,连接创建散落在各处,无法复用连接,也无法统一配置字符集、超时等参数。
封装的意义就在于解决这三个问题:通过预处理语句绑定参数杜绝注入,通过统一的异常处理让错误可追踪,通过集中管理连接让配置只写一次。此外,封装后还可以顺带实现读写分离、慢查询日志、查询缓存等高级特性,这些在裸用PDO时几乎不可能优雅地做到。
连接管理:单例模式与依赖注入的选择
单例模式实现
单例模式保证整个请求周期内只创建一个PDO连接,这是中小项目最常用的方式。核心思路是把构造函数设为私有,通过静态方法获取唯一实例:
<?php
class Database
{
private static ?PDO $instance = null;
private function __construct()
{
// 私有构造,禁止外部new
}
public static function getInstance(): PDO
{
if (self::$instance === null) {
$dsn = 'mysql:host=127.0.0.1;dbname=test;charset=utf8mb4';
self::$instance = new PDO($dsn, 'root', 'password', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_PERSISTENT => false,
]);
}
return self::$instance;
}
}
注意几个关键配置项。PDO::ATTR_ERRMODE设为异常模式,让所有数据库错误都以异常形式抛出,便于统一捕获;ATTR_DEFAULT_FETCH_MODE设为关联数组,避免到处写FETCH_ASSOC;ATTR_EMULATE_PREPARES设为false关闭模拟预处理,让MySQL真正使用服务端预处理,既能防止注入又能提升重复查询性能。
依赖注入方式
单例的缺点是隐式依赖和难以测试。在较大项目中,更推荐把PDO实例通过构造函数注入,配合容器管理。这样单元测试时可以轻松替换成内存数据库或者Mock对象:
<?php
class UserRepository
{
public function __construct(private PDO $pdo)
{
}
public function findByName(string $name): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE name = :name');
$stmt->execute([':name' => $name]);
$row = $stmt->fetch();
return $row ?: null;
}
}
// 在入口处组装依赖
$pdo = new PDO($dsn, 'root', 'password', $options);
$repo = new UserRepository($pdo);
两种方式并不冲突。单体项目可以用单例快速起步,当项目规模扩大、需要写测试时,再逐步迁移到依赖注入。
查询封装:预处理、取值与异常的统一处理
有了连接管理,下一步是封装常用的增删改查方法。目标是让业务代码一行就能完成查询,同时自动处理参数绑定和异常转换:
<?php
class Db
{
public function __construct(private PDO $pdo)
{
}
// 查询多行
public function select(string $sql, array $params = []): array
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// 查询单行
public function selectOne(string $sql, array $params = []): ?array
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
$row = $stmt->fetch();
return $row === false ? null : $row;
}
// 查询单个标量值,例如COUNT
public function scalar(string $sql, array $params = [])
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchColumn();
}
// 写操作,返回受影响行数
public function execute(string $sql, array $params = []): int
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->rowCount();
}
// 插入并返回自增ID
public function insert(string $sql, array $params = []): int
{
$this->execute($sql, $params);
return (int) $this->pdo->lastInsertId();
}
}
使用时业务代码非常简洁,例如$db->selectOne('SELECT * FROM users WHERE id = ?', [42])。参数全部走占位符绑定,从根源上避免了SQL注入。无论使用命名占位符还是问号占位符,都不要手动拼接变量进SQL字符串,这是封装的第一铁律。
异常处理方面,建议不要在每个方法里try-catch后吞掉异常。数据库错误应该让它向上抛出,在最外层统一捕获并记录日志、返回友好错误信息。如果确实需要转换异常类型,可以做一层自定义异常:
<?php
class DbException extends RuntimeException
{
}
try {
$db->execute('UPDATE users SET name = ? WHERE id = ?', ['tom', 42]);
} catch (PDOException $e) {
// 记录日志,包含错误码和SQL信息
error_log('DB Error: ' . $e->getMessage());
throw new DbException('数据操作失败', 0, $e);
}
事务封装与进阶技巧
涉及多次写操作时必须使用事务保证原子性。手写begin、commit、rollBack容易漏掉回滚,封装成回调方式最安全:
<?php
public function transaction(callable $callback)
{
$this->pdo->beginTransaction();
try {
$result = $callback($this);
$this->pdo->commit();
return $result;
} catch (Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
// 使用示例:转账操作
$db->transaction(function (Db $db) {
$db->execute('UPDATE accounts SET balance = balance - 100 WHERE id = ?', [1]);
$db->execute('UPDATE accounts SET balance = balance + 100 WHERE id = ?', [2]);
});
这种写法把commit和rollBack固化在框架层,业务代码只关注逻辑,任何一步抛异常都会自动回滚,不存在忘记回滚导致连接状态残留的问题。
再补充几个进阶实践。一是长连接:高并发场景下可以开启PDO::ATTR_PERSISTENT减少连接建立开销,但要注意事务中断可能残留连接状态,需要谨慎评估。二是IN查询的处理:预处理不支持直接绑定数组,可以先构造占位符再绑定:
<?php
$ids = [1, 2, 3];
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$sql = "SELECT * FROM users WHERE id IN ($placeholders)";
$rows = $db->select($sql, $ids);
三是慢查询监控:可以在封装层记录执行时间,超过阈值的SQL写入日志,为后续优化提供数据。四是不要过度封装:如果封装层最终演变成一套简易ORM或者查询构造器,不如直接引入成熟的方案。对于简单项目,上面这套百行左右的封装已经足够;对于复杂项目,可以考虑在封装层之上为每张表建立Repository类,把SQL集中管理,让业务逻辑与数据访问彻底解耦。这套分层思路无论项目大小都适用,是PDO封装最值得坚持的最佳实践。