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

前面三章依次拆开了 Controller、Service 和 Repository。HTTP 请求进入 Controller,Service 执行业务规则,Repository 保存或查询数据。不同层面对数据的理解并不相同,DTO 用来描述数据穿过应用边界时采用的结构。

本章继续使用心跳接口,创建请求 DTO 和响应 DTO,并讲清它们与 HealthStatus、Repository 数据以及 JSON 之间的关系。

1. DTO

DTO 是 Data Transfer Object 的缩写,即数据传输对象。它的职责是携带一组准备跨越边界的数据,本身通常不执行完整业务流程。

在当前项目中,最明显的边界是 HTTP:

dto-boundary.txt
1
客户端 JSON
2
-> 请求 DTO
3
-> Controller
4
-> Service 使用业务参数和业务对象
5
-> Controller
6
-> 响应 DTO
7
-> 客户端 JSON

请求 DTO 描述接口准备接收并使用什么数据,响应 DTO 描述服务端承诺返回什么数据。它们让 HTTP 数据结构不必与 Service 或 Repository 内部的数据结构完全相同。

DTO 仍然是普通 Java 对象,可以使用类或 record 声明。它通常不需要成为 Spring Bean:请求 DTO 由消息转换器根据请求内容创建,响应 DTO 由 Controller 或转换方法创建,每次请求都可以产生不同实例。

本章使用 record 声明 DTO。record 自动提供构造方法、访问方法、值比较和字符串表示,适合表达创建后不再修改的数据载体。DTO 可以提供简单的转换方法,但不会自己调用 HealthService、访问 Repository 或决定心跳检查规则。

DTO 不是 Spring MVC 专属概念。模块之间调用、远程服务通信和消息队列事件也可以使用 DTO。不过 DTO 应该对应清晰的传输边界,不能把项目中的所有普通对象都统一称为 DTO。

2. 数据角色

同样包含 status 和 service 字段的对象,也可能承担不同角色:

类型当前案例主要职责
请求 DTOHealthCheckRequest描述客户端可以提交的数据
响应 DTOHealthResponse、HealthData描述客户端能够看到的数据
业务结果HealthStatus表达 Service 产生的心跳结果
Repository 数据当前同样使用 HealthStatus表达需要保存和查询的内容
配置对象HealthProperties承载 application.yaml 中的配置

对象当前字段相同,不表示它们永远应该合并。它们由不同原因发生变化:

  • 请求 DTO 随客户端输入要求变化;
  • 响应 DTO 随接口契约变化;
  • 业务对象随业务规则变化;
  • 持久化模型随表结构和查询方式变化;
  • 配置对象随应用配置变化。

当前内存 Repository 直接保存 HealthStatus,因为存储结构非常简单。以后使用数据库时,如果表中加入主键、创建时间和内部状态,可以创建单独的持久化模型,再由 Repository 转换;这些内部字段不一定需要出现在 HTTP 响应中。

请求 DTO 与响应 DTO

请求和响应即使字段相似,也不应该默认共用同一个类型。例如客户端只提交服务名称,服务端还会返回检查状态:

request.json
1
{
2
"service": "postgresql"
3
}
response.json
1
{
2
"code": 0,
3
"message": "success",
4
"data": {
5
"status": "UP",
6
"service": "postgresql"
7
}
8
}

请求中没有 status,因为客户端不应该自行决定检查结果。将同一个 DTO 同时用于输入和输出,可能让原本只读的服务端字段也变成可提交字段。

为方向分别命名能够直接表达用途:

命名方向
HealthCheckRequest客户端到服务端
HealthResponse服务端到客户端
HealthData响应中的心跳数据

3. 请求 DTO

请求 DTO 是 Controller 接受外部输入的第一层类型。它应该只声明当前接口需要读取的字段,并使用 Bean Validation 描述基础输入约束。

这里继续使用前面已经加入项目的 Validation Starter。Controller 章节把请求 record 暂时写在控制器内部,本章只负责将它移动到职责更清晰的 DTO 文件,不再重复添加依赖。

接下来把原本写在 HealthController 内部的请求 record 移到单独文件:

