目里如何統(tǒng)一處理異常更清晰)
一段段帶著異常堆棧的報(bào)錯(cuò)信息七零八落地散落在每個(gè)Controller里每個(gè)方法都塞滿了try-catch返回給前端的錯(cuò)誤結(jié)構(gòu)五花八門有時(shí)是{“msg”:“失敗”}有時(shí)是{“message”:“系統(tǒng)錯(cuò)誤”}甚至還有直接把Exception的toString丟出去的。這絕不是少數(shù)SpringBoot項(xiàng)目的真實(shí)寫照。統(tǒng)一異常處理不是為了少寫幾行代碼而是為了把“出錯(cuò)”這件事變得可預(yù)測、可控制、可解釋。異常處理不是后端開發(fā)的收尾工作而是接口契約的第一道防線。別再用try-catch包裹一切很多開發(fā)者習(xí)慣在業(yè)務(wù)方法里加一層try-catch捕獲后返回一個(gè)Result對象試圖讓調(diào)用方“感知”錯(cuò)誤。這種做法最大的問題是讓業(yè)務(wù)代碼和錯(cuò)誤處理邏輯糾纏在一起。你會在一個(gè)查詢訂單的方法里看到catch (NullPointerException e) { return Result.error(500, 訂單不存在) }這種荒謬的代碼——空指針被當(dāng)成了業(yè)務(wù)分支。把異常捕獲在業(yè)務(wù)層等于把錯(cuò)誤處理的責(zé)任打散到了每一處業(yè)務(wù)邏輯里最終無人能說清楚系統(tǒng)到底有哪些異常邊界。正確的做法是業(yè)務(wù)代碼只管拋出異常無論是業(yè)務(wù)異常如訂單不存在、庫存不足還是系統(tǒng)異常如數(shù)據(jù)庫連接失敗、文件讀寫錯(cuò)誤都讓它們向上傳播。統(tǒng)一異常處理的第一原則就是“讓異常飛一會兒”直到它們抵達(dá)全局異常處理器。這樣Controller里的方法可以保持干凈只關(guān)心正常流程錯(cuò)誤分支全部交給一個(gè)集中地??吹竭@里你可能會擔(dān)心難道業(yè)務(wù)代碼里連判斷都不做了當(dāng)然要做判斷但判斷后的動作是throw new OrderNotFoundException(orderId)而不是return Result.error()。前者保留了異常的類型、上下文和堆棧后者則把錯(cuò)誤降級成了一個(gè)普通返回值丟失了一切診斷價(jià)值。讓異常處理器成為唯一的出口SpringBoot提供了RestControllerAdvice和ExceptionHandler的組合這正是統(tǒng)一異常處理的正統(tǒng)機(jī)制。一個(gè)全局異常處理器可以捕獲所有Controller層拋出的異常并根據(jù)異常類型返回統(tǒng)一的響應(yīng)體。寫一個(gè)全局異常處理器并不難難的是讓它成為系統(tǒng)里唯一處理異常的出口。這意味著你要克制住到處添加局部try-catch的沖動也要避免在Service層捕獲后吞掉異?;蛑匦掳b成模糊的運(yùn)行時(shí)異常。一個(gè)清晰的做法是定義一個(gè)GlobalExceptionHandler類內(nèi)部針對不同異常類型編寫處理方法。對業(yè)務(wù)異常返回對應(yīng)的錯(cuò)誤碼和提示信息對參數(shù)校驗(yàn)異常返回字段級別的錯(cuò)誤詳情對未知異常返回兜底的“系統(tǒng)繁忙”提示并記錄完整日志。每個(gè)異常處理方法只做三件事提取上下文、記錄日志、構(gòu)造標(biāo)準(zhǔn)響應(yīng)體。不要在這個(gè)類里寫業(yè)務(wù)邏輯也不要讓它依賴具體的Controller或者Service。它就像一個(gè)路由器輸入是異常對象輸出是HTTP響應(yīng)。它的存在讓其他人再也無需關(guān)心“異常應(yīng)該怎么返回”這個(gè)問題。業(yè)務(wù)異常與系統(tǒng)異常必須分家如果你的項(xiàng)目里只有一種Exception類所有錯(cuò)誤都用它來表示那么統(tǒng)一異常處理很快就會演化成一場災(zāi)難。因?yàn)檎{(diào)用方無法區(qū)分“這個(gè)錯(cuò)誤是可預(yù)期的業(yè)務(wù)規(guī)則沖突”還是“系統(tǒng)出了不可恢復(fù)的故障”。業(yè)務(wù)異常和系統(tǒng)異常的邊界就是產(chǎn)品與技術(shù)的邊界。建議至少設(shè)計(jì)兩類異常BizException業(yè)務(wù)異常和SystemException系統(tǒng)異常兩者都繼承自一個(gè)自定義的BaseException。業(yè)務(wù)異常里攜帶錯(cuò)誤碼、錯(cuò)誤消息、可能還有導(dǎo)致錯(cuò)誤的業(yè)務(wù)參數(shù)。系統(tǒng)異常則通常由底層技術(shù)框架拋出比如數(shù)據(jù)庫異常、Redis連接異常、第三方接口超時(shí)等。對業(yè)務(wù)異常響應(yīng)碼通常是200但業(yè)務(wù)碼為非零對系統(tǒng)異常HTTP狀態(tài)碼應(yīng)如實(shí)反映錯(cuò)誤性質(zhì)如500。這種區(qū)分讓你在前端拿到響應(yīng)后可以立刻判斷是彈提示框還是跳錯(cuò)誤頁。更進(jìn)一步系統(tǒng)異常應(yīng)該包裝成統(tǒng)一的自定義異常后拋出而不是裸拋NullPointerException或SQLException?!奥銙仭毕到y(tǒng)底層異常等于把你的數(shù)據(jù)庫表結(jié)構(gòu)或緩存策略泄露給了前端調(diào)用者。雖然最終返回給用戶的消息是統(tǒng)一的但異常鏈上的堆棧信息可能包含敏感細(xì)節(jié)。全局處理器要負(fù)責(zé)把這些底層異常轉(zhuǎn)換為SystemException并輸出到服務(wù)端日志而前端只能看到“系統(tǒng)繁忙”。錯(cuò)誤碼是契約不是字符串很多項(xiàng)目用code字段表示錯(cuò)誤但隨便定義幾個(gè)數(shù)字比如1代表成功-1代表失敗。這種錯(cuò)誤碼體系既無法表達(dá)錯(cuò)誤的具體場景也不具備擴(kuò)展性。錯(cuò)誤碼應(yīng)當(dāng)是一份精心維護(hù)的契約文檔而不是隨手寫的魔數(shù)。推薦用“模塊編號 錯(cuò)誤場景編號”的格式例如1101表示“訂單模塊-訂單不存在”2203表示“庫存模塊-庫存不足”。每個(gè)錯(cuò)誤碼對應(yīng)一個(gè)標(biāo)準(zhǔn)的message和可能的補(bǔ)救動作提示。錯(cuò)誤碼的管理要集中在一個(gè)常量類或枚舉類中并且和全局異常處理器聯(lián)動。比如BizException構(gòu)造時(shí)傳入ErrorCodeOrderEnum.ORDER_NOT_FOUND處理器再從枚舉中提取message返回。不要在異常拋出位置寫死m(xù)essage字符串否則一旦產(chǎn)品要求修改提示語你就要chase所有出現(xiàn)該字符串的地方。當(dāng)你把錯(cuò)誤碼作為唯一的契約前端就能根據(jù)錯(cuò)誤碼做精準(zhǔn)的交互邏輯而不是用正則匹配message里的關(guān)鍵詞。同時(shí)錯(cuò)誤碼必須覆蓋所有可能的異常分支。有些異常比如參數(shù)校驗(yàn)失敗Spring默認(rèn)返回MethodArgumentNotValidException其結(jié)構(gòu)包含fields、rejectedValue等。你應(yīng)當(dāng)用統(tǒng)一的錯(cuò)誤碼如PARAM_ERROR包裝它并把字段名和校驗(yàn)信息放進(jìn)data字段而不是直接把異常內(nèi)部的DefaultMessage丟給前端。參數(shù)校驗(yàn)異常是接口開發(fā)中最常見、最容易被忽視的異常不加處理就會露出Spring的默認(rèn)響應(yīng)結(jié)構(gòu)。全局處理器里要專門為MethodArgumentNotValidException、BindException、ConstraintViolationException編寫處理方法把這些異常轉(zhuǎn)換成統(tǒng)一格式。參數(shù)校驗(yàn)異常別讓它裸奔當(dāng)你在Controller里寫Valid RequestBody UserDTO userDTO時(shí)一旦參數(shù)校驗(yàn)失敗Spring會拋出MethodArgumentNotValidException。如果不做任何處理Spring的默認(rèn)錯(cuò)誤響應(yīng)是400狀態(tài)返回一個(gè)比較啰嗦的JSON。很多項(xiàng)目在開發(fā)初期不管這些前端拿到什么就是什么但到了聯(lián)調(diào)階段前端會很痛苦。參數(shù)校驗(yàn)異常的處理是衡量一個(gè)項(xiàng)目異常處理規(guī)范程度的試金石。建議在全局處理器中捕獲MethodArgumentNotValidException遍歷bindingResult.getFieldErrors()提取每個(gè)字段的field和defaultMessage組裝成一個(gè)ListFieldErrorVO放進(jìn)統(tǒng)一響應(yīng)體的data里。這樣前端可以精確地告訴用戶“郵箱格式不正確”“密碼長度不能少于6位”。如果你用的是Validated配合DTO上的NotNull等注解同樣要處理ConstraintViolationException它的異常結(jié)構(gòu)不同于前者不要漏掉。這還引出一個(gè)關(guān)鍵點(diǎn)統(tǒng)一異常處理不是只處理你自己拋出的異常還要處理框架拋出的、Spring Security拋出的、甚至JDK發(fā)起的異常。每一個(gè)出口都要堵上。當(dāng)異常發(fā)生在過濾器里怎么辦RestControllerAdvice只能捕獲進(jìn)入DispatcherServlet后的異常也就是Controller層及其調(diào)用鏈上的異常。但如果異常發(fā)生在Filter中比如自定義的認(rèn)證過濾器里拋出了AuthenticationException或者OncePerRequestFilter里讀取請求體時(shí)發(fā)生了IOException全局異常處理器是捕獲不到的。這是一個(gè)常見的陷阱你以為統(tǒng)一異常處理已經(jīng)覆蓋了所有卻漏了過濾器這個(gè)最前置的關(guān)卡。解決方案有兩種。第一種在Filter里自己try-catch然后手動寫入響應(yīng)。但這樣你沒法把錯(cuò)誤碼、標(biāo)準(zhǔn)響應(yīng)體統(tǒng)一封裝除非你在Filter里硬編碼JSON序列化這很丑陋。第二種把攔截器鏈的異常通過HandlerExceptionResolver轉(zhuǎn)發(fā)給全局處理器。具體做法是注入HandlerExceptionResolver在Filter的catch塊中調(diào)用resolver.resolveException(request, response, handler, exception)。這樣異常就能復(fù)用ExceptionHandler中的邏輯響應(yīng)體格式保持一致。過濾器的異常處理不是例外而是全局異常處理的一部分。此外還有DispatcherServlet中的processDispatchResult階段可能出現(xiàn)的異常比如視圖解析失敗或者響應(yīng)已經(jīng)被提交后拋出的異常這些情況很難再返回自定義JSON。一個(gè)務(wù)實(shí)的做法是在網(wǎng)關(guān)層或反向代理層兜底統(tǒng)一對非200響應(yīng)做結(jié)構(gòu)轉(zhuǎn)換。對于大多數(shù)微服務(wù)場景SpringBoot只處理自己能處理的那一段真正對外的統(tǒng)一要交給網(wǎng)關(guān)。異步任務(wù)里的異常不會自己消失Async方法拋出的異常默認(rèn)情況下不會自動進(jìn)入RestControllerAdvice因?yàn)槿之惓L幚砥鞅O(jiān)聽的是請求線程的異常傳播路徑而異步線程有自己的調(diào)用棧。異步任務(wù)里的異常如果沒有處理會直接跑到線程池的Thread.UncaughtExceptionHandler甚至被靜默吞掉。這是很多系統(tǒng)出現(xiàn)“偶發(fā)數(shù)據(jù)不一致”卻查不到日志的根源。解決異步異常有幾個(gè)層次。如果異步任務(wù)在線程池中執(zhí)行你可以為線程池設(shè)置UncaughtExceptionHandler在那里記錄日志和上報(bào)監(jiān)控。更好的方式是在異步任務(wù)中顯式地捕獲異常并通過一個(gè)回調(diào)接口或Future的get()方法獲取異常。如果你用了Async那么調(diào)用方應(yīng)該具備拿到異步結(jié)果或異常的能力而不是“發(fā)出去就不管了”。對于消息消費(fèi)場景比如RocketMQ或Kafka的consumer建議單獨(dú)建立一套“消息異常處理機(jī)制”把反序列化失敗、業(yè)務(wù)處理失敗的消息發(fā)送到死信隊(duì)列而不是嘗試在全局異常處理器中統(tǒng)一處理。異步世界里的異常注定和同步請求的異常是兩條路但都要有明確的歸屬。對于使用CompletableFuture組合調(diào)用的場景注意異常傳播的特殊性。whenComplete、exceptionally是捕獲異步異常的常規(guī)手段但如果你只是join()異常會被包裝為CompletionException。全局處理器不會自動解開這層包裝你需要手動處理。異步異常處理的核心思想是不要假設(shè)異常會自動被誰接住所有離線任務(wù)都要有兜底的異常捕獲和記錄。日志要打在正確的地方統(tǒng)一異常處理不只是“返回一個(gè)好看的JSON”它還承擔(dān)著記錄日志的功能。但這個(gè)日志不是簡單的log.error(e.getMessage(), e)。錯(cuò)誤日志要包含請求的方法、URL、參數(shù)、用戶ID如果有、異常類名、錯(cuò)誤碼、對應(yīng)的堆棧。只有把這些上下文信息都記錄下來事后排查問題才不至于去猜是哪條請求觸發(fā)的。建議在全局異常處理器的“未知異?!碧幚矸种е杏胠og.error記錄完整堆棧在業(yè)務(wù)異常處理分支中用log.warn記錄因?yàn)闃I(yè)務(wù)異常屬于可預(yù)期情況未必需要報(bào)警。區(qū)分日志級別是異常處理規(guī)范化的體現(xiàn)。同時(shí)要避免在業(yè)務(wù)代碼中反復(fù)打印同樣的日志。比如在Service里捕獲異常打印了一次又在Controller里打印了一次到了全局處理器又打一次最終日志文件膨脹且排查困難。建議的日志策略是底層異常必須打印在“第一次捕獲到該異常的地方”全局處理器只記錄未被捕獲的異常和異常處理結(jié)果。這樣同一個(gè)異常的日志不會重復(fù)且總有記錄。某些情況下全局處理器可能會在返回前將異常信息脫敏。例如異常消息中包含手機(jī)號或身份證號必須在寫入日志前進(jìn)行脫敏處理。日志不是給用戶看的但用戶可能通過下載日志文件看到泄露數(shù)據(jù)。所以全局異常處理器在構(gòu)造日志時(shí)要主動過濾掉敏感字段這比事后審計(jì)要便宜得多。把異常處理變成可觀測的一部分統(tǒng)一異常處理的最終目標(biāo)是讓異常變成系統(tǒng)可觀測性的一部分而不是埋藏在雪花般的日志文件里。每個(gè)被處理器接住的異常都應(yīng)該可以關(guān)聯(lián)到一個(gè)全局唯一的traceId。如果在網(wǎng)關(guān)層或Filter里引入了traceId生成器如MDC那么異常日志就可以與調(diào)用鏈日志串聯(lián)起來。異常響應(yīng)體中帶上traceId是給用戶最好的“憑證”。當(dāng)用戶反饋“訂單支付失敗”前端把traceId提交過來后端就能直接定位到那幾行日志。更進(jìn)一步建議對異常類型和錯(cuò)誤碼做監(jiān)控統(tǒng)計(jì)。比如每分鐘記錄“訂單模塊-庫存不足”觸發(fā)了多少次如果超過閾值就發(fā)出告警。錯(cuò)誤碼的分布就是系統(tǒng)健康度的晴雨表。沒有統(tǒng)一的異常處理你很難做到這種統(tǒng)計(jì)因?yàn)殄e(cuò)誤散落在各處格式都不一致。有了全局異常處理器你可以在其中埋點(diǎn)把異常類型、錯(cuò)誤碼、請求路徑、耗時(shí)等信息發(fā)送到Prometheus或自定義計(jì)數(shù)器里。異常處理不該是一個(gè)被動的“兜底”而應(yīng)該是一個(gè)主動的“感知層”。統(tǒng)一異常處理背后體現(xiàn)的是對系統(tǒng)邊界感的尊重。你不可能讓異常不出現(xiàn)但你可以讓異常出現(xiàn)時(shí)系統(tǒng)依然保持優(yōu)雅。最大的風(fēng)險(xiǎn)不是代碼有bug而是bug發(fā)生時(shí)你連發(fā)生了什么都不知道。別再到處寫try-catch了讓全局異常處理器成為你的遮陽傘但不要指望它能擋住所有地方的雨。過濾器、異步任務(wù)、消息隊(duì)列每一個(gè)異常逃逸點(diǎn)都值得你重新審視一遍——這就是統(tǒng)一異常處理遠(yuǎn)不止一個(gè)注解那么簡單。