各位 PHP 开发者朋友们,大家晚上好!
欢迎来到今天的讲座,主题是《PHP如何优雅封装数据库操作层:从“面条代码”到“米其林大餐”》。
我知道,在座的各位,只要干过两年代码,就没有哪个肩膀上不落着一层厚厚的“屎山”。特别是当你要去维护一个三年前的项目,发现数据库操作代码像是一团乱麻,所有的逻辑、SQL、业务判断都挤在一个 if-else 堆里,甚至还得在循环里去拼凑 SQL 语句的时候,你肯定想顺着网线爬过去,把当年的自己打一顿。
今天,我们不讲虚的,不讲什么抽象工厂模式的大道理(虽然我们会用到),我们就来实战。我们要把那些像“嚼蜡”一样的原生 mysqli_query,变成“入口即化”的高级封装。
准备好了吗?让我们把这门课程的菜单列出来:
- 拒绝重复造轮子:连接管理、异常处理。
- 披萨店的启示:为什么我们需要查询构建器。
- 流式接口的艺术:链式调用到底哪里优雅。
- 安全第一:SQL注入的克星与预编译。
- 多库切换:如何让系统具备“分身术”。
- 仓储模式:让业务逻辑与数据访问彻底分手。
第一部分:拒绝“裸奔”——基础封装层的构建
很多新手(甚至是老手)在写数据库操作时,第一反应是:$conn = new mysqli(...)。这没问题,就像你饿了想吃饭,直接去厨房拿生米一样。
但问题是,如果你每次都要写那一串连接字符串,还要处理各种报错,还要关闭连接,你的代码会迅速膨胀。而且,PHP 的 Apache/Nginx 进程模型决定了,频繁创建销毁数据库连接是性能的大忌。
我们要建立一个“连接池”的概念。在单例模式的世界里,我们只需要一个门面(Facade),来管理唯一的连接。
1.1 配置与单例
首先,我们得有个地方放配置。不要去读 .env 文件,太慢了。我们就在代码里写个常量。
<?php
// Database.php
class Database
{
private static ?Database $instance = null;
private ?PDO $connection = null;
// 私有化构造函数,防止外部 new
private function __construct()
{
$dsn = "mysql:host=localhost;dbname=my_project;charset=utf8mb4";
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, // 报错模式
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, // 默认查出来的数组是关联数组
PDO::ATTR_EMULATE_PREPARES => false, // 真正的预编译,性能好
];
try {
// 这里是“米其林大厨”的第一次登场
$this->connection = new PDO($dsn, 'root', 'password', $options);
} catch (PDOException $e) {
// 生产环境别直接打印,但要记录日志
die("系统连接数据库失败,请检查配置。详情:" . $e->getMessage());
}
}
// 获取单例实例
public static function getInstance(): self
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
// 获取连接对象
public function getConnection(): PDO
{
return $this->connection;
}
// 禁止克隆
private function __clone() {}
// 禁止反序列化
public function __wakeup()
{
throw new Exception("Cannot unserialize singleton");
}
}
看,这就像是你开了一家餐厅,进门只有一个服务员,不需要每来一个客人就开一个新厨房。无论你调用多少次 Database::getInstance(),拿到的都是同一个连接对象。
第二部分:披萨店的启示——查询构建器
有了连接,我们怎么查数据?
如果还是用 $db->query('SELECT * FROM users WHERE id = 1'),这太枯燥了。而且万一以后我们要加个 ORDER BY 或者 LIMIT,你还得去拼字符串。
这时候,我们要引入 Query Builder(查询构建器) 的概念。它就像是一家披萨店,你有无数的配料,你可以点:一半芝士、一半培根、不要洋葱、加个蛋。
核心思想:所有的操作都返回 self,这样你就可以连续调用方法。
2.1 QueryBuilder 的实现
<?php
class QueryBuilder
{
private PDO $pdo;
private string $table;
private array $wheres = [];
private array $joins = [];
private string $type = 'select'; // select, insert, update, delete
private array $data = []; // 用于 insert/update 的数据
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
// 1. 表名设置
public function table(string $table): self
{
$this->table = $table;
return $this;
}
// 2. Select 操作
public function select(array $columns = ['*']): self
{
$this->type = 'select';
$this->columns = $columns;
return $this;
}
// 3. Where 条件 (这是重点,我们要动态拼接 SQL)
public function where(string $column, string $operator, $value): self
{
$this->wheres[] = [
'column' => $column,
'operator' => $operator,
'value' => $value, // 这里其实是占位符
'condition' => 'AND'
];
return $this;
}
// 封装一个简单的 where 等于
public function whereEq(string $column, $value): self
{
return $this->where($column, '=', $value);
}
// 4. 执行查询
public function get(): array
{
if ($this->type !== 'select') {
throw new Exception("Only select query supports get method");
}
// 构建 SQL
$sql = "SELECT " . implode(', ', $this->columns) . " FROM {$this->table}";
// 处理 Join
if (!empty($this->joins)) {
$sql .= ' ' . implode(' ', $this->joins);
}
// 处理 Where
if (!empty($this->wheres)) {
$whereParts = [];
$params = [];
foreach ($this->wheres as $index => $where) {
// 生成类似 :where_0 的占位符
$paramKey = ":where_{$index}";
$whereParts[] = "{$where['column']} {$where['operator']} {$paramKey}";
$params[$paramKey] = $where['value'];
}
$sql .= " WHERE " . implode(' ', $whereParts);
}
// 执行
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// 5. Insert 操作
public function insert(array $data): int
{
$this->type = 'insert';
$this->data = $data;
$columns = implode(', ', array_keys($data));
$placeholders = ':' . implode(', :', array_keys($data));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
$stmt = $this->pdo->prepare($sql);
$stmt->execute($data);
return (int) $this->pdo->lastInsertId();
}
}
2.2 使用示例:从地狱到天堂
想象一下,以前的代码是这样的:
// 恶心的代码
$sql = "SELECT * FROM users WHERE id > " . $minId . " AND status = 'active' ORDER BY created_at DESC LIMIT 10";
$result = $db->query($sql);
foreach ($result as $row) {
// 业务逻辑...
}
现在,看看这行代码:
// 优雅的代码
$users = (new QueryBuilder(Database::getInstance()->getConnection()))
->table('users')
->select(['id', 'username', 'email'])
->whereEq('status', 'active')
->where('id', '>', 10) // 支持链式,不用再写那个恶心的大号 where 字符串了
->orderBy('created_at', 'DESC') // 拉个友好的扩展方法
->limit(10)
->get();
foreach ($users as $user) {
// 业务逻辑...
}
看到了吗?这就是可读性。你的代码在自我介绍:“我要去 users 表,我要选这几个字段,条件是状态是激活的,ID大于10,按时间倒序,只取10条。”
第三部分:SQL注入的克星——预编译
很多封装如果不注意安全,那就是在喂用户吃毒药。SQL注入之所以可怕,是因为用户可以修改你的 SQL 结构。
让我们回溯到 QueryBuilder 的 get 方法。
// 危险的做法
$whereParts[] = "{$where['column']} {$where['operator']} {$where['value']}";
// 如果 $where['value'] 是 "1 OR 1=1",SQL 就崩了。
我们的代码已经使用了 prepare。这就像是把 SQL 变成了一个带模板的汉堡,而你给进去的参数只是生菜和芝士。prepare 不会把参数当成代码执行,而永远是当成数据执行。
所以,封装的核心不仅仅是把代码变短,更是把安全性固化为标准。
第四部分:流式接口的艺术——为什么要有链式调用?
你可能会问:“老哥,这代码虽然好看,但我写起来麻烦啊,我又不能一行写完。”
这就触及到流式接口 的本质了。它的好处不仅仅是“一行写完”,而是组合性。
假设你是一个服务层,你想获取用户,并且必须保证用户是“已验证”的。你不能在 QueryBuilder 里面写死这个逻辑,因为查订单的时候你就不想验证用户了。
这时候,你的 QueryBuilder 变成了通用的工具。
// 场景:查找活跃的VIP用户
$vips = (new QueryBuilder($pdo))
->table('users')
->join('orders', 'users.id', '=', 'orders.user_id') // 自动处理 join
->whereEq('users.is_vip', 1)
->whereEq('orders.status', 'paid')
->groupBy('users.id')
->having('COUNT(orders.id) > ?', 5) // 额外的聚合条件
->select('users.*')
->get();
你看,这里的 -> 就像是一条流水线。数据从 users 流进来,经过 join(加工),经过 where(筛选),最后流到 get()(端盘子上菜)。
这种封装极大地提高了复用性。你不需要为“查所有用户”、“查活跃用户”、“查VIP用户”分别写三个不同的 SQL 拼接函数。
第五部分:多库切换——让系统具备“分身术”
一个大型项目,往往会有多个数据库。比如,用户数据在主库,日志数据在从库,报表数据在另一个 ES 集群(虽然这里讲的是 SQL,但思路一样)。
如果不封装,你每次都要判断 $useMaster ? new PDO($master) : new PDO($slave),代码里全是 if。
我们要引入一个 DatabaseManager(数据库管理器)。
5.1 抽象工厂模式的应用
class DatabaseManager
{
private array $connections = [];
public function connection(string $name = 'default'): PDO
{
if (!isset($this->connections[$name])) {
$config = $this->getConfig($name); // 从配置文件读
$this->connections[$name] = new PDO(
$config['dsn'],
$config['user'],
$config['pass'],
$config['options']
);
}
return $this->connections[$name];
}
private function getConfig(string $name): array
{
// 这里可以读取复杂的配置,支持读写分离
if ($name === 'master') {
return [
'dsn' => 'mysql:host=master-host...',
'user' => 'root',
'pass' => 'pwd'
];
}
return [
'dsn' => 'mysql:host=slave-host...',
'user' => 'root',
'pass' => 'pwd'
];
}
}
然后,我们的 QueryBuilder 不再持有具体的 PDO,而是通过构造函数注入:
class QueryBuilder
{
// 改为注入一个 connection key
public function __construct(private string $connectionName)
{
$this->pdo = DatabaseManager::getInstance()->connection($this->connectionName);
}
}
使用:
// 查用户在主库
(new QueryBuilder('master'))->table('users')->get();
// 查日志在从库
(new QueryBuilder('slave'))->table('logs')->get();
当老板突然说:“我们分库分表了,用户表切到 db1 去了!” 你只需要改一下 getConfig 函数里的逻辑,或者改个配置文件,所有的查询代码完全不需要动。
这就是可扩展性。
第六部分:仓储模式——业务逻辑与数据访问的“离婚”
最后,我们要谈谈架构的顶层设计。如果数据库层已经封装得很好了,是不是就不用再封装了?
当然不是!数据库操作层只负责“怎么查”,不负责“查什么”。
比如,你要写一个“用户登录”的业务。
// 不好的做法
$user = $db->query("SELECT * FROM users WHERE username = '$username'")->fetch();
if ($user && password_verify($password, $user['password'])) {
$_SESSION['user_id'] = $user['id'];
}
你看,这里不仅有 SQL,还有 Session 管理,还有密码验证。如果以后要改成 MemCache 缓存用户信息,或者改成 SSO 单点登录,你得把 db->query 那一行代码到处改。
我们要引入 Repository(仓储) 模式。
6.1 定义接口
interface UserRepositoryInterface
{
public function findByUsername(string $username): ?UserEntity;
public function save(UserEntity $user): bool;
}
// 用户实体
class UserEntity {
public $id;
public $username;
// ...
}
// 实现
class UserRepository implements UserRepositoryInterface
{
private QueryBuilder $qb;
public function __construct(QueryBuilder $qb)
{
$this->qb = $qb;
}
public function findByUsername(string $username): ?UserEntity
{
$rows = $this->qb
->table('users')
->whereEq('username', $username)
->get();
if (empty($rows)) {
return null;
}
// 把数组变成对象,数据脱敏
return new UserEntity($rows[0]);
}
}
6.2 业务逻辑层
class AuthService
{
private UserRepository $userRepository;
public function __construct(UserRepository $userRepository)
{
$this->userRepository = $userRepository;
}
public function login(string $username, string $password): bool
{
$user = $this->userRepository->findByUsername($username);
if ($user === null) {
return false;
}
if (password_verify($password, $user->passwordHash)) {
// 登录成功逻辑
return true;
}
return false;
}
}
为什么这样优雅?
- 解耦:
AuthService根本不关心 SQL 是怎么写的,也不关心数据库是 MySQL 还是 Oracle,它只知道$user = $repo->find(...)。 - 测试友好:你要测试
AuthService,不需要连真实的数据库。你只需要 Mock 一个假的UserRepository,让它返回一个假对象,测试就能跑起来。 - 维护:如果以后数据库表名
users改成了sys_accounts,你只需要改UserRepository里的$this->qb->table('sys_accounts'),其他代码全部不动。
第七部分:实战演练——重构你的代码
假设你有一个 Controller,现在的代码是这样的:
<?php
// OldController.php
$conn = new mysqli('localhost', 'root', '', 'shop');
// 获取列表
$sql = "SELECT * FROM products WHERE category_id = {$catId} AND stock > 0 ORDER BY price DESC";
$res = $conn->query($sql);
// 计算总价
$total = 0;
while ($row = $res->fetch_assoc()) {
$total += $row['price'];
echo "<div>{$row['name']} - {$row['price']}</div>";
}
// 获取详情
$sql2 = "SELECT * FROM orders WHERE user_id = {$userId} AND status = 'pending'";
$res2 = $conn->query($sql2);
// ... 处理订单
如果我们应用今天讲的封装:
<?php
// NewController.php
// 1. 使用单例连接
$db = Database::getInstance();
// 2. 使用查询构建器
$products = (new QueryBuilder($db->getConnection()))
->table('products')
->whereEq('category_id', $catId)
->where('stock', '>', 0)
->orderBy('price', 'DESC')
->get();
// 3. 使用仓储
$productRepo = new ProductRepository($db->getConnection());
$orderRepo = new OrderRepository($db->getConnection());
$orders = $orderRepo->getPendingOrders($userId);
对比一下:
原来的代码,$conn 在第一行就实例化了,但后面才用。如果在中间抛出异常,连接可能没关闭。
原来的代码,SQL 字符串里直接插变量,一旦 $catId 是个恶意的数字,数据库就挂了。
原来的代码,所有的业务逻辑(循环算价、打印 HTML)都混在一起。
新的代码,职责清晰。QueryBuilder 只管 SQL 生成,ProductRepository 只管商品数据,Controller 只管渲染。
第八部分:进阶技巧——事务与性能
光有封装还不够,还得有管理。
8.1 事务管理
在电商系统中,扣库存和创建订单必须同时成功或同时失败。
class Database {
// ...
public function beginTransaction(): bool
{
return $this->connection->beginTransaction();
}
public function commit(): bool
{
return $this->connection->commit();
}
public function rollBack(): bool
{
return $this->connection->rollBack();
}
}
我们在业务层可以这样用:
$db->beginTransaction();
try {
$productRepo->decreaseStock($productId, $qty);
$orderRepo->create($orderData);
$db->commit();
} catch (Exception $e) {
$db->rollBack();
throw $e;
}
8.2 慢查询日志
封装层可以自动记录执行的 SQL 和耗时,这对于排查性能问题至关重要。
public function get(): array
{
$startTime = microtime(true);
// ... 执行 SQL ...
$duration = microtime(true) - $startTime;
if ($duration > 1.0) { // 超过1秒报警
error_log("Slow Query detected: {$sql} took {$duration}s");
}
return $result;
}
第九部分:总结(虽然我不喜欢总结,但不得不提)
我们今天干了什么?
- 用 Singleton 把数据库连接管理得井井有条。
- 用 QueryBuilder 把 SQL 拼接变成了像写英语句子一样的自然语言。
- 用 Prepared Statements 给系统穿上了一层防弹衣,拒绝 SQL 注入。
- 用 DatabaseManager 实现了多库切换的灵活性。
- 用 Repository Pattern 实现了业务逻辑与数据访问的彻底分离。
封装的终极目的,不是为了让代码看起来花哨,而是为了降低认知负担。
当你看到 UserRepository::getById(1),你不需要去想这是查哪张表、用不用加索引、是不是分库的表,你只需要知道“这行代码能拿到一个用户”。
这就是优雅。这就是可维护性与扩展性的源泉。
记住,代码是写给人看的,顺便给机器运行。如果你的封装让同事看不懂,或者让你三个月后看着想砸电脑,那就不叫优雅,那叫作恶。
好了,今天的讲座到此结束。希望大家回去之后,能把你那个布满 mysql_query 的项目重构一下。哪怕只重构一个模块,你的幸福感都会提升 500%。
谢谢大家!