【Kotlin + Spring Boot 4 从零到架构师】第 24 篇:全局异常处理
本系列定位:零基础入门,从 Kotlin 语法一路到 Spring Boot 4 高级架构(DDD + Modulith),适合 Java 开发者转型,也适合纯新手系统学习。
本篇你将学到
- 为什么需要全局异常处理
@RestControllerAdvice+@ExceptionHandler的使用- 自定义业务异常体系设计
- 统一错误响应格式
- 常见 Spring Boot 异常的处理策略
学完本篇,你将为 mini-shop 构建一套完整的异常处理体系,无论发生什么错误,前端都能收到结构一致的错误响应。
一、为什么需要全局异常处理
1.1 没有异常处理时的混乱
kotlin
@RestController
class ProductController {
@GetMapping("/{id}")
fun getById(@PathVariable id: Long): Product {
val product = productService.findById(id)
?: throw RuntimeException("商品不存在") // 直接抛 RuntimeException
return product
}
}
当出错时,Spring Boot 默认返回:
json
{
"timestamp": "2025-07-29T12:00:00.000+00:00",
"status": 500,
"error": "Internal Server Error",
"message": "商品不存在",
"path": "/api/products/1"
}
问题:
- 状态码不准确:商品不存在应该是 404,不是 500
- 格式不统一:每个异常的响应格式可能不同
- 前端处理困难:没有统一的错误码
- 安全风险:内部异常细节暴露给客户端
1.2 理想的异常处理
所有异常 → 统一格式响应
{
"code": 404, ← 业务状态码
"message": "商品不存在", ← 人类可读的提示
"timestamp": "...", ← 时间戳
"path": "/api/products/999" ← 请求路径
}
下面是「没有异常处理」与「理想异常处理」的流程对比图:
#mermaid-svg-Ju9wTd4jsWvGCwvD{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ju9wTd4jsWvGCwvD .error-icon{fill:#552222;}#mermaid-svg-Ju9wTd4jsWvGCwvD .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ju9wTd4jsWvGCwvD .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .marker.cross{stroke:#333333;}#mermaid-svg-Ju9wTd4jsWvGCwvD svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ju9wTd4jsWvGCwvD p{margin:0;}#mermaid-svg-Ju9wTd4jsWvGCwvD .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster-label text{fill:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster-label span{color:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster-label span p{background-color:transparent;}#mermaid-svg-Ju9wTd4jsWvGCwvD .label text,#mermaid-svg-Ju9wTd4jsWvGCwvD span{fill:#333;color:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .node rect,#mermaid-svg-Ju9wTd4jsWvGCwvD .node circle,#mermaid-svg-Ju9wTd4jsWvGCwvD .node ellipse,#mermaid-svg-Ju9wTd4jsWvGCwvD .node polygon,#mermaid-svg-Ju9wTd4jsWvGCwvD .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .rough-node .label text,#mermaid-svg-Ju9wTd4jsWvGCwvD .node .label text,#mermaid-svg-Ju9wTd4jsWvGCwvD .image-shape .label,#mermaid-svg-Ju9wTd4jsWvGCwvD .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ju9wTd4jsWvGCwvD .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .rough-node .label,#mermaid-svg-Ju9wTd4jsWvGCwvD .node .label,#mermaid-svg-Ju9wTd4jsWvGCwvD .image-shape .label,#mermaid-svg-Ju9wTd4jsWvGCwvD .icon-shape .label{text-align:center;}#mermaid-svg-Ju9wTd4jsWvGCwvD .node.clickable{cursor:pointer;}#mermaid-svg-Ju9wTd4jsWvGCwvD .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .arrowheadPath{fill:#333333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ju9wTd4jsWvGCwvD .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ju9wTd4jsWvGCwvD .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ju9wTd4jsWvGCwvD .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster text{fill:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD .cluster span{color:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ju9wTd4jsWvGCwvD .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ju9wTd4jsWvGCwvD rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ju9wTd4jsWvGCwvD .icon-shape,#mermaid-svg-Ju9wTd4jsWvGCwvD .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ju9wTd4jsWvGCwvD .icon-shape p,#mermaid-svg-Ju9wTd4jsWvGCwvD .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ju9wTd4jsWvGCwvD .icon-shape .label rect,#mermaid-svg-Ju9wTd4jsWvGCwvD .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ju9wTd4jsWvGCwvD .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ju9wTd4jsWvGCwvD .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ju9wTd4jsWvGCwvD :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ✅ 理想异常处理
Controller 抛出 BusinessException
GlobalExceptionHandler 捕获
统一 ErrorResponse 格式
code/message/timestamp/path
❌ 没有异常处理
Controller 抛出 RuntimeException
Spring Boot 默认返回 500
状态码不准确
格式不统一
前端处理困难
安全风险
二、自定义业务异常
2.1 异常体系设计
RuntimeException
└── BusinessException ← 所有业务异常的基类
├── ResourceNotFoundException ← 资源不存在
├── BusinessRuleException ← 业务规则违反
├── AuthenticationException ← 认证失败
└── AuthorizationException ← 授权失败
下面是异常体系的类图关系:
#mermaid-svg-7ddX8MIXZoXw9Wea{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-7ddX8MIXZoXw9Wea .error-icon{fill:#552222;}#mermaid-svg-7ddX8MIXZoXw9Wea .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-7ddX8MIXZoXw9Wea .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-7ddX8MIXZoXw9Wea .marker{fill:#333333;stroke:#333333;}#mermaid-svg-7ddX8MIXZoXw9Wea .marker.cross{stroke:#333333;}#mermaid-svg-7ddX8MIXZoXw9Wea svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-7ddX8MIXZoXw9Wea p{margin:0;}#mermaid-svg-7ddX8MIXZoXw9Wea g.classGroup text{fill:#9370DB;stroke:none;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-7ddX8MIXZoXw9Wea g.classGroup text .title{font-weight:bolder;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster-label text{fill:#333;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster-label span{color:#333;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster-label span p{background-color:transparent;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster text{fill:#333;}#mermaid-svg-7ddX8MIXZoXw9Wea .cluster span{color:#333;}#mermaid-svg-7ddX8MIXZoXw9Wea .nodeLabel,#mermaid-svg-7ddX8MIXZoXw9Wea .edgeLabel{color:#131300;}#mermaid-svg-7ddX8MIXZoXw9Wea .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-7ddX8MIXZoXw9Wea .label text{fill:#131300;}#mermaid-svg-7ddX8MIXZoXw9Wea .labelBkg{background:#ECECFF;}#mermaid-svg-7ddX8MIXZoXw9Wea .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-7ddX8MIXZoXw9Wea .classTitle{font-weight:bolder;}#mermaid-svg-7ddX8MIXZoXw9Wea .node rect,#mermaid-svg-7ddX8MIXZoXw9Wea .node circle,#mermaid-svg-7ddX8MIXZoXw9Wea .node ellipse,#mermaid-svg-7ddX8MIXZoXw9Wea .node polygon,#mermaid-svg-7ddX8MIXZoXw9Wea .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-7ddX8MIXZoXw9Wea .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea g.clickable{cursor:pointer;}#mermaid-svg-7ddX8MIXZoXw9Wea g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-7ddX8MIXZoXw9Wea g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-7ddX8MIXZoXw9Wea .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-7ddX8MIXZoXw9Wea .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-7ddX8MIXZoXw9Wea .dashed-line{stroke-dasharray:3;}#mermaid-svg-7ddX8MIXZoXw9Wea .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-7ddX8MIXZoXw9Wea #compositionStart,#mermaid-svg-7ddX8MIXZoXw9Wea .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #compositionEnd,#mermaid-svg-7ddX8MIXZoXw9Wea .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #dependencyStart,#mermaid-svg-7ddX8MIXZoXw9Wea .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #dependencyStart,#mermaid-svg-7ddX8MIXZoXw9Wea .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #extensionStart,#mermaid-svg-7ddX8MIXZoXw9Wea .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #extensionEnd,#mermaid-svg-7ddX8MIXZoXw9Wea .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #aggregationStart,#mermaid-svg-7ddX8MIXZoXw9Wea .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #aggregationEnd,#mermaid-svg-7ddX8MIXZoXw9Wea .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #lollipopStart,#mermaid-svg-7ddX8MIXZoXw9Wea .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea #lollipopEnd,#mermaid-svg-7ddX8MIXZoXw9Wea .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-7ddX8MIXZoXw9Wea .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-7ddX8MIXZoXw9Wea .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-7ddX8MIXZoXw9Wea .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-7ddX8MIXZoXw9Wea .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-7ddX8MIXZoXw9Wea :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} <<Java>>
RuntimeException
BusinessException
+Int code
+String message
ResourceNotFoundException
+ResourceNotFoundException(resource, id)
BusinessRuleException
+BusinessRuleException(message)
AuthenticationException
+AuthenticationException(message)
AuthorizationException
+AuthorizationException(message)
2.2 实现
kotlin
package com.example.minishop.exception
/**
* 业务异常基类
*
* @param code 业务状态码
* @param message 错误信息
*/
open class BusinessException(
val code: Int,
override val message: String
) : RuntimeException(message)
/** 资源不存在异常(404) */
class ResourceNotFoundException(
resource: String,
id: Any
) : BusinessException(
code = 404,
message = "$resource 不存在:$id"
)
/** 业务规则违反(400) */
class BusinessRuleException(
message: String
) : BusinessException(
code = 400,
message = message
)
/** 认证失败(401) */
class AuthenticationException(
message: String = "认证失败,请登录"
) : BusinessException(
code = 401,
message = message
)
/** 权限不足(403) */
class AuthorizationException(
message: String = "权限不足"
) : BusinessException(
code = 403,
message = message
)
2.3 在业务代码中使用
kotlin
@Service
class ProductService(
private val productRepository: ProductRepository
) {
fun findById(id: Long): Product {
return productRepository.findById(id)
.orElseThrow { ResourceNotFoundException("商品", id) } // ← 抛业务异常
}
fun decreaseStock(productId: Long, quantity: Int) {
val product = findById(productId)
if (product.stock < quantity) {
throw BusinessRuleException("库存不足,当前库存:${product.stock}")
}
product.stock -= quantity
productRepository.save(product)
}
}
三、统一错误响应格式
kotlin
package com.example.minishop.dto
import java.time.LocalDateTime
/**
* 统一错误响应
*
* @param code 业务状态码
* @param message 错误信息
* @param path 请求路径
* @param timestamp 时间戳
* @param details 字段级错误详情(校验失败时使用)
*/
data class ErrorResponse(
val code: Int,
val message: String,
val path: String,
val timestamp: LocalDateTime = LocalDateTime.now(),
val details: Map<String, String>? = null // 字段校验错误详情
)
四、@RestControllerAdvice 全局异常处理
4.1 核心注解
| 注解 | 作用 |
|---|---|
@RestControllerAdvice |
全局异常处理器(= @ControllerAdvice + @ResponseBody) |
@ExceptionHandler(XxxException::class) |
处理指定类型的异常 |
4.2 完整实现
kotlin
package com.example.minishop.exception
import com.example.minishop.dto.ErrorResponse
import jakarta.servlet.http.HttpServletRequest
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.FieldError
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
import org.springframework.web.servlet.NoHandlerFoundException
@RestControllerAdvice
class GlobalExceptionHandler {
private val log = LoggerFactory.getLogger(GlobalExceptionHandler::class.java)
/**
* 资源不存在异常 → 404
*/
@ExceptionHandler(ResourceNotFoundException::class)
fun handleNotFound(
ex: ResourceNotFoundException,
request: HttpServletRequest
): ResponseEntity<ErrorResponse> {
log.warn("资源不存在:${ex.message}")
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse(
code = ex.code,
message = ex.message,
path = request.requestURI
))
}
/**
* 业务规则违反 → 400
*/
@ExceptionHandler(BusinessRuleException::class)
fun handleBusinessRule(
ex: BusinessRuleException,
request: HttpServletRequest
): ResponseEntity<ErrorResponse> {
log.warn("业务规则违反:${ex.message}")
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse(
code = ex.code,
message = ex.message,
path = request.requestURI
))
}
/**
* 统一处理所有 BusinessException
*/
@ExceptionHandler(BusinessException::class)
fun handleBusinessException(
ex: BusinessException,
request: HttpServletRequest
): ResponseEntity<ErrorResponse> {
val httpStatus = when (ex.code) {
401 -> HttpStatus.UNAUTHORIZED
403 -> HttpStatus.FORBIDDEN
404 -> HttpStatus.NOT_FOUND
else -> HttpStatus.BAD_REQUEST
}
log.warn("业务异常 [${ex.code}]:${ex.message}")
return ResponseEntity.status(httpStatus)
.body(ErrorResponse(
code = ex.code,
message = ex.message,
path = request.requestURI
))
}
/**
* Bean Validation 校验失败 → 400 + 字段详情
*/
@ExceptionHandler(MethodArgumentNotValidException::class)
fun handleValidation(
ex: MethodArgumentNotValidException,
request: HttpServletRequest
): ResponseEntity<ErrorResponse> {
val details = ex.bindingResult.fieldErrors.associate { error: FieldError ->
error.field to (error.defaultMessage ?: "校验失败")
}
log.warn("参数校验失败:$details")
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse(
code = 400,
message = "请求参数校验失败",
path = request.requestURI,
details = details
))
}
/**
* 参数类型不匹配(如 /api/products/abc 中 abc 不是数字)→ 400
*/
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
fun handleTypeMismatch(
ex: MethodArgumentTypeMismatchException,
request: HttpServletRequest
): ResponseEntity<ErrorResponse> {
log.warn("参数类型不匹配:${ex.message}")
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponse(
code = 400,
message = "参数 '${ex.name}' 的值 '${ex.value}' 无法转换为 ${ex.requiredType?.simpleName}",
path = request.requestURI
))
}
/**
* 请求路径不存在 → 404
*/
@ExceptionHandler(NoHandlerFoundException::class)
fun handleNotFound(
ex: NoHandlerFoundException,
request: HttpServletRequest
): ResponseEntity<ErrorResponse> {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ErrorResponse(
code = 404,
message = "请求路径不存在",
path = request.requestURI
))
}
/**
* 兜底:所有未被上面捕获的异常 → 500
*/
@ExceptionHandler(Exception::class)
fun handleUnexpected(
ex: Exception,
request: HttpServletRequest
): ResponseEntity<ErrorResponse> {
log.error("未处理的异常", ex) // 记录完整堆栈
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ErrorResponse(
code = 500,
message = "服务器内部错误,请稍后重试",
path = request.requestURI
))
}
}
4.3 异常处理优先级
Spring 选择 @ExceptionHandler 时遵循最精确匹配原则:
抛出 ResourceNotFoundException
→ 匹配 @ExceptionHandler(ResourceNotFoundException::class) ✅ 优先
→ 而非 @ExceptionHandler(BusinessException::class) ← 父类,次选
→ 而非 @ExceptionHandler(Exception::class) ← 最不精确
把具体的异常处理器写在前面,兜底的
Exception::class放最后。
下面是异常处理器的匹配优先级流程图:
#mermaid-svg-ycWQgfqcRt4yL54d{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ycWQgfqcRt4yL54d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ycWQgfqcRt4yL54d .error-icon{fill:#552222;}#mermaid-svg-ycWQgfqcRt4yL54d .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ycWQgfqcRt4yL54d .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ycWQgfqcRt4yL54d .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ycWQgfqcRt4yL54d .marker.cross{stroke:#333333;}#mermaid-svg-ycWQgfqcRt4yL54d svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ycWQgfqcRt4yL54d p{margin:0;}#mermaid-svg-ycWQgfqcRt4yL54d .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ycWQgfqcRt4yL54d .cluster-label text{fill:#333;}#mermaid-svg-ycWQgfqcRt4yL54d .cluster-label span{color:#333;}#mermaid-svg-ycWQgfqcRt4yL54d .cluster-label span p{background-color:transparent;}#mermaid-svg-ycWQgfqcRt4yL54d .label text,#mermaid-svg-ycWQgfqcRt4yL54d span{fill:#333;color:#333;}#mermaid-svg-ycWQgfqcRt4yL54d .node rect,#mermaid-svg-ycWQgfqcRt4yL54d .node circle,#mermaid-svg-ycWQgfqcRt4yL54d .node ellipse,#mermaid-svg-ycWQgfqcRt4yL54d .node polygon,#mermaid-svg-ycWQgfqcRt4yL54d .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ycWQgfqcRt4yL54d .rough-node .label text,#mermaid-svg-ycWQgfqcRt4yL54d .node .label text,#mermaid-svg-ycWQgfqcRt4yL54d .image-shape .label,#mermaid-svg-ycWQgfqcRt4yL54d .icon-shape .label{text-anchor:middle;}#mermaid-svg-ycWQgfqcRt4yL54d .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ycWQgfqcRt4yL54d .rough-node .label,#mermaid-svg-ycWQgfqcRt4yL54d .node .label,#mermaid-svg-ycWQgfqcRt4yL54d .image-shape .label,#mermaid-svg-ycWQgfqcRt4yL54d .icon-shape .label{text-align:center;}#mermaid-svg-ycWQgfqcRt4yL54d .node.clickable{cursor:pointer;}#mermaid-svg-ycWQgfqcRt4yL54d .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ycWQgfqcRt4yL54d .arrowheadPath{fill:#333333;}#mermaid-svg-ycWQgfqcRt4yL54d .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ycWQgfqcRt4yL54d .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ycWQgfqcRt4yL54d .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ycWQgfqcRt4yL54d .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ycWQgfqcRt4yL54d .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ycWQgfqcRt4yL54d .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ycWQgfqcRt4yL54d .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ycWQgfqcRt4yL54d .cluster text{fill:#333;}#mermaid-svg-ycWQgfqcRt4yL54d .cluster span{color:#333;}#mermaid-svg-ycWQgfqcRt4yL54d div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ycWQgfqcRt4yL54d .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ycWQgfqcRt4yL54d rect.text{fill:none;stroke-width:0;}#mermaid-svg-ycWQgfqcRt4yL54d .icon-shape,#mermaid-svg-ycWQgfqcRt4yL54d .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ycWQgfqcRt4yL54d .icon-shape p,#mermaid-svg-ycWQgfqcRt4yL54d .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ycWQgfqcRt4yL54d .icon-shape .label rect,#mermaid-svg-ycWQgfqcRt4yL54d .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ycWQgfqcRt4yL54d .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ycWQgfqcRt4yL54d .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ycWQgfqcRt4yL54d :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
是
否
抛出异常
如 ResourceNotFoundException
是否有
@ExceptionHandler
精确匹配?
执行精确匹配的处理器
@ExceptionHandler(ResourceNotFoundException::class)
是否有
@ExceptionHandler
匹配父类?
执行父类处理器
@ExceptionHandler(BusinessException::class)
是否有
@ExceptionHandler
匹配根类?
执行根类处理器
@ExceptionHandler(Exception::class)
返回默认 500 错误
五、需要额外配置的异常
5.1 启用 404 异常
Spring Boot 默认不抛 NoHandlerFoundException,需要在配置中启用:
yaml
spring:
mvc:
throw-exception-if-no-handler-found: true
web:
resources:
add-mappings: false # 禁用静态资源映射(可选)
5.2 处理 JPA 空结果异常
Spring Data JPA 的 findById 返回 Optional,不会抛异常。但如果你用了 getOne() 或 orElseThrow():
kotlin
// 方式一:orElseThrow 抛业务异常(推荐)
val product = productRepository.findById(id)
.orElseThrow { ResourceNotFoundException("商品", id) }
// 方式二:让 JPA 的 EntityNotFoundException 被全局处理器捕获
@ExceptionHandler(EntityNotFoundException::class)
fun handleEntityNotFound(ex: EntityNotFoundException, request: HttpServletRequest)
: ResponseEntity<ErrorResponse> { ... }
六、效果验证
场景一:查询不存在的商品
bash
curl http://localhost:8080/api/products/999
json
{
"code": 404,
"message": "商品 不存在:999",
"path": "/api/products/999",
"timestamp": "2025-07-29T15:30:00"
}
场景二:参数校验失败
bash
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"", "price": -1}'
json
{
"code": 400,
"message": "请求参数校验失败",
"path": "/api/products",
"timestamp": "2025-07-29T15:31:00",
"details": {
"name": "商品名称不能为空",
"price": "价格必须大于 0"
}
}
场景三:路径参数类型错误
bash
curl http://localhost:8080/api/products/abc
json
{
"code": 400,
"message": "参数 'id' 的值 'abc' 无法转换为 Long",
"path": "/api/products/abc",
"timestamp": "2025-07-29T15:32:00"
}
场景四:未知服务器错误
bash
curl http://localhost:8080/api/products
# 如果数据库连接断了
json
{
"code": 500,
"message": "服务器内部错误,请稍后重试",
"path": "/api/products",
"timestamp": "2025-07-29T15:33:00"
}
本篇小结
| 知识点 | 核心内容 |
|---|---|
@RestControllerAdvice |
全局异常处理器 |
@ExceptionHandler |
按异常类型分别处理 |
| 精确匹配 | 具体异常优先于父类异常 |
BusinessException |
业务异常基类 |
ResourceNotFoundException |
404 资源不存在 |
BusinessRuleException |
400 业务规则违反 |
ErrorResponse |
统一错误响应格式 |
| 兜底处理 | Exception::class 捕获所有未处理异常 |
| 校验异常处理 | MethodArgumentNotValidException → 字段级详情 |
| 日志策略 | 业务异常 warn,未知异常 error(记录堆栈) |
下篇预告
成功返回数据、失败返回错误------两种响应的格式如何统一?下一篇设计 mini-shop 的统一
ApiResponse<T>,让所有接口的响应格式一致。
如果本篇内容对你有帮助,欢迎点赞收藏!有任何疑问,欢迎在评论区交流。