异常处理¶
异常表示某项操作没有按预期完成。例如参数无法解析、用户名冲突、文件存储服务不可达。后端需要同时告诉客户端“发生了哪类错误”,并给开发者留下安全的排查线索。
本文以 hhjava 的 Spring MVC 业务接口为例。认证过滤链和响应式网关有各自的错误出口,不能用一个 Controller Advice 接管全部请求。
入门:Java 遇到错误后会发生什么¶
方法执行到 throw 后,不再继续执行当前正常路径,而是沿方法调用链寻找能处理它的 catch。若一直没有处理,当前请求最终由框架或运行环境处理;不是整个服务必然立刻退出。
| 语法或类型 | 含义 |
|---|---|
throw new ... |
实际抛出一个异常对象,例如业务条件不满足 |
方法后的 throws IOException |
声明此方法可能把异常交给调用者,本身不创建或抛出异常 |
try / catch |
尝试执行一段代码,捕获指定类型的异常;子类型 catch 应放在父类型前面 |
finally |
正常完成或异常传播时通常都会执行的收尾代码;进程强制终止等情况不能保证执行,里面不应 return 掩盖结果 |
| 受检异常(checked exception) | 例如 IOException,编译器要求捕获或声明继续抛出 |
| 非受检异常(unchecked exception) | RuntimeException 及其子类无需强制声明;业务异常可以继承它,但仍需要明确处理策略 |
| Error | 常表示虚拟机等严重问题;不要用 catch (Throwable) 把它与普通业务错误混为一谈 |
| cause(原因异常)、栈追踪 | cause 保存底层原因,栈追踪记录调用位置;用于受控诊断,不直接作为对外消息 |
捕获不是为了“消灭报错”。只有能恢复、补充合适上下文或到达协议响应边界时才处理;不知道怎样恢复,就保留失败信号。访问文件、网络、数据库的流或连接需要关闭,优先用 try (资源声明),即 try-with-resources;要求资源实现 AutoCloseable。
1. HTTP 状态和业务码是两回事¶
- HTTP 状态在响应行中,供客户端、代理和监控识别请求结果。
ResponseResult.code在 JSON 中,表达项目约定的业务结果。message是可公开的提示,data承载业务数据。
业务码不一定是合法的 HTTP 状态。ResponseResult.error(...) 只创建响应体,不会自动修改 HTTP 状态;需要由 ResponseEntity 等方式设置状态。
hhjava 的公共处理器保留 CustomException 中的业务码,另行映射 HTTP 状态:
| 情况 | HTTP 状态 |
|---|---|
| 参数校验失败、JSON 无法解析、参数类型错误 | 400 |
| 需要登录、密码认证失败、Token 无效或过期 | 401 |
| 身份已确认但权限不足 | 403 |
| 业务数据不存在 | 404 |
| 业务数据冲突 | 409 |
| 未识别的服务端故障、存储 SDK 异常 | 500 |
Spring Web 的 ErrorResponse 异常保留框架确定的状态和必要响应头;不能把所有异常都变成 HTTP 200 或 500。
2. 在哪一层处理什么¶
Controller:校验输入、调用 Service、返回结果
↓
Service:执行业务规则;无法完成时抛出明确异常
↓
Mapper / 存储组件:访问数据库或对象存储
异常向上传播 → 对应 HTTP 边界统一转换响应
@ExceptionHandler 是 Spring MVC 异常解析机制的一部分;@RestControllerAdvice 可以把处理规则集中应用到多个 Controller。它不是依靠在每个业务方法外手工添加 AOP 切面实现的。Spring MVC 异常处理说明
当前项目的职责分工:
| 代码入口 | 适用范围与职责 |
|---|---|
common 的 GlobalExceptionHandler |
低优先级兜底,只增强 @RestController,处理业务异常、参数错误及服务故障 |
user 的 AuthApiExceptionHandler |
高优先级处理注册和移动认证 Controller,保持用户名冲突 409、登录失败 401 等契约 |
| Spring Security / Authorization Server 过滤链 | 浏览器认证和 OAuth2/OIDC 协议错误,协议响应不强行包装为 ResponseResult |
gateway 的 CustomAuthenticationHandler |
WebFlux 安全链的 401/403;不是 Servlet Advice |
3. 公共处理器如何被其他模块发现¶
hhjava-common 中的 ServletExceptionAutoConfiguration 使用 @AutoConfiguration,并通过下面的资源文件导出:
src/main/resources/META-INF/spring/
org.springframework.boot.autoconfigure.AutoConfiguration.imports
其中包含这一行:
com.hh.common.exception.ServletExceptionAutoConfiguration
自动配置限定 Servlet Web 应用,并用 @ConditionalOnMissingBean(GlobalExceptionHandler.class) 避免重复注册。消费方不需要扩大扫描范围才能获得这个兜底处理器;WebFlux 网关也不会因此加载 Servlet 异常处理。
理解自动配置的完整步骤见 SpringBoot。
4. 文件存储失败为什么不能吞掉¶
文件 Starter 将上传、删除、下载中的 SDK 故障转换成 FileStorageException,保留原始 cause。这个异常不依赖 HTTP,也不在 Starter 内构造 ResponseResult。
特别是删除操作:如果 SDK 删除失败,必须把失败传给上层,不能只打一条日志后正常返回,否则 Controller 会错误地告诉客户端“删除成功”。
文件 Controller 负责关闭自己打开的输入流;存储服务抛出异常后,由公共处理器返回安全的 500。有关流的所有权和 MinIO 配置见 MinIO 文件存储。
5. 日志和响应不要泄露秘密¶
不能直接把 exception.getMessage() 返回给客户端,也不能默认打印完整 Throwable:SDK 异常可能带有请求地址、凭据或底层响应正文。
当前公共处理器按错误级别记录请求方法、匹配到的路由模板、状态、异常类型;服务端故障另外记录 cause 类型和首个栈帧。它不记录实际 URL、请求体、请求头和异常原始消息。网关认证失败只在 debug 级别记录异常类型。
原始 cause 留在异常对象中用于受控排查,不等于允许把它输出到普通日志。客户端提示应稳定、可理解;“用户不存在”和“密码错误”使用相同登录失败提示。
6. 如何验证¶
在 hhjava 根目录执行:
mvn -pl hhjava-service/hhjava-user -am test
mvn -pl hhjava-service/hhjava-backup-file -am test
mvn -pl hhjava-gateway -am test
重点检查状态码、响应结构、专用处理器优先级、异常日志脱敏,以及存储删除失败是否向上传播。模拟 SDK 故障的测试不等于真实 MinIO 已经连接成功。
7. 动手:把业务异常转换为可读的 HTTP 响应¶
目标:请求一条不存在的演示记录,收到 HTTP 404 和 JSON,而不是异常堆栈。前置条件是完成 Spring Boot 入门练习;不要求完成数据库练习。所有文件都在 Mac 的独立 hhjava-learning-basics 中,不修改 hhjava 的公共异常处理器。
7.1 定义业务异常¶
文件:src/main/java/com/example/learning/error/LearningNotFoundException.java
package com.example.learning.error;
// 用明确类型表达“学习资源不存在”;不把 HTTP 和底层数据库异常拼进业务消息。
public class LearningNotFoundException extends RuntimeException {
public LearningNotFoundException() {
super("学习资源不存在");
}
}
7.2 创建能触发异常的教学接口¶
文件:src/main/java/com/example/learning/error/LearningErrorController.java
package com.example.learning.error;
import com.example.learning.ApiResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class LearningErrorController {
@GetMapping("/learning/missing")
public ApiResponse<Void> missing() {
// 固定触发用于观察传播;真实业务应由 Service 判断查找结果并抛出。
throw new LearningNotFoundException();
}
@PostMapping("/learning/names")
public ApiResponse<String> name(@Valid @RequestBody NameRequest request) {
return ApiResponse.success(request.name());
}
public record NameRequest(@NotBlank String name) {}
}
@RequestBody 读取 JSON;@Valid 触发对象字段校验;@NotBlank 不允许 null、空字符串或全空白。Boot 3 对应这里的 jakarta.validation 包。
7.3 集中处理两类异常¶
文件:src/main/java/com/example/learning/error/LearningExceptionAdvice.java
package com.example.learning.error;
import com.example.learning.ApiResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice(basePackageClasses = LearningErrorController.class)
public class LearningExceptionAdvice {
@ExceptionHandler(LearningNotFoundException.class)
public ResponseEntity<ApiResponse<Void>> notFound() {
return ResponseEntity.status(404)
.body(new ApiResponse<>(404, "学习资源不存在", null));
}
@ExceptionHandler({MethodArgumentNotValidException.class,
HttpMessageNotReadableException.class})
public ResponseEntity<ApiResponse<Void>> badRequest() {
return ResponseEntity.badRequest()
.body(new ApiResponse<>(400, "请求参数不正确", null));
}
}
@ExceptionHandler 按异常类型选择处理方法;ResponseEntity 同时表达 HTTP 状态、响应头和响应体。本例 basePackageClasses 限定增强 error 包及其子包的 Controller,不改变其他示例的行为。代码不回显原始 JSON 或异常消息,也不加一个吞掉所有异常的 catch;生产兜底行为见前文 hhjava 的处理器设计。
7.4 发出成功和失败请求¶
在学习工程根目录重新运行 mvn spring-boot:run。另开 Mac 终端:
curl -i 'http://127.0.0.1:18080/learning/missing'
curl -i -X POST 'http://127.0.0.1:18080/learning/names' \
-H 'Content-Type: application/json' --data '{"name":"Java"}'
curl -i -X POST 'http://127.0.0.1:18080/learning/names' \
-H 'Content-Type: application/json' --data '{"name":""}'
依次预期:404“学习资源不存在”;200,data 为“Java”;400“请求参数不正确”。失败响应的 data 在本练习为 null,不假定所有 hhjava 端点也使用相同空值策略。
7.5 自动检查,避免改代码后忘了错误契约¶
文件:src/test/java/com/example/learning/LearningErrorTest.java
package com.example.learning;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
class LearningErrorTest {
@Autowired
private MockMvc mvc;
@Test
void shouldMapMissingResource() throws Exception {
mvc.perform(get("/learning/missing"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("学习资源不存在"));
}
@Test
void shouldRejectBlankName() throws Exception {
mvc.perform(post("/learning/names").contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(400));
}
@Test
void shouldRejectMalformedJson() throws Exception {
mvc.perform(post("/learning/names").contentType(MediaType.APPLICATION_JSON)
.content("{"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.message").value("请求参数不正确"));
}
}
学习工程根目录执行 mvn -Dtest=LearningErrorTest test,预期通过。若空名字仍返回成功,检查 validation 依赖、@Valid 和注解包;若 Advice 不生效,检查包是否在启动类扫描范围、增强范围是否涵盖 Controller。过滤器在 Controller 之前发生的认证错误,不应靠扩大此 Advice 的 catch 范围解决。