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

上一章把 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 响应:

exception-boundary.txt
1
客户端请求
2
-> Controller
3
-> Service / Repository
4
-> 抛出异常
5
-> Spring MVC 查找异常处理方法
6
-> ProblemDetail
7
-> HTTP 状态码 + JSON 响应体

这样做并不是隐藏异常,而是完成边界转换。应用内部保留有意义的 Java 异常类型,HTTP 客户端得到自己能够理解的状态码和错误内容。

2. 全局处理

@ExceptionHandler 可以放在某个 Controller 中,只处理该控制器调用链抛出的异常。随着 Controller 增多,如果每个类都重复编写相同方法,错误格式和状态码很容易逐渐不一致。

@RestControllerAdvice 用来声明适用于多个 Controller 的统一处理组件。先创建处理器骨架,并通过 @Order 让它优先处理本项目明确声明的异常:

src/main/java/cn/usehook/spring/controller/ApiExceptionHandler.java
1
package cn.usehook.spring.controller;
2
3
import org.springframework.core.Ordered;
4
import org.springframework.core.annotation.Order;
5
import org.springframework.web.bind.annotation.RestControllerAdvice;
6
7
@RestControllerAdvice
8
@Order(Ordered.HIGHEST_PRECEDENCE)
9
public class ApiExceptionHandler {}

@RestControllerAdvice 组合了 @ControllerAdvice 和响应体语义。类位于应用根包 cn.usehook.spring 之下,会被组件扫描注册为 Bean;其中的 @ExceptionHandler 方法返回对象时,Spring MVC 会通过消息转换器把对象写入响应体。

自定义处理器只需要接管项目希望定制的异常。对于请求方法不支持、媒体类型不支持等 Spring MVC 内置异常,可以开启 Spring Boot 的问题详情支持,让框架继续生成 RFC 9457 响应。把下面的配置合并到现有 application.yaml:

src/main/resources/application.yaml
1
spring:
2
mvc:
3
problemdetails:
4
enabled: true

开启后,Spring Boot 会注册处理内置 Web 异常的组件。它的顺序是 0,因此自定义 Advice 使用更高优先级:自定义方法先处理字段校验和业务异常,没有接管的内置异常再交还给 Boot。这里的顺序决定多个全局 Advice 之间谁先尝试处理,不会改变 Controller 内局部 @ExceptionHandler 优先于全局 Advice 的规则。

一次异常的查找过程可以简化为:

exception-resolution.txt
1
Controller 调用链抛出异常
2
-> 查找当前 Controller 中匹配的 @ExceptionHandler
3
-> 查找自定义 @RestControllerAdvice 中匹配的 @ExceptionHandler
4
-> 查找 Spring Boot 内置的问题详情处理器
5
-> 交给 Spring MVC 其余异常解析器
6
-> 仍未处理时由服务器生成 500 响应

当前 Controller 中的局部处理方法优先于全局处理方法。在同一个处理类中同时存在具体异常和父类型异常时,Spring MVC 会选择更接近实际异常类型的方法。因此可以为业务异常分别声明处理方法,再谨慎提供最后的兜底处理。

3. ProblemDetail

ProblemDetail 是 Spring 对 RFC 9457 HTTP API 问题详情规范的实现。与自己随意创建错误字段相比,它先提供一组含义明确的标准字段:

字段含义当前 404 示例
type标识问题类型的 URIabout:blank
titleHTTP 问题的简短名称Not Found
statusHTTP 状态码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。

可以通过一个私有方法统一创建它:

src/main/java/cn/usehook/spring/controller/ApiExceptionHandler.java
1
private ProblemDetail createProblem(HttpStatus status, String errorCode, String detail) {
2
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
3
problem.setType(URI.create("about:blank"));
4
problem.setProperty("errorCode", errorCode);
5
return problem;
6
}

异常处理方法可以直接返回 ProblemDetail。Spring MVC 会使用其中的 status 作为 HTTP 状态;没有手动设置 instance 时,还会使用当前请求路径补齐它。JSON 响应通常使用 application/problem+json:

not-found.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():

src/main/java/cn/usehook/spring/health/HealthService.java
1
public HealthStatus getRequired(String service) {
2
String normalizedService = normalizeService(service);
3
return repository.findByService(normalizedService)
4
.orElseThrow(() -> new HealthStatusNotFoundException(normalizedService));
5
}

然后在现有 HealthController 中增加一个查询已保存状态的接口:

src/main/java/cn/usehook/spring/controller/HealthController.java
1
@GetMapping("/health/status/{service}")
2
public HealthResponse storedStatus(@PathVariable String service) {
3
return HealthResponse.success(healthService.getRequired(service));
4
}

