合作方提交一次租赁预留,需要提供设备、时段、金额和请求编号。把内部合同对象直接序列化,很容易把数据库主键、租户归属或持久化版本一起变成外部接口。以后修改实体结构时,原本只影响数据库适配器的变更,也会变成合作方升级。DTO 的实际作用是给这个变化设置一个明确边界:远程请求只携带完成用例所需的数据,应用代码决定怎样构造内部对象,响应只暴露经过选择的结果。

本实验沿用系列中的租赁语义,但只实现单租户、单设备的教学端点。运行时固定 Java 21 与 H2 2.1.214,HTTP 使用 JDK 自带的 HttpServer。请求编码选用标准表单编码,响应使用一个字段集合固定的 JSON 对象。这样可以直接观察编码、解析、组装和落库四个环节,也避免把序列化框架配置当成 DTO 模式本身。

一次远程调用携带哪些数据

Fowler 的 DTO 条目把减少远程方法调用次数作为主要动机,并把序列化机制的封装列为额外好处。租赁接口如果分别请求合同编号、金额、币种,就需要三个往返。一次返回三个字段,调用次数可以降为一次。这是接口形状产生的差别;本机环回请求的耗时无法说明跨地域网络能节省多少毫秒。DTO 原作者条目

实验的创建请求包含七个字段:请求编号、设备编号、金额、币种、开始时间、结束时间和协议版本。字段集合严格匹配,未知字段和重复字段均拒绝。严格匹配便于展示契约边界,却意味着增加字段需要双方协调;正式接口也可以选择忽略未知字段,但必须同时约定安全字段、扩展空间和降级行为,不能让解析器默认配置替代兼容性决策。

响应只有编号、金额和币种。测试将响应解析成对象后,与预期的三个字段逐项比较;若内部租户字段或数据库列被意外加进来,比较会失败。字段名称相同不代表对象可以复用:持久化对象可以具有生命周期和内部关联,DTO 则需要有可传输、可版本化的表达形式。把两个类型分开以后,数据库重命名和接口兼容便能分别评审。

Assembler 的输入输出

RentalEndpoint.CreateDto 保存外部文本,assemble 构造 Rental。金额通过 BigDecimal 解析,再交给冻结基线中的 Money;时间通过 Instant 解析,再构造冻结基线中的半开区间 Period。结束时间早于开始时间时,领域类型会拒绝构造。传输层无需重新定义区间规则,但必须把异常翻译为调用方能够识别的响应。

协议版本和币种白名单在组装入口检查。请求编号限定为字母、数字和连字符,既约束存储尺寸,也使本例固定格式的响应编码具有可证明的输入边界。完整 JSON 接口应使用成熟编码器处理任意字符串,不能把这个受限示例中的字符串拼接扩展成通用序列化器。金额仅允许正值且不超过教学上限,超限不会进入数据库事务。

Assembler 不负责授权。设备编号能解析,只说明它符合文本形状;它是否属于当前调用方,仍由应用边界决定。实验把固定身份映射为租户 T1,只允许 E1,E2 会得到拒绝响应。租户值由服务端确定,没有放入请求 DTO。这个做法把外部输入与授权上下文分开,但固定请求头只是一种可控的测试身份,不具备生产认证能力。

领域类型也不应依赖 HTTP 状态码。外部数据格式、业务约束和访问控制是不同失败来源,端点分别产生 400、422、403 等响应。开始或结束时间不能解析为 Instant 时返回 422,避免让时间解析异常泄漏为 500。真正部署时还要设计稳定的错误码、字段路径和关联编号,使合作方能区分可修正输入、不可重试权限失败与临时服务故障。本实验用短文本描述结果,未实现完整错误协议。

SQL 事务与远程结果之间

创建用例先验证 DTO,再打开真实 JDBC 连接。它根据请求编号查询已有合同;相同编号与相同载荷返回已有结果,不再插入。相同编号携带不同金额则返回冲突。首次请求把租赁写入文件数据库并提交,然后端点才写 HTTP 成功响应。这个顺序让服务端结果具有明确的落库边界,也留下提交成功而响应丢失的窗口。

窗口不能靠“客户端收到超时就当失败”消除。客户可能没有收到响应,数据库却已经有合同。再次提交必须带回原请求编号,服务端才能辨认它与首次调用的关系。如果客户端每次重试都生成新编号,去重条件就失效。对于多并发调用,还需要处理唯一键竞争并重新查询结果;本例使用默认串行分发,只验证顺序重试,不能证明并发幂等。

