15.2 数据传输对象

用稳定、可序列化的数据契约跨进程传输一组值,减少往返并隔离内部对象模型。

学习目标

  • 能解释订单领域对象为什么不能直接越过进程边界,并画出一次 DTO 传输的责任链
  • 能修改 TypeScript 的 DTO 组装与版本校验代码,让调用次数、字段边界和兼容策略可测试
  • 能回答:当契约变化、网络失败或调用次数上升时,什么运行证据支持采用或拒绝数据传输对象

为什么 15.2 数据传输对象值得单独学习

想象结算页面需要订单编号、买家姓名、金额和行项目。如果页面每要一个值就向另一台机器再问一次,页面会被网络等待切成很多小段;如果把整个订单对象原样交过去,接收方又会拿到不该依赖的内部关系。这个问题的关键不在于“多定义一个类型”,而在于决定一次请求到底承诺哪些数据。

没有清楚的传输边界时,字段小改动会沿着远程调用、数据库模型和客户端一起扩散。更糟的是,调用方可能以为自己拿到的是本地对象,实际却要面对超时、重试和半成功。我们需要一张窄而稳定的“数据清单”,让发送方和接收方都能检查自己的责任。

从订单请求建立概念模型

只把需要的数据装进一次请求

跨进程传输时,(Data Transfer Object,简称 DTO)是一份专门为边界准备的值集合。它可以包含 orderIdcustomerNametotalCents,但不应带着 Order 的取消方法、数据库会话或延迟加载关系。

DTO 的收益有两个可观察证据:一次请求可以带回一个用例所需的字段,客户端也只能依赖公开清单。它不是把所有字段复制一遍的仪式;字段应该从客户端用例倒推,隐藏的内部字段、密码、ORM 标记和未来可能变化的关系都应被排除。

编码是边界的一部分

在离开进程前,DTO 必须经过;到达接收方后再按契约还原。请注意序列化结果不是领域对象本身,而是带有字段名、类型和版本含义的载荷。

一组四次 getter 调用可能产生四次网络往返;一个 DTO 可以把同一用例压缩成一次请求。减少往返并不等于无限增大载荷:如果每个页面都拿整张订单表,网络字节、隐私风险和缓存失效都会上升。判断标准是“这次用例是否一次拿齐恰好需要的数据”。

目录单元到教学证据

15.2 数据传输对象

本章精确对应 manifest 单元 poeaa24-pattern-33-data-transfer-object,范围限定为:用专门的数据载体跨进程交付一组值,同时不让领域对象直接越过边界。订单与结算跨进程调用贯穿正文,学习证据不是记住 DTO 三个字,而是能交出一张字段清单、一段可测试的组装函数和一份失败样本。

完成本单元需要同时回答三件事:谁决定字段,谁拥有业务行为,哪一步负责兼容旧客户端。若实验只显示“请求成功”,却无法指出一次改字段会影响哪个契约、哪个版本和哪一次重试,就还没有完成模式级判断。

专属可视化实验:看数据如何过界

先预测:如果客户端只需要订单摘要,把 LineItem 的内部关联也放进载荷,会让哪一个边界变得不稳定?如果把三个 getter 合成一次 DTO 请求,哪一个计数会下降?点击阶段按钮,再打开故障注入,观察“领域对象 → DTO → 网络 → 客户端”的责任如何变化。

专属 DTO 边界图 · 只选当前用例需要的字段
Data Transfer Object:把用例数据装进稳定边界一次请求传递一组值;领域对象的行为和内部关系留在服务端1. 选择字段2. 组装并序列化3. 校验并消费领域对象 · 服务端Order {id, customerNameitems[], paymentToken行为与内部关系留在边界内显式映射OrderSummaryDtoschemaVersion: 1orderId, customerNametotalCents只保留用例需要的字段序列化客户端readSummary(dto)读取公开字段校验 schemaVersion一次请求,一组值当前观察:只选当前用例需要的字段Order 仍属于服务端;字段清单决定 DTO 的边界。DTO 交付数据,不交付领域对象;版本和失败策略属于同一条边界
DTO 把用例所需字段打包成一次传输;故障开关展示领域对象原样越界后的泄漏风险。

三个确定性阶段快照

下面的三个快照把同一条数据流拆开。每步都保留字段、边界和证据,先写下预测,再用图核对;不要把图中“结果到达”误认为“领域对象已经跨过边界”。

分步1 / 3

1. 选择用例字段

服务端从订单聚合中挑出当前用例需要的字段。此时 Order 仍然只属于服务端,DTO 的字段清单是下一步要执行的边界决定。

专属 DTO 边界图 · 只选当前用例需要的字段
Data Transfer Object:把用例数据装进稳定边界一次请求传递一组值;领域对象的行为和内部关系留在服务端1. 选择字段2. 组装并序列化3. 校验并消费领域对象 · 服务端Order {id, customerNameitems[], paymentToken行为与内部关系留在边界内显式映射OrderSummaryDtoschemaVersion: 1orderId, customerNametotalCents只保留用例需要的字段序列化客户端readSummary(dto)读取公开字段校验 schemaVersion一次请求,一组值当前观察:只选当前用例需要的字段Order 仍属于服务端;字段清单决定 DTO 的边界。DTO 交付数据,不交付领域对象;版本和失败策略属于同一条边界
DTO 把用例所需字段打包成一次传输;故障开关展示领域对象原样越界后的泄漏风险。

代码实践:把领域对象压成可测试契约

下面的 TypeScript 草图把领域对象和 DTO 分成两个类型。toOrderSummaryDto 是唯一的组装入口,因此可以断言它不会携带 paymentTokenitems 关系或可调用的方法。

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 字段。请修改 OrderSummaryDtotoOrderSummaryDto,再写出两条断言:序列化结果必须包含 currency,且不能包含 paymentTokenitems

问题 2:验证一次调用。fetchOrderSummary 注入一个 mock client,页面需要订单编号、客户名和总价。请写出最小测试检查远程方法只调用一次,并说明为什么不能分别调用 getOrderIdgetCustomerNamegetTotal

问题 3:诊断兼容与失败。 服务器把 totalCents 改成字符串,旧客户端仍只接受版本 1;同时一次超时后服务端可能已经扣款。请分别给出版本处理和重试处理,并写出你要观察的两条运行证据。

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

数据传输对象

专门为跨边界准备的一组数据字段;它像一张寄送清单,只列出这次用例要交付的内容,不把原对象的行为和内部关系带出去。

序列化

把内存里的字段变成可以通过网络发送的文本或字节;接收方按同一约定还原,而不是直接共享发送方的对象。

粗粒度契约

围绕一个完整用例组织数据,让调用方一次拿到所需值;它不是无限膨胀的“大对象”。

版本化

给契约的演进标出可识别的版本,并说明旧客户端遇到新字段或新类型时怎么办。

部分失败

一次跨边界操作只完成了部分步骤或只返回了部分数据;系统必须明确重试、降级或补偿的责任。

远程外观

面向用例提供远程入口的对象;它组织调用,但不应把领域对象或业务规则泄漏给客户端。

前后导航

参考资料

资料与写作方式声明

本章以Patterns of Enterprise Application Architecture权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…