这个接口与 GET /api/v1/health/{service} 的含义不同:原接口执行一次新的检查并保存结果,新接口只查询 Repository 中已经存在的结果。查询不到时,Service 抛出 HealthStatusNotFoundException,而不是由 Controller 判断 Optional。

业务异常与 HTTP 状态的对应关系集中放在 ApiExceptionHandler:

异常HTTP 状态应用错误代码
InvalidServiceNameException400 Bad Requestinvalid_service_name
HealthStatusNotFoundException404 Not Foundhealth_status_not_found

这里不要直接用一个 IllegalArgumentException 处理方法接住所有参数异常。具体业务异常能够表达稳定含义,而宽泛父类型可能把程序错误也错误地归类为客户端问题。

5. 请求异常

请求到达 Controller 方法之前,Spring MVC 已经可能抛出异常。上一章的 HealthCheckRequest 使用 @Valid 校验整个请求 DTO,因此字段约束失败时会产生 MethodArgumentNotValidException。

我们需要从 BindingResult 中取出字段名与校验消息,再放入 ProblemDetail 的 errors 扩展字段:

src/main/java/cn/usehook/spring/controller/ApiExceptionHandler.java
01
List<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();
09
10
ProblemDetail problem = createProblem(
11
HttpStatus.BAD_REQUEST,
12
"request_validation_failed",
13
"One or more request fields are invalid"
14
);
15
problem.setProperty("errors", errors);

错误列表不包含客户端提交的原值。密码、令牌或其他敏感字段以后也可能参与校验,统一处理器不应该把被拒绝的值原样复制到响应或普通日志中。

如果约束直接写在 @RequestParam、@PathVariable 等方法参数上,Spring MVC 的方法校验可能抛出 HandlerMethodValidationException。它与整个 DTO 校验产生的异常不同,因此完整处理器会单独接住它。当前案例的路径参数仍由 Service 校验,这个处理方法是为后续接口保留一致的 400 响应。

JSON 语法错误或请求体无法读取时,DTO 尚未创建,因此 Bean Validation 不会执行。HttpMessageNotReadableException 应返回一条通用说明,不要把底层 JSON 解析器的完整异常消息交给客户端。

请求异常处理结果
{"service":""}MethodArgumentNotValidException400,并返回字段错误
{"service":}HttpMessageNotReadableException400,说明请求体无法读取
在方法参数上直接违反约束HandlerMethodValidationException400,说明请求参数校验失败
使用不支持的 Content-TypeSpring MVC 媒体类型异常保留框架对应的 415 语义

6. 完整处理器

现在把业务异常、请求异常和未预期异常放入同一个处理器。代码中还会出现两个用于兜底处理的类型:

类型作用
Logger把未预期异常的完整信息保留在服务端日志中
ErrorResponse标识已经携带 HTTP 状态和问题详情的 Spring Web 异常

兜底方法声明处理 Exception,匹配范围非常宽。如果不加区分,原本应该返回 405 或 415 的内置异常也会被改成 500。因此,代码遇到 ErrorResponse 时重新抛出原异常,让刚才启用的 Spring Boot 问题详情处理器继续处理;只有其余未预期异常才记录日志并返回通用 500。

