大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
错误处理
这一课你将能做到
- 设计统一的 API 错误响应格式,不再让调用方面对千奇百怪的错误体
- 实现 RFC 7807 Problem Details 标准,用
application/problem+json返回结构化错误 - 在 Spring Boot 中搭建全局异常处理器,把异常翻译成正确的 HTTP 状态码和错误响应
为什么要学
错误处理是 API 的「售后服务」。调用方拿到 500 和一堆堆栈信息,跟拿到「课程不存在」的明确提示,体验天差地别。好的错误设计让前端能自动处理异常(比如 401 跳登录页),差的错误设计让联调变成猜谜。
先建立图像
错误响应演进:
Level 0:裸异常
HTTP/1.1 500 Internal Server Error
{"timestamp":"...","status":500,"error":"Internal Server Error","trace":"java.lang.NullPointerException..."}
Level 1:统一包装
HTTP/1.1 404 Not Found
{"code":404,"message":"课程不存在","data":null}
Level 2:Problem Details(RFC 7807)
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{
"type": "https://api.codeforge.com/problems/lesson-not-found",
"title": "课程不存在",
"status": 404,
"detail": "ID 为 123 的课程不存在",
"instance": "/api/lessons/123"
}
跟着做
步骤 1:统一响应包装
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.code = 200;
response.message = "success";
response.data = data;
return response;
}
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.code = code;
response.message = message;
return response;
}
// getter/setter
}
步骤 2:自定义业务异常
public class BusinessException extends RuntimeException {
private final int code;
private final String message;
public BusinessException(int code, String message) {
super(message);
this.code = code;
this.message = message;
}
// getter
}
public class LessonNotFoundException extends BusinessException {
public LessonNotFoundException(Long id) {
super(404, "课程不存在: " + id);
}
}
public class SubmissionDeniedException extends BusinessException {
public SubmissionDeniedException() {
super(403, "您没有权限提交此课程的作业");
}
}
步骤 3:全局异常处理器
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
log.warn("业务异常: {}", e.getMessage());
return ResponseEntity.status(e.getCode())
.body(ApiResponse.error(e.getCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(
MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(400, "参数校验失败: " + message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnknownException(Exception e) {
log.error("系统异常", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(500, "服务器内部错误,请稍后重试"));
}
}
步骤 4:RFC 7807 Problem Details
public class ProblemDetail {
private String type; // 问题类型 URI
private String title; // 简短描述
private int status; // HTTP 状态码
private String detail; // 详细说明
private String instance; // 发生问题的资源 URI
// 扩展字段
private Map<String, Object> properties;
// getter/setter
}
@RestControllerAdvice
public class ProblemDetailExceptionHandler {
@ExceptionHandler(LessonNotFoundException.class)
public ResponseEntity<ProblemDetail> handleLessonNotFound(LessonNotFoundException e,
HttpServletRequest request) {
ProblemDetail problem = new ProblemDetail();
problem.setType("https://api.codeforge.com/problems/lesson-not-found");
problem.setTitle("课程不存在");
problem.setStatus(404);
problem.setDetail(e.getMessage());
problem.setInstance(request.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problem);
}
}
步骤 5:在 Controller 中使用
@GetMapping("/{id}")
public ApiResponse<Lesson> getById(@PathVariable Long id) {
Lesson lesson = lessonRepository.findById(id)
.orElseThrow(() -> new LessonNotFoundException(id));
return ApiResponse.success(lesson);
}
@PostMapping("/{id}/submissions")
public ApiResponse<Submission> submit(@PathVariable Long id,
@RequestBody @Valid SubmissionRequest request,
@AuthenticationPrincipal User user) {
if (!submissionService.canSubmit(user.getId(), id)) {
throw new SubmissionDeniedException();
}
Submission submission = submissionService.create(user.getId(), id, request);
return ApiResponse.success(submission);
}
步骤 6:测试错误响应
# 404 课程不存在
curl -s http://localhost:8080/api/lessons/99999 | jq
# 400 参数校验失败
curl -s -X POST http://localhost:8080/api/lessons \
-H "Content-Type: application/json" \
-d '{"title":""}' | jq
# 403 无权限
curl -s -X POST http://localhost:8080/api/lessons/123/submissions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer invalid-token" \
-d '{"code":"..."}' | jq
讲清楚要点
- 错误码和 HTTP 状态码要一致:别返回 200 然后在 body 里写
"code": 500。调用方的 HTTP 客户端(如 axios、fetch)会根据状态码决定走 then 还是 catch。 - 错误信息要对用户友好,对开发有用:「课程不存在」比「NullPointerException」好,但最好带上上下文:「ID 为 123 的课程不存在」。
- 异常分层处理:Controller 层不 try-catch,抛给全局处理器;Service 层抛业务异常;DAO 层抛技术异常。全局处理器统一翻译。
- Problem Details 是国际标准:RFC 7807 定义了
application/problem+json格式,包括 type、title、status、detail、instance 五个标准字段,可扩展自定义字段。 - 敏感信息不能泄露:堆栈信息、SQL 语句、内部 IP 不能出现在错误响应里。开发环境可以详细,生产环境要脱敏。
你可能会懵的地方
-
为什么不用 HTTP 状态码表达所有错误? HTTP 状态码是传输层语义,粒度粗(比如 400 可能是参数缺失、格式错误、业务规则不满足)。业务错误需要更细的错误码(如
COURSE_EXPIRED、QUOTA_EXCEEDED),但 HTTP 状态码保持 200/400/404/500 等标准值。 -
校验失败应该返回 400 还是 422? 400 Bad Request = 请求格式错误(如 JSON 语法错误);422 Unprocessable Entity = 格式正确但语义错误(如邮箱格式不对、必填字段为空)。严格来说校验失败应该返 422,但很多框架和团队习惯返 400。Spring Boot 的
@Valid默认抛MethodArgumentNotValidException,通常映射到 400。 -
错误码用数字还是字符串? 数字(如 40401)节省空间,但可读性差;字符串(如
LESSON_NOT_FOUND)可读性好,但传输略大。推荐:HTTP 状态码用数字,业务错误码用字符串。 -
全局异常处理器会拦截所有异常吗? 会拦截 Controller 层抛出的异常。但 Filter、Interceptor、异步线程里的异常不会被
@RestControllerAdvice捕获。需要额外配置ErrorController或HandlerExceptionResolver。 -
生产环境要不要返回堆栈? 绝对不要。堆栈包含类名、方法名、行号,可能泄露代码结构。生产环境只返回错误码和用户友好的 message,详细错误记日志。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 所有错误都返 200,body 里写错误码 | 调用方无法通过 HTTP 状态码判断成功/失败 | 错误返 4xx/5xx,body 里给详细错误码 |
直接抛 RuntimeException("出错了") | 无法区分错误类型,无法映射状态码 | 自定义业务异常,带 code 和 message |
| 错误响应格式不统一 | 前端需要写多套解析逻辑 | 统一 ApiResponse 或 Problem Details |
| 把 SQL 异常直接抛给前端 | 泄露数据库结构,可能被注入攻击 | 全局处理器捕获,返回「系统繁忙」 |
卡点急救
- 异常被吞了,没走到全局处理器:检查是否有
@Controller忘了加@RestController(返回视图而不是 JSON),或异常在 Filter/Interceptor 中被捕获。 - 校验异常没有自定义 message:
@Valid需要配合BindingResult或全局处理器才能拿到字段错误信息。 - 异步方法异常没处理:
@Async方法抛异常会走AsyncUncaughtExceptionHandler,不是@RestControllerAdvice。需要单独配置。
面试常问
-
Spring Boot 怎么做全局异常处理?
@RestControllerAdvice+@ExceptionHandler,按异常类型匹配处理方法。能讲出处理顺序(精确类型优先于父类)、ResponseEntity包装、ProblemDetail标准。 -
HTTP 状态码和业务错误码怎么配合? HTTP 状态码表达传输层结果(200/400/404/500),业务错误码表达业务语义(
LESSON_NOT_FOUND、QUOTA_EXCEEDED)。两者结合:HTTP 状态码让客户端快速分类处理,业务错误码让前端精确提示。 -
RFC 7807 是什么? Problem Details for HTTP APIs,定义了
application/problem+json格式,包含 type、title、status、detail、instance 五个字段,让错误响应标准化、机器可读。
自测三问
- 如果用户提交作业时课程已下架,应该返回什么 HTTP 状态码和业务错误码?
- 为什么生产环境不能返回 Java 堆栈信息?
- 设计一个「库存不足」的错误响应,用 Problem Details 格式写 JSON。
小结一句话
错误处理的核心是「一致性」:统一的响应格式、正确的 HTTP 状态码、清晰的业务错误码;RFC 7807 让错误响应成为一等公民。
延伸
- RFC 7807 Problem Details
- Spring Boot 异常处理文档
- 下一课:认证——JWT、OAuth2 与 API Key 管理。