学完这篇,你能分清 PDO、查询构造器、ORM 这三层各自该干什么,并照着写出一套够用的数据库封装,知道什么时候该停下来、什么时候该继续往上加。
第一步:PDO 连接层,把三件事定死
先写一个最小的连接封装,一个静态实例就够,重点是三个属性必须设:
final class Db
{
private static ?PDO $pdo = null;
public static function pdo(): PDO
{
if (self::$pdo === null) {
$dsn = 'mysql:host=127.0.0.1;dbname=forum;charset=utf8mb4';
self::$pdo = new PDO($dsn, 'user', 'pass', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return self::$pdo;
}
}
- 错误走异常,别再用 `if ($st === false)` 满地判断
- 默认返回关联数组,省掉每处的 `fetch(PDO::FETCH_ASSOC)`
- 关掉模拟预处理,让 MySQL 真正做预处理,类型更准、注入面更小
注意:`ATTR_EMULATE_PREPARES => false` 之后,同一个命名占位符不能重复使用(`WHERE a=:x OR b=:x` 不通),要写成两个参数。老代码迁移时最容易在这里翻车。
第二步:抽一层便捷方法
业务代码里不该到处出现 `prepare / execute / fetch`,统一收口:
public static function run(string $sql, array $bind = []): PDOStatement
{
$st = self::pdo()->prepare($sql);
$st->execute(array_values($bind));
return $st;
}
public static function all(string $sql, array $bind = []): array
{
return self::run($sql, $bind)->fetchAll();
}
public static function one(string $sql, array $bind = []): ?array
{
$row = self::run($sql, $bind)->fetch();
return $row === false ? null : $row;
}
public static function value(string $sql, array $bind = [])
{
return self::run($sql, $bind)->fetchColumn();
}
再配一个表名函数处理前缀,例如 `t('threads')` 返回 `pre_threads`。
注意:前缀拼接只在这一个函数里做。如果每个 SQL 手写 `pre_threads`,将来改前缀就是全项目搜索替换。
第三步:查询构造器,把「拼 SQL」收口成一个类
到这一步,业务层只写链式,SQL 由构造器生成,值一律走占位符:
$list = Db::table('threads')
->where('status', 1)
->whereIn('fid', [3, 7, 9])
->orderBy('dateline', 'desc')
->limit(20)
->get();
实现要点只有四条:
- `where()` 支持 `(字段, 值)` 和 `(字段, 操作符, 值)` 两种签名
- 值全部推进 bind 数组,SQL 串里只出现 `?`
- `whereIn` 按数组长度生成 `?,?,?`,不是把值拼进去
- 排序字段、分组字段必须白名单
public function whereIn(string $col, array $vals): static
{
$this->checkColumn($col);
$ph = implode(',', array_fill(0, count($vals), '?'));
$this->where[] = "$col IN ($ph)";
array_push($this->bind, ...array_values($vals));
return $this;
}
注意:表名、字段名、排序方向是「结构」不是「值」,占位符保护不了它们。 必须用白名单或 `preg_match('/^[A-Za-z0-9_]+$/', $col)` 卡死。这是自制查询构造器最常见的漏洞点,比忘写占位符危险得多。
第四步:事务和慢查询日志一起做
事务包成一个闭包,异常自动回滚:
public static function transaction(callable $fn)
{
$pdo = self::pdo();
$pdo->beginTransaction();
try {
$r = $fn();
$pdo->commit();
return $r;
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
慢查询记录直接塞进 `run()` 里:`microtime(true)` 前后夹一下,超过阈值(比如 200ms)就把 SQL、bind、耗时、调用文件行号写进日志。
注意:MySQL 不支持嵌套事务,循环里再开一层事务只会静默失效。确实需要嵌套就用计数器模拟 savepoint。
第五步:什么时候才该上 ORM
该上的信号:表 20 张以上、一对多/多对多关联在业务里反复出现、多人协作需要统一的模型约定。
不该上的信号:表不到 10 张、查询以「列表 + 详情」为主、不想引入 Composer 依赖。无框架、无需 Composer 的轻量系统(比如本社区在用的 Clara BBS 这类)通常就是 PDO 加查询构造器一路走到底,够用、好排查、无额外学习成本。
真上了 ORM,第一件要防的事是 N+1:列表页循环里查关联,100 条帖子会变成 101 条 SQL。解法是预加载(`with` / eager loading),不是把 ORM 撤掉。
第六步:推荐的演进节奏
- 裸 PDO + 手写 SQL——能跑,但 SQL 散落各处
- 加便捷方法层——`all/one/value/insert/update` 统一入口,占位符强制
- 加查询构造器——读多写少、条件组合多的场景收益最大
- 加表模型层——一张表一个类,定义可写字段白名单、默认值、时间戳
- 需要关联和复杂查询时,再考虑完整 ORM
核心原则只有一句:上层只依赖 `Db::table()` 这一组接口。 底层从 PDO 换成构造器、再换成 ORM,调用方代码不用动,这套封装才算演进得对。
小结
- PDO 层只管连接和三个关键属性,异常模式、关联数组、关闭模拟预处理
- 便捷方法层消灭重复的 prepare/execute,所有值走占位符
- 查询构造器只保护「值」,「结构」(表名、字段名、排序方向)必须白名单
- 事务用闭包封装,慢查询日志内置在统一执行入口
- 表少、关联简单就别上 ORM;上了 ORM 先解决 N+1
- 演进的关键是接口稳定,而不是一步到位