DTO 决定传输哪些数据,JSON 契约还要决定字段叫什么、时间代表什么,以及客户端能否完整保存一个编号。Java 类型正确,不等于浏览器收到的数据一定没有歧义。
本章基于 Java 21、Spring Boot 4.1.1 和默认的 Jackson 3,增加一个独立的检查结果预览接口。它不改动原有 HealthStatus,也不要求提前完成数据库章节。
1. Jackson 3 的配置入口
项目已有 spring-boot-starter-webmvc 和 spring-boot-starter-validation,无需另装 JSON 库。Boot 自动配置 tools.jackson.databind.json.JsonMapper,MVC 使用同一套配置读写 JSON。
Jackson 3 的包名有一处容易混淆的例外:
| 内容 | 当前包名 |
|---|---|
| 数据绑定、JSON 树、JsonMapper | tools.jackson.databind.* |
| 底层异常等核心类型 | tools.jackson.core.* |
JsonProperty、JsonIgnore、JsonInclude | com.fasterxml.jackson.annotation.* |
注解包仍然沿用原名称,不要统一替换成不存在的 tools.jackson.annotation。Java 时间支持已并入 Jackson 3 数据绑定模块,不需要再注册 Jackson 2 的 JavaTimeModule。详见 Jackson 3 官方版本说明。
将以下内容合并到现有的 spring 配置节点中,不要创建第二个同名节点:
01spring:02jackson:03deserialization:04fail-on-unknown-properties: true05fail-on-null-for-primitives: true06datatype:07enum:08fail-on-numbers-for-enums: true09read-enums-using-to-string: false10write-enums-using-to-string: false11datetime:12write-dates-as-timestamps: false13adjust-dates-to-context-time-zone: false
这些是本项目主动选择的规则:拒绝拼错的输入字段;枚举使用名称;时间使用文本表示。特别注意,Jackson 3 的枚举和日期选项位于 datatype.enum、datatype.datetime 下,不能把 Jackson 2 的配置路径原样复制过来。
配置作用于整个应用,也会影响使用自动配置 Mapper 的 HTTP 客户端。接收外部系统响应时,如果需要兼容对方新增字段,应给那个响应 DTO 单独指定宽松策略。不要为了一个外部接口让所有业务请求静默接受拼写错误。
属性配置足够时直接使用它。确实需要代码扩展时,Boot 4 的入口是 org.springframework.boot.jackson.autoconfigure.JsonMapperBuilderCustomizer;不要在 Controller 中随意 new JsonMapper,绕过应用配置。Spring Boot JSON 配置说明列出了这些配置与类型的对应关系。
2. 字段命名与枚举
接口约定状态只能是 UP 或 DOWN:
1package cn.usehook.spring.controller.dto;23public enum ContractStatus {4UP, DOWN5}
创建只负责接收输入的 DTO:
01package cn.usehook.spring.controller.dto;0203import com.fasterxml.jackson.annotation.JsonProperty;04import jakarta.validation.constraints.NotBlank;05import jakarta.validation.constraints.NotNull;06import jakarta.validation.constraints.Size;07import java.time.OffsetDateTime;0809public record SnapshotRequest(10@NotBlank @Size(max = 50) String service,11@NotNull ContractStatus status,12@JsonProperty("observed_at") @NotNull OffsetDateTime observedAt,13@Size(max = 200) String note14) {}
Java 保留 observedAt 驼峰命名,对外使用 observed_at。注解适合局部契约;全局改为 snake_case 会改变其他接口字段,不应在已发布 API 中悄悄切换。
数字 0 不应意外代表 UP。配置已禁止数字枚举;UNKNOWN、拼写错误或不符合约定的大小写也不应自动变成一个正常状态。业务状态名称一旦公开,重命名 enum 常量就可能破坏客户端。
未知字段与未知枚举是两种问题。前者表示请求多出 servcie 这样的属性,后者表示已知 status 属性给出不支持的值。它们都会在消息转换阶段失败,还没有进入 Bean Validation。
3. 时间点与显示时区
先按业务语义选择类型:
| 类型 | 表达什么 | 示例用途 |
|---|---|---|
Instant | 时间线上的确定时刻 | 检查发生时间、创建时间 |
OffsetDateTime | 带 UTC 偏移的日期时间 | 接收外部系统带偏移的时间 |
LocalDate | 没有时区的日历日期 | 账单日期、生日 |
LocalDateTime | 没有偏移信息的本地日期时间 | 必须配合明确地区规则的预约时间 |
Duration | 持续多久 | 请求超时、检查间隔 |
2026-10-04T16:00:00+08:00 与 2026-10-04T08:00:00Z 是同一个时刻。输入允许携带偏移,保存和对外输出则统一转换为 Instant,不会因为服务器搬到另一时区而改变事件时间。
LocalDateTime 没有足够信息单独定位一个全球时间点。也不要给 Instant 格式化成没有偏移的字符串后仍宣称它是 UTC。需要按上海时间展示时,在展示层显式使用 ZoneId.of("Asia/Shanghai")。
创建响应 DTO:
01package cn.usehook.spring.controller.dto;0203import com.fasterxml.jackson.annotation.JsonIgnore;04import com.fasterxml.jackson.annotation.JsonInclude;05import com.fasterxml.jackson.annotation.JsonProperty;06import java.time.Instant;0708public record SnapshotResponse(09String id,10String service,11ContractStatus status,12@JsonProperty("checked_at") Instant checkedAt,13@JsonProperty("received_at") Instant receivedAt,14@JsonInclude(JsonInclude.Include.NON_NULL) String note,15@JsonIgnore String internalTag16) {}
note 为 null 时省略,但空字符串仍会保留。internalTag 用来演示显式忽略,只保存在 Java 对象里。真正的密码和令牌应从响应模型中移除,不应依赖一条注解作为唯一保护。
日期输出规则使用前面的 datatype.datetime 配置;字段只改变名称,不需要额外写自定义序列化器。若决定裁剪时间精度,也应在契约中固定精度,并测试数据库读写后是否保持一致。
4. 注入 Clock 与大整数编号
不要让可测试的业务逻辑隐式依赖系统当前时间。注册一个明确命名的时钟,避免与 Bean 章节中的多个 Clock 冲突:
01package cn.usehook.spring.config;0203import java.time.Clock;04import org.springframework.context.annotation.Bean;05import org.springframework.context.annotation.Configuration;0607@Configuration(proxyBeanMethods = false)08public class ContractClockConfiguration {09@Bean10Clock contractClock() {11return Clock.systemUTC();12}13}
使用时钟生成接收时间,同时将数据库式 long 编号转换为字符串:
01package cn.usehook.spring.health;0203import cn.usehook.spring.controller.dto.SnapshotRequest;04import cn.usehook.spring.controller.dto.SnapshotResponse;05import java.time.Clock;06import java.time.Instant;07import org.springframework.beans.factory.annotation.Qualifier;08import org.springframework.stereotype.Component;0910@Component11public class SnapshotFactory {12private final Clock clock;1314public SnapshotFactory(@Qualifier("contractClock") Clock clock) {15this.clock = clock;16}1718public SnapshotResponse create(long id, SnapshotRequest request) {19return new SnapshotResponse(20Long.toString(id), request.service(), request.status(),21request.observedAt().toInstant(), Instant.now(clock),22request.note(), "contract-preview"23);24}25}
JavaScript 的安全整数上限是 9007199254740991。像 2080629848610828288 这样的编号超出该范围,不应作为 JSON 数字交给前端后再转字符串,因为精度可能已经丢失。编号使用字符串只影响传输模型,数据库仍可使用 bigint。
这条规则只适用于标识符等无需前端算术的数据。不要因此把页码、数量等全部序列化成字符串,也不需要注册一个影响所有 Long 的全局序列化器。
Clock.fixed() 可以为测试提供固定时刻,见 Java 21 Clock 文档。测量执行耗时则应使用 System.nanoTime() 或指标工具,不依赖可能调整的墙上时钟。
5. 预览接口与空值语义
01package cn.usehook.spring.controller;0203import cn.usehook.spring.controller.dto.SnapshotRequest;04import cn.usehook.spring.controller.dto.SnapshotResponse;05import cn.usehook.spring.health.SnapshotFactory;06import jakarta.validation.Valid;07import org.springframework.web.bind.annotation.PostMapping;08import org.springframework.web.bind.annotation.RequestBody;09import org.springframework.web.bind.annotation.RestController;1011@RestController12public class SnapshotController {13private final SnapshotFactory factory;1415public SnapshotController(SnapshotFactory factory) {16this.factory = factory;17}1819@PostMapping("/api/v1/health/snapshots/preview")20public SnapshotResponse preview(@Valid @RequestBody SnapshotRequest request) {21return factory.create(2080629848610828288L, request);22}23}
固定编号只用于观察 JSON 表示;这不是创建记录的接口。真正保存数据时,应把 Repository 返回的编号交给转换逻辑。
本章中,note 缺失与显式 null 都表示没有备注,普通 record 也会把两者都绑定成 null。Integer、Boolean 包装类型同样不能单独区分「字段没出现」与「出现但为 null」。
如果 PATCH 约定「缺失代表不修改,null 代表清空」,需要能保留字段是否出现的输入结构。例如在限定字段的 JSON 树中,先检查 has("note"),再检查 get("note").isNull();不要直接把所有字段绑定成可空 record 后猜测用户意图。另一种简单做法是为清空动作提供明确的命令 DTO。
面对第三方的可扩展响应,可以在专门的响应 DTO 上使用 @JsonIgnoreProperties(ignoreUnknown = true),它也来自 com.fasterxml.jackson.annotation。当前 SnapshotRequest 保持严格,不加这条注解。
6. 验证真实响应
未引入 Security 时可省略认证头;完成 JWT 章节后,使用具有 monitor Scope 的 Token。新路径已经落在现有 /api/v1/health/** 保护规则内。
合并后续资源权限方案后,health 管理接口统一要求 SCOPE_monitor.admin,这里也应改用管理员 Token,不需要单独添加公开白名单。
1curl -i http://localhost:8080/api/v1/health/snapshots/preview \2-H "Authorization: Bearer $TOKEN" \3-H 'Content-Type: application/json' \4-d '{"service":"spring","status":"UP","observed_at":"2026-10-04T16:00:00+08:00"}'
响应中的 id 是字符串,checked_at 是 UTC 时间,received_at 是服务端接收时刻。缺少 note 与 internalTag 是预期行为:
1{2"id": "2080629848610828288",3"service": "spring",4"status": "UP",5"checked_at": "2026-10-04T08:00:00Z",6"received_at": "2026-10-04T08:00:01Z"7}
示例中的接收时间仅表示格式,实际值取决于请求时刻。添加未知字段 servcie、把状态改成数字或给 observed_at 一个无偏移的本地时间字符串,都应验证为请求失败;JSON 转换异常由统一异常处理中的 HttpMessageNotReadableException 分支负责。
7. 固定时间验证契约
下面的 JUnit 测试只创建普通对象,不启动服务器。项目需已有 spring-boot-starter-test 测试依赖。
01package cn.usehook.spring.health;0203import cn.usehook.spring.controller.dto.ContractStatus;04import cn.usehook.spring.controller.dto.SnapshotRequest;05import java.time.Clock;06import java.time.Instant;07import java.time.OffsetDateTime;08import java.time.ZoneOffset;09import org.junit.jupiter.api.Test;10import tools.jackson.databind.DeserializationFeature;11import tools.jackson.databind.cfg.DateTimeFeature;12import tools.jackson.databind.cfg.EnumFeature;13import tools.jackson.databind.json.JsonMapper;1415import static org.assertj.core.api.Assertions.assertThat;16import static org.assertj.core.api.Assertions.assertThatThrownBy;1718class SnapshotContractTests {19private final JsonMapper mapper = JsonMapper.builder()20.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)21.enable(EnumFeature.FAIL_ON_NUMBERS_FOR_ENUMS)22.disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)23.build();2425@Test26void preservesIdAndNormalizesTheTimePoint() {27Instant received = Instant.parse("2026-10-04T08:00:01Z");28SnapshotFactory factory = new SnapshotFactory(Clock.fixed(received, ZoneOffset.UTC));29var request = new SnapshotRequest("spring", ContractStatus.UP,30OffsetDateTime.parse("2026-10-04T16:00:00+08:00"), null);31var response = factory.create(2080629848610828288L, request);32var json = mapper.readTree(mapper.writeValueAsString(response));3334assertThat(json.get("id").isString()).isTrue();35assertThat(json.get("id").stringValue()).isEqualTo("2080629848610828288");36assertThat(json.get("checked_at").stringValue()).isEqualTo("2026-10-04T08:00:00Z");37assertThat(response.receivedAt()).isEqualTo(received);38assertThat(json.has("note")).isFalse();39assertThat(json.has("internalTag")).isFalse();40}4142@Test43void rejectsNumericEnumsAndUnknownProperties() {44assertThatThrownBy(() -> mapper.readValue("0", ContractStatus.class))45.isInstanceOf(tools.jackson.core.JacksonException.class);46assertThatThrownBy(() -> mapper.readValue(47"""48{"service":"spring","status":"UP",49"observed_at":"2026-10-04T08:00:00Z","servcie":"spring"}50""", SnapshotRequest.class51)).isInstanceOf(tools.jackson.core.JacksonException.class);52}53}
这里构建独立 Mapper 来验证明确的字段契约,不能代替对应用自动配置的验证;前面的 curl 才会经过实际配置与 MVC 转换器。后续Spring MVC 测试可将这些字段断言迁移到接口测试。
大整数、时间偏移、可空字段和枚举都是客户端会长期依赖的契约。内部数据模型可以持续调整,接口表示则应通过明确规则与测试保持稳定。