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

上一章从 Spring MVC 内部解释了请求如何找到控制器方法,以及 Java 返回值为什么能够转换成 JSON。本章把视角放回项目代码,集中学习 HealthController:它怎样声明接口、接收请求数据、调用 HealthService,最后生成符合 HTTP 语义的响应。

开始之前,项目中已经存在下面两个类型:

类型来源作用
HealthService前面的 IoC 与 Bean 章节执行心跳检查
HealthStatus前面的 Java 类章节保存检查结果中的 status 和 service

我们会继续沿用 /api/v1/health,先保留已经能够运行的 GET 接口,再逐步添加指定服务检查和 JSON 请求。这样每一步都建立在前一步之上。

1. Controller 的位置

Controller 位于 HTTP 协议与应用业务之间。客户端使用路径、请求方法、请求头和 JSON 表达需求,Service 则使用普通 Java 参数和业务对象表达能力。Controller 负责在两者之间转换:

controller-boundary.txt
1
HTTP 请求
2
-> Spring MVC 根据映射找到 Controller 方法
3
-> Controller 接收并校验请求数据
4
-> Controller 调用 Service
5
-> Service 返回业务结果
6
-> Controller 创建响应数据并选择 HTTP 状态
7
-> Spring MVC 把返回值写入 HTTP 响应

一个 REST Controller 通常完成四项工作:

工作心跳接口中的示例
声明请求入口GET /api/v1/health
接收请求数据读取路径中的服务名称或 JSON 请求体
调用应用能力执行 healthService.check()
生成 HTTP 响应返回状态码、响应头和响应数据

这并不意味着所有与请求有关的工作都应该写进 Controller。服务名称是否有效、一次检查包含哪些业务步骤,属于 Service;数据如何保存和查询,属于 Repository。Controller 只保留 HTTP 边界必须知道的内容。

如果把业务规则直接写在控制器里,同一项能力以后就很难被定时任务、消息消费者或另一个接口复用。因此,判断 Controller 是否清晰的关键,不是方法行数是否足够少,而是 HTTP 细节和业务规则是否已经分开。

2. 声明与映射

我们先从当前项目已有的心跳接口开始。下面的代码包含了一个能够独立工作的 Controller,不依赖后面才会定义的类型:

src/main/java/cn/usehook/spring/controller/HealthController.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.health.HealthService;
04
import cn.usehook.spring.health.HealthStatus;
05
import org.springframework.web.bind.annotation.CrossOrigin;
06
import org.springframework.web.bind.annotation.GetMapping;
07
import org.springframework.web.bind.annotation.RequestMapping;
08
import org.springframework.web.bind.annotation.RestController;
09
10
@RestController
11
@RequestMapping("/api/v1")
12
@CrossOrigin(origins = "*")
13
public class HealthController {
14
15
private final HealthService healthService;
16
17
public HealthController(HealthService healthService) {
18
this.healthService = healthService;
19
}
20
21
@GetMapping("/health")
22
public HealthResponse health() {
23
HealthStatus data = healthService.check();
24
return new HealthResponse(0, "success", data);
25
}
26
27
public record HealthResponse(int code, String message, HealthStatus data) {}
28
}

这段代码中的注解分成类级别和方法级别两组:

位置注解作用
类@RestController把类声明为 REST 控制器,方法返回值默认写入响应体
类@RequestMapping("/api/v1")为类中所有接口添加公共路径前缀
类@CrossOrigin(origins = "*")允许课程页面跨来源请求本地心跳接口
方法@GetMapping("/health")处理 GET 请求,并补充 /health 路径

最终路径由类上的 /api/v1 与方法上的 /health 组合而成。客户端发送 GET /api/v1/health 时,Spring MVC 会调用 health()。

@RestController 组合了 @Controller 和 @ResponseBody 的语义。当前类位于组件扫描范围内,因此会被 Spring 容器发现;它返回的 HealthResponse 会交给消息转换器写入响应体,而不是被当作页面视图名称。

当前课程网页和本地 Spring Boot 应用不属于同一个来源,所以公开的心跳示例使用了 @CrossOrigin(origins = "*")。这不是所有业务接口的通用配置。涉及用户数据、凭证或写操作时,应该只允许可信来源,并优先在 MVC 配置中统一管理 CORS。

HTTP 方法

Spring MVC 为常用 HTTP 方法提供了对应的映射注解:

注解HTTP 方法常见用途
@GetMappingGET读取资源,不执行由客户端请求的状态修改
@PostMappingPOST提交处理请求或创建资源
@PutMappingPUT使用完整数据替换资源
@PatchMappingPATCH修改资源的部分内容
@DeleteMappingDELETE删除资源

