15.2 数据传输对象
用稳定、可序列化的数据契约跨进程传输一组值,减少往返并隔离内部对象模型。
学习目标
- 能解释订单领域对象为什么不能直接越过进程边界,并画出一次 DTO 传输的责任链
- 能修改 TypeScript 的 DTO 组装与版本校验代码,让调用次数、字段边界和兼容策略可测试
- 能回答:当契约变化、网络失败或调用次数上升时,什么运行证据支持采用或拒绝数据传输对象
为什么 15.2 数据传输对象值得单独学习
想象结算页面需要订单编号、买家姓名、金额和行项目。如果页面每要一个值就向另一台机器再问一次,页面会被网络等待切成很多小段;如果把整个订单对象原样交过去,接收方又会拿到不该依赖的内部关系。这个问题的关键不在于“多定义一个类型”,而在于决定一次请求到底承诺哪些数据。
没有清楚的传输边界时,字段小改动会沿着远程调用、数据库模型和客户端一起扩散。更糟的是,调用方可能以为自己拿到的是本地对象,实际却要面对超时、重试和半成功。我们需要一张窄而稳定的“数据清单”,让发送方和接收方都能检查自己的责任。
从订单请求建立概念模型
只把需要的数据装进一次请求
跨进程传输时,↡只携带数据字段、可被编码后跨边界传输的对象;它不把领域行为和内部关联一起暴露给调用方。(Data Transfer Object,简称 DTO)是一份专门为边界准备的值集合。它可以包含 orderId、customerName 和 totalCents,但不应带着 Order 的取消方法、数据库会话或延迟加载关系。
DTO 的收益有两个可观察证据:一次请求可以带回一个用例所需的字段,客户端也只能依赖公开清单。它不是把所有字段复制一遍的仪式;字段应该从客户端用例倒推,隐藏的内部字段、密码、ORM 标记和未来可能变化的关系都应被排除。
编码是边界的一部分
在离开进程前,DTO 必须经过↡把内存中的字段按双方约定转换成字节或文本,以便跨进程传输和还原。;到达接收方后再按契约还原。请注意序列化结果不是领域对象本身,而是带有字段名、类型和版本含义的载荷。
一组四次 getter 调用可能产生四次网络往返;一个 DTO 可以把同一用例压缩成一次请求。减少往返并不等于无限增大载荷:如果每个页面都拿整张订单表,网络字节、隐私风险和缓存失效都会上升。判断标准是“这次用例是否一次拿齐恰好需要的数据”。
目录单元到教学证据
15.2 数据传输对象
本章精确对应 manifest 单元 poeaa24-pattern-33-data-transfer-object,范围限定为:用专门的数据载体跨进程交付一组值,同时不让领域对象直接越过边界。订单与结算跨进程调用贯穿正文,学习证据不是记住 DTO 三个字,而是能交出一张字段清单、一段可测试的组装函数和一份失败样本。
完成本单元需要同时回答三件事:谁决定字段,谁拥有业务行为,哪一步负责兼容旧客户端。若实验只显示“请求成功”,却无法指出一次改字段会影响哪个契约、哪个版本和哪一次重试,就还没有完成模式级判断。
专属可视化实验:看数据如何过界
先预测:如果客户端只需要订单摘要,把 LineItem 的内部关联也放进载荷,会让哪一个边界变得不稳定?如果把三个 getter 合成一次 DTO 请求,哪一个计数会下降?点击阶段按钮,再打开故障注入,观察“领域对象 → DTO → 网络 → 客户端”的责任如何变化。
三个确定性阶段快照
下面的三个快照把同一条数据流拆开。每步都保留字段、边界和证据,先写下预测,再用图核对;不要把图中“结果到达”误认为“领域对象已经跨过边界”。
1. 选择用例字段
服务端从订单聚合中挑出当前用例需要的字段。此时 Order 仍然只属于服务端,DTO 的字段清单是下一步要执行的边界决定。
代码实践:把领域对象压成可测试契约
下面的 TypeScript 草图把领域对象和 DTO 分成两个类型。toOrderSummaryDto 是唯一的组装入口,因此可以断言它不会携带 paymentToken、items 关系或可调用的方法。
type Order = {
id: number;
customerName: string;
totalCents: number;
paymentToken: string;
items: Array<{ sku: string; quantity: number }>;
};
type OrderSummaryDto = {
schemaVersion: 1;
orderId: number;
customerName: string;
totalCents: number;
};
function toOrderSummaryDto(order: Order): OrderSummaryDto {
return {
schemaVersion: 1,
orderId: order.id,
customerName: order.customerName,
totalCents: order.totalCents,
};
}注意这里没有让 OrderSummaryDto 继承 Order。继承会把“当前需要的字段”变成“未来可能暴露的字段”;显式返回对象虽然多写几行,却把信息流锁在一个可以审查、可以测试的地方。
再看服务端如何一次取得摘要,客户端如何拒绝未知的旧版本。示例中的 fetchOrderSummary 只代表一个远程调用;真实项目还要把超时、重试上限和请求幂等键写入接口契约。
type SummaryClient = {
getOrderSummary(id: number): Promise<OrderSummaryDto>;
};
function readSummary(dto: OrderSummaryDto) {
if (dto.schemaVersion !== 1) {
throw new Error("unsupported order summary version");
}
return `${dto.orderId}: ${dto.customerName} / ${dto.totalCents}`;
}
async function fetchOrderSummary(
client: SummaryClient,
id: number,
): Promise<string> {
const dto = await client.getOrderSummary(id);
return readSummary(dto);
}这段代码的可验证性质很具体:mock getOrderSummary 后断言它只被调用一次;给 readSummary 一个版本号 1 的对象时得到摘要;给它一个不认识的版本时得到明确错误,而不是把未知字段静默当成正确数据。
契约如何应对变化与失败
用粗粒度契约减少远程往返
↡围绕一个完整用例组织字段与操作、让调用方不必逐字段远程读取的边界契约。不是“字段越多越好”,而是一次调用覆盖一个有意义的用例。订单摘要适合一次取回;实时库存、支付授权和推荐列表可能有不同的生命周期,硬塞进同一个 DTO 反而会把无关变化绑定在一起。
可以用运行指标检查粒度是否合适:记录一次页面请求的远程调用次数、载荷字节数、P95 延迟和被客户端真正读取的字段比例。如果调用次数从 4 降到 1、载荷只增加必要字段且延迟下降,说明边界变粗带来了证据;如果一次小字段修改同时触发多个团队发布,契约已经过宽。
让版本化成为显式策略
DTO 不是永远不变的结构。增加可选字段通常可以由旧客户端忽略;删除字段、改变类型或改变含义则需要↡为数据契约标出可识别的演进编号,并规定不同编号之间如何兼容。策略。可以选择 URL 版本、媒体类型版本或消息头版本,但必须把“谁校验、谁默认、谁停止支持”写进测试。
新增字段时先保持旧字段可用,客户端只读取它声明依赖的字段;服务端在一段迁移窗口内同时接受旧格式和新格式。不要把数据库迁移版本直接当作 DTO 版本:数据库内部重构不一定改变外部字段,反过来也可能只改了展示含义就必须升级契约。
把部分失败留在边界上
网络调用可能在服务端已写入、响应却丢失;也可能 DTO 中某个可选字段生成失败,但摘要本身仍然可用。这类↡一次跨边界操作只完成了部分步骤或只返回了部分数据,需要明确回退、重试或降级责任。不能用“返回一个空对象”掩盖。
为每个失败写出阶段和动作:序列化失败不应发送请求;超时只按幂等策略重试;版本不支持应快速拒绝并记录客户端版本;可选字段失败可以降级,但必需字段缺失必须失败。DTO 让这些判断有了窄边界,却不会替你决定业务补偿,补偿仍由用例服务负责。
与远程外观的协作边界
↡在远程边界上提供面向用例的入口、把多次细粒度内部调用收拢为一次粗粒度请求的对象。负责“调用哪个用例”,DTO 负责“这个用例传哪些值”。两者经常一起出现,但职责不同:外观可以调用订单服务并组装摘要,DTO 不应该自己发网络请求或决定是否允许取消订单。
评审时画出这条链:客户端意图 → 远程外观 → DTO 组装 → 序列化 → 服务端用例。客户端依赖 DTO 契约,服务端拥有领域对象和业务规则;任何让客户端拿到 Order、让 DTO 持有数据库连接、让外观复制业务规则的实现,都应该停下来重新分配责任。
| 评审问题 | 采用 DTO 的证据 | 应拒绝或改用其他方案的信号 |
|---|---|---|
| 粒度 | 一次用例请求拿齐需要字段,调用次数可测 | 每个 getter 都成为一次远程调用 |
| 边界 | 领域对象不出服务端,载荷字段有清单 | 客户端能访问关联对象、方法或内部令牌 |
| 演进 | schemaVersion、兼容窗口和弃用日期有测试 | 改一个字段就必须同时升级所有客户端 |
| 失败 | 超时、版本拒绝和降级动作可区分 | 所有异常都变成空 DTO 或 200 响应 |
常见误区
本章小结
- DTO 只携带用例所需的数据,不携带领域行为、数据库关系或敏感内部字段。
- 一次粗粒度请求可以减少往返,但载荷仍要保持窄、可审查、可测试。
- 序列化、版本化和部分失败都属于边界契约,不能交给框架默认行为。
- 远程外观组织用例,DTO 描述数据;领域对象和业务规则仍由服务端拥有。
可验证练习
练习
问题 1:改 Demo 代码。 在专属图的“选择用例字段”阶段,把 customerName 改成可选字段,并新增 currency 字段。请修改 OrderSummaryDto 与 toOrderSummaryDto,再写出两条断言:序列化结果必须包含 currency,且不能包含 paymentToken 或 items。
问题 2:验证一次调用。 给 fetchOrderSummary 注入一个 mock client,页面需要订单编号、客户名和总价。请写出最小测试检查远程方法只调用一次,并说明为什么不能分别调用 getOrderId、getCustomerName 和 getTotal。
问题 3:诊断兼容与失败。 服务器把 totalCents 改成字符串,旧客户端仍只接受版本 1;同时一次超时后服务端可能已经扣款。请分别给出版本处理和重试处理,并写出你要观察的两条运行证据。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 数据传输对象
专门为跨边界准备的一组数据字段;它像一张寄送清单,只列出这次用例要交付的内容,不把原对象的行为和内部关系带出去。
- 序列化
把内存里的字段变成可以通过网络发送的文本或字节;接收方按同一约定还原,而不是直接共享发送方的对象。
- 粗粒度契约
围绕一个完整用例组织数据,让调用方一次拿到所需值;它不是无限膨胀的“大对象”。
- 版本化
给契约的演进标出可识别的版本,并说明旧客户端遇到新字段或新类型时怎么办。
- 部分失败
一次跨边界操作只完成了部分步骤或只返回了部分数据;系统必须明确重试、降级或补偿的责任。
- 远程外观
面向用例提供远程入口的对象;它组织调用,但不应把领域对象或业务规则泄漏给客户端。
前后导航
参考资料
- Martin Fowler:Data Transfer Object 模式目录:核对模式的公开摘要与基本边界。
- Martin Fowler:Patterns of Enterprise Application Architecture:核对全书主题、模式族和章节范围。
- Pearson:Patterns of Enterprise Application Architecture:交叉核对英文版出版信息与目录范围。