上一章从 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 负责在两者之间转换:
1HTTP 请求2-> Spring MVC 根据映射找到 Controller 方法3-> Controller 接收并校验请求数据4-> Controller 调用 Service5-> 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,不依赖后面才会定义的类型:
01package cn.usehook.spring.controller;0203import cn.usehook.spring.health.HealthService;04import cn.usehook.spring.health.HealthStatus;05import org.springframework.web.bind.annotation.CrossOrigin;06import org.springframework.web.bind.annotation.GetMapping;07import org.springframework.web.bind.annotation.RequestMapping;08import org.springframework.web.bind.annotation.RestController;0910@RestController11@RequestMapping("/api/v1")12@CrossOrigin(origins = "*")13public class HealthController {1415private final HealthService healthService;1617public HealthController(HealthService healthService) {18this.healthService = healthService;19}2021@GetMapping("/health")22public HealthResponse health() {23HealthStatus data = healthService.check();24return new HealthResponse(0, "success", data);25}2627public 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 方法 | 常见用途 |
|---|---|---|
@GetMapping | GET | 读取资源,不执行由客户端请求的状态修改 |
@PostMapping | POST | 提交处理请求或创建资源 |
@PutMapping | PUT | 使用完整数据替换资源 |
@PatchMapping | PATCH | 修改资源的部分内容 |
@DeleteMapping | DELETE | 删除资源 |
Java 方法名不会成为接口映射条件。即使把 health() 改成 readCurrentHealth(),只要注解没有改变,接口仍然是 GET /api/v1/health。反过来,两个 Java 方法即使名称不同,只要声明了无法区分的映射条件,应用启动时仍可能因为映射冲突而失败。
HTTP 方法表达接口行为,不只是为了让路径有所区别。读取已有心跳状态使用 GET;客户端提交一组检查参数并要求服务器执行处理时,可以使用 POST。
3. 请求数据
控制器方法通过参数声明自己要从请求中获得什么。Spring MVC 在调用方法以前读取 HTTP 请求,并把字符串或消息体转换为参数声明的 Java 类型。
| 数据位置 | 常用注解 | 请求示例 |
|---|---|---|
| 路径 | @PathVariable | /health/postgresql |
| 查询字符串 | @RequestParam | /health?service=postgresql |
| 请求头 | @RequestHeader | X-Request-Id: demo-001 |
| JSON 请求体 | @RequestBody | {"service":"postgresql"} |
路径参数适合标识某个具体对象,查询参数适合可选条件、筛选和分页,请求头通常承载认证、内容协商或链路信息,请求体则适合结构化输入。数据放在哪里,应该由接口语义决定。
路径参数
当前 HealthService 只有无参数 check()。为了让 Controller 能把路径中的服务名称传给业务层,先为 Service 增加一个最小的重载方法:
1public HealthStatus check(String service) {2return new HealthStatus(properties.status(), service);3}
这一步只建立 Controller 与 Service 之间的参数通道。服务名称的规范化和业务校验会在下一章补充,不在 Controller 中提前实现。
现在保留默认心跳接口,并增加一个检查指定服务的入口:
1import org.springframework.web.bind.annotation.PathVariable;23@GetMapping("/health/{service}")4public HealthResponse healthByService(@PathVariable String service) {5HealthStatus data = healthService.check(service);6return new HealthResponse(0, "success", data);7}
路径模板中的 {service} 与方法参数 service 同名,因此 @PathVariable 可以直接完成绑定。请求 /api/v1/health/postgresql 时,方法参数的值是 postgresql。
如果 Java 参数名和路径变量名不同,需要在注解中明确指定:
1@GetMapping("/health/{service}")2public HealthResponse healthByService(@PathVariable("service") String serviceName) {3HealthStatus data = healthService.check(serviceName);4return new HealthResponse(0, "success", data);5}
路径变量默认必须存在,不过这里的映射本身就要求路径中包含这一段。希望表达可选条件时,通常使用 @RequestParam,而不是设计一个难以理解的可选路径片段。
查询参数
如果服务名称只是可选条件,也可以改写原来的 /health 方法,使它同时支持默认检查和查询参数。下面是与路径参数方案不同的另一种接口设计,实际项目根据接口语义选择一种即可:
1import org.springframework.web.bind.annotation.RequestParam;23@GetMapping("/health")4public HealthResponse health(@RequestParam(required = false) String service) {5HealthStatus data = service == null6? healthService.check()7: healthService.check(service);8return 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 专章再把它提取到独立文件,并详细讨论输入与输出模型的边界:
1public record HealthCheckRequest(String service) {}
下面继续使用「配置绑定」章节已经添加的 Validation Starter。它不仅能校验配置对象,也提供请求参数校验所需的 Jakarta Validation 实现,因此不需要重复修改 pom.xml。
现在给字段添加约束,并在控制器参数上使用 @Valid:
01import jakarta.validation.Valid;02import jakarta.validation.constraints.NotBlank;03import org.springframework.http.MediaType;04import org.springframework.web.bind.annotation.PostMapping;05import org.springframework.web.bind.annotation.RequestBody;0607@PostMapping(08path = "/health/check",09consumes = MediaType.APPLICATION_JSON_VALUE,10produces = MediaType.APPLICATION_JSON_VALUE11)12public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {13HealthStatus data = healthService.check(request.service());14return new HealthResponse(0, "success", data);15}1617public record HealthCheckRequest(18@NotBlank(message = "service must not be blank") String service19) {}
客户端发送请求后,数据会依次经历下面几个步骤:
1读取 HTTP 请求体2-> 根据 Content-Type 选择消息转换器3-> 把 JSON 转换为 HealthCheckRequest4-> Bean Validation 检查 service5-> 调用 Controller 方法
1curl -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 依赖:
1private final HealthService healthService;23public HealthController(HealthService healthService) {4this.healthService = healthService;5}
容器创建 HealthController Bean 时,会找到 HealthService Bean 并传给构造方法。Controller 不需要知道 Service 如何创建,也不应该在字段中手动执行 new HealthService(...)。
当请求数据准备完成后,控制器只负责把业务需要的值交给 Service:
1@PostMapping("/health/check")2public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {3HealthStatus data = healthService.check(request.service());4return new HealthResponse(0, "success", data);5}
这段方法可以分成连续的三步:
1HealthCheckRequest2-> 取出 service,调用 HealthService3-> 得到 HealthStatus4-> 创建 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:
1public record HealthResponse(int code, String message, HealthStatus data) {}
它生成的 JSON 结构如下:
1{2"code": 0,3"message": "success",4"data": {5"status": "UP",6"service": "spring"7}8}
直接返回 HealthResponse 时,成功响应通常是 200 OK。需要设置响应头或明确控制状态码时,可以返回 ResponseEntity<HealthResponse>:
01import org.springframework.http.ResponseEntity;0203@GetMapping("/health")04public ResponseEntity<HealthResponse> health() {05HealthStatus data = healthService.check();06HealthResponse body = new HealthResponse(0, "success", data);07return 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/health | health() | 检查配置中的默认服务 |
GET /api/v1/health/{service} | healthByService() | 检查路径指定的服务 |
POST /api/v1/health/check | check() | 使用 JSON 提交检查参数 |
01package cn.usehook.spring.controller;0203import cn.usehook.spring.health.HealthService;04import cn.usehook.spring.health.HealthStatus;05import jakarta.validation.Valid;06import jakarta.validation.constraints.NotBlank;07import org.springframework.http.MediaType;08import org.springframework.http.ResponseEntity;09import org.springframework.web.bind.annotation.CrossOrigin;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")19@CrossOrigin(origins = "*")20public class HealthController {2122private final HealthService healthService;2324public HealthController(HealthService healthService) {25this.healthService = healthService;26}2728@GetMapping("/health")29public ResponseEntity<HealthResponse> health() {30HealthStatus data = healthService.check();31HealthResponse body = new HealthResponse(0, "success", data);32return ResponseEntity.ok()33.header("Cache-Control", "no-store")34.body(body);35}3637@GetMapping("/health/{service}")38public HealthResponse healthByService(@PathVariable String service) {39HealthStatus data = healthService.check(service);40return new HealthResponse(0, "success", data);41}4243@PostMapping(44path = "/health/check",45consumes = MediaType.APPLICATION_JSON_VALUE,46produces = MediaType.APPLICATION_JSON_VALUE47)48public HealthResponse check(@Valid @RequestBody HealthCheckRequest request) {49HealthStatus data = healthService.check(request.service());50return new HealthResponse(0, "success", data);51}5253public record HealthCheckRequest(54@NotBlank(message = "service must not be blank") String service55) {}5657public record HealthResponse(int code, String message, HealthStatus data) {}58}
默认心跳接口仍然可以直接访问:
1curl 'http://localhost:8080/api/v1/health'
也可以使用 JSON 提交指定服务:
1curl -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 提取为独立类型。