src/main/java/cn/usehook/spring/controller/dto/HealthCheckRequest.java
01
package cn.usehook.spring.controller.dto;
02
03
import jakarta.validation.constraints.NotBlank;
04
import jakarta.validation.constraints.Size;
05
06
public record HealthCheckRequest(
07
@NotBlank(message = "service must not be blank")
08
@Size(max = 50, message = "service must contain at most 50 characters")
09
String service
10
) {}

record 组件名 service 默认对应 JSON 中的同名字段。Jackson 可以通过 record 的构造方法创建对象,不需要为了反序列化再添加无参构造方法或 setter。

@NotBlank 拒绝 null、空字符串和只包含空白的字符串,@Size 把原始输入限制在 50 个字符以内。@Size 本身允许 null,因此这里需要与 @NotBlank 组合使用。这些约束只判断请求数据是否具备进入业务流程的基本条件。

下面先只观察请求方向,方法暂时返回 HealthStatus。下一节创建响应 DTO 后,再替换这个返回类型:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
@PostMapping(
2
path = "/health/check",
3
consumes = MediaType.APPLICATION_JSON_VALUE,
4
produces = MediaType.APPLICATION_JSON_VALUE
5
)
6
public HealthStatus check(@Valid @RequestBody HealthCheckRequest request) {
7
return healthService.check(request.service());
8
}

@RequestBody 表示参数来自请求体,Spring MVC 会根据 Content-Type 选择消息转换器;对于 application/json,JSON 转换器负责创建 HealthCheckRequest。对象创建成功后,@Valid 才会触发字段约束校验。

执行顺序如下:

request-dto-flow.txt
1
读取 HTTP 请求体
2
-> 根据 Content-Type 选择消息转换器
3
-> JSON 消息转换器创建 HealthCheckRequest
4
-> Bean Validation 检查字段约束
5
-> Controller 获得有效 DTO
6
-> 取出 service 调用 HealthService

反序列化与校验是连续但不同的步骤,因此失败类型也不同:

请求情况失败阶段默认结果
请求体不是合法 JSON消息转换抛出 HttpMessageNotReadableException,返回 400
service 缺失、为空或全是空白Bean Validation抛出 MethodArgumentNotValidException,返回 400
service 超过 50 个字符Bean Validation抛出 MethodArgumentNotValidException,返回 400
DTO 校验通过Controller调用 HealthService.check()

表中的 400 是 Spring MVC 的默认处理结果,响应体格式可以再由全局异常处理器统一。DTO 自己只声明约束,不负责创建 HTTP 错误响应。

DTO 校验也不能替代 Service 的业务校验。Controller 之外的调用方可以直接执行 healthService.check(service),所以 Service 仍然需要保证服务名称符合业务规则。两层校验解决的问题不同:DTO 保护传输边界,Service 保护业务边界。

record 适合字段明确、创建后不修改的请求。请求需要复杂构造过程时也可以使用普通类,但不应该为了让 JSON 转换工作就加入与业务无关的可变 setter;应先根据当前消息转换器支持的构造方式设计清晰的数据类型。

4. 响应 DTO

响应 DTO 控制客户端实际看到的字段和层级。先用 HealthData 把内部 HealthStatus 转换为接口数据:

src/main/java/cn/usehook/spring/controller/dto/HealthData.java
01
package cn.usehook.spring.controller.dto;
02
03
import cn.usehook.spring.health.HealthStatus;
04
05
public record HealthData(String status, String service) {
06
07
public static HealthData from(HealthStatus healthStatus) {
08
return new HealthData(healthStatus.status(), healthStatus.service());
09
}
10
}

再创建响应外层结构:

src/main/java/cn/usehook/spring/controller/dto/HealthResponse.java
01
package cn.usehook.spring.controller.dto;
02
03
import cn.usehook.spring.health.HealthStatus;
04
05
public record HealthResponse(int code, String message, HealthData data) {
06
07
public static HealthResponse success(HealthStatus healthStatus) {
08
return new HealthResponse(0, "success", HealthData.from(healthStatus));
09
}
10
}

