,我這樣處理參數(shù)校驗(yàn)與異常提示)
加班到深夜隔壁組的老王又在群里發(fā)飆了因?yàn)榍岸藗鬟^來的一個(gè)pageNum是負(fù)數(shù)導(dǎo)致數(shù)據(jù)庫查詢直接報(bào)錯(cuò)線上告警響成一片。這種場景太熟悉了幾乎每一個(gè)混亂的接口項(xiàng)目都是從參數(shù)校驗(yàn)的失控開始的。我盯著自己代碼里那幾十行冗長的if (xxx null) { return Result.failure(xxx不能為空); }突然意識(shí)到這么寫下去我們不是在寫業(yè)務(wù)是在給未來的自己埋雷。很多人覺得參數(shù)校驗(yàn)不就是加個(gè)NotNull注解嘛異常提示不就是 catch 一下拋出去嗎但真正到了生產(chǎn)環(huán)境你會(huì)發(fā)現(xiàn)事情遠(yuǎn)沒有那么簡單。前端傳參的隨意性、第三方回調(diào)的不可靠性、以及產(chǎn)品經(jīng)理那句“這個(gè)字段用戶可以不填”的曖昧性都在考驗(yàn)著你接口的防御深度。今天我不聊什么高深的設(shè)計(jì)模式就想掏心窩子分享一下我是怎么從一團(tuán)亂麻的if-else中解脫出來用 Spring Boot 搭了一套既能守住底線、又足夠優(yōu)雅的參數(shù)校驗(yàn)與異常提示機(jī)制。這套東西不一定適合所有項(xiàng)目但至少能讓你少掉幾根頭發(fā)。混亂的源頭把校驗(yàn)寫在業(yè)務(wù)邏輯里最開始的我們對(duì)參數(shù)校驗(yàn)的態(tài)度是“順手就寫”。在 Controller 里拿到UserDTO第一件事就是判斷username是不是空白接著判斷email格式對(duì)不對(duì)然后判斷age是否在合理區(qū)間。這些代碼混在核心業(yè)務(wù)邏輯里就像一鍋粥里掉進(jìn)了幾粒沙子吃著硌牙看著礙眼。更要命的是校驗(yàn)規(guī)則與業(yè)務(wù)邏輯的耦合直接導(dǎo)致代碼的不可復(fù)用性。你在 A 接口里寫的郵箱正則到了 B 接口又得復(fù)制粘貼一遍一旦規(guī)則需要調(diào)整就得滿項(xiàng)目里去找那幾行散落的代碼稍不留神就會(huì)漏掉一處。這種做法的最大問題還不在于代碼冗余而在于異常拋出時(shí)機(jī)的不可控性。假設(shè)業(yè)務(wù)邏輯里有一處校驗(yàn)失敗你拋出一個(gè)IllegalArgumentException但是這個(gè)異常該被誰捕獲前端應(yīng)該看到什么樣的提示信息如果每個(gè)程序員都有一套自己的“方言”那前端對(duì)接起來簡直就是一場災(zāi)難。前端問你要錯(cuò)誤碼你給返回一個(gè)堆棧追蹤字符串前端想要一個(gè)明確的提示你返回一個(gè)“系統(tǒng)錯(cuò)誤”。這種接口層面的“語言不通”是比代碼混亂更令人頭疼的協(xié)作痛點(diǎn)。所以想要處理好參數(shù)校驗(yàn)第一步不是學(xué)什么新注解而是先建立一個(gè)共識(shí)數(shù)據(jù)校驗(yàn)必須結(jié)構(gòu)化、流程化它應(yīng)該發(fā)生在業(yè)務(wù)邏輯被喚醒之前如同機(jī)場的安檢閘機(jī)一件行李、一道關(guān)卡、一個(gè)答復(fù)。核心中樞把異常提示裝進(jìn)一個(gè)“集裝箱”我后來想明白了一個(gè)道理參數(shù)校驗(yàn)的終極形態(tài)不是防住非法數(shù)據(jù)而是讓每一個(gè)非法數(shù)據(jù)都在它該被攔截的地方轉(zhuǎn)化成一種可讀、可控、可追蹤的反饋。要達(dá)到這個(gè)效果必須先建立一個(gè)全局異常處理中樞。Spring Boot 里那個(gè)RestControllerAdvice注解幾乎就是為這個(gè)場景量身定制的。但很多人只會(huì)用它寫一個(gè)ExceptionHandler(value Exception.class)然后返回Result.failure(系統(tǒng)繁忙)這就完全沒發(fā)揮出它的威力。我的做法是先定義一套統(tǒng)一的結(jié)果封裝體叫ResultT它包含code、message和data三個(gè)字段。注意這里的code不是 HTTP 狀態(tài)碼而是業(yè)務(wù)狀態(tài)碼。比如400代表參數(shù)校驗(yàn)失敗401代表未登錄200代表成功。接著在全局異常處理器里我會(huì)專門為MethodArgumentNotValidException寫一個(gè)處理方法。這是關(guān)鍵的一步因?yàn)楫?dāng)Valid注解的實(shí)體校驗(yàn)失敗時(shí)Spring 拋出的正是它。捕獲這個(gè)異常后我絕不會(huì)直接返回異常自帶的默認(rèn)信息而是會(huì)從BindingResult里提取第一條錯(cuò)誤消息并拼裝成“字段名錯(cuò)誤提示”的格式這樣前端拿到手就能直接彈窗展示。但是光處理MethodArgumentNotValidException還遠(yuǎn)遠(yuǎn)不夠。如果前端傳的是一個(gè)根本無法轉(zhuǎn)換成數(shù)字的字符串呢如果 JSON 解析少了一個(gè)引號(hào)呢這時(shí)候拋出的是HttpMessageNotReadableException。如果請(qǐng)求路徑里的參數(shù)類型不對(duì)呢那又是MethodArgumentTypeMismatchException。一個(gè)成熟的異常處理機(jī)制必須要像老中醫(yī)把脈一樣對(duì)各種異常類型辨證施治。我會(huì)在這個(gè)中樞里針對(duì)每一種常見的參數(shù)解析異常都配置好相應(yīng)的提示語確保前端收到的永遠(yuǎn)是我們想讓他們看到的信息而不是一堆英文的異常棧首行。這樣那個(gè)散落在各個(gè)業(yè)務(wù)層里的try-catch才能徹底從代碼里消失還業(yè)務(wù)邏輯一個(gè)清靜。注解的魔力讓校驗(yàn)規(guī)則“長”在實(shí)體上解決了異常出口的問題接下來的核心問題就是怎么定義校驗(yàn)規(guī)則才算優(yōu)雅我的答案是盡可能把規(guī)則內(nèi)聚到實(shí)體對(duì)象上讓實(shí)體自己會(huì)說話。Spring Boot 的javax.validation規(guī)范現(xiàn)在叫jakarta.validation提供了極其豐富的注解比如NotBlank、NotNull、Size、Pattern、Email這些都是基礎(chǔ)中的基礎(chǔ)。但真正讓我覺得省心的是 JSR 380 規(guī)范里那些不那么被注意的注解比如PositiveOrZero只能為非負(fù)數(shù)、Max限制最大值、DecimalMin限制小數(shù)最小值。有人可能會(huì)問直接用NotBlank不就能解決老王那個(gè)pageNum負(fù)數(shù)的問題了嗎是的但這種解決方式太粗糙了。真正的精細(xì)化管理體現(xiàn)在對(duì)字段邊界的定義上。比如分頁參數(shù)我會(huì)直接在 DTO 里定義NotNull(message 頁碼不能為空) Min(value 1, message 頁碼最小值為1) private Integer pageNum;有了這行注解業(yè)務(wù)層根本不需要關(guān)心頁碼是不是負(fù)數(shù)因?yàn)槿魏畏欠ǖ臄?shù)值在進(jìn)入 Controller 之前就會(huì)被 Spring 的Validator攔截下來并精準(zhǔn)地拋出一個(gè)MethodArgumentNotValidException。這讓我覺得校驗(yàn)的本質(zhì)是一場前置的“防守反擊”而不是在業(yè)務(wù)邏輯里疲于奔命的“危機(jī)公關(guān)”。這里需要強(qiáng)調(diào)一個(gè)特別容易被忽視的點(diǎn)在實(shí)體類上使用注解校驗(yàn)時(shí)一定要區(qū)分NotNull和NotBlank的區(qū)別。對(duì)于 Integer、Long 這種包裝類型必須用NotNull對(duì)于 String 類型如果你需要判斷它既不為 null 也不為空白字符串那必須用NotBlank。很多新手用錯(cuò)導(dǎo)致前端傳了一個(gè)空字符串過來居然能蒙混過關(guān)最后在數(shù)據(jù)庫層爆出空指針。還有一個(gè)進(jìn)階技巧就是使用Validated注解的分組校驗(yàn)功能。比如在新增操作時(shí)id必須為空在更新操作時(shí)id必須不為空。通過定義一個(gè)CreateGroup接口和一個(gè)UpdateGroup接口然后在注解上指定groups CreateGroup.class我就可以讓同一個(gè) DTO 在不同場景下?lián)碛胁煌男r?yàn)規(guī)則而不再需要為了這一個(gè)字段去寫兩個(gè) DTO 類。優(yōu)雅的例外處理那些“不聽話”的前端即便我們把實(shí)體注解用到了極致世界上仍然存在一些“不按常理出牌”的請(qǐng)求。比如前端傳的不是 JSON 格式而是一個(gè)字符串混著表情符號(hào)或者前端要傳一個(gè)日期但格式寫成了2024-13-45。對(duì)于這種情況當(dāng)JsonFormat注解里的pattern無法解析時(shí)Spring 會(huì)拋出HttpMessageNotReadableException但默認(rèn)的異常提示信息往往是JSON parse error: Cannot deserialize value of type java.util.Date from String 2024-13-45——這種話給前端看人家根本不知道啥意思。我的處理原則是對(duì)于能夠明確歸類為“參數(shù)無法理解”的異常統(tǒng)一提示為“請(qǐng)求參數(shù)格式錯(cuò)誤請(qǐng)檢查后重試”。但如果你希望更友好一點(diǎn)想在提示里帶上具體的字段名就需要定制 Jackson 的反序列化器或者在異常處理里解析exception.getCause(). 操作起來比較復(fù)雜所以我的經(jīng)驗(yàn)是在絕大多數(shù) C 端項(xiàng)目中給一個(gè)統(tǒng)一的、不那么嚇人的提示就夠了。如果前端連 JSON 都拼不對(duì)那無論我們反饋多么精確的提示對(duì)他們來說都是徒勞的。還有一種場景非常特殊就是跨域請(qǐng)求中的 preflight 請(qǐng)求請(qǐng)求方法是 OPTIONS。這種情況下前端通常不會(huì)攜帶任何業(yè)務(wù)參數(shù)如果我們對(duì) OPTIONS 請(qǐng)求也強(qiáng)制進(jìn)行參數(shù)校驗(yàn)?zāi)敲礊g覽器控制臺(tái)必然報(bào) 403 或者 400。因此在一個(gè)成熟的異常處理中樞里必須攔截掉OPTIONS請(qǐng)求直接返回 200 狀態(tài)碼讓它通過。這看似不是參數(shù)校驗(yàn)的范疇但實(shí)際上是為了保證校驗(yàn)機(jī)制能正常運(yùn)行的前提條件。從“校驗(yàn)”到“反饋”信息鏈路不能斷很多出色的架構(gòu)師會(huì)把參數(shù)校驗(yàn)和異常提示看作一個(gè)不可分割的整體我非常認(rèn)同。沒有異常提示的校驗(yàn)是裸奔沒有校驗(yàn)的異常提示是空中樓閣。在打磨異常提示的時(shí)候有一條鐵律我一直在遵守給用戶看的提示永遠(yuǎn)不要包含系統(tǒng)內(nèi)部的技術(shù)細(xì)節(jié)。比如數(shù)據(jù)庫字段超長導(dǎo)致的DataIntegrityViolationException你不能直接把那個(gè)Data truncation: Data too long for column username甩給用戶。你應(yīng)該先通過異常判斷是否屬于字段超長然后精準(zhǔn)地返回“用戶名長度超出限制最大20個(gè)字符”。這一步就需要我們?cè)诋惓L幚碇袠欣飳懸粋€(gè)專門針對(duì)DataIntegrityViolationException的處理器并去解析異常信息里攜帶的字段名。這里想分享一個(gè)我自己做的小發(fā)明其實(shí)也很常規(guī)就是利用 Spring 的MessageSource做國際化消息管理。在resources目錄下建一個(gè)ValidationMessages.properties然后把所有注解里的message ...全部提取出來統(tǒng)一管理。比如user.username.notblank用戶名不能為空。這樣一來以后產(chǎn)品經(jīng)理想改提示文案就不需要程序員改代碼重新發(fā)版了直接運(yùn)維改一下配置文件刷新即可。雖然這個(gè)步驟有點(diǎn)麻煩但對(duì)于接口數(shù)量巨大的項(xiàng)目來說這種將提示文案與代碼邏輯解耦的做法簡直就是解放生產(chǎn)力的神兵利器。我們不能忽視的是日志。異常提示是給用戶看的而異常詳情是留給程序員排查問題的。在全局異常處理器里如果捕獲到的是非預(yù)期異常比如Exception.class在返回“系統(tǒng)繁忙請(qǐng)稍后重試”的同時(shí)一定要用log.error(接口調(diào)用異常請(qǐng)求參數(shù){}請(qǐng)求路徑{}, params, requestURI, exception)把完整的堆棧信息記錄下來。沒有日志支撐的異常處理相當(dāng)于在漆黑的夜里排查電路故障。我們既要保證用戶在界面上看到的是一片風(fēng)平浪靜友好的提示也要保證在故障排查時(shí)我們手里有一盞能照亮所有角落的探照燈詳細(xì)的日志。這個(gè)信息鏈條如果斷了那系統(tǒng)就真的成了黑盒。性能與體驗(yàn)校驗(yàn)的成本與控制有人覺得在 Spring Boot 里搞這么多注解校驗(yàn)會(huì)影響性能。實(shí)際上Hibernate Validator這是javax.validation的參考實(shí)現(xiàn)的執(zhí)行效率是極高的一次反射遍歷幾十個(gè)注解的開銷相對(duì)于一次數(shù)據(jù)庫查詢的耗時(shí)來說幾乎可以忽略不計(jì)。真正的性能殺手從來不是校驗(yàn)本身而是因?yàn)閰?shù)校驗(yàn)不到位導(dǎo)致的無效計(jì)算和無用 SQL 執(zhí)行。比如一個(gè)非法的userId字符串被傳到了 Service 層然后代碼傻乎乎地去 Redis 里查緩存去 MySQL 里查記錄最終發(fā)現(xiàn)查不到才拋出異常這不僅浪費(fèi)了資源還延長了接口響應(yīng)時(shí)間。對(duì)于大流量的接口我甚至?xí)ㄗh使用快速失敗Fail-Fast原則。默認(rèn)情況下Spring 的校驗(yàn)會(huì)收集所有的字段錯(cuò)誤然后一次性返回這種模式叫FailFast關(guān)閉模式。但我傾向于在配置里開啟spring.mvc.servlet.load-on-startup之類的優(yōu)化嗎不對(duì)這里的做法是在Validator配置里調(diào)用validate()方法進(jìn)行手動(dòng)校驗(yàn)或者直接依賴默認(rèn)的throwExceptionIfNoHandlerFound。實(shí)際上更直接的做法是在 DTO 的校驗(yàn)順序上把最容易出錯(cuò)的、成本最低的校驗(yàn)項(xiàng)放在前面比如NotBlank顯然比Pattern執(zhí)行更快。雖然這種微觀優(yōu)化意義不大但如果你有微服務(wù)之間的 RPC 調(diào)用一個(gè)干凈的參數(shù)校驗(yàn)?zāi)軒湍闶∪ゴ罅坎槐匾男蛄谢途W(wǎng)絡(luò)傳輸開銷。在用戶體驗(yàn)層面我悟出一個(gè)道理好的參數(shù)校驗(yàn)提示不應(yīng)該像法官宣判罪行一樣冷冰冰而應(yīng)該像一個(gè)貼心的導(dǎo)航員明確告訴你偏航了多少米下一步該怎么走。比如不要用“參數(shù)非法”而要具體到“年齡必須介于 18 到 100 歲之間”。我們的目標(biāo)不是把前端懟得啞口無言而是極大縮短雙方溝通的路徑。當(dāng)后端返回的每條錯(cuò)誤信息都能直接被前端綁定到具體的Input框下并且用戶稍微一讀就知道怎么修改時(shí)這套校驗(yàn)體系才真正成功了。融會(huì)貫通實(shí)戰(zhàn)中的代碼模式說了這么多理論最后還是得上點(diǎn)“硬菜”。我給大家展示一下我某個(gè)項(xiàng)目里一個(gè)典型的 Controller 寫法PostMapping(/user) public ResultUserVO createUser(Validated RequestBody UserCreateDTO userDTO) { // 你的核心業(yè)務(wù)邏輯直接使用 userDTO無需任何 if 校驗(yàn) return Result.success(userService.create(userDTO)); }這個(gè) Controller 極其清爽把所有的校驗(yàn)責(zé)任都推給了 Spring 的容器機(jī)制。然后在UserCreateDTO里public class UserCreateDTO { NotBlank(message {user.name.notblank}) Size(min 2, max 10, message {user.name.size}) private String name; NotNull(message {user.age.notnull}) Min(value 18, message {user.age.min}) Max(value 100, message {user.age.max}) private Integer age; Email(message {user.email.format}) private String email; }這段代碼重劍無鋒大巧不工。一眼看上去就知道數(shù)據(jù)需要滿足什么條件甚至不需要寫注釋。接著在全局那個(gè)GlobalExceptionHandler里ExceptionHandler(MethodArgumentNotValidException.class) public ResultVoid handleValid(MethodArgumentNotValidException e) { String msg e.getBindingResult().getFieldErrors().stream() .findFirst() .map(err - err.getField() err.getDefaultMessage()) .orElse(參數(shù)校驗(yàn)失敗); return Result.failure(400, msg); }這是一套非常經(jīng)典的鏈路。它打破了以往“業(yè)務(wù)邏輯里遍布 try-catch”的厚重感把異常判斷變成了一種聲明式編程。如果你現(xiàn)在還在為自己的項(xiàng)目里那幾十個(gè)重復(fù)的if而苦惱不妨試著踏出這一步。你會(huì)發(fā)現(xiàn)當(dāng)你終于不再手寫那些“參數(shù)不能為空”的判斷時(shí)你會(huì)感到一種前所未有的心情舒暢——原來代碼真的可以像干凈的表格一樣不僅嚴(yán)謹(jǐn)而且賞心悅目。最后我想說參數(shù)校驗(yàn)與異常提示看似是接口開發(fā)中最不起眼的一環(huán)但它恰恰是衡量一個(gè)后端工程師是否“靠譜”的隱形標(biāo)尺。一個(gè)隨手寫著“系統(tǒng)錯(cuò)誤”的后端和一個(gè)能精準(zhǔn)告訴前端“這個(gè)字段的值必須在0-100之間”的后端在團(tuán)隊(duì)里的口碑是截然不同的。這不只是技術(shù)優(yōu)劣的問題更是對(duì)待工程質(zhì)量的態(tài)度問題。寫接口太多很容易讓人浮躁總覺得 CURD 而已。但就像藝術(shù)大師不會(huì)因?yàn)楫嫴夹【头笱芰耸乱粯右粋€(gè)真正熱愛代碼的人絕不會(huì)允許自己的接口在面臨非法參數(shù)時(shí)手忙腳亂地如同一頭困獸。我建議你從今天下班前去重構(gòu)一個(gè)你最常調(diào)用的接口的校驗(yàn)邏輯。把那幾十行if-else連根鏟除讓注解和全局配置來替你守住那道防線。當(dāng)你看到那簡潔得如同詩句般的代碼時(shí)你會(huì)覺得這晚的加班不只是為了趕進(jìn)度更是為了留存一份關(guān)于優(yōu)雅的尊嚴(yán)。