Java 方法名不会成为接口映射条件。即使把 health() 改成 readCurrentHealth(),只要注解没有改变,接口仍然是 GET /api/v1/health。反过来,两个 Java 方法即使名称不同,只要声明了无法区分的映射条件,应用启动时仍可能因为映射冲突而失败。

HTTP 方法表达接口行为,不只是为了让路径有所区别。读取已有心跳状态使用 GET;客户端提交一组检查参数并要求服务器执行处理时,可以使用 POST。

3. 请求数据

控制器方法通过参数声明自己要从请求中获得什么。Spring MVC 在调用方法以前读取 HTTP 请求,并把字符串或消息体转换为参数声明的 Java 类型。

数据位置常用注解请求示例
路径@PathVariable/health/postgresql
查询字符串@RequestParam/health?service=postgresql
请求头@RequestHeaderX-Request-Id: demo-001
JSON 请求体@RequestBody{"service":"postgresql"}

路径参数适合标识某个具体对象,查询参数适合可选条件、筛选和分页,请求头通常承载认证、内容协商或链路信息,请求体则适合结构化输入。数据放在哪里,应该由接口语义决定。

路径参数

当前 HealthService 只有无参数 check()。为了让 Controller 能把路径中的服务名称传给业务层,先为 Service 增加一个最小的重载方法:

src/main/java/cn/usehook/spring/health/HealthService.java
1
public HealthStatus check(String service) {
2
return new HealthStatus(properties.status(), service);
3
}

这一步只建立 Controller 与 Service 之间的参数通道。服务名称的规范化和业务校验会在下一章补充,不在 Controller 中提前实现。

现在保留默认心跳接口,并增加一个检查指定服务的入口:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
import org.springframework.web.bind.annotation.PathVariable;
2
3
@GetMapping("/health/{service}")
4
public HealthResponse healthByService(@PathVariable String service) {
5
HealthStatus data = healthService.check(service);
6
return new HealthResponse(0, "success", data);
7
}

路径模板中的 {service} 与方法参数 service 同名,因此 @PathVariable 可以直接完成绑定。请求 /api/v1/health/postgresql 时,方法参数的值是 postgresql。

如果 Java 参数名和路径变量名不同,需要在注解中明确指定:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
@GetMapping("/health/{service}")
2
public HealthResponse healthByService(@PathVariable("service") String serviceName) {
3
HealthStatus data = healthService.check(serviceName);
4
return new HealthResponse(0, "success", data);
5
}

路径变量默认必须存在,不过这里的映射本身就要求路径中包含这一段。希望表达可选条件时,通常使用 @RequestParam,而不是设计一个难以理解的可选路径片段。

查询参数

如果服务名称只是可选条件,也可以改写原来的 /health 方法,使它同时支持默认检查和查询参数。下面是与路径参数方案不同的另一种接口设计,实际项目根据接口语义选择一种即可:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
import org.springframework.web.bind.annotation.RequestParam;
2
3
@GetMapping("/health")
4
public HealthResponse health(@RequestParam(required = false) String service) {
5
HealthStatus data = service == null
6
? healthService.check()
7
: healthService.check(service);
8
return new HealthResponse(0, "success", data);
9
}

请求 /api/v1/health 时,service 为 null,方法检查配置中的默认服务;请求 /api/v1/health?service=postgresql 时,Spring 把查询字符串绑定到 service 参数。因为这段代码是在改写原有的 health(),所以不要再保留另一个映射条件完全相同的 @GetMapping("/health") 方法。

@RequestHeader 的绑定方式与之类似,但数据来源是请求头。例如 @RequestHeader("X-Request-Id") String requestId 会读取 X-Request-Id;如果请求头允许缺失,可以设置 required = false。

JSON 请求体

参数形成一组结构化数据时,可以让 @RequestBody 把 JSON 转换为 Java 对象。本章先把这个数据对象声明在 Controller 内部,后面的 DTO 专章再把它提取到独立文件,并详细讨论输入与输出模型的边界:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
public record HealthCheckRequest(String service) {}

下面继续使用「配置绑定」章节已经添加的 Validation Starter。它不仅能校验配置对象,也提供请求参数校验所需的 Jakarta Validation 实现,因此不需要重复修改 pom.xml。

现在给字段添加约束,并在控制器参数上使用 @Valid:

