异常处理
Laravel 的
app/Exceptions/Handler.php统一处理异常;Spring Boot 的对应物是@RestControllerAdvice+@ExceptionHandler,代码就写在普通类里,比 Laravel 还直观。
为什么需要全局异常处理
Java 是强类型语言,方法签名都声明「可能抛什么异常」。如果不管,异常会被框架转成默认错误响应(HTML 或简单 JSON),对前端不友好。统一处理可以做到:
- 返回统一
{code, message, data}结构 - 业务异常返回对应业务码,不暴露堆栈
- 未预期异常返回 500 并记录日志
最小可用:@RestControllerAdvice
package com.example.demo.common;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ApiResponse<Void> handleBusiness(BusinessException ex) {
log.warn("业务异常: {}", ex.getMessage());
return ApiResponse.of(ex.getCode(), ex.getMessage(), null);
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleOther(Exception ex) {
log.error("未预期异常", ex);
return ApiResponse.of(500, "服务器开小差了", null);
}
}@RestControllerAdvice= 全局的「控制器增强」,拦截所有控制器抛出的异常- 每个
@ExceptionHandler(某异常.class)处理一类异常 - 返回统一结构给前端
类比 Laravel
@RestControllerAdvice ≈ Handler::render() + Handler::report()。不同之处:Spring 按异常类型分类处理,Laravel 靠 instanceof 判断 —— 思想一致,Spring 更「路由化」。
业务异常:自己定义
Laravel 里你 abort(403, '没有权限') 或抛 HttpException;Spring 里定义自己的业务异常,携带业务码:
package com.example.demo.common;
import lombok.Getter;
@Getter
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
public static BusinessException notFound(String msg) {
return new BusinessException(404, msg);
}
public static BusinessException badRequest(String msg) {
return new BusinessException(400, msg);
}
}使用:
@Service
public class PostService {
public Post get(Long id) {
return repository.findById(id)
.orElseThrow(() -> BusinessException.notFound("帖子不存在: " + id));
}
}TIP
Laravel 的 ModelNotFoundException 自动 404;Spring 的 Optional.orElseThrow 手动抛异常,语义完全一样,只是要自己声明。
常用异常类型对照
| 场景 | 异常(异常处理器可拦截) | HTTP |
|---|---|---|
| 校验失败 | MethodArgumentNotValidException | 400 |
| 参数类型错误 | MethodArgumentTypeMismatchException | 400 |
| 路径不存在 | NoResourceFoundException | 404 |
| 请求体缺失/格式错 | HttpMessageNotReadableException | 400 |
| 方法不允许 | HttpRequestMethodNotSupportedException | 405 |
| 自己抛业务异常 | 自定义 BusinessException | 自定义 |
配合 @ResponseStatus
简单场景可以不写处理器,直接在异常上标注:
@ResponseStatus(HttpStatus.NOT_FOUND)
public class PostNotFoundException extends RuntimeException {
public PostNotFoundException(String message) {
super(message);
}
}抛出去自动 404。适合「只关心状态码、不关心响应结构」的场景。
精确匹配 vs 兜底
@ExceptionHandler 的查找规则:越具体的异常类型越优先。
@ExceptionHandler(BusinessException.class) // 先匹配这个
public ApiResponse<Void> handleBusiness(BusinessException ex) { ... }
@ExceptionHandler(Exception.class) // 其他所有异常兜底
public ApiResponse<Void> handleOther(Exception ex) { ... }兜底要写日志
Exception.class 兜底处理器一定要 log.error("...", ex) 记录完整堆栈,否则生产环境出事连日志都没有。Laravel 的 report() 同理。
校验错误详解(返回 422 风格)
统一校验错误响应,让前端好解析:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(a, b) -> a));
return ApiResponse.of(422, "参数校验失败", errors);
}输出类似:
{
"code": 422,
"message": "参数校验失败",
"data": {
"title": "标题不能为空",
"status": "状态最小为 1"
}
}对应 Laravel 的 {"errors": {"title": ["..."]}} 结构,字段名到错误信息的映射。
异常链路上的两层处理
还记得 请求生命周期 吗?异常处理有两层:
Controller → @RestControllerAdvice(Spring MVC 层)
→ @ControllerAdvice 没兜住 → Servlet 容器默认错误页(Filter 层)@RestControllerAdvice 只在 Spring MVC 内生效。Filter 抛的异常(比如 CORS Filter 内部出错)它管不到,需要在 ErrorController 兜底。日常开发,把精力放在 @RestControllerAdvice 即可。
对照 Laravel Handler
| Laravel | Spring Boot |
|---|---|
Handler::render($request, $e) | @ExceptionHandler(XxxException.class) 方法 |
Handler::report($e) | 处理器方法里的日志调用 |
abort(404) | 抛自定义异常(带 @ResponseStatus 或处理器) |
ModelNotFoundException 自动 404 | Optional.orElseThrow 手动抛 |
| 自定义异常类 + 判断类型 | 自定义异常类 + 按类型分类处理器 |
实践建议
- 业务异常必须统一:定义
BusinessException,业务代码里只抛它 - 控制器不 try-catch:除非确实需要局部处理,否则让异常冒泡到全局处理器(和 Laravel「别到处 try-catch」的忠告一致)
- 外部依赖异常转换:在 Service 层把第三方/数据层异常转成业务异常再抛
- 别吞异常:
catch (Exception e) { /* 空 */ }是 Java 圈最臭的代码之一
接下来,日志:日志。