本节目标:把
libraryd的 JSON 契约固定成一组 record,让请求只能传该传的字段,响应只暴露该暴露的字段,领域对象保持独立。
适用版本:JDK 25(Temurin 25.0.5)、Jackson 2.22.4。
承接的问题
6.1 节让 BookDto 和 LoanDto 能正确序列化了。但「能序列化」不等于「契约设计正确」。把领域对象直接当响应体,会遇到三个具体问题:
Loan只有id、isbn、memberId、borrowedOn、dueOn、returnedOn、renewCount,没有status和overdueDays。客户端要自己判断「在借、逾期还是已归还」,每个客户端都得复制一遍规则。Book和Loan是内部模型。将来给Loan加一个internalNote或fineCents,如果直接序列化领域对象,字段会立刻暴露给所有客户端。- 请求方向更危险。如果
POST /api/books直接绑定Book,客户端可以传id、returnedOn这类本该由服务端决定的字段。这种「批量赋值」(mass assignment)是很多越权漏洞的入口。
所以需要三组对象:请求 DTO、响应 DTO、领域对象。它们形状相似,但职责不同,不能合并。
核心机制一:三组 record 的分工
先看 libraryd 的契约。请求 DTO 只包含客户端有资格提供的字段:
package com.plume.library.web;
public record CreateBookRequest(String isbn, String title, String author, int year) {}
public record UpdateBookRequest(String title, String author, int year) {}
public record BorrowRequest(String isbn, String memberId) {}
public record ReturnRequest(String isbn) {}
响应 DTO 只包含客户端需要看到的字段:
package com.plume.library.web;
import java.time.LocalDate;
public record BookDto(String isbn, String title, String author, int year) {}
public record LoanDto(
long id,
String isbn,
String memberId,
LocalDate borrowedOn,
LocalDate dueOn,
LocalDate returnedOn,
int renewCount,
String status,
long overdueDays) {}
public record ReturnReceiptDto(
String isbn,
String memberId,
LocalDate returnedOn,
long fineCents) {}
分页和错误各有自己的信封:
package com.plume.library.web;
import java.util.List;
import java.util.Map;
public record PageResponse<T>(List<T> items, long total, int page, int size) {}
public record ApiError(
String type,
String title,
int status,
String detail,
String code,
String requestId,
Map<String, String> errors) {}
三组对象的边界可以这样记:
| 对象 | 谁创建 | 允许出现的字段 | 典型错误 |
|---|---|---|---|
| 请求 DTO | 客户端 JSON | 只含可写字段 | 暴露 id、returnedOn |
| 响应 DTO | 服务端 | 含派生字段,屏蔽内部字段 | 直接返回领域对象 |
| 领域对象 | 服务端 | 业务不变量 | 被 Jackson 注解污染 |
UpdateBookRequest 没有 isbn,因为 ISBN 是路径参数,也是主键,不允许通过请求体修改。BorrowRequest 没有 borrowedOn 和 dueOn,借期由 Loan.open 根据当天计算,客户端不能自己指定到期日。
核心机制二:派生字段在边界计算
LoanDto 里的 status 和 overdueDays 不是数据库列,而是根据 returnedOn 和 dueOn 算出来的。计算放在 DTO 的转换方法里,让所有响应走同一套规则:
package com.plume.library.web;
import com.plume.library.domain.Loan;
import java.time.LocalDate;
public record LoanDto(
long id,
String isbn,
String memberId,
LocalDate borrowedOn,
LocalDate dueOn,
LocalDate returnedOn,
int renewCount,
String status,
long overdueDays) {
public static LoanDto from(Loan loan, LocalDate today) {
return new LoanDto(
loan.id(),
loan.isbn(),
loan.memberId(),
loan.borrowedOn(),
loan.dueOn(),
loan.returnedOn(),
loan.renewCount(),
statusOf(loan, today),
loan.overdueDays(today));
}
private static String statusOf(Loan loan, LocalDate today) {
if (!loan.active()) {
return "RETURNED";
}
return loan.overdueDays(today) > 0 ? "OVERDUE" : "ACTIVE";
}
}
status 只有三个值:ACTIVE、OVERDUE、RETURNED。判断顺序很重要:先看是否已归还,再看是否逾期。已归还的记录即使 dueOn 早就过了,状态也应该是 RETURNED,而不是 OVERDUE。
三种状态的本机输出:
[loan-active ] {"id":1,"isbn":"9787111213826","memberId":"m-001","borrowedOn":"2026-10-15","dueOn":"2026-10-29","returnedOn":null,"renewCount":0,"status":"ACTIVE","overdueDays":0}
[loan-overdue] {"id":1,"isbn":"9787115428028","memberId":"m-002","borrowedOn":"2026-09-28","dueOn":"2026-10-12","returnedOn":null,"renewCount":0,"status":"OVERDUE","overdueDays":6}
[loan-closed ] {"id":1,"isbn":"9787111213826","memberId":"m-001","borrowedOn":"2026-10-15","dueOn":"2026-10-29","returnedOn":"2026-10-17","renewCount":0,"status":"RETURNED","overdueDays":0}
这三条来自本机用 Jackson 2.22.4 序列化的结果。注意 overdueDays 在已归还时是 0,因为 Loan.overdueDays(today) 在 returnedOn 非空时用归还日计算,归还日早于到期日就压成 0。
派生字段放在响应 DTO 而不是领域对象里,有一个实际好处:today 是请求上下文的一部分,不是领域对象的固有属性。同一个 Loan 在 10 月 18 日查是 ACTIVE,在 11 月 2 日查就是 OVERDUE。把 today 传进转换方法,比把「当前时间」塞进领域对象更清楚。
核心机制三:请求 DTO 阻止批量赋值
假设 POST /api/books 直接绑定领域 Book:
// 不要这样做
Book book = JsonSupport.apiMapper().readValue(body, Book.class);
Book 的组件是 isbn、title、author、year,看起来风险不大。但 Loan 的组件里有 id、returnedOn、renewCount。如果借阅接口直接绑定 Loan,客户端可以传 "returnedOn":"2026-01-01",把一条新借阅伪装成已归还。
请求 DTO 的价值就是「只暴露可写字段」。BorrowRequest 只有 isbn 和 memberId,客户端即使传了 returnedOn,Jackson 也会因为未知字段而报错,本机报错原文:
[request-unknown] UnrecognizedPropertyException - Unrecognized field "extra" (class CreateBookRequest), not marked as ignorable
本机这条输出用 CreateBookRequest 加了一个 extra 字段,为了可读性把类名简化成了 CreateBookRequest。错误原文把类名和字段名都指出来了,web 层可以把它映射成 400,并在 ApiError.errors 里指出是哪个字段。
严格模式是请求边界的默认选择。如果为了「兼容旧客户端」把未知字段关掉,拼错的 memberID(大写 D)会被静默丢弃,服务端用 null 继续执行,最后报一个和根因无关的空指针。让错误在边界暴露,比在服务层排查便宜。
核心机制四:分页、错误与 null 策略
分页响应不是直接把 List<BookDto> 返回,而是包一层:
PageResponse<BookDto> page = new PageResponse<>(
List.of(new BookDto("9787111213826", "Effective Java", "Joshua Bloch", 2018)),
41, 0, 20);
本机输出:
[page ] {"items":[{"isbn":"9787111213826","title":"Effective Java","author":"Joshua Bloch","year":2018}],"total":41,"page":0,"size":20}
items 是当前页,total 是满足条件的总数,page 从 0 开始,size 是每页大小。客户端需要 total 计算总页数;如果只返回数组,客户端不知道还有多少数据。第 4.3 节的分页 SQL 用 count(*) over() 或单独的 count 查询得到 total,这里只负责形状。
错误响应按 Problem Details 的字段组织,媒体类型是 application/problem+json:
ApiError error = new ApiError(
"https://plumephp.com/problems/book-already-borrowed",
"Book already borrowed", 409,
"ISBN 9787111213826 already has an active loan",
"E_BOOK_ALREADY_BORROWED", "3f2a-request",
Map.of("isbn", "already borrowed"));
本机输出:
[error ] {"type":"https://plumephp.com/problems/book-already-borrowed","title":"Book already borrowed","status":409,"detail":"ISBN 9787111213826 already has an active loan","code":"E_BOOK_ALREADY_BORROWED","requestId":"3f2a-request","errors":{"isbn":"already borrowed"}}
字段各司其职:
type是稳定的问题类型 URI,客户端可以按它做分支,不要按detail做匹配。title是给人看的短标题。status与 HTTP 状态码一致。detail是这次请求的具体说明。code是libraryd的ErrorCode值,便于日志和客户端精确判断。requestId对应响应头X-Request-Id,排查时能把客户端报错和服务端日志串起来。errors是字段级错误,校验失败时使用;没有字段错误时可以是空 Map。
status 不要和 ErrorCode.httpStatus() 各写一份。ApiError 构造时从 ErrorCode 取状态码,避免路由里再写一个 switch,两处不一致时以领域错误码为准。
null 字段的取舍
returnedOn 在活动借阅时是 null。默认序列化会保留字段,本机输出:
[default] {"id":1,"isbn":"9787111213826","memberId":"m-001","borrowedOn":"2026-10-18","dueOn":"2026-11-01","returnedOn":null,"renewCount":0,"status":"ACTIVE","overdueDays":0}
如果设置 NON_NULL,字段会消失,本机输出:
[non-null] {"id":1,"isbn":"9787111213826","memberId":"m-001","borrowedOn":"2026-10-18","dueOn":"2026-11-01","renewCount":0,"status":"ACTIVE","overdueDays":0}
两种都能用,但同一个 API 要统一:
- 保留
null:结构稳定,客户端可以始终按同一个字段集解析;缺点是 payload 略大。 - 省略
null:payload 小;缺点是客户端要区分「字段缺失」和「值为空」。
libraryd 选择保留 null,因为 LoanDto 的字段集是契约的一部分,客户端不应该靠字段是否存在来判断状态。状态已经由 status 表达,returnedOn 只提供日期。
在 libraryd 里落地:一个 DtoMapper
转换逻辑集中在一个类里,web 层只调用它,不自己拼 DTO:
package com.plume.library.web;
import com.plume.library.domain.Book;
import com.plume.library.domain.Loan;
import java.time.LocalDate;
import java.util.List;
public final class DtoMapper {
private DtoMapper() {
}
public static BookDto toDto(Book book) {
return new BookDto(book.isbn(), book.title(), book.author(), book.year());
}
public static LoanDto toDto(Loan loan, LocalDate today) {
return LoanDto.from(loan, today);
}
public static ReturnReceiptDto toReceipt(Loan closed, long fineCents) {
return new ReturnReceiptDto(
closed.isbn(), closed.memberId(), closed.returnedOn(), fineCents);
}
public static <T> PageResponse<T> page(List<T> items, long total, int page, int size) {
return new PageResponse<>(items, total, page, size);
}
}
ApiError 的 type 在 web 层由错误码转成 kebab-case 的 URI,例如 E_BOOK_ALREADY_BORROWED 去掉前缀后变成 book-already-borrowed。type 一旦发布就不要改,客户端可能按它做分支;要改语义就新增一个类型。status 直接取 ErrorCode.httpStatus(),code 取同一个错误码的 wire 值,两处不会漂移。
PageResponse 和 ApiError 在 web 层组装,不进入领域包。领域包不知道 HTTP 状态码,也不知道 Problem Details 的字段名。这是模块依赖方向的要求:web 依赖 domain,反过来不成立。
常见错误与边界
错误一:请求直接绑定领域对象。 Book、Loan 的组件包含服务端字段,客户端可以借此覆盖 id、returnedOn。请求 DTO 只保留可写字段,是防止批量赋值的第一道门。
错误二:创建和更新共用一个 DTO。 如果 UpdateBookRequest 也带 isbn,客户端就能通过更新接口改主键。创建需要 isbn,更新不需要,两个 record 分开定义,哪怕字段只差一个。
错误三:响应直接返回领域对象。 领域对象缺少 status、overdueDays,客户端得自己算;将来领域对象加内部字段,还会无意中暴露。响应 DTO 是面向客户端的稳定视图,领域对象可以自由演进。
错误四:用 Map<String, Object> 当响应。 字段名靠字符串约定,编译器帮不上忙;重构时改了一个 key,测试可能仍然通过。DTO 的字段是编译期检查的,改名会立刻编译失败。
错误五:分页只返回数组。 没有 total,客户端无法知道总页数;没有 page、size,也无法判断当前页。分页信封不是冗余,是协议的一部分。
边界:错误码与 HTTP 状态。 ErrorCode 已经带了 httpStatus(),不要在路由里为每个异常写 switch。新增错误码时,同时补上 value()、httpStatus() 和默认 title(),三处保持一致。
边界:DTO 的校验。 本节只负责形状,字段是否必填、长度是否超限,属于第 8 章的 Jakarta Validation。DTO 可以带校验注解,但转换方法不应该承担校验职责。
小结
- 请求 DTO、响应 DTO、领域对象是三组不同的类型,形状相似但职责不同。
- 请求 DTO 只暴露可写字段,阻止客户端覆盖
id、returnedOn等内部状态。 LoanDto的status和overdueDays在边界计算,规则只写一遍。PageResponse提供items、total、page、size,客户端才能正确分页。ApiError按 Problem Details 组织,type稳定、code精确、requestId可追踪。libraryd保留null字段,让响应结构稳定;转换逻辑集中在DtoMapper。
DTO 的形状定下来后,还剩一类边界没有处理:当响应里出现子类型,或者对象之间互相引用时,Jackson 默认行为会失败。下一节看多态、循环引用,以及把 JSON 写进 PostgreSQL 的 jsonb 列。
阅读导航:上一节:6.1 Jackson 基础:绑定、命名与日期 · 下一节:6.3 序列化边界:多态、循环引用与 JSONB 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。