異常處理的最佳實踐(下)

前文對異常處理的策略作了大體的介紹,本文將側重於一些細節,有助於幫我們更好地在異常發生時定位問題。

異常處理模式

前文所述的異常處理策略主要側重於系統的頂層,包括服務端請求處理和用戶操作處理,也即最後一層屏障。對於代碼結構較爲簡單的小型的App來說,往往通過函數調用堆棧就可以判斷異常在代碼中的位置了,於是這樣的異常處理策略通常是夠用的了。然而對於龐大的企業級應用來說,代碼層次要複雜得多,尤其是使用依賴注入等技術對模塊進行了解耦以後,僅僅通過拋出異常的堆棧信息往往難以追溯到問題發生的具體原因。此時就需要一些額外的模式來增加異常中包含的信息。

異常傳遞模式

構造一個新的異常包裝原有的異常,並將新異常拋出。

例:

try {
  …
} catch (Exception ex) {
  throw newInvalidOperationException("Failed to process data XXX." ,ex);
}

這個模式有什麼作用呢?回憶一下我們在工作中上碰到的異常,最頭痛的恐怕就是類似NullPointException這樣詳細而無用的類型。對於那些因爲髒數據而導致的問題,爲了判斷變量爲空的原因,需要耗費大量精力閱讀源代碼,設想各種邊界條件,從而倒推出可能的非法輸入。即使發現了一處問題,也無法確定是不是就是該問題導致的異常,從而令那些Bug無法被放心地關閉,而是像幽靈一下永遠困擾着項目,時不時就要reopen一下。

而採用了異常傳遞模式以後,我們可以確保異常真實地反映了異常發生時所涉及的數據及代碼行爲,而非那些無用的詳細信息。這樣,我們就可以簡單地找出那份出錯的數據,用調試模式再跑一邊邏輯,不但大大提高了定位問題的效率,也可以用於驗證所採用的解決方案是否真正修復了問題。

注意:新異常的堆棧信息僅限於其被拋出的位置,爲了儘可能保留現場的細節,需要將原來的異常作爲子異常嵌入新異常中。然而並非所有的語言框架都支持子異常,有時需要通過自定義異常的方式來支持該模式。另外打印異常的時候,務必也要將子異常信息通過遞歸的方式輸出,從而便於通過日誌分析問題。

循環中的異常處理

異常傳遞模式的一個特殊應用場景便是在循環中處理異常。對於一個循環操作來說,如果簡單地在循環外部作異常處理,一旦其中任何一次循環中出了問題,整個操作就會被打斷。現實中髒數據往往只是佔很小的比例,並且循環之間也沒有依賴關係。僅僅因爲個別數據的問題導致其他正常數據無法繼續處理,這是一種非常低效而不合理的處理方式。

因此更好的方式是在循環內做異常處理。然而此時新的問題又來了——如果有複數個髒數據出現,該如何向上彙報?僅僅將異常打印出來怕是不夠的,因爲外層邏輯可能需要通過分析異常的詳細信息來執行不同的操作。此時更好的解決方案是應用異常傳遞模式來對循環中的異常進行收集:

varexList = new List<Exception>();
while(…) {
  try {
  } catch (Exception ex) {
    exList.Add(new InvalidOperationException(ex,data));
  }
}
if (exList.Count > 0){
  throwAggregateException("Exception in loop xxx.", exList);
}

注:並非所有的語言框架對此模式有原生支持,很可能需要自定義實現。另外打印異常的策略也需要權衡,例如只打印前n個子異常,或者進一步根據異常的類型做分類等等。

異常記錄模式

記錄異常並將其重新拋出。

例:

try {
  …
} catch (Exception ex) {
  log(ex);
  throw;
}

該模式的要點在於,記錄異常後,不能吃掉異常,而要將異常重新拋出。在關鍵節點應用改模式,有助於分析錯誤,記錄用戶行爲,並追蹤惡意活動和安全隱患。在實踐中,往往會將這些異常信息打印到特殊的日誌流中,從而觸發一系列報警及恢復動作。

異常保護模式

記錄異常,將原來的異常替換爲另一個異常,並拋出這個新的異常。

例:

try {
  …
} catch (Exception ex) {
  log (ex);
  throw newInvalidOperationException("Failed to process data XXX.");
}

看到這個大家可能會問“哎?這個模式和異常傳遞模式不是差不多咩?不對,子異常怎麼記錄一下就丟了啊?”哼哼,其實這正是此模式的目的——將子異常丟棄,外部就不知道異常的細節了嘛。在現實中,異常信息的泄露往往是非常危險的,很可能暴露系統實現的細節,甚至被黑客挖出一些漏洞來。因此將關鍵信息丟棄,丟給外部一個看似友好卻不包含任何細節信息的異常,是一種不得不採取的措施。

然而問題來了,這樣的模式在防住敵人的同時,也難住了自己人——通過日誌雖然可以查到內部異常的細節,然而卻難以和用戶當時碰到的異常場景對應上。通過比對各處日誌的時間戳也許是個辦法,然而數據量大起來就會變得難以分析。

好在這個問題並非是一個魚和熊掌不可兼得的單選題。有一個簡單的解決方案便是在記錄原始異常和拋出新異常的時候,生成一個GUID。根據這個唯一的GUID,便可以將異常的細節和發生異常的場景一一對應起來,從而可以對問題發生的場景進行完整的復現。

對於使用了SOA架構的企業應用來說,該模式也是一種避免異常在WebService調用之間傳遞的方式。當然,這可能意味着需要額外構造一套通過GUID收集各處異常信息的工具,方便完整地分析問題。

異常的分類

當異常發生的時候,除了無奈地記錄一下,彈出個框告知用戶以外,難道真的沒有什麼別的辦法了麼?答案當然是否定的,如果是網絡抽風之類的問題,完全可以先重試幾下,說不定就通了呢。然而並非所有的異常都適合使用重試的手段來處理。如果是因爲錯誤的輸入造成的異常,即使重試到世界末日也不會有什麼用的,反而會加重服務器的負擔呢。

於是爲了能夠細緻地處理異常,往往需要將異常進行如下的分類:

數據異常

可以進一步分爲服務端數據異常和客戶端數據異常。客戶端數據異常往往意味着用戶的無效輸入;服務端數據異常則通常是因爲數據庫或配置文件中的髒數據。無論是哪一種數據異常,忽略後繼續執行後續操作是危險的,重複本次操作則是無意義的,因此唯一的處理方式就是記錄並報錯。

系統異常

系統異常通常歸因於IOException,尤其同是網絡相關的操作發生的異常。對於此類異常,只要令系統恢復到正常狀態,重試之前的操作就可以成功執行下去。重試的方式有多種:

即時重試

即在操作失敗時不斷重試(可以指定最大次數)。該策略常用於客戶端,有助於提升用戶體驗。注意如果重試的次數很多,每次重試的間隔必須遞增(常用的方式是翻倍),以避免產生數據風暴對服務器施加進一步的壓力。

隊列重試

將處理失敗的請求重新加入隊列,等後續數據處理完後再重試。該策略常用於服務端,對於不需要實時處理的請求,這個方式可以節省對系統資源的佔用,並將個別系統的問題進行隔離。

編碼異常

顧名思義,就是代碼寫得一塌糊塗所造成的異常啦。嚴格地來說,編碼異常是不應該被單獨分類的。或者說,這類異常就不應該出現在線上系統中。對於需求中所描述的典型數據和典型場景,還跑不通,就可以視爲編碼異常。在實踐中,測試往往沒那麼全面,於是一些邊界條件所觸發的異常,也可以視爲是某種“數據問題”。因此這種類型的異常的處理方式,和數據異常基本是一致的。

小結

本文描述了一些異常處理中一些細節技巧,然而這些技巧還是要配合前文所述的基本策略才能更好地發揮作用。異常處理其實並不難,關鍵就是如何儘可能完整地收集到復現問題所需的數據,並且向上層的處理策略提供特定採取操作所需的信息。可惜大部分語言框架並未提供完整的異常處理策略,很多還需要大家自己動手實現。作爲參考,可以看一下Enterprise Library中基於.net的實現(https://msdn.microsoft.com/en-us/library/dn169621.aspx)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章