誤攔在業(yè)務(wù)之外)
07-全局異常處理-把錯(cuò)誤攔在業(yè)務(wù)之外大家好我是黒漂技術(shù)佬。上篇我們把分頁(yè)殼子PageResult講透了這篇聊一個(gè)平時(shí)看不見(jiàn)、出事要你命的東西——全局異常處理。新手寫(xiě)業(yè)務(wù)最容易踩的坑在一個(gè)方法里try-catch包半屏catch 里e.printStackTrace()然后return null。前端拿到null一臉問(wèn)號(hào)不知道是參數(shù)錯(cuò)了、沒(méi)登錄還是服務(wù)器炸了。更慘的是數(shù)據(jù)庫(kù)拋個(gè)SQLException直接把堆棧塞進(jìn) HTTP 響應(yīng)用戶(hù)看見(jiàn)一坨紅色異常頁(yè)面?!癠RM Ultra的后端用 Spring Boot 3 的RestControllerAdvice把異常收口得極其干凈。今天我們就拆開(kāi)看它是怎么做到的以及為什么業(yè)務(wù)代碼里不該出現(xiàn) try-catch”。一、先搞懂異常到底該在哪處理很多教程一上來(lái)就貼RestControllerAdvice但沒(méi)講清楚為什么。我們先建立一條原則業(yè)務(wù)方法的職責(zé)是把事做成不是把錯(cuò)兜住。什么意思假設(shè)你寫(xiě)扣庫(kù)存// 反面教材在業(yè)務(wù)里瘋狂 try-catchpublicvoiddeductStock(LongdeviceId,StringslotNo,intqty){try{DeviceProductdprepository.findByDeviceIdAndSlotNo(deviceId,slotNo);if(dpnull){thrownewRuntimeException(貨道不存在);}if(dp.getStock()qty){thrownewRuntimeException(庫(kù)存不足);}dp.setStock(dp.getStock()-qty);repository.save(dp);}catch(Exceptione){e.printStackTrace();// 錯(cuò)日志沒(méi)、異常吞了throwe;// 或者 return; 錯(cuò)上加錯(cuò)}}這種寫(xiě)法的毛病業(yè)務(wù)代碼里夾著一堆異常處理讀起來(lái)像在跟異常搏斗看不清主干邏輯catch后e.printStackTrace()既不進(jìn)日志框架、也進(jìn)不了監(jiān)控系統(tǒng)拋出的還是RuntimeException前端收到一個(gè) 500 堆棧根本不知道庫(kù)存不足是業(yè)務(wù)邏輯錯(cuò)還是系統(tǒng)崩了。正確思路業(yè)務(wù)里只管拋統(tǒng)一在一個(gè)地方接。拋異常是廉價(jià)的、語(yǔ)義明確的怎么把異常翻譯成前端能懂的報(bào)文交給全局處理器。二、業(yè)務(wù)異常 BusinessException給錯(cuò)誤一個(gè)業(yè)務(wù)身份項(xiàng)目先定義了一個(gè)專(zhuān)屬異常把業(yè)務(wù)錯(cuò)誤和普通系統(tǒng)錯(cuò)誤區(qū)分開(kāi)// 項(xiàng)目源碼com.urm.ultra.common.BusinessExceptionGetterpublicclassBusinessExceptionextendsRuntimeException{privatefinalintcode;publicBusinessException(Stringmessage){this(500,message);}publicBusinessException(intcode,Stringmessage){super(message);this.codecode;}}注意三點(diǎn)它繼承RuntimeException是運(yùn)行時(shí)異常不用方法簽名上寫(xiě)throws業(yè)務(wù)里隨手throw new BusinessException(...)即可帶了一個(gè)code字段。這就是業(yè)務(wù)碼——Result里那個(gè)code0 表示成功非 0 表示各類(lèi)錯(cuò)誤提供了兩個(gè)構(gòu)造只傳message時(shí)默認(rèn)code500傳(code, message)時(shí)自定義業(yè)務(wù)碼比如庫(kù)存不足可以拋new BusinessException(4001, 庫(kù)存不足)前端就能按碼做特定提示。有了它扣庫(kù)存就能寫(xiě)得極其干凈// 項(xiàng)目源碼風(fēng)格示意業(yè)務(wù)里只拋不抓publicvoiddeductStock(LongdeviceId,StringslotNo,intqty){DeviceProductdprepository.findByDeviceIdAndSlotNo(deviceId,slotNo);if(dpnull){thrownewBusinessException(貨道不存在);}if(dp.getStock()null||dp.getStock()qty){thrownewBusinessException(4001,庫(kù)存不足);}dp.setStock(dp.getStock()-qty);repository.save(dp);}主干邏輯三行錯(cuò)誤用throw一句帶過(guò)。這就是把錯(cuò)誤攔在業(yè)務(wù)之外的第一層含義業(yè)務(wù)代碼只描述正常流程 在異常點(diǎn)拋出自帶語(yǔ)義的異常。三、RestControllerAdvice全局的異常接盤(pán)俠關(guān)鍵角色登場(chǎng)。RestControllerAdvice是 Spring 的統(tǒng)一異常攔截器專(zhuān)盯所有RestController里拋出的異常。項(xiàng)目里就這么一個(gè)類(lèi)// 項(xiàng)目源碼com.urm.ultra.common.GlobalExceptionHandlerSlf4jRestControllerAdvicepublicclassGlobalExceptionHandler{ExceptionHandler(BusinessException.class)publicResultVoidhandleBusiness(BusinessExceptione){returnResult.error(e.getCode(),e.getMessage());}ExceptionHandler({MethodArgumentNotValidException.class,BindException.class})publicResultVoidhandleValidation(Exceptione){Stringmsg參數(shù)校驗(yàn)失敗;if(einstanceofMethodArgumentNotValidExceptionexex.getBindingResult().getFieldError()!null){msgex.getBindingResult().getFieldError().getDefaultMessage();}elseif(einstanceofBindExceptionexex.getBindingResult().getFieldError()!null){msgex.getBindingResult().getFieldError().getDefaultMessage();}returnResult.error(400,msg);}ExceptionHandler(Exception.class)publicResultVoidhandleException(Exceptione){log.error(系統(tǒng)異常,e);returnResult.error(500,系統(tǒng)異常: e.getMessage());}}逐行拆RestControllerAdvice告訴 Spring我是全局異常處理中心作用范圍覆蓋所有 REST 接口和ControllerAdvice的區(qū)別是它默認(rèn)把返回值當(dāng)ResponseBody處理直接寫(xiě) JSON不用再標(biāo)ResponseBody。Slf4jLombok 注解自動(dòng)生成log對(duì)象兜底異常時(shí)打日志用。四、三個(gè) ExceptionHandler 的分工ExceptionHandler(Xxx.class)表示這個(gè)方法專(zhuān)門(mén)處理 Xxx 類(lèi)型的異常。匹配順序是最具體的優(yōu)先所以三個(gè) handler 的層級(jí)是這樣1. handleBusiness —— 業(yè)務(wù)異常原樣回傳ExceptionHandler(BusinessException.class)publicResultVoidhandleBusiness(BusinessExceptione){returnResult.error(e.getCode(),e.getMessage());}業(yè)務(wù)里拋的BusinessException這里把它翻譯成Result.error(code, message)。比如前面庫(kù)存不足拋的是new BusinessException(4001, 庫(kù)存不足)前端就收到{code:4001,message:庫(kù)存不足,data:null}前端按code4001彈該貨道庫(kù)存不足請(qǐng)重試體驗(yàn)直接拉滿(mǎn)。2. handleValidation —— 參數(shù)校驗(yàn)失敗統(tǒng)一 400ExceptionHandler({MethodArgumentNotValidException.class,BindException.class})publicResultVoidhandleValidation(Exceptione){...}這兩個(gè)異常來(lái)自參數(shù)校驗(yàn)下一篇細(xì)講DTO 上標(biāo)了NotBlank但前端沒(méi)傳Spring 在校驗(yàn)階段就拋MethodArgumentNotValidException。這里統(tǒng)一轉(zhuǎn)成Result.error(400, 字段消息)——把哪個(gè)字段錯(cuò)、錯(cuò)在哪的提示直接塞進(jìn)message前端照著彈就行。BindException是表單/路徑變量綁定失敗時(shí)拋的同類(lèi)異常一并接住保證 GET 和 POST 兩種校驗(yàn)失敗都走同一套返回。3. handleException —— 兜底任何漏網(wǎng)的都?xì)w 500ExceptionHandler(Exception.class)publicResultVoidhandleException(Exceptione){log.error(系統(tǒng)異常,e);returnResult.error(500,系統(tǒng)異常: e.getMessage());}這是最后一道防線。任何你沒(méi)預(yù)料的異常——NPE、SQL 報(bào)錯(cuò)、JSON 解析失敗——都會(huì)被它接住轉(zhuǎn)成code500的標(biāo)準(zhǔn)報(bào)文同時(shí)用log.error把完整堆棧打進(jìn)日志。這一層至關(guān)重要對(duì)前端永遠(yuǎn)拿到結(jié)構(gòu)統(tǒng)一的Result不會(huì)出現(xiàn)有時(shí)返回 Result、有時(shí)拋出 Tomcat 默認(rèn)錯(cuò)誤頁(yè)的分裂對(duì)運(yùn)維異常進(jìn)日志了出問(wèn)題能查對(duì)用戶(hù)不會(huì)看到一坨英文堆棧而是一句系統(tǒng)異常: xxx。注意順序——ExceptionHandler(Exception.class)放在最后。因?yàn)樗罘耗芙铀挟惓G懊娴腂usinessException、MethodArgumentNotValidException更具體Spring 會(huì)優(yōu)先匹配具體的。如果把Exception放第一業(yè)務(wù)異常反而會(huì)被它吃掉那就亂套了。五、為什么這叫與業(yè)務(wù)解耦回看開(kāi)頭那句原則現(xiàn)在我們能說(shuō)清解耦在哪業(yè)務(wù)方法零 try-catch所有異常沿調(diào)用棧一路拋到GlobalExceptionHandler業(yè)務(wù)代碼只剩 happy path 語(yǔ)義化 throw可讀性和可維護(hù)性暴漲異常翻譯集中所有異常 → HTTP 響應(yīng)的映射只有一個(gè)類(lèi)負(fù)責(zé)。哪天你想把 500 報(bào)文從系統(tǒng)異常: xxx改成服務(wù)器開(kāi)小差了只改這一處響應(yīng)結(jié)構(gòu)恒定無(wú)論成功失敗前端永遠(yuǎn)解析同一套R(shí)esult{code,message,data}不用為不同錯(cuò)誤寫(xiě)不同解析分支日志與對(duì)外信息分離兜底異常把堆棧記進(jìn)日志給開(kāi)發(fā)看對(duì)外只回系統(tǒng)異常加簡(jiǎn)短信息給用戶(hù)看避免把內(nèi)部細(xì)節(jié)泄漏出去。這四點(diǎn)合起來(lái)就是把錯(cuò)誤攔在業(yè)務(wù)之外——錯(cuò)誤在邊界全局處理器被統(tǒng)一欄截、翻譯、記錄業(yè)務(wù)核心保持純粹。六、一個(gè)常見(jiàn)誤區(qū)401/404 要不要也管細(xì)心的朋友會(huì)問(wèn)登錄過(guò)期401、資源不存在404呢項(xiàng)目約定上BusinessException的code是可擴(kuò)展的——比如設(shè)備不存在你可以拋new BusinessException(4040, 設(shè)備不存在)統(tǒng)一走code業(yè)務(wù)碼前端按碼提示HTTP 狀態(tài)碼仍是 200。這種業(yè)務(wù)碼優(yōu)先風(fēng)格在前后端分離 小程序里很常見(jiàn)小程序端統(tǒng)一只看Result.code不依賴(lài) HTTP 狀態(tài)碼分支。當(dāng)然你也可以讓 Spring Security 這類(lèi)安全框架直接返回 HTTP 401那屬于另一套鑒權(quán)鏈路。Demo 階段URM Ultra用業(yè)務(wù)碼表達(dá)業(yè)務(wù)失敗足夠清晰也避免了 HTTP 狀態(tài)碼和業(yè)務(wù)的語(yǔ)義打架。七、小結(jié)與避坑清單業(yè)務(wù)里只拋不抓異常沿調(diào)用棧拋到全局處理器業(yè)務(wù)主干才干凈定義BusinessException(code, message)給錯(cuò)誤一個(gè)業(yè)務(wù)身份前端按code做差異化提示RestControllerAdvice 多個(gè)ExceptionHandler按具體優(yōu)先、兜底最后的順序分層接異常BusinessException→ 原樣Result.error(code,msg)校驗(yàn)異常 → 統(tǒng)一 400其它 → 兜底 500 打日志兜底 handler 務(wù)必打log.error否則線上異常就消失了你永遠(yuǎn)不知道系統(tǒng)炸過(guò)好處的本質(zhì)異常翻譯集中、響應(yīng)結(jié)構(gòu)恒定、業(yè)務(wù)與錯(cuò)誤處理解耦。下一篇我們順著MethodArgumentNotValidException這根線把參數(shù)校驗(yàn)講透——看看NotBlank是怎么在進(jìn)業(yè)務(wù)之前就把臟數(shù)據(jù)擋在門(mén)外的。