PHP读模型投影实战:从数组裁剪到DTO与投影器
1. 从“读模型”到“投影”这个组合到底在解决什么问题先聊一个我在实际开发中反复撞见的场景。业务系统跑了两三年订单表、用户表、日志表越接越多很多字段其实已经没人知道当初为什么留下来。某天产品提了个新需求要在后台列表里展示“用户最近一笔订单的商品名称和下单时间”。常规做法是写一条JOIN把用户表、订单表、商品表串起来然后SELECT一堆字段丢给前端。刚开始没啥问题等数据量上来、查询变慢、接口超时你才发现自己早就掉进了一个经典的坑读数据时把整个实体模型原封不动地抛出去了。这其实就是“读模型投影”最想解决的问题。所谓的“读模型”是相对于“写模型”而言的。写模型关心的是业务规则、状态变更、数据一致性它对应的是你数据库里那张正经的业务表字段多、关系复杂、约束齐全。而读模型不关心你怎么写、怎么约束它只关心“这个页面需要什么数据、以什么形状返回最快”。投影就是把写模型这张“大底片”冲洗成读模型需要的“那张照片”——只需要拿部分字段、部分结构甚至可以把多张表的数据提前压平成一份视图或DTO。说白了投影就是一次有目的的“瘦身”。但瘦身不是随便砍字段那么低级的操作它背后涉及查询性能、接口契约、缓存策略、职责拆分等一系列问题。我在好几个项目里把“读模型投影”从简单的数组裁剪演进成一套可复用的服务层机制这次想把完整的思路和代码实践整理出来尤其是给那些正在PHP项目里纠结“到底要不要为每个页面建一个DTO、要不要上Query Bus、投影逻辑放哪一层”的朋友一个可直接参考的答案。2. 为什么PHP项目尤其需要把“读”和“写”拆开很多PHP开发者有个惯性写接口的时候直接拿ORM查出来的Model往JsonResponse里一塞。Laravel里是$user-toArray()ThinkPHP里是-toArray()原生PDO就是fetchAll(PDO::FETCH_ASSOC)。代码是短了但隐患非常大。2.1 接口契约被数据库结构绑架一旦前端需要的数据和数据库表结构不一致你只有两个选择要么在控制器里做一堆字段映射和组装要么干脆把整个Model丢出去让前端自己挑。前者导致控制器越来越臃肿后者导致接口返回一堆没人用的字段不仅浪费带宽还容易把敏感字段漏出去。我见过不止一次因为直接toArray()把用户表的password_hash、internal_remark这类内部字段一并返回虽然前端没展示但安全隐患已经埋下了。2.2 查询效率被“懒加载”和“全字段”拖垮ORM最舒服的地方也是它最坑的地方。查一个用户列表你只需要id和name但ORM默认会把所有字段查出来。再加上关联模型懒加载循环里每次取订单都会触发一条SQLN1问题就这么来的。投影机制能强制你思考“这个读场景到底需要哪些字段、需要哪些关联”从而让查询更精准。2.3 缓存和读模型天然契合读模型是相对稳定的。订单列表页一个月内可能都不改字段但订单写逻辑天天变。如果把读模型独立出来给它建独立的缓存键、独立的缓存过期策略就不会因为写逻辑的小改动而把整个查询缓存打爆。这一点在CQRS模式里体现得最明显——读写分离不只是数据库层面的事代码层面同样可以分离。所以我建议PHP项目从单体架构阶段就开始引入“读模型投影”思想不一定要上完整CQRS但可以在服务层建立一套“查询专用”的方法和返回结构跟写操作严格分开。3. 投影的几种落地姿势数组裁剪、DTO、ViewModel投影在PHP里没有唯一的实现方式不同规模的项目适合不同方案。我按从轻到重给你梳理一遍每种方案我会给出代码骨架和适用场景。3.1 最轻量集中式数组裁剪函数如果项目里没有现成的Service层也不想引入太多类最简单的投影是写一个集中的Presenter或者Transformer。它的本质就是“把数据库行/模型数组转成对外数组”的纯函数。?php namespace App\Presenters; class OrderPresenter { public static function forList(array $orderRow): array { return [ order_no $orderRow[order_no], product_name $orderRow[product_name], amount (float)$orderRow[amount], created_at $orderRow[created_at], ]; } public static function forDetail(array $orderRow, array $items): array { return [ order_no $orderRow[order_no], status_text self::statusText($orderRow[status]), items array_map( fn($item) [ product_name $item[product_name], quantity (int)$item[quantity], price (float)$item[price], ], $items ), ]; } private static function statusText(int $status): string { return match ($status) { 0 待支付, 1 已支付, 2 已发货, default 未知, }; } }这种写法的好处是直观、零依赖、方便单测。控制器里调用一行OrderPresenter::forList($order)就能拿到干净的结构。它特别适合那种“只有一两个页面需要定制字段”的小型项目。但它的缺点也明显如果全项目到处都是XxxPresenter类会膨胀得很厉害而且它跟ORM模型没有强绑定字段名写错只能靠运行时发现。所以它更适合作为过渡方案而不是终极解法。3.2 中间态DTO数据传输对象当项目开始有明确的Service层、Repository层我建议把投影结果封装成DTO。DTO的核心作用是“让方法签名说话”——你一眼扫过去就知道这个方法返回的是OrderListDTO还是OrderDetailDTO而不是一个糊里糊涂的array。?php namespace App\DTO; final class OrderListDTO { public function __construct( public readonly string $orderNo, public readonly string $productName, public readonly float $amount, public readonly string $createdAt, ) {} public static function fromRow(array $row): self { return new self( orderNo: $row[order_no], productName: $row[product_name], amount: (float)$row[amount], createdAt: $row[created_at], ); } public function toArray(): array { return [ order_no $this-orderNo, product_name $this-productName, amount $this-amount, created_at $this-createdAt, ]; } }PHP 8.2以后有了readonly类配合构造函数属性提升写DTO非常舒服。这个方案的优点是类型明确IDE提示友好序列化可控不会多露字段可以附加领域逻辑比如statusText()方法放在DTO里便于缓存DTO本身可序列化缺点是每个读场景都要建一个DTO类数量会增多。但说实话如果一个页面连自己的DTO都不值得建那这个页面的读需求大概率也复杂不到哪里去。3.3 重型方案Query Object / Query Bus如果项目里已经上了CQRS或者业务复杂到“一个读模型要聚合五六个写模型的数据”那就需要更进一步。把查询本身封装成对象比如GetOrderListQuery由专门的QueryHandler执行投影并返回DTO。这种模式已经接近完整CQRS在PHP项目里不算常见但一旦业务够复杂收益非常大——查询和命令彻底分开缓存、审计、监控都能按查询维度来。我这里给出一个简化但完整的Query Bus实现示例基于Laravel容器?php namespace App\Queries; interface Query { } interface QueryBus { public function dispatch(Query $query): mixed; } final class SimpleQueryBus implements QueryBus { public function __construct( private readonly \Illuminate\Contracts\Container\Container $container ) {} public function dispatch(Query $query): mixed { $handlerClass $this-resolveHandlerClass($query); if (!class_exists($handlerClass)) { throw new \RuntimeException(Handler not found for query: . get_class($query)); } return $this-container-make($handlerClass)-handle($query); } private function resolveHandlerClass(Query $query): string { $queryClass get_class($query); return str_replace(Queries, QueryHandlers, $queryClass) . Handler; } }然后定义查询对象和对应的Handler?php namespace App\Queries; final class GetOrderListQuery implements Query { public function __construct( public readonly int $userId, public readonly int $page 1, public readonly int $perPage 20, ) {} } namespace App\QueryHandlers; use App\DTO\OrderListDTO; use App\Queries\GetOrderListQuery; final class GetOrderListQueryHandler { public function __construct( private readonly OrderProjector $projector, ) {} public function handle(GetOrderListQuery $query): array { $rows $this-projector-projectOrderList($query-userId, $query-page, $query-perPage); return array_map(fn($row) OrderListDTO::fromRow($row)-toArray(), $rows); } }这种方案把“读模型投影”上升到了架构层面。每个查询都有独立的Handler测试时可以直接对Handler做单元测试不需要经过Controller和路由。如果你的团队已经习惯分层开发这个方案非常值得尝试。4. 投影器Projector的设计从数据源头就把形状定好不管最终用哪种姿势落地投影逻辑的核心都是“Projector”。它负责真正去数据库取数并且把取数的过程按读场景定制。很多人把这一步放在Repository里但Repository更偏“通用数据访问”而Projector偏“特定读场景定制”。我特意把Projector单独拿出来讲因为它牵扯到性能优化的一块关键拼图。4.1 为读场景定制SQL而不是复用通用查询一个常见的错误是投影时依然调用Repository里的通用方法比如$this-orders-findAllByUser($userId)然后再循环里补关联。投影器应该反过来从“页面需要什么”出发直接构建查询。假设列表页需要展示“用户名、订单号、商品名、订单金额、下单时间”五张表的数据通用做法是取订单列表再逐个查用户、查商品N1爆炸。投影器的做法是直接JOIN?php namespace App\Projectors; use Illuminate\Support\Facades\DB; final class OrderProjector { public function projectOrderList(int $userId, int $page, int $perPage): array { return DB::table(orders) -join(users, users.id, , orders.user_id) -join(order_items, order_items.order_id, , orders.id) -join(products, products.id, , order_items.product_id) -where(orders.user_id, $userId) -select([ users.nickname as user_name, orders.order_no, orders.amount, products.name as product_name, orders.created_at, ]) -orderByDesc(orders.created_at) -forPage($page, $perPage) -get() -all(); } }这么做的好处不仅仅是少几条SQL更重要的是投影器对数据库的真实访问模式负责——它知道这个页面的数据从哪几张表来用哪种JOIN最快哪些索引必须存在。你可以针对每个投影方法写一个Explain单独优化它的索引而不用担心影响其他读场景。4.2 投影器的可选字段裁剪与自动填充有些读场景的字段是可选的比如列表页和导出功能导出的字段可能比列表多几个。这时候投影器可以接收一个array $fields参数动态决定SELECT哪些列。但注意动态字段容易让SQL缓存失效需要用白名单校验private const ALLOWED_FIELDS [ order_no, amount, created_at, product_name, user_name, ]; public function projectOrderList(int $userId, array $fields): array { $fields array_values(array_intersect($fields, self::ALLOWED_FIELDS)); if (empty($fields)) { $fields [order_no, amount, created_at]; } return DB::table(orders) -join(...) -select($fields) -where(orders.user_id, $userId) -get() -all(); }白名单校验是第一道保险免得用户传一个password进来还能被带出去。当然对外接口的字段裁剪主要还是靠DTO的toArray()来把控投影器的动态字段更多是给内部查询做性能优化用的。4.3 投影器的缓存策略由于投影器专门服务读场景它的缓存设计比通用查询简单得多。我建议按“查询参数 版本号”作为缓存键。版本号由投影器内部维护每次投影逻辑结构发生改变时手动递增避免修改投影逻辑后缓存还是旧结构。public function projectOrderListCached(int $userId, int $page, int $perPage): array { $cacheKey sprintf(order_list_v3_%d_%d_%d, $userId, $page, $perPage); return Cache::remember($cacheKey, 600, function () use ($userId, $page, $perPage) { return $this-projectOrderList($userId, $page, $perPage); }); }在这里我要特别强调投影结果的缓存必须只在投影器这一层做不要在Controller里做也不要在Service里重复做。否则一个读场景可能同时存在好几种缓存键数据更新时很难全部失效。投影器是天然的缓存边界——它把“查数据 格式化”封装成一个整体缓存住的就是这个整体的输出。5. 一个完整案例用户订单中心里的投影实战理论说了不少接下来我用一个贴近实际的需求把整套流程串起来。假设我们要开发一个“用户订单中心”的接口包含订单列表、订单详情、订单汇总三个读场景。我按从投影器到Controller的完整链路来写。5.1 场景A订单列表投影列表页显示订单号、商品名取第一个商品、订单总额、状态文本、创建时间。前端的列表是一行一个订单但订单可能包含多个商品所以投影时要用子查询取第一个商品的名称。public function projectOrderList(int $userId, int $page, int $perPage): array { $firstItemSub DB::table(order_items as oi) -select(product_name) -whereColumn(oi.order_id, orders.id) -orderBy(oi.id) -limit(1); return DB::table(orders) -select([ orders.order_no, orders.amount, orders.status, orders.created_at, DB::raw(({$firstItemSub-toSql()}) as first_product_name), ]) -where(orders.user_id, $userId) -orderByDesc(orders.id) -forPage($page, $perPage) -get() -all(); }这里比直接JOIN商品表更精准因为列表只需要第一个商品名不需要把全部商品行都拖出来。如果你用的是Laravel的Eloquent也可以用withCount加关联模型的方式但原生查询构建器在投影场景下往往更符合“按需取数”的原则。5.2 场景B订单详情投影详情页需要展示完整的商品明细包括商品名、单价、数量、小计。这个投影相对直接订单主表查一次明细表查一次然后在内存里组装成结构化数组public function projectOrderDetail(int $orderId): ?array { $order DB::table(orders) -where(id, $orderId) -first(); if ($order null) { return null; } $items DB::table(order_items) -where(order_id, $orderId) -get() -all(); return [ order_no $order-order_no, status $order-status, amount (float)$order-amount, created_at $order-created_at, items array_map(fn($item) [ product_name $item-product_name, unit_price (float)$item-unit_price, quantity (int)$item-quantity, subtotal (float)$item-unit_price * (int)$item-quantity, ], $items), ]; }这里不建议用一条复杂JOIN把订单和明细压平因为一对多结果会产生重复的订单字段反而不如两条简单查询清晰。投影不意味着“所有数据都尽量一条SQL搞定”而是“用最符合场景的查询方式去拿数据”。5.3 场景C订单汇总投影聚合投影订单中心顶部通常有“全部、待付款、待发货、已完成”几个统计标签这个需求也比较典型。投影结果是单个值集合可以用一条GROUP BY搞定public function projectOrderSummary(int $userId): array { $rows DB::table(orders) -select([status, DB::raw(COUNT(*) as cnt)]) -where(user_id, $userId) -groupBy(status) -get() -all(); $summary [0 0, 1 0, 2 0, 3 0]; foreach ($rows as $row) { $summary[(int)$row-status] (int)$row-cnt; } return [ total array_sum($summary), groups $summary, ]; }这种聚合投影最重要的是“不要在每个状态上单独跑一条COUNT”一条GROUP BY就能搞定。投影器存在的意义就是逼你想清楚这个方案而不是随手拼查询。5.4 Controller与DTO的衔接投影器返回的是数组理论上可以直接丢给Controller。但为了让接口层有更清晰的结构我习惯在Controller里再加一层toArray适配或者让DTO直接承接投影结果。下面是完整的Controller示例?php namespace App\Http\Controllers\Api; use App\Projectors\OrderProjector; use App\DTO\OrderListDTO; use App\DTO\OrderDetailDTO; use App\DTO\OrderSummaryDTO; use Illuminate\Http\JsonResponse; final class OrderController extends Controller { public function __construct( private readonly OrderProjector $projector, ) {} public function index(int $userId): JsonResponse { $rows $this-projector-projectOrderList($userId, request()-integer(page, 1), 20); $result array_map(fn($row) OrderListDTO::fromRow($row)-toArray(), $rows); return response()-json([data $result]); } public function show(int $orderId): JsonResponse { $detail $this-projector-projectOrderDetail($orderId); if ($detail null) { return response()-json([message 订单不存在], 404); } return response()-json([data (new OrderDetailDTO($detail))-toArray()]); } public function summary(int $userId): JsonResponse { $summary $this-projector-projectOrderSummary($userId); return response()-json([data (new OrderSummaryDTO($summary))-toArray()]); } }从Controller一眼看过去每个方法只做三件事调投影器拿数据、包成DTO、返回JsonResponse。没有字段拼接没有模型转数组没有业务判断。这就是投影分离带来的直观收益。6. 实战中的五个坑每一个我都踩过投影模式看起来简单但在真实项目里有很多隐蔽的坑。我按踩坑频率给你列一下希望你能绕开。6.1 坑一投影器开始“复用”通用查询退回N1老路最典型的场景有人图省事在Projector里调用Repository的findById再循环取关联代码看起来还挺“整洁”但性能一旦压测就现原形。解决办法是把“这个读场景需要什么数据”重新梳理一遍直接在投影器里写专用的JOIN查询绝不调用写模型相关的Repository方法。投影器只调用投影器相关的数据访问这是铁律。6.2 坑二DTO字段全部用字符串拼接状态/枚举没做语义转换接口层返回status1本身没毛病但前端每个页面都要自己翻译状态含义这在多端项目里非常痛苦。投影器或DTO里应该直接把状态转换成附带文案的结构status 1, status_text 已支付,同理时间字段尽量输出ISO8601或标准字符串不要直接输出2024-05-01 12:00:00让前端猜时区。投影时把格式问题一并解决能省去前后端大量扯皮。6.3 坑三字段裁剪白名单不校验用户传什么字段就返回什么前面提过白名单校验这里再强调一次。动态字段功能如果不用白名单等于给接口开了个“任意字段查询”的洞。哪怕只是一条内部接口后续被扫描工具探测到也可能被利用。所以动态字段必须白名单没有例外。6.4 坑四缓存键没有版本号改投影结构后一直读到旧数据我吃过这个亏。改了一个DTO前端始终收到旧字段排查了半天才发现缓存键没变旧缓存一直没失效。后来我把投影方法的缓存键全部加上v1、v2之类的版本前缀每次改结构就手动升一版问题彻底消失。6.5 坑五投影器越来越庞大最终变成“上帝类”当项目读场景变多一个OrderProjector可能堆了几十个方法又长又难维护。解决办法是按读场景聚合拆分投影器。比如拆成OrderListProjector、OrderDetailProjector、OrderSummaryProjector。这样每个类职责明确测试也好写。如果读场景实在太多就可以考虑上Query Bus让每个Handler对应一个投影场景。7. 投影与搜索引擎热词的映射给正在搜这些关键词的朋友在做这期内容整理的时候我看到搜索热词里有很多跟PHP读模型投影相关的关键词比如“php读取本地文件”“php图片生产”“php接口数组对象”“php序列化中文”“php类”等等。我觉得有必要简要说明一下这些关键词跟读模型投影的关系帮助那些从搜索进来的人快速定位自己需要的知识。7.1 “php接口数组对象”与投影的关联很多人在搜“php接口数组对象”其实是在问“接口返回数组还是对象”。我的答案是接口对外返回数组格式JSON数组没问题但内部处理时最好用对象DTO这样类型安全、IDE能提示。投影器负责把数据库行转成数组DTO负责把数组封装成对象两者配合刚刚好。7.2 “php读取本地文件”与投影的关系读取本地文件通常是做批量导入或配置文件加载跟读模型投影表面上看无关但如果导入后需要往页面展示处理结果同样会用到“读取文件内容 - 投影为预览结构 - 返回给前端”的链路。投影思想和文件读取不冲突反而是处理导入预览的好帮手。7.3 “php序列化中文”与投影的关系PHP的serialize()对中文的处理有时候会出现乱码或者存储长度问题很多人在搜这个。投影模型如果涉及缓存序列化推荐直接用json_encode而不是PHP原生序列化。因为JSON是跨语言通用的后续如果迁移到Java或Go的服务缓存还能复用。这也是投影时要注意的细节——投影结果应该尽量是纯数据结构而不是绑死PHP序列化的复杂对象。7.4 “php类”与投影的关系搜索“php类”通常是想了解PHP面向对象的基础而DTO、Projector、QueryHandler本质上都是类。我建议初学者别把“类”理解成花架子它最大的价值是把逻辑藏起来、把意图显出来。投影器类就是典型的例子外部调用者不需要知道内部是JOIN还是子查询只需要知道这个方法会返回一个可用的数组。7.5 “php域名授权系统网站源码”读模型投影的价值搜这个词的人大概率在做授权验证系统这类系统有一个典型读模型需要展示授权域名列表、授权状态、到期时间。如果直接在业务代码里拼数组客户现场排查问题会非常痛苦。用投影器把授权信息统一塑形既方便接口返回也方便做缓存是个很值得借鉴的思路。7.6 “php物联网项目源码”中读模型投影的特殊性物联网项目的特点是设备上报数据量大、数据结构碎片化。设备状态、历史轨迹、告警列表都是典型的读模型。如果每个页面都直接查原始设备表性能和结构都会很糟糕。用投影器把设备原始数据裁切成页面需要的读模型再配合Redis缓存能明显减轻数据库压力。8. 投影在缓存、监控、日志三个维度的进阶玩法写到这里读模型投影的核心思路和代码实践已经比较完整了。但我觉得还有一个维度值得展开投影器不只是“取数和塑形”它在生产环境里的可观测性和运维价值也非常大。我把这部分单独放在最后算是给已经上手的朋友一个进阶方向。8.1 给投影器加统一的统计埋点为了让投影器在线上运行状况可见我会在每个投影方法里加一个轻量埋点记录执行耗时、返回行数、缓存命中情况。不要手动每个方法加用一个基类或者trait统一处理trait ProjectorStats { private function track(string $projector, string $method, float $startTime, bool $fromCache): void { $elapsed round((microtime(true) - $startTime) * 1000, 2); logger()-channel(projector)-debug(projector_stats, [ projector $projector, method $method, elapsed_ms $elapsed, cache_hit $fromCache, ]); } }有了这个埋点你可以在日志平台按投影方法聚合耗时哪个投影方法慢、哪个缓存命中率低一目了然。我甚至见过有团队把它接进Prometheus直接做投影器维度的监控面板效果非常好。8.2 投影器缓存与缓存标签缓存失效是读模型投影里最麻烦的问题。比如用户订单数据更新后列表页缓存需要失效但订单状态可能被好几个投影场景使用。如果缓存驱动支持标签可以用标签管理投影缓存Cache::tags([orders, user_{$userId}])-remember($cacheKey, 600, function () { return $this-projectOrderList(...); }); // 订单状态变更时 Cache::tags([user_{$userId}])-flush();Laravel的Redis驱动支持tagsMemcached也支持。用标签可以把“订单相关的所有投影缓存”按用户维度批量失效比手动记一堆缓存键高效得多。8.3 投影结果的结构化日志除了性能埋点投影结果的结构化日志也很有价值。尤其是排查线上问题时能知道“某个用户请求订单列表时返回了什么结构”会非常有帮助。当然不能把完整响应都打进去那样日志量太大。我通常只记录投影方法名、关键参数、返回条数、首条记录的order_no。这样既能定位问题又不会刷爆日志。9. 最后聊几句实操体会坦白说“读模型投影”这个概念听起来有点CQRS的架势很多PHP团队一听就觉得“太重了”然后继续在Controller里直接拼数组。但我的实际体会是哪怕你只做最轻量的Presenter层收益也极其明显。它最大的价值不是性能提升而是逼你在写读接口之前想清楚“这个页面到底需要什么”。我踩过几次坑之后现在的新项目都会在Service层里单独划出一个Projector目录跟写操作的Service严格分开。字段裁剪、状态映射、缓存策略都放在投影器里Controller瘦到只剩参数解析和响应的壳子。维护起来非常舒服临时加一个字段只需要改投影器和DTO不用去翻业务逻辑代码。如果你现在正被“接口返回结构混乱”“查询慢但不敢随便优化”“缓存键管理崩溃”这些问题困扰我建议你从小处入手先选一个读场景最复杂的接口用投影器重写一遍。你很快会感受到那种“数据结构彻底掌握在自己手里”的踏实感。等这层重构稳定了再逐步把其他读接口迁移过来整个项目就会变得干净很多。“php方案 读模型投影”这六个字看起来只是技术方案的名字背后却是一整套关于接口设计、查询优化、代码组织的方法论。希望这篇文章能帮你少走弯路。