src/main/java/cn/usehook/spring/controller/ApiExceptionHandler.java
001
package cn.usehook.spring.controller;
002
003
import cn.usehook.spring.health.HealthStatusNotFoundException;
004
import cn.usehook.spring.health.InvalidServiceNameException;
005
import java.net.URI;
006
import java.util.List;
007
import java.util.Map;
008
import java.util.Objects;
009
import org.slf4j.Logger;
010
import org.slf4j.LoggerFactory;
011
import org.springframework.core.Ordered;
012
import org.springframework.core.annotation.Order;
013
import org.springframework.http.HttpStatus;
014
import org.springframework.http.ProblemDetail;
015
import org.springframework.http.converter.HttpMessageNotReadableException;
016
import org.springframework.web.ErrorResponse;
017
import org.springframework.web.bind.MethodArgumentNotValidException;
018
import org.springframework.web.bind.annotation.ExceptionHandler;
019
import org.springframework.web.bind.annotation.RestControllerAdvice;
020
import org.springframework.web.method.annotation.HandlerMethodValidationException;
021
022
@RestControllerAdvice
023
@Order(Ordered.HIGHEST_PRECEDENCE)
024
public class ApiExceptionHandler {
025
026
private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);
027
028
@ExceptionHandler(InvalidServiceNameException.class)
029
public ProblemDetail handleInvalidServiceName(InvalidServiceNameException exception) {
030
return createProblem(
031
HttpStatus.BAD_REQUEST,
032
"invalid_service_name",
033
exception.getMessage()
034
);
035
}
036
037
@ExceptionHandler(HealthStatusNotFoundException.class)
038
public ProblemDetail handleHealthStatusNotFound(HealthStatusNotFoundException exception) {
039
return createProblem(
040
HttpStatus.NOT_FOUND,
041
"health_status_not_found",
042
exception.getMessage()
043
);
044
}
045
046
@ExceptionHandler(MethodArgumentNotValidException.class)
047
public ProblemDetail handleRequestValidation(MethodArgumentNotValidException exception) {
048
List<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();
056
057
ProblemDetail problem = createProblem(
058
HttpStatus.BAD_REQUEST,
059
"request_validation_failed",
060
"One or more request fields are invalid"
061
);
062
problem.setProperty("errors", errors);
063
return problem;
064
}
065
066
@ExceptionHandler(HandlerMethodValidationException.class)
067
public ProblemDetail handleMethodValidation() {
068
return createProblem(
069
HttpStatus.BAD_REQUEST,
070
"method_validation_failed",
071
"One or more request parameters are invalid"
072
);
073
}
074
075
@ExceptionHandler(HttpMessageNotReadableException.class)
076
public ProblemDetail handleUnreadableMessage() {
077
return createProblem(
078
HttpStatus.BAD_REQUEST,
079
"request_body_invalid",
080
"Request body is missing or contains malformed JSON"
081
);
082
}
083
084
@ExceptionHandler(Exception.class)
085
public ProblemDetail handleUnexpected(Exception exception) throws Exception {
086
if (exception instanceof ErrorResponse) {
087
throw exception;
088
}
089
090
log.error("Unhandled exception", exception);
091
return createProblem(
092
HttpStatus.INTERNAL_SERVER_ERROR,
093
"internal_server_error",
094
"The server could not complete the request"
095
);
096
}
097
098
private ProblemDetail createProblem(HttpStatus status, String errorCode, String detail) {
099
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
100
problem.setType(URI.create("about:blank"));
101
problem.setProperty("errorCode", errorCode);
102
return problem;
103
}
104
}

最终形成两级协作:自定义 Advice 负责项目业务异常和需要定制内容的请求异常,Spring Boot 的问题详情处理器负责其余内置 Web 异常。最后的 Exception 方法才处理真正未预期的失败。

异常处理器不应该把 exception.getMessage() 或堆栈统一返回给客户端。SQL、文件路径、类名和第三方服务信息可能出现在这些内容中。可预期的业务异常可以使用经过设计的消息,未预期异常只在服务端日志中保留诊断信息。

@RestControllerAdvice 的边界仍然是 Spring MVC 的处理链。过滤器在进入 MVC 之前抛出的异常、安全框架拒绝访问的结果以及后台线程中的异常,不会因为存在这个类就自动进入这些处理方法,它们需要各自所在基础设施提供的错误处理机制。

7. 接口验证

应用刚启动时,Repository 中还没有 redis 的状态。直接查询会触发 404:

terminal.bash
1
curl -i 'http://localhost:8080/api/v1/health/status/redis'

响应状态为 404 Not Found,响应体使用问题详情结构:

not-found-response.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
}

提交空白服务名称时,请求会在进入 Controller 方法前被 DTO 校验拦截:

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

响应状态同样是 400,但 errors 会指出具体字段:

validation-response.json
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 校验失败,因此响应中不会出现字段错误列表:

terminal.bash
1
curl -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 对内置异常的处理:

terminal.bash
1
curl -i -X DELETE 'http://localhost:8080/api/v1/health'

响应状态为 405 Method Not Allowed,响应体仍然使用 ProblemDetail。这类框架生成的问题详情没有本项目添加的 errorCode,客户端应先依据 HTTP 状态和标准字段处理;需要给所有内置异常补充统一扩展字段时,可以再集中定制 ErrorResponse,不必在每个 Controller 中增加处理方法。

合法请求仍然沿用原来的成功流程。先保存 PostgreSQL 的检查结果,再查询已经保存的状态:

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

两次请求都会返回成功的 HealthResponse。异常处理没有进入正常业务流程,也没有改变 Service 和 Repository 的职责;它只在调用失败时把内部异常转换成稳定、准确的 HTTP 响应。

当前成功与失败路径主要依靠手动请求验证。下一篇先把路径、HTTP 方法、状态码和 /api/v1 放进同一份接口契约中,再为这份契约建立自动化测试。