PHP如何优雅封装数据库操作层提高项目可维护性与扩展性

各位 PHP 开发者朋友们,大家晚上好!

欢迎来到今天的讲座,主题是《PHP如何优雅封装数据库操作层:从“面条代码”到“米其林大餐”》。

我知道,在座的各位,只要干过两年代码,就没有哪个肩膀上不落着一层厚厚的“屎山”。特别是当你要去维护一个三年前的项目,发现数据库操作代码像是一团乱麻,所有的逻辑、SQL、业务判断都挤在一个 if-else 堆里,甚至还得在循环里去拼凑 SQL 语句的时候,你肯定想顺着网线爬过去,把当年的自己打一顿。

今天,我们不讲虚的,不讲什么抽象工厂模式的大道理(虽然我们会用到),我们就来实战。我们要把那些像“嚼蜡”一样的原生 mysqli_query,变成“入口即化”的高级封装。

准备好了吗?让我们把这门课程的菜单列出来:

  1. 拒绝重复造轮子:连接管理、异常处理。
  2. 披萨店的启示:为什么我们需要查询构建器。
  3. 流式接口的艺术:链式调用到底哪里优雅。
  4. 安全第一:SQL注入的克星与预编译。
  5. 多库切换:如何让系统具备“分身术”。
  6. 仓储模式:让业务逻辑与数据访问彻底分手。

第一部分:拒绝“裸奔”——基础封装层的构建

很多新手(甚至是老手)在写数据库操作时,第一反应是:$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 结构。

让我们回溯到 QueryBuilderget 方法。

// 危险的做法
$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;
    }
}

为什么这样优雅?

  1. 解耦AuthService 根本不关心 SQL 是怎么写的,也不关心数据库是 MySQL 还是 Oracle,它只知道 $user = $repo->find(...)
  2. 测试友好:你要测试 AuthService,不需要连真实的数据库。你只需要 Mock 一个假的 UserRepository,让它返回一个假对象,测试就能跑起来。
  3. 维护:如果以后数据库表名 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;
}

第九部分:总结(虽然我不喜欢总结,但不得不提)

我们今天干了什么?

  1. Singleton 把数据库连接管理得井井有条。
  2. QueryBuilder 把 SQL 拼接变成了像写英语句子一样的自然语言。
  3. Prepared Statements 给系统穿上了一层防弹衣,拒绝 SQL 注入。
  4. DatabaseManager 实现了多库切换的灵活性。
  5. Repository Pattern 实现了业务逻辑与数据访问的彻底分离。

封装的终极目的,不是为了让代码看起来花哨,而是为了降低认知负担
当你看到 UserRepository::getById(1),你不需要去想这是查哪张表、用不用加索引、是不是分库的表,你只需要知道“这行代码能拿到一个用户”。

这就是优雅。这就是可维护性与扩展性的源泉。

记住,代码是写给人看的,顺便给机器运行。如果你的封装让同事看不懂,或者让你三个月后看着想砸电脑,那就不叫优雅,那叫作恶。

好了,今天的讲座到此结束。希望大家回去之后,能把你那个布满 mysql_query 的项目重构一下。哪怕只重构一个模块,你的幸福感都会提升 500%。

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注