跳到主内容
创建时间: 2026-10-04最后更新: 2026-10-04

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 树、JsonMappertools.jackson.databind.*
底层异常等核心类型tools.jackson.core.*
JsonProperty、JsonIgnore、JsonIncludecom.fasterxml.jackson.annotation.*

注解包仍然沿用原名称,不要统一替换成不存在的 tools.jackson.annotation。Java 时间支持已并入 Jackson 3 数据绑定模块,不需要再注册 Jackson 2 的 JavaTimeModule。详见 Jackson 3 官方版本说明。

将以下内容合并到现有的 spring 配置节点中,不要创建第二个同名节点:

src/main/resources/application.yaml
01
spring:
02
jackson:
03
deserialization:
04
fail-on-unknown-properties: true
05
fail-on-null-for-primitives: true
06
datatype:
07
enum:
08
fail-on-numbers-for-enums: true
09
read-enums-using-to-string: false
10
write-enums-using-to-string: false
11
datetime:
12
write-dates-as-timestamps: false
13
adjust-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:

src/main/java/cn/usehook/spring/controller/dto/ContractStatus.java
1
package cn.usehook.spring.controller.dto;
2
3
public enum ContractStatus {
4
UP, DOWN
5
}

创建只负责接收输入的 DTO:

src/main/java/cn/usehook/spring/controller/dto/SnapshotRequest.java
01
package cn.usehook.spring.controller.dto;
02
03
import com.fasterxml.jackson.annotation.JsonProperty;
04
import jakarta.validation.constraints.NotBlank;
05
import jakarta.validation.constraints.NotNull;
06
import jakarta.validation.constraints.Size;
07
import java.time.OffsetDateTime;
08
09
public 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 note
14
) {}

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:

src/main/java/cn/usehook/spring/controller/dto/SnapshotResponse.java
01
package cn.usehook.spring.controller.dto;
02
03
import com.fasterxml.jackson.annotation.JsonIgnore;
04
import com.fasterxml.jackson.annotation.JsonInclude;
05
import com.fasterxml.jackson.annotation.JsonProperty;
06
import java.time.Instant;
07
08
public record SnapshotResponse(
09
String id,
10
String service,
11
ContractStatus status,
12
@JsonProperty("checked_at") Instant checkedAt,
13
@JsonProperty("received_at") Instant receivedAt,
14
@JsonInclude(JsonInclude.Include.NON_NULL) String note,
15
@JsonIgnore String internalTag
16
) {}

note 为 null 时省略,但空字符串仍会保留。internalTag 用来演示显式忽略,只保存在 Java 对象里。真正的密码和令牌应从响应模型中移除,不应依赖一条注解作为唯一保护。

日期输出规则使用前面的 datatype.datetime 配置;字段只改变名称,不需要额外写自定义序列化器。若决定裁剪时间精度,也应在契约中固定精度,并测试数据库读写后是否保持一致。

4. 注入 Clock 与大整数编号

不要让可测试的业务逻辑隐式依赖系统当前时间。注册一个明确命名的时钟,避免与 Bean 章节中的多个 Clock 冲突:

src/main/java/cn/usehook/spring/config/ContractClockConfiguration.java
01
package cn.usehook.spring.config;
02
03
import java.time.Clock;
04
import org.springframework.context.annotation.Bean;
05
import org.springframework.context.annotation.Configuration;
06
07
@Configuration(proxyBeanMethods = false)
08
public class ContractClockConfiguration {
09
@Bean
10
Clock contractClock() {
11
return Clock.systemUTC();
12
}
13
}

使用时钟生成接收时间,同时将数据库式 long 编号转换为字符串:

src/main/java/cn/usehook/spring/health/SnapshotFactory.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.controller.dto.SnapshotRequest;
04
import cn.usehook.spring.controller.dto.SnapshotResponse;
05
import java.time.Clock;
06
import java.time.Instant;
07
import org.springframework.beans.factory.annotation.Qualifier;
08
import org.springframework.stereotype.Component;
09
10
@Component
11
public class SnapshotFactory {
12
private final Clock clock;
13
14
public SnapshotFactory(@Qualifier("contractClock") Clock clock) {
15
this.clock = clock;
16
}
17
18
public SnapshotResponse create(long id, SnapshotRequest request) {
19
return new SnapshotResponse(
20
Long.toString(id), request.service(), request.status(),
21
request.observedAt().toInstant(), Instant.now(clock),
22
request.note(), "contract-preview"
23
);
24
}
25
}

