上一章把 HTTP 数据整理成了请求 DTO 和响应 DTO。正常请求已经能够沿着 Controller -> Service -> Repository 完成心跳检查,但失败时还缺少一条同样清晰的返回路径:Service 抛出的业务异常、DTO 校验失败和 JSON 解析失败,目前会由不同的默认机制生成响应。
本章继续使用心跳接口,把这些异常集中交给 Spring MVC 处理,并使用 ProblemDetail 返回结构稳定、状态码准确的错误信息。
1. 异常边界
异常处理之前,先要确定每一层负责什么。产生异常的组件最清楚失败原因,Controller 层则最清楚这个失败应该怎样表示成 HTTP 响应。
| 异常来源 | 当前案例 | 应该表达的事实 |
|---|---|---|
| 请求体转换 | JSON 格式错误 | 客户端提交的请求体无法读取 |
| DTO 校验 | service 为空或过长 | 请求字段不符合接口约束 |
| Service | 服务名称无效 | 业务输入不符合规则 |
| Service 查询 | 心跳状态不存在 | 请求的资源尚未保存 |
| 未预期代码错误 | 空指针等程序缺陷 | 服务端无法完成请求 |
Service 不应该创建 ResponseEntity,也不应该决定返回 400 还是 404。它只抛出能够表达业务原因的异常,例如 InvalidServiceNameException 和 HealthStatusNotFoundException。异常离开 Controller 调用链后,再由 Spring MVC 把它转换为 HTTP 响应:
1客户端请求2-> Controller3-> Service / Repository4-> 抛出异常5-> Spring MVC 查找异常处理方法6-> ProblemDetail7-> HTTP 状态码 + JSON 响应体
这样做并不是隐藏异常,而是完成边界转换。应用内部保留有意义的 Java 异常类型,HTTP 客户端得到自己能够理解的状态码和错误内容。
2. 全局处理
@ExceptionHandler 可以放在某个 Controller 中,只处理该控制器调用链抛出的异常。随着 Controller 增多,如果每个类都重复编写相同方法,错误格式和状态码很容易逐渐不一致。
@RestControllerAdvice 用来声明适用于多个 Controller 的统一处理组件。先创建处理器骨架,并通过 @Order 让它优先处理本项目明确声明的异常:
1package cn.usehook.spring.controller;23import org.springframework.core.Ordered;4import org.springframework.core.annotation.Order;5import org.springframework.web.bind.annotation.RestControllerAdvice;67@RestControllerAdvice8@Order(Ordered.HIGHEST_PRECEDENCE)9public class ApiExceptionHandler {}
@RestControllerAdvice 组合了 @ControllerAdvice 和响应体语义。类位于应用根包 cn.usehook.spring 之下,会被组件扫描注册为 Bean;其中的 @ExceptionHandler 方法返回对象时,Spring MVC 会通过消息转换器把对象写入响应体。
自定义处理器只需要接管项目希望定制的异常。对于请求方法不支持、媒体类型不支持等 Spring MVC 内置异常,可以开启 Spring Boot 的问题详情支持,让框架继续生成 RFC 9457 响应。把下面的配置合并到现有 application.yaml:
1spring:2mvc:3problemdetails:4enabled: true
开启后,Spring Boot 会注册处理内置 Web 异常的组件。它的顺序是 0,因此自定义 Advice 使用更高优先级:自定义方法先处理字段校验和业务异常,没有接管的内置异常再交还给 Boot。这里的顺序决定多个全局 Advice 之间谁先尝试处理,不会改变 Controller 内局部 @ExceptionHandler 优先于全局 Advice 的规则。
一次异常的查找过程可以简化为:
1Controller 调用链抛出异常2-> 查找当前 Controller 中匹配的 @ExceptionHandler3-> 查找自定义 @RestControllerAdvice 中匹配的 @ExceptionHandler4-> 查找 Spring Boot 内置的问题详情处理器5-> 交给 Spring MVC 其余异常解析器6-> 仍未处理时由服务器生成 500 响应
当前 Controller 中的局部处理方法优先于全局处理方法。在同一个处理类中同时存在具体异常和父类型异常时,Spring MVC 会选择更接近实际异常类型的方法。因此可以为业务异常分别声明处理方法,再谨慎提供最后的兜底处理。
3. ProblemDetail
ProblemDetail 是 Spring 对 RFC 9457 HTTP API 问题详情规范的实现。与自己随意创建错误字段相比,它先提供一组含义明确的标准字段:
| 字段 | 含义 | 当前 404 示例 |
|---|---|---|
type | 标识问题类型的 URI | about:blank |
title | HTTP 问题的简短名称 | Not Found |
status | HTTP 状态码 | 404 |
detail | 本次失败的具体说明 | Health status not found: redis |
instance | 本次请求对应的 URI | /api/v1/health/status/redis |
本章显式把 type 设置为 about:blank,此时 title 使用状态码对应的标准原因短语。为了让客户端进一步区分同为 400 的不同错误,本项目增加 errorCode 扩展字段,例如 invalid_service_name 和 request_validation_failed。
可以通过一个私有方法统一创建它:
1private ProblemDetail createProblem(HttpStatus status, String errorCode, String detail) {2ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);3problem.setType(URI.create("about:blank"));4problem.setProperty("errorCode", errorCode);5return problem;6}
异常处理方法可以直接返回 ProblemDetail。Spring MVC 会使用其中的 status 作为 HTTP 状态;没有手动设置 instance 时,还会使用当前请求路径补齐它。JSON 响应通常使用 application/problem+json:
1{2"type": "about:blank",3"title": "Not Found",4"status": 404,5"detail": "Health status not found: redis",6"instance": "/api/v1/health/status/redis",7"errorCode": "health_status_not_found"8}
上一章成功响应使用 code、message 和 data,错误响应使用 ProblemDetail。成功与失败不必强行共用一个外层结构,但同一种失败应该保持稳定格式,也不能为了统一 JSON 外观而把所有错误都返回 200 OK。
ProblemDetail 还允许通过 setProperty() 添加扩展字段。使用项目中的 JSON 转换器时,这些扩展会出现在 JSON 顶层。本章会用 errorCode 标识应用错误,用 errors 保存 DTO 字段校验结果;标准字段继续表达整个请求为什么失败,扩展字段再补充客户端需要的信息。
4. 业务异常
前面已经定义了两种业务异常:InvalidServiceNameException 表示服务名称不符合规则,HealthStatusNotFoundException 表示没有查到指定服务的已保存状态。它们分别适合映射为 400 和 404。
为了让 404 可以通过接口实际观察,先确认 HealthService 中包含「Repository」章节介绍的 getRequired():
1public HealthStatus getRequired(String service) {2String normalizedService = normalizeService(service);3return repository.findByService(normalizedService)4.orElseThrow(() -> new HealthStatusNotFoundException(normalizedService));5}
然后在现有 HealthController 中增加一个查询已保存状态的接口:
1@GetMapping("/health/status/{service}")2public HealthResponse storedStatus(@PathVariable String service) {3return HealthResponse.success(healthService.getRequired(service));4}
这个接口与 GET /api/v1/health/{service} 的含义不同:原接口执行一次新的检查并保存结果,新接口只查询 Repository 中已经存在的结果。查询不到时,Service 抛出 HealthStatusNotFoundException,而不是由 Controller 判断 Optional。
业务异常与 HTTP 状态的对应关系集中放在 ApiExceptionHandler:
| 异常 | HTTP 状态 | 应用错误代码 |
|---|---|---|
InvalidServiceNameException | 400 Bad Request | invalid_service_name |
HealthStatusNotFoundException | 404 Not Found | health_status_not_found |
这里不要直接用一个 IllegalArgumentException 处理方法接住所有参数异常。具体业务异常能够表达稳定含义,而宽泛父类型可能把程序错误也错误地归类为客户端问题。
5. 请求异常
请求到达 Controller 方法之前,Spring MVC 已经可能抛出异常。上一章的 HealthCheckRequest 使用 @Valid 校验整个请求 DTO,因此字段约束失败时会产生 MethodArgumentNotValidException。
我们需要从 BindingResult 中取出字段名与校验消息,再放入 ProblemDetail 的 errors 扩展字段:
01List<Map<String, String>> errors = exception.getBindingResult()02.getFieldErrors()03.stream()04.map(error -> Map.of(05"field", error.getField(),06"message", Objects.requireNonNullElse(error.getDefaultMessage(), "invalid value")07))08.toList();0910ProblemDetail problem = createProblem(11HttpStatus.BAD_REQUEST,12"request_validation_failed",13"One or more request fields are invalid"14);15problem.setProperty("errors", errors);
错误列表不包含客户端提交的原值。密码、令牌或其他敏感字段以后也可能参与校验,统一处理器不应该把被拒绝的值原样复制到响应或普通日志中。
如果约束直接写在 @RequestParam、@PathVariable 等方法参数上,Spring MVC 的方法校验可能抛出 HandlerMethodValidationException。它与整个 DTO 校验产生的异常不同,因此完整处理器会单独接住它。当前案例的路径参数仍由 Service 校验,这个处理方法是为后续接口保留一致的 400 响应。
JSON 语法错误或请求体无法读取时,DTO 尚未创建,因此 Bean Validation 不会执行。HttpMessageNotReadableException 应返回一条通用说明,不要把底层 JSON 解析器的完整异常消息交给客户端。
| 请求 | 异常 | 处理结果 |
|---|---|---|
{"service":""} | MethodArgumentNotValidException | 400,并返回字段错误 |
{"service":} | HttpMessageNotReadableException | 400,说明请求体无法读取 |
| 在方法参数上直接违反约束 | HandlerMethodValidationException | 400,说明请求参数校验失败 |
使用不支持的 Content-Type | Spring MVC 媒体类型异常 | 保留框架对应的 415 语义 |
6. 完整处理器
现在把业务异常、请求异常和未预期异常放入同一个处理器。代码中还会出现两个用于兜底处理的类型:
| 类型 | 作用 |
|---|---|
Logger | 把未预期异常的完整信息保留在服务端日志中 |
ErrorResponse | 标识已经携带 HTTP 状态和问题详情的 Spring Web 异常 |
兜底方法声明处理 Exception,匹配范围非常宽。如果不加区分,原本应该返回 405 或 415 的内置异常也会被改成 500。因此,代码遇到 ErrorResponse 时重新抛出原异常,让刚才启用的 Spring Boot 问题详情处理器继续处理;只有其余未预期异常才记录日志并返回通用 500。
001package cn.usehook.spring.controller;002003import cn.usehook.spring.health.HealthStatusNotFoundException;004import cn.usehook.spring.health.InvalidServiceNameException;005import java.net.URI;006import java.util.List;007import java.util.Map;008import java.util.Objects;009import org.slf4j.Logger;010import org.slf4j.LoggerFactory;011import org.springframework.core.Ordered;012import org.springframework.core.annotation.Order;013import org.springframework.http.HttpStatus;014import org.springframework.http.ProblemDetail;015import org.springframework.http.converter.HttpMessageNotReadableException;016import org.springframework.web.ErrorResponse;017import org.springframework.web.bind.MethodArgumentNotValidException;018import org.springframework.web.bind.annotation.ExceptionHandler;019import org.springframework.web.bind.annotation.RestControllerAdvice;020import org.springframework.web.method.annotation.HandlerMethodValidationException;021022@RestControllerAdvice023@Order(Ordered.HIGHEST_PRECEDENCE)024public class ApiExceptionHandler {025026private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);027028@ExceptionHandler(InvalidServiceNameException.class)029public ProblemDetail handleInvalidServiceName(InvalidServiceNameException exception) {030return createProblem(031HttpStatus.BAD_REQUEST,032"invalid_service_name",033exception.getMessage()034);035}036037@ExceptionHandler(HealthStatusNotFoundException.class)038public ProblemDetail handleHealthStatusNotFound(HealthStatusNotFoundException exception) {039return createProblem(040HttpStatus.NOT_FOUND,041"health_status_not_found",042exception.getMessage()043);044}045046@ExceptionHandler(MethodArgumentNotValidException.class)047public ProblemDetail handleRequestValidation(MethodArgumentNotValidException exception) {048List<Map<String, String>> errors = exception.getBindingResult()049.getFieldErrors()050.stream()051.map(error -> Map.of(052"field", error.getField(),053"message", Objects.requireNonNullElse(error.getDefaultMessage(), "invalid value")054))055.toList();056057ProblemDetail problem = createProblem(058HttpStatus.BAD_REQUEST,059"request_validation_failed",060"One or more request fields are invalid"061);062problem.setProperty("errors", errors);063return problem;064}065066@ExceptionHandler(HandlerMethodValidationException.class)067public ProblemDetail handleMethodValidation() {068return createProblem(069HttpStatus.BAD_REQUEST,070"method_validation_failed",071"One or more request parameters are invalid"072);073}074075@ExceptionHandler(HttpMessageNotReadableException.class)076public ProblemDetail handleUnreadableMessage() {077return createProblem(078HttpStatus.BAD_REQUEST,079"request_body_invalid",080"Request body is missing or contains malformed JSON"081);082}083084@ExceptionHandler(Exception.class)085public ProblemDetail handleUnexpected(Exception exception) throws Exception {086if (exception instanceof ErrorResponse) {087throw exception;088}089090log.error("Unhandled exception", exception);091return createProblem(092HttpStatus.INTERNAL_SERVER_ERROR,093"internal_server_error",094"The server could not complete the request"095);096}097098private ProblemDetail createProblem(HttpStatus status, String errorCode, String detail) {099ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);100problem.setType(URI.create("about:blank"));101problem.setProperty("errorCode", errorCode);102return problem;103}104}
最终形成两级协作:自定义 Advice 负责项目业务异常和需要定制内容的请求异常,Spring Boot 的问题详情处理器负责其余内置 Web 异常。最后的 Exception 方法才处理真正未预期的失败。
异常处理器不应该把 exception.getMessage() 或堆栈统一返回给客户端。SQL、文件路径、类名和第三方服务信息可能出现在这些内容中。可预期的业务异常可以使用经过设计的消息,未预期异常只在服务端日志中保留诊断信息。
@RestControllerAdvice 的边界仍然是 Spring MVC 的处理链。过滤器在进入 MVC 之前抛出的异常、安全框架拒绝访问的结果以及后台线程中的异常,不会因为存在这个类就自动进入这些处理方法,它们需要各自所在基础设施提供的错误处理机制。
7. 接口验证
应用刚启动时,Repository 中还没有 redis 的状态。直接查询会触发 404:
1curl -i 'http://localhost:8080/api/v1/health/status/redis'
响应状态为 404 Not Found,响应体使用问题详情结构:
1{2"type": "about:blank",3"title": "Not Found",4"status": 404,5"detail": "Health status not found: redis",6"instance": "/api/v1/health/status/redis",7"errorCode": "health_status_not_found"8}
提交空白服务名称时,请求会在进入 Controller 方法前被 DTO 校验拦截:
1curl -i -X POST 'http://localhost:8080/api/v1/health/check' \2-H 'Content-Type: application/json' \3-d '{"service":" "}'
响应状态同样是 400,但 errors 会指出具体字段:
01{02"type": "about:blank",03"title": "Bad Request",04"status": 400,05"detail": "One or more request fields are invalid",06"instance": "/api/v1/health/check",07"errorCode": "request_validation_failed",08"errors": [09{10"field": "service",11"message": "service must not be blank"12}13]14}
请求体不是合法 JSON 时,消息转换先于 DTO 校验失败,因此响应中不会出现字段错误列表:
1curl -i -X POST 'http://localhost:8080/api/v1/health/check' \2-H 'Content-Type: application/json' \3-d '{"service":}'
这个请求返回 400,errorCode 为 request_body_invalid。如果使用接口没有声明的 HTTP 方法,还可以观察 Spring Boot 对内置异常的处理:
1curl -i -X DELETE 'http://localhost:8080/api/v1/health'
响应状态为 405 Method Not Allowed,响应体仍然使用 ProblemDetail。这类框架生成的问题详情没有本项目添加的 errorCode,客户端应先依据 HTTP 状态和标准字段处理;需要给所有内置异常补充统一扩展字段时,可以再集中定制 ErrorResponse,不必在每个 Controller 中增加处理方法。
合法请求仍然沿用原来的成功流程。先保存 PostgreSQL 的检查结果,再查询已经保存的状态:
1curl -X POST 'http://localhost:8080/api/v1/health/check' \2-H 'Content-Type: application/json' \3-d '{"service":"PostgreSQL"}'45curl 'http://localhost:8080/api/v1/health/status/postgresql'
两次请求都会返回成功的 HealthResponse。异常处理没有进入正常业务流程,也没有改变 Service 和 Repository 的职责;它只在调用失败时把内部异常转换成稳定、准确的 HTTP 响应。
当前成功与失败路径主要依靠手动请求验证。下一篇先把路径、HTTP 方法、状态码和 /api/v1 放进同一份接口契约中,再为这份契约建立自动化测试。