HealthData.from() 和 HealthResponse.success() 只负责对象转换与响应组装,不执行心跳检查规则。Controller 因而不需要逐个读取字段,只需要调用语义明确的工厂方法:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
HealthStatus status = healthService.check(request.service());
2
return HealthResponse.success(status);

即使 HealthData 和当前 HealthStatus 恰好拥有相同字段,独立的响应 DTO 仍然建立了明确边界。以后 HealthStatus 增加内部诊断信息时,只有主动加入 HealthData 的字段才会暴露给客户端。

Controller 返回 HealthResponse 后,JSON 消息转换器会读取三个 record 组件并写入响应体,其中 HealthData 会成为 data 下的嵌套对象。这个过程是请求反序列化的反方向:Java 对象被序列化为 JSON。

响应 DTO 也让接口可以重新组织内部数据。例如内部对象可能通过枚举保存状态,通过 Instant 保存时间;响应层可以决定输出哪些字段以及采用怎样的嵌套关系,而不要求 Repository 按照前端页面结构保存数据。

这里使用 code、message 和 data 是当前项目选择的响应格式,不是 DTO 或 Spring MVC 强制要求的结构。如果接口不需要统一外层结构,也可以直接返回 HealthData。无论选择哪一种形式,都应该让客户端能够长期依赖其字段含义。

ResponseEntity<HealthResponse> 与 HealthResponse 也不是同一个层次。前者表示完整 HTTP 响应,可以设置状态码和响应头;后者只描述响应体的数据结构。直接返回 HealthResponse 时,成功请求默认得到 200 OK。

5. DTO 转换

本章创建的 HTTP DTO 不应该一路穿过 Service 再进入 Repository。完整的数据方向如下:

dto-conversion.txt
1
JSON
2
-> HealthCheckRequest(请求 DTO)
3
-> service 字符串
4
-> HealthService
5
-> HealthStatus(业务结果)
6
-> HealthStatusRepository(保存业务结果)
7
-> HealthStatus
8
-> HealthData + HealthResponse(响应 DTO)
9
-> JSON

当前转换很简单,可以由 Controller 和响应 DTO 的静态工厂方法完成:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {
2
HealthStatus status = healthService.check(request.service());
3
return HealthResponse.success(status);
4
}

这三行分别完成三件事:从请求 DTO 取出业务输入,调用 Service,最后把业务结果转换为响应 DTO。Controller 负责协调边界转换,但没有实现心跳规则。

当一个 DTO 只有少数字段时,显式构造或 from() 方法最容易阅读。字段很多、多个接口共享复杂映射时,可以提取独立 Mapper;是否提取取决于转换复杂度,不需要为每个 DTO 预先创建一层空转发。

转换位置可以按照方向判断:

转换合适位置
请求 DTO 转为 Service 参数Controller 或边界 Mapper
业务对象转为响应 DTOController、响应 DTO 工厂或边界 Mapper
业务对象与持久化模型互转Repository 内部的数据 Mapper
配置文本转为配置对象Spring Boot 配置绑定

Repository 不接收 HealthCheckRequest,因为 JSON 字段结构不属于数据访问契约;Service 不返回 HealthResponse,因为业务能力不应该固定为 HTTP 响应格式。

6. DTO 设计

只暴露允许传输的字段

请求 DTO 只包含客户端允许控制的数据,响应 DTO 只包含客户端需要看到的数据。数据库主键、内部备注、权限标识和诊断详情不应因为对象可以序列化就自动暴露。

为不同操作创建不同输入

创建、修改和查询可能需要不同字段。例如创建资源时不允许客户端指定服务器主键,修改资源时又可能只允许改变部分字段。一个包含所有可选字段的通用 DTO 很难准确表达每个操作的要求。

区分缺失、空值和默认值

JSON 字段缺失、显式传入 null 和传入空字符串可能具有不同含义。对于可选数字或布尔字段,包装类型 Integer、Boolean 能够保留 null;基本类型 int、boolean 则会使用 Java 默认值。类型选择要与接口契约一致。

DTO 字段通常不使用 Optional<T>。Optional 更适合作为方法返回值表达查询可能没有结果;JSON 是否允许缺失或 null,应该通过字段类型、校验和序列化规则明确约定。