JavaScript 的安全整数上限是 9007199254740991。像 2080629848610828288 这样的编号超出该范围,不应作为 JSON 数字交给前端后再转字符串,因为精度可能已经丢失。编号使用字符串只影响传输模型,数据库仍可使用 bigint。

这条规则只适用于标识符等无需前端算术的数据。不要因此把页码、数量等全部序列化成字符串,也不需要注册一个影响所有 Long 的全局序列化器。

Clock.fixed() 可以为测试提供固定时刻,见 Java 21 Clock 文档。测量执行耗时则应使用 System.nanoTime() 或指标工具,不依赖可能调整的墙上时钟。

5. 预览接口与空值语义

src/main/java/cn/usehook/spring/controller/SnapshotController.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.controller.dto.SnapshotRequest;
04
import cn.usehook.spring.controller.dto.SnapshotResponse;
05
import cn.usehook.spring.health.SnapshotFactory;
06
import jakarta.validation.Valid;
07
import org.springframework.web.bind.annotation.PostMapping;
08
import org.springframework.web.bind.annotation.RequestBody;
09
import org.springframework.web.bind.annotation.RestController;
10
11
@RestController
12
public class SnapshotController {
13
private final SnapshotFactory factory;
14
15
public SnapshotController(SnapshotFactory factory) {
16
this.factory = factory;
17
}
18
19
@PostMapping("/api/v1/health/snapshots/preview")
20
public SnapshotResponse preview(@Valid @RequestBody SnapshotRequest request) {
21
return 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,不需要单独添加公开白名单。

terminal.bash
1
curl -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 是预期行为:

response.json
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 测试依赖。

src/test/java/cn/usehook/spring/health/SnapshotContractTests.java
01
package cn.usehook.spring.health;
02
03
import cn.usehook.spring.controller.dto.ContractStatus;
04
import cn.usehook.spring.controller.dto.SnapshotRequest;
05
import java.time.Clock;
06
import java.time.Instant;
07
import java.time.OffsetDateTime;
08
import java.time.ZoneOffset;
09
import org.junit.jupiter.api.Test;
10
import tools.jackson.databind.DeserializationFeature;
11
import tools.jackson.databind.cfg.DateTimeFeature;
12
import tools.jackson.databind.cfg.EnumFeature;
13
import tools.jackson.databind.json.JsonMapper;
14
15
import static org.assertj.core.api.Assertions.assertThat;
16
import static org.assertj.core.api.Assertions.assertThatThrownBy;
17
18
class SnapshotContractTests {
19
private 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();
24
25
@Test
26
void preservesIdAndNormalizesTheTimePoint() {
27
Instant received = Instant.parse("2026-10-04T08:00:01Z");
28
SnapshotFactory factory = new SnapshotFactory(Clock.fixed(received, ZoneOffset.UTC));
29
var request = new SnapshotRequest("spring", ContractStatus.UP,
30
OffsetDateTime.parse("2026-10-04T16:00:00+08:00"), null);
31
var response = factory.create(2080629848610828288L, request);
32
var json = mapper.readTree(mapper.writeValueAsString(response));
33
34
assertThat(json.get("id").isString()).isTrue();
35
assertThat(json.get("id").stringValue()).isEqualTo("2080629848610828288");
36
assertThat(json.get("checked_at").stringValue()).isEqualTo("2026-10-04T08:00:00Z");
37
assertThat(response.receivedAt()).isEqualTo(received);
38
assertThat(json.has("note")).isFalse();
39
assertThat(json.has("internalTag")).isFalse();
40
}
41
42
@Test
43
void rejectsNumericEnumsAndUnknownProperties() {
44
assertThatThrownBy(() -> mapper.readValue("0", ContractStatus.class))
45
.isInstanceOf(tools.jackson.core.JacksonException.class);
46
assertThatThrownBy(() -> mapper.readValue(
47
"""
48
{"service":"spring","status":"UP",
49
"observed_at":"2026-10-04T08:00:00Z","servcie":"spring"}
50
""", SnapshotRequest.class
51
)).isInstanceOf(tools.jackson.core.JacksonException.class);
52
}
53
}

这里构建独立 Mapper 来验证明确的字段契约,不能代替对应用自动配置的验证;前面的 curl 才会经过实际配置与 MVC 转换器。后续Spring MVC 测试可将这些字段断言迁移到接口测试。

大整数、时间偏移、可空字段和枚举都是客户端会长期依赖的契约。内部数据模型可以持续调整,接口表示则应通过明确规则与测试保持稳定。