前面三章依次拆开了 Controller、Service 和 Repository。HTTP 请求进入 Controller,Service 执行业务规则,Repository 保存或查询数据。不同层面对数据的理解并不相同,DTO 用来描述数据穿过应用边界时采用的结构。
本章继续使用心跳接口,创建请求 DTO 和响应 DTO,并讲清它们与 HealthStatus、Repository 数据以及 JSON 之间的关系。
1. DTO
DTO 是 Data Transfer Object 的缩写,即数据传输对象。它的职责是携带一组准备跨越边界的数据,本身通常不执行完整业务流程。
在当前项目中,最明显的边界是 HTTP:
1客户端 JSON2-> 请求 DTO3-> Controller4-> Service 使用业务参数和业务对象5-> Controller6-> 响应 DTO7-> 客户端 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 字段的对象,也可能承担不同角色:
| 类型 | 当前案例 | 主要职责 |
|---|---|---|
| 请求 DTO | HealthCheckRequest | 描述客户端可以提交的数据 |
| 响应 DTO | HealthResponse、HealthData | 描述客户端能够看到的数据 |
| 业务结果 | HealthStatus | 表达 Service 产生的心跳结果 |
| Repository 数据 | 当前同样使用 HealthStatus | 表达需要保存和查询的内容 |
| 配置对象 | HealthProperties | 承载 application.yaml 中的配置 |
对象当前字段相同,不表示它们永远应该合并。它们由不同原因发生变化:
- 请求 DTO 随客户端输入要求变化;
- 响应 DTO 随接口契约变化;
- 业务对象随业务规则变化;
- 持久化模型随表结构和查询方式变化;
- 配置对象随应用配置变化。
当前内存 Repository 直接保存 HealthStatus,因为存储结构非常简单。以后使用数据库时,如果表中加入主键、创建时间和内部状态,可以创建单独的持久化模型,再由 Repository 转换;这些内部字段不一定需要出现在 HTTP 响应中。
请求 DTO 与响应 DTO
请求和响应即使字段相似,也不应该默认共用同一个类型。例如客户端只提交服务名称,服务端还会返回检查状态:
1{2"service": "postgresql"3}
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 移到单独文件:
01package cn.usehook.spring.controller.dto;0203import jakarta.validation.constraints.NotBlank;04import jakarta.validation.constraints.Size;0506public record HealthCheckRequest(07@NotBlank(message = "service must not be blank")08@Size(max = 50, message = "service must contain at most 50 characters")09String service10) {}
record 组件名 service 默认对应 JSON 中的同名字段。Jackson 可以通过 record 的构造方法创建对象,不需要为了反序列化再添加无参构造方法或 setter。
@NotBlank 拒绝 null、空字符串和只包含空白的字符串,@Size 把原始输入限制在 50 个字符以内。@Size 本身允许 null,因此这里需要与 @NotBlank 组合使用。这些约束只判断请求数据是否具备进入业务流程的基本条件。
下面先只观察请求方向,方法暂时返回 HealthStatus。下一节创建响应 DTO 后,再替换这个返回类型:
1@PostMapping(2path = "/health/check",3consumes = MediaType.APPLICATION_JSON_VALUE,4produces = MediaType.APPLICATION_JSON_VALUE5)6public HealthStatus check(@Valid @RequestBody HealthCheckRequest request) {7return healthService.check(request.service());8}
@RequestBody 表示参数来自请求体,Spring MVC 会根据 Content-Type 选择消息转换器;对于 application/json,JSON 转换器负责创建 HealthCheckRequest。对象创建成功后,@Valid 才会触发字段约束校验。
执行顺序如下:
1读取 HTTP 请求体2-> 根据 Content-Type 选择消息转换器3-> JSON 消息转换器创建 HealthCheckRequest4-> Bean Validation 检查字段约束5-> Controller 获得有效 DTO6-> 取出 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 转换为接口数据:
01package cn.usehook.spring.controller.dto;0203import cn.usehook.spring.health.HealthStatus;0405public record HealthData(String status, String service) {0607public static HealthData from(HealthStatus healthStatus) {08return new HealthData(healthStatus.status(), healthStatus.service());09}10}
再创建响应外层结构:
01package cn.usehook.spring.controller.dto;0203import cn.usehook.spring.health.HealthStatus;0405public record HealthResponse(int code, String message, HealthData data) {0607public static HealthResponse success(HealthStatus healthStatus) {08return new HealthResponse(0, "success", HealthData.from(healthStatus));09}10}
HealthData.from() 和 HealthResponse.success() 只负责对象转换与响应组装,不执行心跳检查规则。Controller 因而不需要逐个读取字段,只需要调用语义明确的工厂方法:
1HealthStatus status = healthService.check(request.service());2return 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。完整的数据方向如下:
1JSON2-> HealthCheckRequest(请求 DTO)3-> service 字符串4-> HealthService5-> HealthStatus(业务结果)6-> HealthStatusRepository(保存业务结果)7-> HealthStatus8-> HealthData + HealthResponse(响应 DTO)9-> JSON
当前转换很简单,可以由 Controller 和响应 DTO 的静态工厂方法完成:
1public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {2HealthStatus status = healthService.check(request.service());3return HealthResponse.success(status);4}
这三行分别完成三件事:从请求 DTO 取出业务输入,调用 Service,最后把业务结果转换为响应 DTO。Controller 负责协调边界转换,但没有实现心跳规则。
当一个 DTO 只有少数字段时,显式构造或 from() 方法最容易阅读。字段很多、多个接口共享复杂映射时,可以提取独立 Mapper;是否提取取决于转换复杂度,不需要为每个 DTO 预先创建一层空转发。
转换位置可以按照方向判断:
| 转换 | 合适位置 |
|---|---|
| 请求 DTO 转为 Service 参数 | Controller 或边界 Mapper |
| 业务对象转为响应 DTO | Controller、响应 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:
01package cn.usehook.spring.controller;0203import cn.usehook.spring.controller.dto.HealthCheckRequest;04import cn.usehook.spring.controller.dto.HealthResponse;05import cn.usehook.spring.health.HealthService;06import cn.usehook.spring.health.HealthStatus;07import jakarta.validation.Valid;08import org.springframework.http.MediaType;09import org.springframework.http.ResponseEntity;10import org.springframework.web.bind.annotation.GetMapping;11import org.springframework.web.bind.annotation.PathVariable;12import org.springframework.web.bind.annotation.PostMapping;13import org.springframework.web.bind.annotation.RequestBody;14import org.springframework.web.bind.annotation.RequestMapping;15import org.springframework.web.bind.annotation.RestController;1617@RestController18@RequestMapping("/api/v1")19public class HealthController {2021private final HealthService healthService;2223public HealthController(HealthService healthService) {24this.healthService = healthService;25}2627@GetMapping("/health")28public ResponseEntity<HealthResponse> health() {29HealthResponse body = HealthResponse.success(healthService.check());30return ResponseEntity.ok()31.header("Cache-Control", "no-store")32.body(body);33}3435@GetMapping("/health/{service}")36public HealthResponse healthByService(@PathVariable String service) {37return HealthResponse.success(healthService.check(service));38}3940@PostMapping(41path = "/health/check",42consumes = MediaType.APPLICATION_JSON_VALUE,43produces = MediaType.APPLICATION_JSON_VALUE44)45public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {46HealthStatus status = healthService.check(request.service());47return HealthResponse.success(status);48}49}
GET 接口没有请求体,因此只使用响应 DTO。POST 接口先把 JSON 转换为 HealthCheckRequest,Controller 取出 service 交给 Service,再把返回的 HealthStatus 转换为 HealthResponse。Repository 是否使用内存或数据库,不会改变这两个 DTO。
客户端可以发送下面的 JSON 请求:
1curl -X POST 'http://localhost:8080/api/v1/health/check' \2-H 'Content-Type: application/json' \3-d '{"service":"PostgreSQL"}'
Service 把名称规范为 postgresql,Repository 保存业务结果,响应 DTO 再生成稳定的 JSON:
1{2"code": 0,3"message": "success",4"data": {5"status": "UP",6"service": "postgresql"7}8}
如果把 service 改为空白字符串,可以观察请求 DTO 的校验结果:
1curl -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。