2247 字
约 7 分钟
14
S18-A27 · 仓储的返回:为什么"返回实体"比"返回行"更难维护

一个团队的仓储接口从 return OrderPO 改成 return Order(领域实体),理由是"少一层转换"。半年后两个问题浮现:列表页需要"只要 3 个字段",而实体要求"全部装配"(于是"查 20 条要 20 次 join");报表需要"跨聚合的聚合值",而实体里没有(于是"又加了一个 PO 方法在同一个仓储里")。

仓储的"返回类型"决定了三件事:谁负责装配、能不能"只查要用的"、以及**"一个仓储"到底服务几个视角**.

"返回类型"这件事,其实在"定'谁有资格知道表结构'":返回 PO,"表结构"泄露给"调用方";返回实体,"表结构"被"仓储"藏起来,而"装配成本"也一起被藏起来。于是"两种选择"不是"抽象程度"的高低,而是"把哪种成本放在哪一层"。

1. 三种"返回类型"的取舍

返回 装配责任 灵活性
PO/行对象 调用方 高(能"只查字段")
领域实体 仓储 低(要"完整装配")
读模型(DTO/投影) 查询侧 最高(按场景定制)

第二项"看起来最干净",而它"把'装配'压给了仓储"——于是"任何'只要一部分'的场景",都要"在仓储里加个方法".

"事务外访问集合"的故障"看一眼栈就知道",而它"根因在'返回类型'":

// 典型栈(截取)
org.hibernate.LazyInitializationException: failed to lazily initialize a collection
  at Order.getItems(Order.java:88)          ← 实体在"事务之后"被访问
  at OrderService.toDto(OrderService.java:42)
// 判据:栈里出现"业务 getter" → 是"实体带出了事务边界"

2. "返回实体"带来的三种压力

压力 表现
装配成本 列表页"只要 3 字段"也要"装全"
方法膨胀 仓储里出现 findForList/findForReport
事务边界变宽 装配"懒加载"时"事务已结束"

第三项是"最典型的故障":"返回实体、离开事务后访问集合"→ LazyInitializationException——而"修法"往往是"打开 open-in-view",把问题藏起来.

"拆两个接口"在代码上"只是多一个 interface",而它"把冲突变成分工":

// 两个接口,同一个实现类
interface OrderCommandRepo { Order load(AggregateId id); void save(Order o); }
interface OrderQuery { List<OrderRow> listByUser(long uid); OrderDetail detail(long id); }
// 判据:写侧"要实体",读侧"要投影"——互不影响

"open-in-view=true"之所以"看起来能修 LazyInitializationException",是因为它"把事务延长到'渲染完成'"——于是"懒加载"能在"视图层"触发。而它的代价是"连接被占到最后",在"高并发"下"连接池被吃满";所以它"是"止疼片",不是"治疗"——**"治疗"是"别把实体带出事务".

3. "读写分离"的三种做法

做法 说明
写侧"仓储返回实体" 命令路径"完整装配"合理
读侧"查询返回投影" 按场景"只查要用的"
一个仓储"两个接口" 写仓储 + 读查询(CQRS-lite)

第三项是"最小改动":"同一个类"分两个接口——OrderRepository(写)与"OrderQuery(读)"——**于是"读"不再污染"写"的接口.

"构造器投影"的写法"明确到字段",而它"自带'少查就少错'":

// 只查 3 个字段(构造器投影)
public record OrderRow(long id, String status, BigDecimal amount) {}
@Query("select new com.x.OrderRow(o.id, o.status, o.amount) from OrderPO o where o.userId = :uid")
List<OrderRow> listByUser(@Param("uid") long uid);
// 判据:SQL 里"只出现 3 列"

"接口投影"在"重构"时最危险:它"按 getter 名"动态代理——于是"改了字段名","编译期不报错",而"运行时"才抛"找不到方法"。而"构造器投影"是"显式的":**字段名改了"编译就红",于是"改的人"必然"顺手改查询".

4. "投影"的三种写法

写法 说明
接口投影(Spring Data) 声明"要哪些 getter"
构造器投影(new Dto(...)) 类型安全、可控
原生 SQL + 映射 最灵活(要"防注入")