保持接口字段稳定

响应 DTO 是客户端依赖的契约。重命名字段、改变类型或移动嵌套层级都可能破坏已有客户端。内部业务对象可以重构,但 DTO 的不兼容变化需要接口版本和迁移策略。

不要把 DTO 注册为单例 Bean

DTO 保存某次请求或某次响应的数据,每个实例内容不同。给 DTO 添加 @Component 会把一次调用的数据错误地放进共享单例对象。消息转换器或普通构造方法会在需要时创建 DTO,无需交给容器长期管理。

7. 心跳接口

前面已经分别创建了 HealthCheckRequest、HealthData 和 HealthResponse。现在把它们接入 Controller,同时保留前文已有的两个 GET 心跳接口。三个入口最终都使用同一种响应 DTO,新增的 POST 接口则额外使用请求 DTO:

src/main/java/cn/usehook/spring/controller/HealthController.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.controller.dto.HealthCheckRequest;
04
import cn.usehook.spring.controller.dto.HealthResponse;
05
import cn.usehook.spring.health.HealthService;
06
import cn.usehook.spring.health.HealthStatus;
07
import jakarta.validation.Valid;
08
import org.springframework.http.MediaType;
09
import org.springframework.http.ResponseEntity;
10
import org.springframework.web.bind.annotation.GetMapping;
11
import org.springframework.web.bind.annotation.PathVariable;
12
import org.springframework.web.bind.annotation.PostMapping;
13
import org.springframework.web.bind.annotation.RequestBody;
14
import org.springframework.web.bind.annotation.RequestMapping;
15
import org.springframework.web.bind.annotation.RestController;
16
17
@RestController
18
@RequestMapping("/api/v1")
19
public class HealthController {
20
21
private final HealthService healthService;
22
23
public HealthController(HealthService healthService) {
24
this.healthService = healthService;
25
}
26
27
@GetMapping("/health")
28
public ResponseEntity<HealthResponse> health() {
29
HealthResponse body = HealthResponse.success(healthService.check());
30
return ResponseEntity.ok()
31
.header("Cache-Control", "no-store")
32
.body(body);
33
}
34
35
@GetMapping("/health/{service}")
36
public HealthResponse healthByService(@PathVariable String service) {
37
return HealthResponse.success(healthService.check(service));
38
}
39
40
@PostMapping(
41
path = "/health/check",
42
consumes = MediaType.APPLICATION_JSON_VALUE,
43
produces = MediaType.APPLICATION_JSON_VALUE
44
)
45
public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {
46
HealthStatus status = healthService.check(request.service());
47
return HealthResponse.success(status);
48
}
49
}

GET 接口没有请求体,因此只使用响应 DTO。POST 接口先把 JSON 转换为 HealthCheckRequest,Controller 取出 service 交给 Service,再把返回的 HealthStatus 转换为 HealthResponse。Repository 是否使用内存或数据库,不会改变这两个 DTO。

客户端可以发送下面的 JSON 请求:

terminal.bash
1
curl -X POST 'http://localhost:8080/api/v1/health/check' \
2
-H 'Content-Type: application/json' \
3
-d '{"service":"PostgreSQL"}'

Service 把名称规范为 postgresql,Repository 保存业务结果,响应 DTO 再生成稳定的 JSON:

response.json
1
{
2
"code": 0,
3
"message": "success",
4
"data": {
5
"status": "UP",
6
"service": "postgresql"
7
}
8
}

如果把 service 改为空白字符串,可以观察请求 DTO 的校验结果:

terminal.bash
1
curl -i -X POST 'http://localhost:8080/api/v1/health/check' \
2
-H 'Content-Type: application/json' \
3
-d '{"service":" "}'

这次请求会在调用 Controller 方法之前返回 400,HealthService 和 Repository 都不会执行。至此,每一种对象只服务于自己的边界:DTO 负责 HTTP 数据结构,Service 负责业务规则,Repository 负责数据访问。请求或响应格式以后发生变化时,不需要让 JSON 细节进入 Service 和 Repository。