src/main/java/cn/usehook/spring/controller/HealthController.java
01
import jakarta.validation.Valid;
02
import jakarta.validation.constraints.NotBlank;
03
import org.springframework.http.MediaType;
04
import org.springframework.web.bind.annotation.PostMapping;
05
import org.springframework.web.bind.annotation.RequestBody;
06
07
@PostMapping(
08
path = "/health/check",
09
consumes = MediaType.APPLICATION_JSON_VALUE,
10
produces = MediaType.APPLICATION_JSON_VALUE
11
)
12
public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {
13
HealthStatus data = healthService.check(request.service());
14
return new HealthResponse(0, "success", data);
15
}
16
17
public record HealthCheckRequest(
18
@NotBlank(message = "service must not be blank") String service
19
) {}

客户端发送请求后,数据会依次经历下面几个步骤:

request-body-flow.txt
1
读取 HTTP 请求体
2
-> 根据 Content-Type 选择消息转换器
3
-> 把 JSON 转换为 HealthCheckRequest
4
-> Bean Validation 检查 service
5
-> 调用 Controller 方法
terminal.bash
1
curl -X POST 'http://localhost:8080/api/v1/health/check' \
2
-H 'Content-Type: application/json' \
3
-d '{"service":"postgresql"}'

consumes 表示该方法能够读取 application/json 请求体,produces 表示它能够生成 JSON 响应。@RequestBody 负责反序列化,@Valid 负责触发约束校验,两者不是同一个步骤。

JSON 语法错误、消息体无法转换,或者 service 没有通过校验时,控制器方法不会带着错误数据继续执行。Spring MVC 会进入异常处理流程,默认将常见的请求体校验失败转换为 400 Bad Request。

4. 调用 Service

Controller 通过构造方法声明 HealthService 依赖:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
private final HealthService healthService;
2
3
public HealthController(HealthService healthService) {
4
this.healthService = healthService;
5
}

容器创建 HealthController Bean 时,会找到 HealthService Bean 并传给构造方法。Controller 不需要知道 Service 如何创建,也不应该在字段中手动执行 new HealthService(...)。

当请求数据准备完成后,控制器只负责把业务需要的值交给 Service:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
@PostMapping("/health/check")
2
public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {
3
HealthStatus data = healthService.check(request.service());
4
return new HealthResponse(0, "success", data);
5
}

这段方法可以分成连续的三步:

controller-conversion.txt
1
HealthCheckRequest
2
-> 取出 service,调用 HealthService
3
-> 得到 HealthStatus
4
-> 创建 HealthResponse

Controller 可以从 HTTP 输入中整理出 Service 需要的参数,但不应该复制 Service 的业务判断。例如,服务名称是否合法、是否需要规范为小写、检查过程包含哪些步骤,都应该由 healthService.check(service) 决定。否则同一个 Service 被定时任务或另一个 Controller 调用时,规则会不一致。

这里还要区分两类校验:Controller 中的 Bean Validation 检查请求结构是否合格,Service 则保护业务规则。即使 HTTP 请求已经通过 @NotBlank,Service 仍可能被其他入口直接调用,所以不能假设它永远只会收到 Controller 验证过的数据。

同样,Service 应该返回 HealthStatus 这样的业务结果,而不是 ResponseEntity。HTTP 状态码、响应头和响应体结构属于 Controller,下一章会继续说明 Service 如何保持独立于 HTTP。

5. 响应与异常

Controller 方法返回对象后,@RestController 让 Spring MVC 使用消息转换器把对象写入响应体。当前案例暂时在控制器内部声明响应 record:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
public record HealthResponse(int code, String message, HealthStatus data) {}

它生成的 JSON 结构如下:

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

直接返回 HealthResponse 时,成功响应通常是 200 OK。需要设置响应头或明确控制状态码时,可以返回 ResponseEntity<HealthResponse>:

src/main/java/cn/usehook/spring/controller/HealthController.java
01
import org.springframework.http.ResponseEntity;
02
03
@GetMapping("/health")
04
public ResponseEntity<HealthResponse> health() {
05
HealthStatus data = healthService.check();
06
HealthResponse body = new HealthResponse(0, "success", data);
07
return ResponseEntity.ok()
08
.header("Cache-Control", "no-store")
09
.body(body);
10
}

ResponseEntity 表示完整 HTTP 响应,泛型参数 HealthResponse 表示响应体的 Java 类型。这里使用 200 OK,同时通过 Cache-Control: no-store 告诉客户端不要保存心跳结果。

HTTP 状态码表达请求在协议层面的结果,响应体中的 code 是应用自己定义的字段。即使响应体写着 code: 0,真正的 HTTP 状态仍然由 ResponseEntity、@ResponseStatus 或 MVC 的默认行为决定。

场景常见状态码
正常获得检查结果200 OK
请求格式或参数不合法400 Bad Request
指定的资源不存在404 Not Found
当前状态与操作冲突409 Conflict
未预期的服务器错误500 Internal Server Error

