Note 14. 健壮性保障:统一响应封装与全局异常处理 摘要 :一个工业级的后端 API,其优雅之处不仅在于成功时的响应结构清晰,更在于失败时的反馈准确且安全。直接向前端暴露原始的 Java 异常堆栈,是业余开发者的典型特征,这不仅增加了前后端的沟通成本,更可能泄露数据库表结构等敏感信息。本章我们将从零构建一套坚不可摧的"防御体系":首先设计统一响应结构,建立前后端之间严格的"通信契约";其次深入理解 Java 异常体系与 Spring 事务回滚的底层关联;最后利用 Spring MVC 的全局异常处理机制,实现从参数校验到系统崩溃的全链路异常自动化处理,并将这一切与上一章学习的 JSR-303 校验体系完美融合。
本章学习路径
问题溯源 :深入分析传统 API 响应的混乱现状,理解统一响应体的必要性和设计原则。契约设计 :从零设计状态码枚举和通用响应体,掌握 HTTP 状态码与业务状态码的协作关系。异常体系 :深入 Java 异常分类,理解受检异常与非受检异常的本质差异,设计自定义业务异常。拦截机制 :剖析 Spring MVC 异常处理流程,掌握全局异常处理器的工作原理。全面覆盖 :处理参数校验异常、业务异常、系统异常、404错误、方法不支持等各类场景。字段级反馈 :实现参数校验失败时,向前端返回字段级的错误信息。实战演练 :通过完整的用户注册案例,验证整套体系的有效性。14.1. 从混乱到秩序:API 响应的演进之路 14.1.1. 传统 API 的五种混乱模式 在正式设计统一响应体之前,我们先来审视一个真实项目中可能出现的各种响应格式。假设我们正在开发一个用户管理系统,包含查询、新增、更新、删除等功能。
模式一:直接返回实体对象
1 2 3 4 @GetMapping("/users/{id}") public User getUser (@PathVariable Long id) { return userService.getUserById(id); }
当用户存在时,前端收到:
1 2 3 4 5 6 { "id" : 1 , "username" : "zhangsan" , "email" : "zhangsan@example.com" , "createTime" : "2025-01-01T10:00:00" }
问题分析 :
前端无法通过响应体判断操作是否成功,只能依赖 HTTP 状态码 当用户不存在时会抛出异常,前端收到的可能是 HTML 错误页或不规范的 JSON 缺少提示信息,前端需要自己编写业务提示语 模式二:直接返回布尔值或字符串
1 2 3 4 5 @DeleteMapping("/users/{id}") public String deleteUser (@PathVariable Long id) { userService.deleteById(id); return "删除成功" ; }
前端收到的是纯文本:删除成功
问题分析 :
响应格式不是 JSON,前端无法统一处理 失败时可能返回"删除失败",前端需要通过字符串匹配来判断成功与否 无法携带额外数据(如删除的记录数) 模式三:随意自定义的 JSON 结构
1 2 3 4 5 6 7 8 9 10 11 12 13 @PostMapping("/users") public Map<String, Object> createUser (@RequestBody User user) { Map<String, Object> result = new HashMap <>(); try { userService.save(user); result.put("success" , true ); result.put("msg" , "创建成功" ); } catch (Exception e) { result.put("success" , false ); result.put("error" , e.getMessage()); } return result; }
成功时返回:
1 2 3 4 { "success" : true , "msg" : "创建成功" }
失败时返回:
1 2 3 4 { "success" : false , "error" : "用户名已存在" }
问题分析 :
成功和失败的字段名不一致(msg vs error) 不同接口可能用不同的字段名(有的用 message,有的用 msg) 前端需要针对每个接口编写不同的解析逻辑 模式四:Spring Boot 默认的错误响应
当代码抛出异常时,Spring Boot 返回默认的错误页面或 JSON:
1 2 3 4 5 6 7 { "timestamp" : "2025-12-17T10:15:30.000+00:00" , "status" : 500 , "error" : "Internal Server Error" , "message" : "用户名已存在" , "path" : "/users" }
问题分析 :
包含了技术细节(如 path、status),不适合直接展示给用户 字段名与我们自定义的成功响应不一致 没有业务状态码,只有 HTTP 状态码 模式五:不同开发者的个人风格
1 2 3 4 5 6 7 8 return Map.of("code" , 200 , "data" , user);return Map.of("status" , "ok" , "result" , user);return Map.of("errcode" , 0 , "errmsg" , "success" , "data" , user);
问题分析 :
团队内部缺乏统一标准 前端需要适配多种不同的格式 代码审查和维护成本极高 14.1.2. 混乱现状的根本原因 通过上面的案例,我们可以总结出传统 API 响应混乱的三大根本原因:
维度 问题表现 影响 缺乏契约 没有统一的响应格式规范 前端需要针对每个接口编写不同的处理逻辑 职责不清 Controller 既要处理业务,又要处理异常封装 代码臃肿,逻辑混乱 安全隐患 直接向前端暴露异常堆栈或数据库错误信息 可能泄露敏感信息,为攻击者提供线索
14.1.3. 理想的统一响应体应该是什么样的? 在设计统一响应体之前,我们先明确它应该满足哪些要求:
核心要求 :
结构统一 :所有接口的响应都使用相同的 JSON 结构信息完整 :包含状态码、提示信息、业务数据易于扩展 :可以方便地添加新的字段(如 traceId、timestamp)类型安全 :使用泛型,避免 Object 类型对前端友好 :提示信息应该是人类可读的,而非技术术语设计目标 :
无论是成功还是失败,前端都能收到类似下面的统一格式:
成功响应示例 :
1 2 3 4 5 6 7 8 9 { "code" : 200 , "message" : "操作成功" , "data" : { "id" : 1 , "username" : "zhangsan" } , "timestamp" : 1734422400000 }
失败响应示例 :
1 2 3 4 5 { "code" : 5002 , "message" : "用户名已存在" , "timestamp" : 1734422400000 }
参数校验失败示例 :
1 2 3 4 5 6 7 8 9 { "code" : 4000 , "message" : "参数校验失败" , "data" : { "username" : "用户名长度需在4-20字符之间" , "email" : "邮箱格式不正确" } , "timestamp" : 1734422400000 }
14.1.4. HTTP 状态码 vs 业务状态码:职责划分 在设计状态码之前,我们必须先理解 HTTP 状态码和业务状态码的区别。这是很多初学者容易混淆的地方。
HTTP 状态码的定位 :
HTTP 状态码是网络协议层面的标准,由 RFC 7231 规范定义,用于表示 HTTP 请求的处理结果。
HTTP 状态码范围 含义 典型场景 2xx 请求成功被服务器接收、理解并处理 200 OK, 201 Created 3xx 需要客户端进一步操作才能完成请求 301 永久重定向, 302 临时重定向 4xx 客户端错误,请求包含语法错误或无法完成请求 400 参数错误, 401 未认证, 403 无权限, 404 不存在 5xx 服务器错误,服务器在处理请求时发生了错误 500 内部错误, 502 网关错误, 503 服务不可用
业务状态码的定位 :
业务状态码是应用层面的自定义编码,用于表示具体的业务处理结果。
核心设计原则 :
HTTP 状态码用于表示"通信状态"
200:服务器成功接收请求并返回了响应(不管业务成功还是失败) 400:请求格式错误(如 JSON 格式不正确) 401:未登录或 Token 失效 403:已登录但无权限 404:请求的资源路径不存在 500:服务器内部错误 业务状态码用于表示"业务结果"
200:业务处理成功 4000:参数校验失败 5001:用户不存在 5002:用户名已存在 5101:库存不足 实际应用场景对比 :
场景 HTTP 状态码 业务状态码 说明 用户注册成功 200 200 通信成功,业务成功 用户名已存在 200 5002 通信成功,业务失败(这是预期内的业务逻辑) 参数校验失败 400 4000 客户端参数错误 用户未登录 401 4001 认证失败 接口不存在 404 4004 路径不存在 数据库挂了 500 500 服务器内部错误
为什么"用户名已存在"返回 HTTP 200?
这是很多初学者的疑问。让我们通过一个对比来理解:
错误做法 (HTTP 状态码与业务状态混用):
1 2 3 4 5 6 7 8 @PostMapping("/users") public ResponseEntity<String> register (@RequestBody UserDTO dto) { if (userService.existsByUsername(dto.getUsername())) { return ResponseEntity.status(400 ).body("用户名已存在" ); } userService.save(dto); return ResponseEntity.ok("注册成功" ); }
问题 :
前端收到 HTTP 400,可能会误以为是请求参数格式错误 浏览器的 Network 面板会标红显示,但这其实不是网络错误 在微服务架构中,网关可能会拦截 400 响应,认为是客户端错误 正确做法 (HTTP 状态码表示通信,业务状态码表示结果):
1 2 3 4 5 6 7 8 9 10 11 12 13 @PostMapping("/users") public Result<Void> register (@RequestBody @Validated UserDTO dto) { userService.register(dto); return Result.success(); } public void register (UserDTO dto) { if (existsByUsername(dto.getUsername())) { throw new BusinessException (ResultCode.USER_ALREADY_EXIST); } save(dto); }
响应 (HTTP 200):
1 2 3 4 5 { "code" : 5002 , "message" : "用户名已存在" , "timestamp" : 1734422400000 }
优势 :
HTTP 200 表示服务器成功处理了请求 业务状态码 5002 明确告知是"用户名已存在"的业务问题 浏览器 Network 面板显示正常,不会误导开发者 在微服务架构中,网关不会误判为通信错误 14.2. 核心基建:从零构建响应体模型 14.2.1. 第一步:设计状态码枚举 状态码的设计需要遵循一定的规范,避免出现"魔法数字"。我们将状态码设计为枚举类型,这样可以:
提供代码提示和自动补全 避免拼写错误 集中管理所有状态码 方便后期维护和扩展 设计原则 :
状态码范围 用途 示例 200 成功 200: 操作成功 500 系统级错误(非预期) 500: 系统繁忙,请稍后重试 4000-4999 客户端错误(参数、权限等) 4000: 参数校验失败 4001: 未登录 4003: 无权限 4004: 资源不存在 5000-5999 业务逻辑错误(预期内) 5001: 用户不存在 5002: 用户名已存在 5101: 库存不足
文件路径 :src/main/java/com/example/demo/common/ResultCode.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 package com.example.demo.common;import lombok.AllArgsConstructor;import lombok.Getter;@Getter @AllArgsConstructor public enum ResultCode { SUCCESS(200 , "操作成功" ), FAILURE(500 , "系统繁忙,请稍后重试" ), PARAM_VALID_ERROR(4000 , "参数校验失败" ), UNAUTHORIZED(4001 , "您还未登录或登录已失效,请重新登录" ), FORBIDDEN(4003 , "您没有权限执行此操作" ), NOT_FOUND(4004 , "请求的资源不存在" ), METHOD_NOT_SUPPORTED(4005 , "不支持该请求方法" ), MEDIA_TYPE_NOT_SUPPORTED(4006 , "不支持的媒体类型" ), USER_NOT_EXIST(5001 , "用户不存在" ), USER_ALREADY_EXIST(5002 , "用户名或邮箱已被注册" ), USER_ACCOUNT_LOCKED(5003 , "您的账号已被冻结,请联系管理员" ), USER_PASSWORD_ERROR(5004 , "用户名或密码错误" ), USER_VERIFY_CODE_ERROR(5005 , "验证码错误或已失效" ), ORDER_STOCK_NOT_ENOUGH(5101 , "商品库存不足" ), ORDER_STATUS_INVALID(5102 , "订单状态异常,无法执行此操作" ), ORDER_NOT_EXIST(5103 , "订单不存在" ), BALANCE_NOT_ENOUGH(5201 , "账户余额不足" ), THIRD_PARTY_SERVICE_ERROR(5202 , "支付服务暂时不可用,请稍后重试" ); private final int code; private final String message; }
设计细节解析 :
使用 Lombok 简化代码
@Getter:自动生成 getCode() 和 getMessage() 方法@AllArgsConstructor:自动生成全参数构造器分组管理
用注释将不同模块的状态码分组 每个模块预留一定的编号空间(如用户模块 5001-5099) 便于后期扩展,不会出现编号冲突 JavaDoc 注释
每个枚举值都有详细的说明 说明适用场景和触发条件 方便团队成员查阅 14.2.2. 第二步:设计通用响应体 Result<T> 响应体的设计是整个系统的"门面",需要仔细推敲每个字段的必要性和合理性。
设计考量 :
字段选择
code:必需,业务状态码message:必需,提示信息data:可选,业务数据(查询结果、创建的对象等)timestamp:可选但推荐,用于问题排查泛型设计
使用 <T> 而不是 Object,提供类型安全 让 IDE 和 Swagger 能够自动推断数据类型 序列化优化
使用 @JsonInclude(NON_NULL) 避免返回 "data": null 减少响应体大小,优化网络传输 构造器封装
使用私有构造器 + 静态工厂方法 强制使用 Result.success() 等标准方法创建对象 避免开发者遗漏必要字段 文件路径 :src/main/java/com/example/demo/common/Result.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 package com.example.demo.common;import com.fasterxml.jackson.annotation.JsonInclude;import lombok.Getter;@Getter @JsonInclude(JsonInclude.Include.NON_NULL) public class Result <T> { private final int code; private final String message; private final T data; private final long timestamp; private Result (int code, String message, T data) { this .code = code; this .message = message; this .data = data; this .timestamp = System.currentTimeMillis(); } public static <T> Result<T> success () { return new Result <>( ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), null ); } public static <T> Result<T> success (T data) { return new Result <>( ResultCode.SUCCESS.getCode(), ResultCode.SUCCESS.getMessage(), data ); } public static <T> Result<T> success (String message) { return new Result <>( ResultCode.SUCCESS.getCode(), message, null ); } public static <T> Result<T> success (String message, T data) { return new Result <>( ResultCode.SUCCESS.getCode(), message, data ); } public static <T> Result<T> error (ResultCode resultCode) { return new Result <>( resultCode.getCode(), resultCode.getMessage(), null ); } public static <T> Result<T> error (ResultCode resultCode, String customMessage) { return new Result <>( resultCode.getCode(), customMessage, null ); } public static <T> Result<T> error (int code, String message) { return new Result <>(code, message, null ); } public static <T> Result<T> error (ResultCode resultCode, T data) { return new Result <>( resultCode.getCode(), resultCode.getMessage(), data ); } public static <T> Result<T> error (ResultCode resultCode, String customMessage, T data) { return new Result <>( resultCode.getCode(), customMessage, data ); } }
设计细节解析 :
@JsonInclude(JsonInclude.Include.NON_NULL) 的作用
未添加此注解时的响应:
1 2 3 4 5 6 { "code" : 200 , "message" : "删除成功" , "data" : null , "timestamp" : 1734422400000 }
添加此注解后的响应:
1 2 3 4 5 { "code" : 200 , "message" : "删除成功" , "timestamp" : 1734422400000 }
优势 :
减少响应体大小(虽然只省略了几个字节,但在高并发场景下累积效果明显) JSON 更简洁,前端解析时不需要判断 data !== null 为什么提供多个重载的 success 和 error 方法?
这是为了适配不同的业务场景,避免开发者手动拼接字符串或创建 Map。
场景对比 :
场景 使用方法 响应示例 删除用户 Result.success(){ "code": 200, "message": "操作成功" }查询用户 Result.success(user){ "code": 200, "message": "操作成功", "data": {...} }发送邮件 Result.success("邮件发送成功"){ "code": 200, "message": "邮件发送成功" }用户不存在 Result.error(ResultCode.USER_NOT_EXIST){ "code": 5001, "message": "用户不存在" }参数校验失败 Result.error(ResultCode.PARAM_VALID_ERROR, errorMap){ "code": 4000, "message": "参数校验失败", "data": {...} }
timestamp 字段的实战价值
在生产环境中,前端可能会反馈"刚才 10 点 15 分左右报错了"。有了 timestamp,我们可以:
快速定位到对应时间段的日志 判断是前端缓存的旧数据还是真实的新请求 在分布式系统中关联不同服务的日志 14.2.3. 使用示例:Controller 中的最佳实践 现在我们已经有了 ResultCode 和 Result<T>,让我们看看如何在 Controller 中优雅地使用它们。
文件路径 :src/main/java/com/example/demo/controller/UserController.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 package com.example.demo.controller;import com.example.demo.common.Result;import com.example.demo.dto.UserDTO;import com.example.demo.service.UserService;import jakarta.validation.Valid;import lombok.RequiredArgsConstructor;import org.springframework.web.bind.annotation.*;import java.util.List;@RestController @RequestMapping("/users") @RequiredArgsConstructor public class UserController { private final UserService userService; @GetMapping public Result<List<UserDTO>> listUsers () { List<UserDTO> users = userService.listAll(); return Result.success(users); } @GetMapping("/{id}") public Result<UserDTO> getUser (@PathVariable Long id) { UserDTO user = userService.getById(id); return Result.success(user); } @PostMapping public Result<Long> createUser (@RequestBody @Valid UserDTO dto) { Long userId = userService.create(dto); return Result.success(userId); } @PutMapping("/{id}") public Result<Void> updateUser ( @PathVariable Long id, @RequestBody @Valid UserDTO dto ) { userService.update(id, dto); return Result.success(); } @DeleteMapping("/{id}") public Result<Void> deleteUser (@PathVariable Long id) { userService.deleteById(id); return Result.success("用户删除成功" ); } }
关键要点 :
Controller 层非常简洁,只负责调用 Service 和返回 Result 所有异常都由 Service 层抛出,Controller 层不需要 try-catch 返回类型始终是 Result<T>,保证响应格式统一 14.3. 异常工程学:构建分层异常体系 14.3.1. Java 异常体系回顾 在设计自定义异常之前,我们需要先理解 Java 的异常体系结构。
核心分类 :
类型 是否必须捕获 典型场景 事务回滚 Error 否 虚拟机错误(OutOfMemoryError) 一般无法恢复 ✅ 自动回滚 受检异常 (Checked Exception) 是 必须 try-catch 或 throws 文件不存在(FileNotFoundException) 网络超时(SocketTimeoutException) ❌ 默认不回滚 需配置 rollbackFor 非受检异常 (Unchecked Exception) 否 可选择性捕获 空指针(NullPointerException) 数组越界(ArrayIndexOutOfBoundsException) 业务异常(BusinessException) ✅ 自动回滚
14.3.2. 为什么自定义业务异常必须继承 RuntimeException? 这是一个非常重要但容易被忽视的设计决策。让我们通过对比来理解。
错误做法:继承 Exception(受检异常)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 public class BusinessException extends Exception { public BusinessException (String message) { super (message); } } public void register (UserDTO dto) throws BusinessException { if (existsByUsername(dto.getUsername())) { throw new BusinessException ("用户名已存在" ); } save(dto); } @PostMapping("/register") public Result<Void> register (@RequestBody @Valid UserDTO dto) { try { userService.register(dto); return Result.success(); } catch (BusinessException e) { return Result.error(5002 , e.getMessage()); } }
问题分析 :
代码臃肿 :
Service 层的每个方法都要声明 throws BusinessException Controller 层被迫到处写 try-catch 违反了"关注点分离"原则 事务回滚失效 :
1 2 3 4 5 6 7 @Transactional public void register (UserDTO dto) throws BusinessException { userMapper.insert(dto); if (condition) { throw new BusinessException ("xxx" ); } }
问题 :Spring 的 @Transactional 默认只在遇到 RuntimeException 或 Error 时回滚。这里抛出的是受检异常,事务会提交,导致脏数据。
解决方法 (但很麻烦):
1 @Transactional(rollbackFor = BusinessException.class)
代码传播性差 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public void methodA () throws BusinessException { methodB(); } public void methodB () throws BusinessException { methodC(); } public void methodC () throws BusinessException { throw new BusinessException ("错误" ); }
每一层都要声明 throws,修改一个方法可能需要改整个调用链。
正确做法:继承 RuntimeException(非受检异常)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class BusinessException extends RuntimeException { private final int code; public BusinessException (ResultCode resultCode) { super (resultCode.getMessage()); this .code = resultCode.getCode(); } } public void register (UserDTO dto) { if (existsByUsername(dto.getUsername())) { throw new BusinessException (ResultCode.USER_ALREADY_EXIST); } save(dto); } @PostMapping("/register") public Result<Void> register (@RequestBody @Valid UserDTO dto) { userService.register(dto); return Result.success(); }
优势分析 :
代码简洁 :
不需要到处声明 throws Controller 层完全不需要 try-catch 事务自动回滚 :
1 2 3 4 5 6 7 8 @Transactional public void register (UserDTO dto) { userMapper.insert(dto); if (condition) { throw new BusinessException (ResultCode.USER_ALREADY_EXIST); } }
符合业务语义 :
业务异常(如"用户不存在")本质上是一种业务流程分支,不是技术故障 不应该强制捕获,而应该让它自然向上传播到全局异常处理器 14.3.3. 设计自定义业务异常 设计目标 :
携带业务状态码 携带错误信息 支持枚举和自定义消息两种构造方式 继承 RuntimeException 文件路径 :src/main/java/com/example/demo/exception/BusinessException.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 package com.example.demo.exception;import com.example.demo.common.ResultCode;import lombok.Getter;@Getter public class BusinessException extends RuntimeException { private final int code; public BusinessException (ResultCode resultCode) { super (resultCode.getMessage()); this .code = resultCode.getCode(); } public BusinessException (ResultCode resultCode, String message) { super (message); this .code = resultCode.getCode(); } public BusinessException (int code, String message) { super (message); this .code = code; } public BusinessException (ResultCode resultCode, String message, Throwable cause) { super (message, cause); this .code = resultCode.getCode(); } }
14.3.4. 业务异常在 Service 层的应用 让我们通过几个真实的业务场景来理解如何使用 BusinessException。
场景一:查询不存在的资源
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Service public class UserService { @Autowired private UserMapper userMapper; public UserDTO getById (Long id) { User user = userMapper.selectById(id); if (user == null ) { throw new BusinessException (ResultCode.USER_NOT_EXIST); } return convertToDTO(user); } }
对比传统做法 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public UserDTO getById (Long id) { User user = userMapper.selectById(id); if (user == null ) { return null ; } return convertToDTO(user); } public Optional<UserDTO> getById (Long id) { User user = userMapper.selectById(id); return Optional.ofNullable(user) .map(this ::convertToDTO); } public Result<UserDTO> getById (Long id) { User user = userMapper.selectById(id); if (user == null ) { return Result.error(ResultCode.USER_NOT_EXIST); } return Result.success(convertToDTO(user)); }
使用异常的优势 :
Service 层的方法签名更简洁,只返回业务数据类型 Controller 层不需要判断 null 或 isPresent 符合"快速失败"原则,问题立即暴露 场景二:业务规则校验
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public void register (UserDTO dto) { if (userMapper.existsByUsername(dto.getUsername())) { throw new BusinessException (ResultCode.USER_ALREADY_EXIST); } if (userMapper.existsByEmail(dto.getEmail())) { throw new BusinessException ( ResultCode.USER_ALREADY_EXIST, "该邮箱已被注册" ); } User user = convertToEntity(dto); userMapper.insert(user); }
场景三:事务回滚验证
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Transactional public void transfer (Long fromId, Long toId, BigDecimal amount) { Account fromAccount = accountMapper.selectById(fromId); if (fromAccount.getBalance().compareTo(amount) < 0 ) { throw new BusinessException (ResultCode.BALANCE_NOT_ENOUGH); } fromAccount.setBalance(fromAccount.getBalance().subtract(amount)); accountMapper.updateById(fromAccount); Account toAccount = accountMapper.selectById(toId); toAccount.setBalance(toAccount.getBalance().add(amount)); accountMapper.updateById(toAccount); }
场景四:第三方服务异常转换
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public void sendSms (String mobile, String code) { try { smsClient.send(mobile, code); } catch (SmsClientException e) { log.error("短信发送失败, mobile: {}" , mobile, e); throw new BusinessException ( ResultCode.THIRD_PARTY_SERVICE_ERROR, "短信发送失败,请稍后重试" , e ); } }
14.4. 核心实战:全局异常处理器的完整实现 现在我们已经有了统一的响应体和自定义异常,接下来要解决的问题是:如何将异常自动转换为统一的 Result 格式返回给前端?
答案是:全局异常处理器 。
14.4.1. Spring MVC 异常处理机制剖析 在深入代码之前,我们先理解 Spring MVC 处理请求的完整流程。
核心组件 :
DispatcherServlet :前端控制器,所有请求的入口HandlerExceptionResolver :异常解析器接口@RestControllerAdvice :全局异常处理注解,是 @ControllerAdvice + @ResponseBody 的组合@ExceptionHandler :标注在方法上,指定要处理的异常类型14.4.2. @RestControllerAdvice 的工作原理 @RestControllerAdvice 是一个 AOP 切面,它会拦截所有 Controller 抛出的异常。
注解解析 :
1 @RestControllerAdvice = @ControllerAdvice + @ResponseBody
@ControllerAdvice:标记这是一个增强类,作用于所有 @Controller 或 @RestController@ResponseBody:方法返回值会自动序列化为 JSON作用域 :
1 2 3 4 5 6 7 8 9 10 11 @RestControllerAdvice public class GlobalExceptionHandler { }@RestControllerAdvice(basePackages = "com.example.demo.controller") public class GlobalExceptionHandler { }@RestControllerAdvice(annotations = RestController.class) public class GlobalExceptionHandler { }
14.4.3. 完整的全局异常处理器实现 我们将异常分为以下几类,分别处理:
异常类型 触发场景 处理策略 MethodArgumentNotValidException@Valid 或 @Validated 校验失败提取字段错误信息,返回字段级错误 ConstraintViolationExceptionURL 参数(@PathVariable、@RequestParam)校验失败 提取错误信息 BusinessExceptionService 层主动抛出的业务异常 提取 code 和 message,直接返回 NoHandlerFoundException请求的路径不存在(404) 返回友好提示 HttpRequestMethodNotSupportedException请求方法不支持(如 GET 请求了 POST 接口) 返回支持的方法列表 HttpMediaTypeNotSupportedExceptionContent-Type 不支持 返回支持的媒体类型 Exception所有未被上述异常捕获的情况 记录详细日志,返回模糊提示
文件路径 :src/main/java/com/example/demo/handler/GlobalExceptionHandler.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 package com.example.demo.handler;import com.example.demo.common.Result;import com.example.demo.common.ResultCode;import com.example.demo.exception.BusinessException;import jakarta.servlet.http.HttpServletRequest;import jakarta.validation.ConstraintViolation;import jakarta.validation.ConstraintViolationException;import lombok.extern.slf4j.Slf4j;import org.springframework.http.HttpStatus;import org.springframework.validation.BindingResult;import org.springframework.validation.FieldError;import org.springframework.web.HttpMediaTypeNotSupportedException;import org.springframework.web.HttpRequestMethodNotSupportedException;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.ResponseStatus;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.web.servlet.NoHandlerFoundException;import java.util.HashMap;import java.util.Map;import java.util.Set;import java.util.stream.Collectors;@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Result<Map<String, String>> handleMethodArgumentNotValidException ( MethodArgumentNotValidException e ) { BindingResult bindingResult = e.getBindingResult(); Map<String, String> errors = new HashMap <>(); for (FieldError fieldError : bindingResult.getFieldErrors()) { String fieldName = fieldError.getField(); String errorMessage = fieldError.getDefaultMessage(); errors.put(fieldName, errorMessage); } log.warn("请求体参数校验失败, URI: {}, 错误详情: {}" , getCurrentRequestURI(), errors); return Result.error(ResultCode.PARAM_VALID_ERROR, errors); } @ExceptionHandler(ConstraintViolationException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Result<Void> handleConstraintViolationException ( ConstraintViolationException e ) { Set<ConstraintViolation<?>> violations = e.getConstraintViolations(); String errorMsg = violations.stream() .map(violation -> violation.getMessage()) .collect(Collectors.joining("; " )); log.warn("URL参数校验失败, URI: {}, 错误信息: {}" , getCurrentRequestURI(), errorMsg); return Result.error(ResultCode.PARAM_VALID_ERROR, errorMsg); } @ExceptionHandler(BusinessException.class) @ResponseStatus(HttpStatus.OK) public Result<Void> handleBusinessException (BusinessException e) { log.warn("业务异常, code: {}, message: {}, URI: {}" , e.getCode(), e.getMessage(), getCurrentRequestURI()); return Result.error(e.getCode(), e.getMessage()); } @ExceptionHandler(NoHandlerFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public Result<Void> handleNotFoundException (NoHandlerFoundException e) { log.warn("请求路径不存在, Method: {}, URI: {}" , e.getHttpMethod(), e.getRequestURL()); String message = String.format( "请求路径 [%s %s] 不存在" , e.getHttpMethod(), e.getRequestURL() ); return Result.error(ResultCode.NOT_FOUND, message); } @ExceptionHandler(HttpRequestMethodNotSupportedException.class) @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) public Result<Void> handleMethodNotSupportedException ( HttpRequestMethodNotSupportedException e ) { log.warn("请求方法不支持, Method: {}, URI: {}, 支持的方法: {}" , e.getMethod(), getCurrentRequestURI(), e.getSupportedHttpMethods()); String message = String.format( "不支持 %s 请求,请使用 %s" , e.getMethod(), e.getSupportedHttpMethods() ); return Result.error(ResultCode.METHOD_NOT_SUPPORTED, message); } @ExceptionHandler(HttpMediaTypeNotSupportedException.class) @ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE) public Result<Void> handleMediaTypeNotSupportedException ( HttpMediaTypeNotSupportedException e ) { log.warn("Content-Type 不支持, ContentType: {}, URI: {}, 支持的类型: {}" , e.getContentType(), getCurrentRequestURI(), e.getSupportedMediaTypes()); String message = String.format( "不支持 %s 格式,请使用 %s" , e.getContentType(), e.getSupportedMediaTypes() ); return Result.error(ResultCode.MEDIA_TYPE_NOT_SUPPORTED, message); } @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public Result<Void> handleSystemException (Exception e) { log.error("系统发生未处理异常, URI: {}, 异常类型: {}" , getCurrentRequestURI(), e.getClass().getName(), e); return Result.error(ResultCode.FAILURE); } private String getCurrentRequestURI () { try { HttpServletRequest request = ((org.springframework.web.context.request.ServletRequestAttributes) org.springframework.web.context.request.RequestContextHolder .getRequestAttributes()) .getRequest(); return request.getRequestURI(); } catch (Exception e) { return "Unknown" ; } } }
代码要点解析 :
异常处理顺序
Spring 会按照"从具体到抽象"的顺序匹配异常处理器:
1 2 3 4 5 BusinessException ↓ RuntimeException ↓ Exception
如果抛出的是 BusinessException,会优先匹配 handleBusinessException,而不是 handleSystemException。
为什么业务异常返回 HTTP 200?
1 2 @ExceptionHandler(BusinessException.class) @ResponseStatus(HttpStatus.OK)
因为"用户不存在"、"库存不足"等业务异常,本质上是业务流程的一部分,不是通信错误。HTTP 200 表示服务器成功处理了请求,业务结果通过 JSON 中的 code 字段来判断。
日志级别的选择
异常类型 日志级别 原因 参数校验失败 WARN 客户端传参错误,不是服务器故障 业务异常 WARN 预期内的业务流程分支 系统异常 ERROR 非预期的故障,需要立即修复
为什么不能返回 e.getMessage()?
假设数据库连接失败,异常信息可能是:
1 2 3 4 com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure. The last packet sent successfully to the server was 0 milliseconds ago. The driver has not received any packets from the server.
如果直接返回给前端:
用户看不懂技术术语 暴露了数据库类型(MySQL) 可能被攻击者利用 正确做法:
后端日志记录详细信息 前端只收到"系统繁忙,请稍后重试" 14.4.4. 配置 404 异常的统一处理 在 Spring Boot 中,当一个请求无法匹配到任何 Controller 时,默认行为是转发到 /error 路径,而不是直接抛出 NoHandlerFoundException 异常。为了能像处理其他业务异常一样,通过全局异常处理器(@RestControllerAdvice)来捕获 404 错误并返回统一的 JSON 格式,我们需要进行一些配置。
方案一:抛出异常(旧版方式,已不推荐)
在较早的 Spring Boot 版本中,可以通过以下配置强制应用抛出 NoHandlerFoundException:
文件路径 :src/main/resources/application.yml
以下配置在 Spring Boot 2.6 及以后版本中已被弃用,在 3.x 版本中可能不再有效。
1 2 3 4 5 6 spring: mvc: throw-exception-if-no-handler-found: true web: resources: add-mappings: false
版本变更与注意事项 :
配置已弃用 :spring.mvc.throw-exception-if-no-handler-found 属性自 Spring Boot 2.6 起已被标记为废弃 (Deprecated) 。这意味着它在未来的版本中将被移除,不应再继续使用。功能限制 :此方法需要关闭 Spring Boot 的默认静态资源映射 (spring.web.resources.add-mappings: false)。这会导致 src/main/resources/static 目录下的所有文件都无法通过应用直接访问。适用场景 :
✅ 仅适用于老旧项目维护。 ❌ 不适用于新项目,或需要同时提供 API 和静态资源(如文档、后台管理页面)的项目。 方案二:自定义 ErrorController(当前推荐的最佳实践)
为了解决旧方案的限制并适应 Spring Boot 的发展,目前推荐通过实现 ErrorController 接口来统一处理所有 HTTP 错误,包括 404。这种方式无需修改任何 application.yml 配置,并且可以完美兼容静态资源服务。
核心思想 :利用 Spring Boot 内置的错误处理机制。当发生任何无法处理的错误(包括 404)时,Spring Boot 会将请求转发到 /error 路径。我们只需编写一个 Controller 来接管这个路径的处理权即可。
实现步骤 :
创建一个自定义的 ErrorController。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import org.springframework.boot.web.servlet.error.ErrorController;import org.springframework.http.HttpStatus;import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;import javax.servlet.http.HttpServletRequest;@RestController public class GlobalErrorController implements ErrorController { @RequestMapping("/error") public Result<Void> handleError (HttpServletRequest request) { Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code" ); if (statusCode != null && statusCode == HttpStatus.NOT_FOUND.value()) { return Result.error(ResultCode.NOT_FOUND, "您访问的资源不存在" ); } return Result.error(ResultCode.FAILURE, "服务器发生未知错误" ); } }
这种方式的优势 :
✅ 无需弃用配置 :完全不依赖已废弃的 application.yml 属性。 ✅ 兼容静态资源 :Spring Boot 的静态资源服务可以保持开启,不影响正常功能。 ✅ 统一处理入口 :不仅是 404,像 500、403、401 等所有 HTTP 错误最终都会汇集到这个 Controller,便于实现统一、规范的错误响应格式。 ✅ 适用所有项目 :无论是前后端分离还是单体应用,此方案都表现良好。 14.5. 字段级错误反馈:让前端精准定位问题 在上一章,我们学习了 JSR-303 参数校验。当校验失败时,Spring 会抛出 MethodArgumentNotValidException。现在我们已经有了全局异常处理器,可以捕获这个异常并转换为统一的响应格式。
但我们还可以做得更好:返回字段级的错误信息 ,让前端能够精确地将错误提示显示在对应的输入框下方。
14.5.1. 问题场景 假设我们有这样一个 DTO:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @Data public class UserRegisterDTO { @NotBlank(message = "用户名不能为空") @Size(min = 4, max = 20, message = "用户名长度需在4-20字符之间") private String username; @NotBlank(message = "密码不能为空") @Pattern( regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)[\\s\\S]{8,16}$", message = "密码必须8-16位,且包含大小写字母和数字" ) private String password; @NotBlank(message = "邮箱不能为空") @Email(message = "邮箱格式不正确") private String email; }
当客户端发送以下请求时:
1 2 3 4 5 6 POST /users/register { "username" : "ab" , "password" : "123" , "email" : "invalid" }
传统做法 (拼接所有错误信息):
1 2 3 4 5 { "code" : 4000 , "message" : "用户名长度需在4-20字符之间; 密码必须8-16位,且包含大小写字母和数字; 邮箱格式不正确" , "timestamp" : 1734422400000 }
问题 :
前端只能显示一个通用的错误提示 无法精确定位到具体的输入框 用户体验差 理想做法 (字段级错误):
1 2 3 4 5 6 7 8 9 10 { "code" : 4000 , "message" : "参数校验失败" , "data" : { "username" : "用户名长度需在4-20字符之间" , "password" : "密码必须8-16位,且包含大小写字母和数字" , "email" : "邮箱格式不正确" } , "timestamp" : 1734422400000 }
优势 :
前端可以遍历 data 对象,将每个字段的错误提示显示在对应的输入框下方 用户能清楚知道每个字段的具体问题 体验极佳 14.5.2. 实现字段级错误反馈 我们已经在全局异常处理器中实现了这个功能,让我们再次回顾关键代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public Result<Map<String, String>> handleMethodArgumentNotValidException ( MethodArgumentNotValidException e ) { BindingResult bindingResult = e.getBindingResult(); Map<String, String> errors = new HashMap <>(); for (FieldError fieldError : bindingResult.getFieldErrors()) { String fieldName = fieldError.getField(); String errorMessage = fieldError.getDefaultMessage(); errors.put(fieldName, errorMessage); } log.warn("请求体参数校验失败, URI: {}, 错误详情: {}" , getCurrentRequestURI(), errors); return Result.error(ResultCode.PARAM_VALID_ERROR, errors); }
代码解析 :
BindingResult 的作用
MethodArgumentNotValidException 内部包含一个 BindingResult 对象BindingResult 存储了所有字段的校验结果FieldError 的结构
1 2 3 4 5 FieldError fieldError = ...fieldError.getField(); fieldError.getDefaultMessage(); fieldError.getRejectedValue(); fieldError.getCode();
构建 Map 的优势
Map 的 key 是字段名,前端可以直接定位 Map 的 value 是错误信息,可以直接显示 显示效果 :
1 2 3 4 5 6 7 8 用户名: [ab_____] ↑ 用户名长度需在4-20字符之间(红色提示) 密码: [123____] ↑ 密码必须8-16位,且包含大小写字母和数字(红色提示) 邮箱: [invalid] ↑ 邮箱格式不正确(红色提示)
14.5.3. 处理嵌套对象的校验错误 如果 DTO 中包含嵌套对象,错误信息的字段名会带有路径。
示例 DTO :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 @Data public class OrderDTO { @NotNull private Long productId; @Valid @NotNull private AddressDTO address; } @Data public class AddressDTO { @NotBlank(message = "收货人姓名不能为空") private String receiverName; @NotBlank(message = "收货人电话不能为空") private String receiverPhone; }
校验失败时的错误 Map :
1 2 3 4 5 6 7 8 9 { "code" : 4000 , "message" : "参数校验失败" , "data" : { "productId" : "不能为null" , "address.receiverName" : "收货人姓名不能为空" , "address.receiverPhone" : "收货人电话不能为空" } }
前端处理 :
1 2 3 4 5 6 7 8 const error = errors['address.receiverName' ]const [parent, field] = 'address.receiverName' .split ('.' )