查询端点严格接受一个合法的 id 参数;缺失、未知、重复字段和空编号返回 400,合法编号查无合同返回 404。查询按编号和服务端租户共同过滤。合作方只能得到可见的合同响应,而不能拿 DTO 中的某个租户字符串替换查询范围。授权失败的设备、缺失币种、未知字段、旧版本和过大输入都在写入前结束。每一种拒绝都留有实际 HTTP 状态,最终通过独立进程读回数据库,确认这些请求没有留下额外合同。

数据库关闭连接与 JVM 退出是两个不同层次。本实验由 Python 启动独立服务进程,完成请求后发送终止信号并等待退出,再启动另一个 JVM 执行计数查询。第二个进程不可能复用第一个进程的 Java 对象缓存。读回一条合同说明文件数据库保留了本轮已提交状态;这仍不是断电恢复、文件系统损坏或跨主机容灾的证据。

复现实验与反例

下载包解压后设置 JAVA_HOME 指向 Java 21,运行 bash run-lab.sh 10。入口校验冻结基线,校验 H2 文件的 SHA-256,然后以 --release 21 -Xlint:all -Werror 编译。H2 不随 ZIP 分发;解析器从固定 Maven Central 地址取得指定版本,也支持通过 H2_JAR 指向已经核对过的本地缓存。显式提供了错误文件时会直接失败,不会悄悄回退到其他依赖。

下载本章累计源码 · 校验清单

labs/10/run.py 是实验驱动。它对合法请求实际发起 POST,再通过 GET 解析响应,断言 DTO 字段恰好为三项。随后执行未知字段、缺少币种、旧版本、越权设备、超过一千零二十四字节的请求体、无身份和冲突载荷等负例。各次请求及响应保存在 evidence/10/http-requests.json,服务端 SQL 与提交信息保存在 http-server.log。

粗粒度与逐属性查询也使用真实请求。逐属性对照发起三个 GET,每次只使用返回对象的一项;整体查询只发一个 GET。实验断言调用次数为三比一,没有把请求耗时作为性能结论。服务端实际上已经返回完整 DTO,因此这个对照模拟的是客户端错误使用方式,未建立一个真正按属性暴露资源的生产接口。

restart-readback.log 由服务退出后的新 JVM 生成,包含 PERSISTED_COUNT=1。只有状态码正确还不够:如果代码先插入,再发现币种缺失并返回失败,HTTP 测试可能通过,数据却已经污染。因此拒绝响应和持久状态必须一起检查。读者修改拒绝路径时,可以先让它错误落库,再运行入口,验证最终计数断言确实能抓住副作用。

请求体限制发生在解析之前。驱动发送超过上限的字节序列,端点只读取上限加一字节来判断超限,随后立即返回拒绝。这个细节避免先把任意大的正文完整读入内存,再检查长度的常见错误。它只限制本请求体的大小,仍未覆盖连接数量、慢速发送和响应大小;这些约束需要服务器和网关共同承担。

原始日志记录绑定参数便于核验实验,但正式系统通常不能原样记录完整请求。金额、身份信息和合作方凭据具有不同敏感等级,排障需要的关联编号也不等于允许保存业务全文。保留可定位请求的标识、错误分类和必要状态,再为敏感字段设置脱敏和留存策略,可以减少调试工具反过来扩大数据暴露范围的风险。

演进远程边界

DTO 的版本不应直接复用数据库乐观锁版本。本例的 version=1 表示请求协议版本,持久化表没有把它当作合同修改计数。若后来增加合同修改接口,通常需要另一个期望状态版本,用来防止覆盖并发变更。两个数字即使恰好相等,所解决的问题也完全不同。

列表接口还有分页、排序和读取一致性问题。一次返回全部合同虽然减少了往返,却可能产生无限制响应;把每个字段拆成远程对象,又会扩大请求数。合理的粒度应围绕调用方的业务操作设计,例如“一页待交付合同”,并为分页游标、金额精度和时区制定稳定契约。减少调用次数只是其中一个约束。

这里的端点没有 TLS、真实认证、速率限制、完整跨租户数据集和并发幂等保证。固定身份与固定设备让 DTO、组装、授权位置和提交顺序可以逐项验证。扩展到正式合作方接口前,需要把这些限制替换为明确机制,并保留相同的非法输入无副作用检查。

参考资料