业务方法失败时,Controller 不应该捕获所有 Exception 并统一返回 400。参数错误、资源不存在、业务冲突和程序缺陷具有不同含义;过于宽泛的捕获会把服务器缺陷伪装成客户端错误。

下一篇 Service 会定义并抛出具体业务异常,后面的「统一异常处理」再使用 @RestControllerAdvice 和 ProblemDetail 把不同异常集中转换为 HTTP 响应。本章只确定边界:Controller 可以选择成功状态码和响应头,异常映射不重复散落在每个接口方法中。

6. 边界与组织

我们现在可以根据依赖类型和代码内容判断一段逻辑是否应该留在 Controller:

适合放在 Controller应该移出 Controller
MVC 注解和 HTTP 类型SQL、数据库连接和存储细节
请求与响应数据对象可复用的业务规则
Service 构造器依赖手动创建 Service 及其依赖
参数提取和状态码选择事务管理和跨资源业务流程
少量边界转换代码当前请求的共享可变字段

HealthController 默认是单例 Bean,多个请求会共享同一个控制器对象。因此,service、请求头和某次响应等请求数据必须保存在方法参数或局部变量中,不能写入控制器的可变字段。

随着接口增加,应该按业务资源拆分 Controller,而不是把所有路径放进一个 ApiController。当前项目可以把健康检查相关接口保留在 HealthController;以后用户和订单拥有各自的 Controller。

包也可以按职责表达这一层的结构:控制器类放在 controller,与 HTTP 请求和响应有关的数据对象放在 controller.dto,业务类继续放在 health。DTO 专章会把当前嵌套在 HealthController 中的 record 提取出来。

CORS、JSON 格式和拦截器等跨越多个 Controller 的规则,通常通过 MVC 配置统一管理。只有某个接口确实拥有独立规则时,才直接在控制器上声明。当前的 @CrossOrigin(origins = "*") 是为了让课程网页能够调用公开的本地心跳接口,而不是生产业务接口的默认做法。

7. 完整案例

下面把本章代码组合成一个完整的 HealthController。它提供三个入口:

HTTP 请求Controller 方法用途
GET /api/v1/healthhealth()检查配置中的默认服务
GET /api/v1/health/{service}healthByService()检查路径指定的服务
POST /api/v1/health/checkcheck()使用 JSON 提交检查参数
src/main/java/cn/usehook/spring/controller/HealthController.java
01
package cn.usehook.spring.controller;
02
03
import cn.usehook.spring.health.HealthService;
04
import cn.usehook.spring.health.HealthStatus;
05
import jakarta.validation.Valid;
06
import jakarta.validation.constraints.NotBlank;
07
import org.springframework.http.MediaType;
08
import org.springframework.http.ResponseEntity;
09
import org.springframework.web.bind.annotation.CrossOrigin;
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
@CrossOrigin(origins = "*")
20
public class HealthController {
21
22
private final HealthService healthService;
23
24
public HealthController(HealthService healthService) {
25
this.healthService = healthService;
26
}
27
28
@GetMapping("/health")
29
public ResponseEntity<HealthResponse> health() {
30
HealthStatus data = healthService.check();
31
HealthResponse body = new HealthResponse(0, "success", data);
32
return ResponseEntity.ok()
33
.header("Cache-Control", "no-store")
34
.body(body);
35
}
36
37
@GetMapping("/health/{service}")
38
public HealthResponse healthByService(@PathVariable String service) {
39
HealthStatus data = healthService.check(service);
40
return new HealthResponse(0, "success", data);
41
}
42
43
@PostMapping(
44
path = "/health/check",
45
consumes = MediaType.APPLICATION_JSON_VALUE,
46
produces = MediaType.APPLICATION_JSON_VALUE
47
)
48
public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {
49
HealthStatus data = healthService.check(request.service());
50
return new HealthResponse(0, "success", data);
51
}
52
53
public record HealthCheckRequest(
54
@NotBlank(message = "service must not be blank") String service
55
) {}
56
57
public record HealthResponse(int code, String message, HealthStatus data) {}
58
}

默认心跳接口仍然可以直接访问:

terminal.bash
1
curl 'http://localhost:8080/api/v1/health'

也可以使用 JSON 提交指定服务:

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

两种请求最终都会调用 HealthService。Controller 到这里已经完成了 HTTP 边界的工作,但 healthService.check(service) 如何验证业务输入、组织依赖并保持无状态,还需要由下一章的 Service 继续说明。之后的 Repository 和 DTO 章节会分别补上数据访问边界,并把嵌套的数据 record 提取为独立类型。