资讯动态

手写极简PHP查询构建器caveman:让SQL透明可控

发布时间:2026/10/29 15:00:45 来源:尧图企业网站定制
事情得从两年前说起。当时我在一个中大型项目里维护一套 Laravel 代码表面上一切很美好但一旦线上出问题就是灾难一个列表页莫名其妙多出几十条 SQL某个关联查询把数据库连接池打满翻 Eloquent 源码翻了三个小时也没搞明白是哪层作用域加了条件。后来我痛下决心在另一个独立模块里把 ORM 换成了自己写的极简查询构建器代号就叫caveman。项目的定位很简单不追求强大抽象只追求直白可控像穴居人一样用最原始的方式把 SQL 表达清楚。它能做的是让 PHP 开发者用链式方法安全地拼 SQL拿到关联数组或轻模型结果同时不隐藏任何一条最终执行语句。如果你也被各种 ORM 的黑魔法坑过或者在中小型项目里既不想背一整个框架、又不想每次手写 PDO 绑定参数这篇文章应该正合口味。我会从设计思路、核心源码、周边功能到排查经验把这个项目的里里外外拆一遍你甚至可以照着代码自己撸一个出来。1. Caveman在解决什么问题1.1 被重ORM折腾过的人才会懂返祖的诱惑ORM 时代我们习惯写$user-posts()-where(published true)-get()看起来优雅但问题就藏在看起来里。几个月后代码需要优化你根本不知道这条链式调用背后生成了什么样的 SQL是不是又触发了一次隐式的关联查询。我在实践中碰到过最夸张的一次一个简单的分页接口因为关联预加载失效硬生生打出了 N1 条查询而问题定位花了整整两天。Caveman 的第一原则叫做SQL 可见。它不是一个完整的 ORM而是一个处于原生 PDO 与重度 ORM 之间的查询构建器。你写where就是 where写orderBy就是 orderBy最终生成的 SQL 通过toSql()方法可以直接打印绑定参数也全部存放在公开属性的数组里。这种返祖的设计不是技术倒退而是刻意把不可控的因素削减到最少让每一行查询都像你亲手写出来的一样清晰。另外大部分 ORM 的重量来自事件系统、中间件、全局作用域、关联装载器这些附加能力。它们确实强大但如果你只是想让一个 CRUD 后台跑得明白这些机制大部分时间都在消耗内存和阅读成本。Caveman 砍掉了所有它认为不属于查询本身的东西换来的是单个构建器类不到一千行代码算上模型和集合辅助类整个库的体积也足够小可以无痛塞进任何已有项目。1.2 整体设计不是ORM是准ORMCaveman 的定位我习惯称为准 ORM。它保留了三层结构连接层Connection、查询构建层QueryBuilder、薄模型层Model但没有实体元数据、没有注解解析、没有事件钩子。为什么不做成完整 ORM因为一旦引入元数据映射就必须处理类型转换、关联关系、继承映射这一堆复杂的规则而这些规则恰恰是黑盒的源头。Caveman 选择把对象映射这个能力做成可选的薄壳查询构建器默认返回关联数组如果你需要简单实体可以用 Model 基类做一层属性赋值如果你需要关联查询就在模型里手动写方法用 Caveman 的查询构建器组装子查询。一切都是显式调用没有任何隐形的自动行为。这个取舍在真实项目里非常受用。举个例子我的一个后台报表系统有三十多张表但查询需求几乎全是联表聚合。用 Eloquent 时我得不断在模型里定义关系否则就享受不到便利用 Caveman 之后我直接写query()-join(...)-groupBy(...)-having(...)SQL 自由度反而更高报表拿到数组丢给前端渲染根本不经过模型的类型转换。1.3 为什么绑定PDO而不是自研驱动数据库连接这块Caveman 直接站在 PDO 的肩膀上。原因很实在第一PDO 是 PHP 官方扩展支持 MySQL、PostgreSQL、SQLite 等多种驱动不需要为每种数据库单独实现协议第二PDO 自带的预处理能力正好契合查询构建器值和结构分离的需求我们只需要把 SQL 骨架拼好值全部通过绑定传递这件事 PDO 是现成的。可能有朋友会问为什么不基于 mysqli 封装或者干脆像 Swoole 那样自研协程驱动这得看项目的适用范围。Caveman 的目标是普通 PHP-FPM 项目追求的是兼容性和可移植性而不是极致性能极限。使用 PDO 还带来一个隐藏好处PDO::ATTR_EMULATE_PREPARES false开启后MySQL 会使用真正的预处理协议值从客户端单独发给服务端从协议层面就避免了拼接产生的注入风险。后面代码篇会细讲这个配置。2. Caveman的四个关键设计读代码前先看这里2.1 链式调用每次返回$this链式调用是查询构建器的灵魂。如果你看过一些实现会发现原理并不复杂where()、orderBy()、limit()这些方法在完成自己的职责后统一return $this把当前对象实例传回调用方这样后续就能继续调用同一个对象的其他方法。Caveman 的每个方法内部只有两件事记录调用参数更新内部状态。以where(status 1)为例方法拿到列名、操作符、值以后往$wheres数组压入一条三元组再把值追加到$bindings数组返回$this。它不立即拼 SQL、不立即查询数据库真正的 SQL 生成发生在toSql()被调用的那一刻。这种延迟构建lazy building模式让我们可以在循环里动态追加条件非常灵活。这里有个细节容易踩坑因为同一个构建器对象是可变的如果在一个方法里把构建器传给另一个函数那边往里面追加了一个 where你原来的条件就会被污染。我的解决办法是提供clone语义在处理可选条件时先$query (clone $baseQuery)再把分支条件加在副本上。下面代码段演示了这种用法。$baseQuery $caveman-table(orders)-where(paid 1); // 分支一按用户查询不能污染基础查询 if ($userId) { $query (clone $baseQuery)-where(user_id $userId); } // 分支二按日期查询 if ($startDate) { $query (clone $baseQuery)-where(created_at $startDate); }2.2 参数绑定值全部走预处理我在早期版本里犯过一个错误图省事直接把值和列名一起拼进 SQL 字符串虽然功能正常但一旦值来自用户输入那就是定时炸弹。后来把所有值统一交给 PDO 预处理整个安全模型才彻底清晰。核心做法是SQL 语句中只出现 SQL 骨架和占位符?所有动态值都追加到$bindings数组。执行时调用executing阶段把绑定数组传给 PDOStatement 的execute($bindings)。这里强调一个关键点——绝对不要用PDO::quote()来做安全防护因为它是字符串转义而非真正的协议级绑定在复杂字符集和旧版驱动下存在转义绕过风险。// toSql() 生成的语句大致长这样 SELECT * FROM orders WHERE status ? AND amount ? // 执行时绑定值 $stmt $pdo-prepare($sql); $stmt-execute([1 500.00]); // 值在客户端和服务端之间走独立通道还有一点要提醒绑定顺序必须与?在 SQL 中的出现顺序完全一致。条件一多特别是 where、having、limit 混在一起时很容易错位。我后面会在排查章节专门讲怎么做一个debugSql()方法来定位这类问题。2.3 结果映射薄薄一层不玩魔法查询构建器的默认返回是关联数组但实际业务里大家还是习惯拿对象。Caveman 的做法是提供Model基类它的映射逻辑非常直白查询结果数组的键直接作为对象的公开属性对象初始化时接受一个数组参数遍历赋值。没有反射、没有注解、没有属性标签。这个设计有一个好处简单到可以预测任何行为。没有类型转换器数据库返回什么就是什么棘手的日期格式问题你自己决定处理没有隐藏的关联装载器不会在你访问$model-posts时偷偷执行查询。牺牲一点魔法便利换来的是熬夜排查问题的时间大幅减少。abstract class Model implements ArrayAccess { public function __construct(array $attributes []) { foreach ($attributes as $key $value) { $this-{$key} $value; } } public function toArray(): array { return get_object_vars($this); } }2.4 预加载把多个查询压成一个虽然 Caveman 不自称 ORM但关联查询是业务刚需不能完全回避。我的做法是提供loadRelations()这类显式预加载方法先执行主查询拿到结果集合再根据外键字段一次性查出关联数据最后在 PHP 内存里按主键分组拼装。这样无论列表里有几百条记录关联查询始终只执行一次。$orders $caveman-table(orders)-limit(100)-get(); $orderIds array_column($orders id); $details $caveman-table(order_details) -whereIn(order_id $orderIds) -get(); // 按 order_id 分组 $grouped []; foreach ($details as $detail) { $grouped[$detail[order_id]][] $detail; }为什么不在模型里做自动预加载因为自动判断这次查询需要哪些关联是 ORM 中最复杂的逻辑它需要分析你后续会访问哪些属性这只能靠魔法方法拦截性能开销和不确定性都太高。Caveman 把决策权还给你——要加载哪些子表自己在代码里写清楚。3. 手写Caveman核心从Connection到QueryBuilder3.1 项目骨架和Composer配置先看一眼目录结构。Caveman 保持最小化四个核心文件就够了连接管理器、查询构建器、模型基类、集合辅助类。如果你不需要集合类三个文件也能跑。caveman/ ├── src/ │ ├── Connection.php │ ├── QueryBuilder.php │ ├── Model.php │ └── Collection.php ├── composer.json └── examples/ └── demo.phpcomposer.json 里的配置很简单核心是 PSR-4 自动加载和 PHP 版本约束。我建议要求 PHP 7.4 以上这样可以用箭头函数、类型属性和协变返回代码能瘦不少。{ name: yourname/caveman, description: A minimal database query builder for PHP, require: { php: 7.4, ext-pdo: * }, autoload: { psr-4: { Caveman\\: src/ } } }3.2 Connection单例PDO连接连接层最核心的工作是创建 PDO 实例并设置正确的属性。我踩过最大的坑是忘记开启ERRMODE_EXCEPTION结果 SQL 报错时只拿到一个false返回错误原因完全看不到。把这个模式打开后所有 SQL 层面的异常都会直接抛出配合全局异常处理器问题当场暴露而不是在三天后由用户投诉才发现。另一个关键配置是ATTR_EMULATE_PREPARES这个参数决定 PDO 使用原生预处理还是本地模拟预处理。在 MySQL 8 上我推荐设置为false强制走原生预处理协议。不过要注意某些老版本 MySQL 驱动对原生预处理的支持有 bug如果线上环境较旧建议先做一轮全量回归再改这个配置。?php namespace Caveman; use PDO; use PDOException; class Connection { private static ?PDO $pdo null; public static function open(array $config): PDO { if (self::$pdo instanceof PDO) { return self::$pdo; } $driver $config[driver] ?? mysql; $dsn self::buildDsn($driver $config); try { self::$pdo new PDO($dsn $config[username] $config[password] [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, PDO::ATTR_TIMEOUT 3, ]); } catch (PDOException $e) { throw new ConnectionException(Database connection failed: . $e-getMessage() 0 $e); } return self::$pdo; } private static function buildDsn(string $driver array $config): string { return match ($driver) { mysql sprintf( mysql:host%s;port%d;dbname%s;charset%s, $config[host] ?? 127.0.0.1 $config[port] ?? 3306 $config[database] $config[charset] ?? utf8mb4 ) sqlite sqlite: . $config[database] pgsql sprintf( pgsql:host%s;port%d;dbname%s, $config[host] ?? 127.0.0.1 $config[port] ?? 5432 $config[database] ) default throw new \InvalidArgumentException(Unsupported driver: {$driver}) }; } }这里match表达式是 PHP 8 的语法如果你还在 PHP 7.4 就换成switch。静态$pdo变量实现了进程内单例避免同一请求重复建立连接。但要提醒一句这个单例设计在 PHP-FPM 下没问题在 Swoole 等常驻内存环境中需要按协程重新设计否则连接会被多个协程共享埋下大坑。3.3 QueryBuilderSQL组装与执行QueryBuilder 是整个库的发动机舱。它的内部状态我用数组属性表示$wheres存条件、$orders存排序、$bindings存绑定值。每次调用方法只是在数组上追加元素toSql()则负责把数组翻译成合法的 SQL 语句。?php namespace Caveman; use PDO; use PDOStatement; class QueryBuilder { protected array $columns [*]; protected string $from ; protected array $wheres []; protected array $orders []; protected int $limit 0; protected int $offset 0; protected array $bindings []; public function __construct(protected PDO $pdo) { } public function table(string $table): static { $this-from $table; return $this; } public function select(string ...$columns): static { $this-columns $columns ?: [*]; return $this; } public function where(string $column string $operator mixed $value null): static { // 支持 where(status 1) 的简写自动补 操作符 if ($value null) { $value $operator; $operator ; } $this-wheres[] [$column $operator]; $this-bindings[] $value; return $this; } public function orderBy(string $column string $direction asc): static { $direction strtolower($direction) desc ? desc : asc; $this-orders[] {$column} {$direction}; return $this; } public function limit(int $limit): static { $this-limit $limit; return $this; } public function offset(int $offset): static { $this-offset $offset; return $this; } public function toSql(): string { $sql SELECT . implode( $this-columns); $sql . FROM . $this-from; if ($this-wheres) { $conditions array_map( fn(array $w) {$w[0]} {$w[1]} ? $this-wheres ); $sql . WHERE . implode( AND $conditions); } if ($this-orders) { $sql . ORDER BY . implode( $this-orders); } if ($this-limit 0) { $sql . LIMIT . $this-limit; if ($this-offset 0) { $sql . OFFSET . $this-offset; } } return $sql; } public function run(): array { $stmt $this-executeStatement(); $rows $stmt-fetchAll(PDO::FETCH_ASSOC); return $rows ?: []; } public function executeStatement(): PDOStatement { $stmt $this-pdo-prepare($this-toSql()); $stmt-execute($this-bindings); return $stmt; } }toSql()的拼接顺序是固定的SELECT字段、FROM表、WHERE条件、ORDER BY排序、LIMIT和OFFSET分页。这个顺序不是随意定的SQL 语法有严格的层级要求LIMIT必须出现在WHERE之后。如果业务上需要HAVING你可以在toSql()里加一个类似的流程但要注意HAVING与WHERE的执行语义不同别混为一谈。3.4 跑一个真实查询现在把三块拼起来写一个真实的调用示例。为了演示我模拟一个订单查询场景筛选出已支付、金额大于某个值的订单按创建时间倒序分页取第一页的二十条。$config [ driver mysql host 127.0.0.1 port 3306 database shop username root password secret charset utf8mb4 ]; $builder new QueryBuilder(Connection::open($config)); $rows $builder -table(orders) -select(id order_no user_id total_amount created_at) -where(paid 1) -where(total_amount 500.00) -orderBy(created_at desc) -limit(20) -offset(0) -run(); echo json_encode($rows JSON_PRETTY_PRINT);调toSql()会得到SELECT id order_no user_id total_amount created_at FROM orders WHERE paid ? AND total_amount ? ORDER BY created_at desc LIMIT 20 OFFSET 0绑定数组是[1 500.00]。你可以清楚看到每个值都不会直接进 SQL而是通过 PDO 的预处理通道。这套拼接逻辑我们已经验证过很多次中小规模查询基本零踩坑。4. 落到真实项目我还补了这些功能4.1 事务处理的正确姿势查询构建器只负责组装和执行单条 SQL但业务里经常需要多条 SQL 一起成功、一起失败。Caveman 提供一个transaction()辅助函数内部包好 begin/commit/rollback 的模板逻辑。function transaction(PDO $pdo callable $callback): mixed { if ($pdo-inTransaction()) { return $callback(); } $pdo-beginTransaction(); try { $result $callback(); $pdo-commit(); return $result; } catch (\Throwable $e) { $pdo-rollBack(); throw $e; } }有几个容易忽略的细节第一inTransaction()检查要放在开始之前否则嵌套事务会在回滚时把外层事务也回滚掉第二回调里抛出的异常必须是\Throwable而不是\Exception因为 PHP 7 以后Error也有可能需要回滚第三commit 成功后记得不要吞掉异常直接抛出让上层处理。实践中我还会给transaction()加一个重试参数用于处理 MySQL 的锁等待超时1213错误码。死锁发生时简单粗暴地重跑整个事务往往就成功了。不过要控制重试次数我一般设为两次再多就说明业务本身的锁设计有问题。function transactionWithRetry(PDO $pdo callable $callback int $retries 2): mixed { for ($attempt 0; ; $attempt) { try { return transaction($pdo $callback); } catch (\PDOException $e) { if ($attempt $retries || $e-getCode() ! 1213) { throw $e; } usleep(100 * 1000); // 100ms } } }4.2 分页和批量写入分页是很高频的需求但很多项目直接LIMIT ? OFFSET ?一把梭数据量一上去就慢得一塌糊涂。Caveman 提供一个paginate()方法它先算总数再取数据。深分页的问题这里也说得直白当 offset 很大时MySQL 仍会扫描被跳过的所有行这不是查询构建器能优化的需要业务上用游标分页来规避。public function paginate(int $perPage int $page 1): array { $countBuilder clone $this; $countBuilder-columns [COUNT(*) as aggregate]; $total (int)($countBuilder-run()[0][aggregate] ?? 0); $rows (clone $this) -limit($perPage) -offset(($page - 1) * $perPage) -run(); return [ data $rows total $total per_page $perPage page $page last_page (int)ceil($total / $perPage) ]; }批量写入是另一个刚需。手写多条INSERT INTO会造成大量网络往返Caveman 的insertBatch()方法会把这些合成一条 SQL多组VALUES一次发送。注意占位符的数量要精确每行多少个字段就得生成多少个?。public function insertBatch(string $table array $rows): int { if (!$rows) return 0; $columns array_keys($rows[0]); $columnList implode( $columns); $placeholders []; foreach ($rows as $row) { $placeholders[] ( . implode( array_fill(0 count($columns) ?)) . ); foreach ($columns as $col) { $this-bindings[] $row[$col]; } } $sql INSERT INTO {$table} ({$columnList}) VALUES . implode( $placeholders); $stmt $this-pdo-prepare($sql); $stmt-execute(array_slice($this-bindings -count($rows) * count($columns))); return $stmt-rowCount(); }这个方法有个前提就是$rows里每行的字段顺序必须一致而且数组的键名完全对齐。如果业务上层的数据来源乱七八糟建议先统一清洗一遍。4.3 SQL调试日志我始终认为查询构建器最重要的一点就是最终 SQL 必须可观测。Caveman 内置一个简单的日志机制在executeStatement()里把 SQL 和绑定值推进一个静态数组便于在请求结束时统一打印或写入日志文件。public static array $logs []; private function executeStatement(): PDOStatement { $sql $this-toSql(); self::$logs[] [ sql $sql bindings $this-bindings time microtime(true) ]; $stmt $this-pdo-prepare($sql); $stmt-execute($this-bindings); return $stmt; }生产环境不建议每条查询都写日志否则日志量和性能开销都上去了。我的做法是只在慢查询时才记录也就是给run()增加一个执行时间统计超过阈值比如 500ms就把 SQL 单独告警。排查线上问题的时候这个日志就是救命的现场录像。5. 和Eloquent、Doctrine、裸PDO对比边界在哪5.1 先上一张对比表为了把 Caveman 的定位讲清楚我拿它和 Laravel 的查询构建器、Doctrine ORM、原生 PDO 做一个直接对比。不同的人需求不同没有所谓巅峰对决只有适不适合。维度原生 PDOCavemanLaravel Query BuilderDoctrine ORM定位数据库访问底层轻量查询构建器框架内置查询构建器完整 ORM 解决方案依赖PHP 扩展仅 PDO整个 Laravel 容器Doctrine 全家桶SQL 可见性手动控制直接可控半隐藏高度抽象学习成本低低中高关联加载手写手动预加载自动/手动自动调试排查直观直观需要绕弯复杂类型映射无轻量无强类型迁移工具无无有有Caveman 和原生 PDO 的区别在于它帮你把拼接、绑定、分页这些重复动作封装好了减少了低级错误和 Laravel 查询构建器的区别是没有全局作用域、模型事件、宏指令这些额外心智负担和 Doctrine 的区别是完全不碰实体元数据和数据库结构管理。5.2 什么时候我推荐用Caveman我个人的使用场景基本是这三类第一中小型项目或内部后台系统数据模型不复杂但查询语句多变需要直接掌控 SQL第二在已有老项目里做局部优化不想为了几个接口引入整个框架的数据库组件第三教学和源码阅读如果想知道查询构建器内部发生了什么Caveman 的代码量非常适合通读。反过来说如果你的系统需要强大的领域模型、复杂的继承映射、自动化的 Schema 迁移或者需要开箱即用的实体仓库模式那 Caveman 帮不了你直接上 Doctrine 这类完整 ORM 更合适。另外如果项目已经用了 Laravel也没有必要为了让代码返祖而强行替换直接在需要的模块里用DB::table()就足够这本质上和我写 Caveman 的思路一致。6. 排查问题实录bindings错位、空IN子句和慢查询6.1 定位参数绑定错位查询构建器最常见的运行时错误是参数顺序不对。比如你先where(a 1)又where(b 2)但代码里哪一步多放了一个值最后 SQL 变成WHERE a ? AND b ?绑定数组却是[1 2 3]数据库可能报Column not found也可能静默匹配错误数据。遇到这种情况不要盲改我用一个辅助方法把 SQL 里的占位符替换为实际绑定值直接在日志里看到伪 SQLpublic function debugSql(): string { $sql $this-toSql(); foreach ($this-bindings as $value) { $value is_numeric($value) ? $value : . addslashes((string)$value) . ; $sql preg_replace(/\?/ $value $sql 1); } return $sql; }这个方法只是用来人工排查的不要实际执行——毕竟值经过了转义但不代表它拼回去之后是合法 SQL。不过看一眼基本就能发现是哪个条件多放值、少放值了。6.2 空IN子句的隐藏坑whereIn(id [])看起来人畜无害但生成的WHERE id IN ()在 MySQL 里直接就是语法错误。这是查询构建器最容易忽略的边界情况。Caveman 的处理方式是如果传入的数组为空就生成一条永远为假的条件1 0这样查询不会崩溃返回空结果集。public function whereIn(string $column array $values): static { if (empty($values)) { $this-wheres[] [1 ]; $this-bindings[] 0; // WHERE 1 0 return $this; } $placeholders implode( array_fill(0 count($values) ?)); $this-wheres[] [$column IN ({$placeholders})]; array_push($this-bindings ...$values); return $this; }可别小看这个细节有次我处理一个筛选表单用户把地区多选框全部取消前端传了个空数组结果后台接口直接 500。从那以后我对所有可能为空的条件都做了兜底。6.3 关于性能和长连接的几个观察最后聊点性能实测的观察。Caveman 比裸 PDO 慢多少我做过一个压测在十万行表上跑一百次简单查询裸 PDO 平均 0.42msCaveman 平均 0.58ms差距约 0.16ms。这个差异来自数组初始化、方法调用栈和 SQL 字符串拼接对业务系统来说完全可以接受。作为对比Eloquent 查询构建器在同场景下大约需要 0.9ms 到 1.5ms差距主要来自容器解析和事件分发。关于长连接PHP-FPM 下推荐短连接因为每个请求结束后连接自动关闭单例Connection::$pdo其实只在单个请求内复用。如果你部署在 Swoole 或 RoadRunner 这类常驻进程里必须把连接绑定到协程或 Worker 维度否则多个协程共享同一个连接事务会互相干扰。这个坑我印象很深第一次把 Caveman 移植到 Swoole 时死活查不出为什么两个并发请求会出现事务已回滚但另一方提交了的诡异现象后来才反应过来是连接共享导致的。说一下我个人在实际项目里使用 Caveman 到现在的一些体感它确实不如 Eloquent 那样开箱即用很多关联关系、事件通知都要自己补但换来的是每个查询行为都可预期出问题时能很快定位这对我维护老项目来说是实打实的红利。如果你也在重 ORM 看不懂 SQL和裸 PDO 重复劳动之间纠结不如先写一个最小可用的查询构建器试试Caveman 的源码完全可以当作起点。另外有个小技巧是构建器对象在循环里使用时要记得 clone否则上一次循环的条件会悄悄带进下一次查询这个看似微小的失误在跑批量数据处理时能把结果偏差放大到难以追踪的程度。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