第二项是"最稳":"DTO 的构造器"明确列出"要哪些字段"——**于是"改了表"时,"编译器"会告诉你"哪里要改".

"方法膨胀"有个"可量化信号",比"感觉"可靠:

// 三个"信号"
1 同一个仓储里出现"findFor/listFor/detailFor"(≥3 个"用途后缀")
2 方法数 > 10,且"一半只被'页面'调用"
3 有方法"永远返回 null 的字段"(装了一半)
// 判据:命中任一 → 该拆"读/写"

5. "一个仓储服务几个视角"的判据

判据 结论
只有"命令"用它 返回实体
只有"查询"用它 返回投影
两者都有 拆两个接口

第三项是"现实里最常见"的:"同一个聚合"既有"下单"也有"列表"——**而"一个接口同时服务两者",必然"某一方要让步".

"列表页 N+1"的修复"先看 SQL 条数",而它"不必猜":

// 三步"确认 N+1"
1 打开"SQL 日志"(或 p6spy)
2 请求一次列表(20 条),数"select 语句"
3 若"20+ 条" → 是"实体装配"逐条加载
// 判据:条数"与行数成正比" = N+1

"仓储方法越加越多"还有一种"缓解":"把'页面专用查询'挪到'查询服务/读模型'里"——于是"仓储只留'聚合的加载与保存'"。而"这条边界"一旦划清,"代码评审"就有了"可执行的判据":**"这个方法'返回的是聚合吗'?不是,它就不该在仓储里"。

6. 排错:五种"仓储返回问题"

现象 成因 处理方向
列表页"查 20 条 20 次 join" 实体装配 改投影
LazyInitializationException 事务外访问 拆读接口/预装配
仓储方法"越加越多" 多视角混用 拆读/写
"只用 3 字段"却"查出全表" 无投影 构造器投影
改表后"处处编译错" 投影过宽 缩小 DTO

"详情页"之所以"可以返回实体",是因为"它只要一条":

// 两种"读"的成本对比
详情(1 条):装配"1 次 join 组" → 几十毫秒,可接受
列表(20 条):装配"20 次 join 组" → 几百毫秒到秒级
// 判据:装配成本 × 行数 —— 行数一大就必须投影

7. 边界:什么情况"返回实体就是对的"

场景 为什么 做法
纯"命令"路径(下单/审批) 需要"完整不变式" 返回实体
单条详情页(一次查一条) 装配成本可接受 返回实体
聚合很小(< 5 字段) 投影收益小 返回实体
已有"缓存层"(装一次多用) 装配被摊薄 返回实体

判断口径一句话:"这个调用方,会不会"只用实体的一部分""。会(列表、报表、下拉框),就该"投影";不会(命令路径要"整体校验"),"实体"才是对的——而"一个仓储同时服务两者",是"方法膨胀"与"装配浪费"的共同来源.

8. 自测

  1. 三种"返回类型"分别由谁负责装配?为什么"返回实体"把压力压给了仓储?
  2. "返回实体"的三种压力里,哪一种"最典型的故障"是什么?
  3. "读写分离"的三种做法里,哪一种"最小改动"?为什么它有效?
  4. "构造器投影"为什么比"接口投影"更稳?改表时它有什么好处?
  5. "一个仓储服务两者"为什么"必然有一方要让步"?
  6. 判断"该返回实体还是投影"的那个问题是什么?

补一句"演进"建议:"先'读投影'、后'拆接口'"——因为"投影"是"局部改动"(一个方法、一个 DTO),而"拆接口"是"结构改动"(要动调用方);先在"最痛的列表页"上做"投影",收益"立刻可见",而"是否值得拆成 CQRS"可以"等第二个痛点出现再定"。

9. 一句话总结

仓储的返回类型,本质是"装配责任放在哪":返回实体 = 仓储负责装配(命令路径合理、读场景浪费);返回投影 = 查询侧负责(读场景高效、命令路径不够)。而现实里"同一个聚合既有命令又有查询",于是"拆成写仓储 + 读查询"几乎总是正解——它让"完整装配"与"只查要用的"各自成立,而不是"让某一方永远让步"。

S18-A27 · 仓储的返回:为什么"返回实体"比"返回行"更难维护
https://www.clxhxhhr.top/posts/6719/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。