- 本文介紹
- 寫在前面的話
- 什麼情況需要自定義異常
- 什麼情況需要手動處理異常
- 自定義業務異常
- 如何對異常進行分類
- 寫在後面的註意
本文介紹
本文僅按照業務系統開發角度描述異常的一些處理看法.不涉及java的異常基礎知識,可以自行查閱 《Java核心技術 捲I》 和 《java程式設計思想》 可以得到更多的基礎資訊.
寫在前面的話
筆者文筆功力尚淺,言語多有不妥,請慷慨指正,必定感激不盡. 本文提出了幾個概念: 處理反饋 業務異常 程式碼錯誤 ,請認真思考一下各中區別.
在開發業務系統中,我們目前絕大多數採用MVC樣式,但是往往有人把service跟controller緊緊的耦合在一起,甚至直接使用Threadlocal來隱式傳值,並且複雜的邏輯幾乎只能使用service中儲存的全域性物件來傳遞處理結果,包括異常.
這樣一來首先有違MVC樣式,二來邏輯十分不清晰,難以維護.本文結合工作經驗,給出一些異常使用建議,使用spring來實戰異常為我們帶來的好處.
常常,我們讀罷了各種java的書,異常的各種機制,特性都很清楚,但是始終還是不知道如何使用,甚至背下了概念,卻不知道如何致用.
我們開發的業務系統,或者是產品,常常面臨著這樣的問題:
- 系統執行出錯,但是完全不知道錯誤發生的位置.
- 我們找到了錯誤的位置,但是完全不知道是因為什麼.
- 系統明明出了錯誤,但是就是看不到錯誤堆疊資訊.
什麼情況需要自定義異常
經常看到一些專案,在全域性定義一個 AppException,然後所有地方都只丟擲這個異常,並且把捕獲的異常case到這個AppException中.會有如下問題:
- 浪費log日誌儲存空間,並且棧頂並不是最接近發生異常的程式碼位置.
- 只有一種異常類,無法精準區分開異常型別
- 異常類後期難以修改以增加其攜帶的資訊.
什麼情況需要手動處理異常
我不會把書上的東西直接複製下來,這裡說一下容易記住的,並且適合業務開發的.
- 你有能力處理異常,並且你知道如何處理
- 你有責任處理異常
自定義業務異常
考慮如下場景: 系統提供一個API,用於修改使用者資訊,伺服器端採用json資料互動.首先我們定義ServiceException,用來表示業務邏輯受理失敗,它僅表示我們處理業務的時候發現無法繼續執行下去.
/**
* 業務受理失敗異常
*/
public class ServiceException extends RuntimeException {
//接收reason引數用來描述業務失敗原因.
public ServiceException(String reason) { super(reason); }
}
接下來看下Controller層.
// UserController.java
/**
* 修改使用者資訊
* @param userID 使用者ID
* @param user 修改使用者資訊表單資料
*/
("{userID}")
public JSONResult updateUser(@PathVariable("userID") Integer userID, @RequestBody UpdateUserForm userForm) {
User user = new User(); //準備業務邏輯層使用的領域模型
BeanUtils.copyProperties(userForm, user); //複製要修改的值
user.setUserId(userID); //設定主鍵到使用者資料中
userService.updateUser(user); //呼叫更新業務邏輯
JSONResult json = new JSONResult(); //準備要響應的資料
json.put("user", user); //把修改後的使用者資料還給頁面
return json; // --
}
關於上述Controller寫法乍一看會有一些冗餘,如果無法理解,請仔細研讀MVC設計樣式. 先不管service,我們來考慮下. 一個業務系統不可能不對使用者提交的資料進行驗證,驗證包括兩方面 : 有效性和合法性,
- 有效性: 比如使用者所在崗位,是否屬於資料庫有記錄的崗位ID,如果不存在,無效.
- 合法性: 比如使用者名稱只允許輸入最多12個字元,使用者提交了20個字元,不合法.
有效性檢查,可以交給java的校驗框架執行,比如JSR303. 假設使用者提交的資料經過驗證都合法,還是有一些情況是不能呼叫修改邏輯的.
- 要修改的使用者ID不存在.
- 使用者被鎖定,不允許修改.
- 樂觀鎖機制發現使用者已經被被人修改過.
- 由於某種原因,我們的程式無法儲存到資料庫.
- 一些程式員錯誤的開發了程式碼,導致儲存過程中出現異常,比如NPE.
對於前3種,我們認為是有效性檢查失敗,第4種屬與我們無法處理的異常,第5種就是程式員bug.
現在的問題是,前三種情況我們如何通知使用者呢?
- 在ccontroller 呼叫userService的checkUserExist()方法.
- 在controller直接書寫業務邏輯.
- 在service響應一個狀態碼機制,比如1 2 3表示錯誤資訊,0 表示沒有任何錯誤.
顯然前2種方法都不可取 ,因為MVC不設計樣式告訴我們,controller是用來接收頁面引數,並且呼叫邏輯處理,最後組織頁面響應的地方.我們不可以在controller進行邏輯處理,controller只應該負責使用者API入口和響應的處理(如若不然,思考一下如果有一天service的程式碼打包成jar放到另一個平臺,沒有controller了,該怎麼辦?)
狀態碼機制是個不錯的選擇,可是如此一來,使用者儲存邏輯變了,比如增加一個情況,不允許修改已經離職的使用者,那麼我們還需要修改controller的程式碼,程式碼量增加,維護成本增高,並且還耦合了service,不符合MVC設計樣式.
那麼怎麼辦呢?現在我們來看下service程式碼如何編寫
/**
* 修改使用者資訊
* @param user 要修改的使用者資料
*/
public void updateUser(User user) {
User userOrig = userDao.getUserById(user.getUserID());
if (null == userOrig) {
throw new ServiceException("使用者不存在");
}
if (userOrig.isLocked()) {
throw new ServiceException("使用者被鎖定,不允許修改");
}
if (!user.getVersion().equals(userOrig.getVersion())) {
throw new ServiceException("使用者已經被別人修改過,請掃清重試");
}
// TODO 儲存使用者資料 ...
}
這樣一來只要我們檢查到不允許儲存的專案,我們就可以直接throw 一個新的異常,異常機制會幫助我們中斷程式碼執行.
接下來有2種選擇:
- 在controller 使用try-catch進行處理.
- 直接把異常拋給上層框架統一處理.
第1種方式是不可取的 ,註意我們丟擲的ServiceException,它僅僅邏輯處理異常,並且我們的方法前面沒有宣告throws ServiceException,這表示他是一個非受查異常.controller也沒有關心會發生什麼異常.
為什麼不定義成受查異常呢? 如果是一個受查異常,那麼意味著controller必須要處理你的異常.並且如果有一天你的業務邏輯變了,可能多一種檢查項,就需要增加一個異常,反之需要刪除一個異常,那麼你的方法簽名也需要改變,controller也隨之要改變,這又變成了緊耦合,這和用狀態碼123表示處理結果沒有什麼不同.
我們可以為每一種檢查項定義一個異常嗎? 可以,但是那樣顯得太多餘了.因為業務邏輯處理失敗的時候,根據我們需求,我們只需要通知使用者失敗的原因(通常應該是一段字串),以及伺服器受理失敗的一個狀態碼(有時可能不需要狀態碼,這要看你的設計了),這樣這需要一個包含原因屬性的異常即可滿足我們需求.
最後我們決定這個異常繼承自RuntimeException.並且包含一個接受一個錯誤原因的建構式,這樣controller層也不需要知道異常,只要全域性捕獲到ServiceException做統一的處理即可,這無論是在struct1,2時代,還是springMVC中,甚至servlet年代,都是極為容易的!
異常不提供無參建構式 ,因為絕對不允許你丟擲一個邏輯處理異常,但是不指明原因,想想看,你是必須要告訴使用者為什麼受理失敗的!
如此一來,我們只需要全域性統一處理下 ServiceException 就可以了,很好,spring為我們提供了ControllerAdvice機制,有關ControllerAdvice,可以查閱springMVC使用檔案,下麵是一個簡單的示例:
"com.xxx.xxx.bussiness.xxx" })
(basePackages = {
public class ModuleControllerAdvice {
private static final Logger LOGGER = LoggerFactory.getLogger(ModuleControllerAdvice.class);
private static final Logger SERVICE_LOGGER = LoggerFactory.getLogger(ServiceException.class);
/**
* 業務受理失敗
*/
(HttpStatus.INTERNAL_SERVER_ERROR)
(ServiceException.class)
private JSONResult handleServiceException(ServiceException exception) {
String message = "業務受理失敗,原因:" + exception.getLocalizedMessage();
SERVICE_LOGGER.info(message);
JSONResult json = new JSONResult();
json.serCode(500001); // 500000表示系統異常,500001表示業務邏輯異常
json.setMessage(message);
return json;
}
}
在這個時候,我們就可以很輕鬆的處理各種情況了.
註意一點,在這個類中,我們定義了2個log物件,分別指向 ServiceException.class 和 ModuleControllerAdvice.class . 並且處理 ServiceException的時候使用了info級別的日誌輸出,這是很有用的.
- 首先,ServiceException一定要和其他的程式碼錯誤分離,不應該混為一談.
- 其次,ServiceException並不一定要記錄日誌,我們應該提供獨立的log物件,方便開關.
接下來你可以在修改使用者的時候想客戶端響應這樣的JSON
{
code: 200001,
message: "業務受理失敗,原因:使用者名稱稱不存在!"
}
如此一來沒有任何地方需要關心異常,或者業務邏輯校驗失敗的情況.使用者也可以得到很友好的錯誤提示.
如何對異常進行分類
如果你只需要一句概括,那麼直接定義一個簡單的異常,用於中斷處理,並且與使用者保持友好互動即可.
如果不可能一句話描述清楚,並且包含附加資訊,比如需要在日誌或者資料庫記錄訊息ID,此時可能專門針對這種重要/複雜業務建立獨立異常.
上述兩種情況因為web系統,是使用者發起請求之後需要等待程式給予響應結果的.
如果是後臺作業,或者複雜業務需要追溯性.這種通常用流程判斷陳述句控制,要用異常處理.我們認為這些流程判斷一定在一個原子性處理中.並且檢查到(不是遇到)的問題(不是異常)需要記錄到使用者可友好檢視的日誌.這種情況屬於處理反饋,並不叫異常.
綜上,筆者通常分為如下幾類:
- 邏輯異常,這類異常用於描述業務無法按照預期的情況處理下去,屬於使用者製造的意外.
- 程式碼錯誤,這類異常用於描述開發的程式碼錯誤,例如NPE,ILLARG,都屬於程式員製造的BUG.
- 專有異常,多用於特定業務場景,用於描述指定作業出現意外情況無法預先處理.
各類異常必須要有單獨的日誌記錄,或者分級,分類可管理.有的時候僅僅想給三方運維看到邏輯異常.
寫在後面的註意
異常設計的初衷是解決程式執行中的各種意外情況,且異常的處理效率比條件判斷方式要低很多.
上面這句話出自,但是我們思考如下幾點:
業務邏輯檢查,也是意外情況
UnknownHostException,表示找不到這樣的主機,這個異常和NoUserException有什麼區別麼?換言之,沒有這樣的主機是異常,沒有這樣的使用者不是異常了麼? 所以一定要弄明白什麼是用異常來控制邏輯,什麼是定義程式異常.
異常處理效率很低
書中所示的例子,是在迴圈中大量使用try-catch進行檢查,但是業務系統,使用者發起請求的次數與該場景天壤地別.淘寶的11`11是個很好的反例.但是請你的系統上到這個級別再考慮這種問題.
- 系統有千萬併發,不可能還去考慮這些中規中矩的按部就班的方式,別忘了MVC本來就浪費很多資源,程式碼量增加很多.
- 業務系統也存在很多巨量任務處理的情況.但是那些任務都是原子性的,現在MVC中的controller和service可不是原子性的,不然為什麼要區分這麼多層呢.
- 如果那麼在乎效率,考慮下重寫Throwable的fillStackTrace方法.你要知道異常的開銷大到底大在什麼地方,fillStackTrace是一個native方法,會填充異常類內部的執行軌跡.
不要用異常進行業務邏輯處理
我們先來看一個例子:
//這是一個非常典型的反例,也是一個誤區.
/**
* 處理業務訊息
* @param message 要處理的訊息
*/
public void processMessage(Message message) {
try{
// 處理訊息驗證
// 處理訊息解析
// 處理訊息入庫
}catch(ValidateException e ){
// 驗證失敗
}catch(ParseException e ){
// 解析失敗
}catch(PersistException e ){
// 入庫失敗
}
}
上述程式碼就是典型的使用異常來處理業務邏輯.這種方式需要嚴重的禁止!上述程式碼最大的問題在於,我們如何利用異常來自動處理事務呢?
然而這和我們的異常中斷service沒有什麼衝突.也並不是一回事.
- 我們提倡在 業務處理 的時候,如果發現無法處理直接丟擲異常即可.
- 而並不是在 邏輯處理 的時候,用異常來判斷邏輯進行的狀況.
改正後的邏輯
/**
* 處理業務訊息
* @param message 要處理的訊息
*/
public void processMessage(Message message) {
// 處理訊息驗證
if(!message.isValud()){
MessageLogService.log("訊息校驗失敗"+message.errors())
return ;
}
// 處理訊息解析
if(!message.parse()){
MessageLogService.log("訊息解析失敗"+message.errors())
return ;
}
// TODO ....
}
最後俏皮一句:微服務橫行的今天,我們在action裡面直接寫業務處理,也無可厚非.