/** * 謹獻給正在長大的小黑 * * 原文出處:https://www.toptal.com/qa/clean-code-and-the-art-of-exception-handling * @author dogstar.huang <chanzonghuang@gmail.com> 2016-04-12 */
異常如同編程自己同樣那麼古老。在過去好些日子裏,當在硬件中完成了編程,或者經過底層編程語言,異經常使用於 警告程序流,以及用於避免硬件失敗。今天,維基百科定義的異常以下:html
異常或者須要特殊處理的例外狀況 -- 常常改變程序的執行流程...git
而且處理他們要求:程序員
專門的編程語言結構或計算機硬件機制。github
因此,異常須要特別的對待,而且一個並處理的異常可能引發非預期的行爲。然後果則常常讓人爲之一驚。 在1996年,著名的阿麗亞娜5型火箭發射失敗是因爲一個未處理的浮點異常。歷史上最嚴重的軟件問題包含了一些其餘 因爲未處理或者誤處理異常的Bug。編程
隨意時間的流逝,這些和其餘(可能不是戲劇性的,但仍然是災難性的)不可勝數的錯誤形成了異常是糟糕的這一印象。
json
可是異常是現代編程中一個基本的元素;他們存在是爲了讓咱們的軟件更好。與其恐懼異常,倒不如擁抱他們並學 習如何從中受益。在這篇文章中,咱們將會討論如何高效管理異常,並使用他們編寫更可維護的整潔代碼。設計模式
隨着面向對象編程(OOP)的興起,對異常的 支持已成爲了現代編程語言的一個關鍵元素。現在,在大部分語言中都內置了強壯的異常處理系統。例如,Ruby 提供瞭如下經典的模式:api
begin do_something_that_might_not_work!rescue SpecificError => e do_some_specific_error_clean_up retry if some_condition_met?ensure this_will_always_be_executedend
前面這些代碼沒有什麼問題。但過分使用這些模式會引發代碼異味,而且不必定會是有益的。一樣地,濫用他們 其實會對你的代碼庫形成不少傷害,如變得脆弱或者混淆錯誤的原由。ruby
圍繞異常的恥辱常常使得程序員感到迷茫。生活中的一個事實就是:異常無可避免,但咱們常常被告之的是必須迅 速果斷地處理異常。正如咱們將會看到的那樣,這不必定是對的。相反,我閃應該學習優雅處理異常的藝術,使得 他們和其餘代碼和諧共處。app
如下是一些推薦的實踐,可幫助你擁抱異常而且使用他們及其能力來保持你的代碼具備可維護性、可擴展性和可讀性:
+ 可維護性:能夠輕易找到並修復新的bug,無須恐懼破壞當前的功能、引入新的bug、或者因爲日益的複雜性而不得不拋棄所有的代碼。
+ 可擴展性:能夠輕易追加到代碼庫,實現新的或者修改需求而不會破壞已有的功能。擴展性提供了靈活度,以及針對代碼庫的高層重用性。
+ 可讀性:容易閱讀而且無須花費太多的時間深挖就能發現它的目的。這對於高效發現bug和未測試的代碼是關鍵的。
這些元素就是咱們稱之爲清潔度或者質量的主要因素,清潔度和質量自己不是直接的試題,而是由前面幾 點結合的度量,正如這幅漫畫所畫得那樣:
也就是說,讓咱們投入到這些實踐並看下他們各自是如何影響這三個度量。
注意:咱們會用Ruby來呈現示例,但這裏所演示的所有構造函數在大部分通用的OOP語言中都有共同性。
ApplicationError
體系大部分語言都帶有大量的異常類,像其餘任何OOP類同樣以繼承體系的形式組織。爲了維持代碼的可讀性、可維護性 以及可擴展性,建立咱們本身的擴展於基礎異常類的特定應用異常子樹是一個好主意。投資一些時間在構造這個體 系的邏輯結構是至關有用的。例如:
class ApplicationError < StandardError; end# Validation Errorsclass ValidationError < ApplicationError; end class RequiredFieldError < ValidationError; end class UniqueFieldError < ValidationError; end# HTTP 4XX Response Errorsclass ResponseError < ApplicationError; end class BadRequestError < ResponseError; end class UnauthorizedError < ResponseError; end # ...
擁有一個針對應用的可擴展的、全面的異常包使得處理這些特定應用場景更加容易。例如,咱們能夠經過一種更自 然的方式決定處理哪個異常。這不只提高了代碼的可讀性,還提升了應用以及類庫(這裏是gem)的可維護性。
從可讀性的視角,這樣更易於閱讀:
rescue ValidationError => e
相比於:
rescue RequiredFieldError, UniqueFieldError, ... => e
從可維護性的視角來講,例如,咱們正在實現一個JSON接口,而且已經定義了本身的帶有若干個子類型的ClientError
, 以便在當客戶發送一個錯誤請求時使用。若是任何其中一個被拋出來,應用應該在它的響應中渲染這個JSON錯誤的陳述。 這對於單單處理ClientError
異常的代碼塊來講修復或者添加邏輯都更簡單,而不是循環每個可能的客戶端錯誤並 爲每一個實現一樣的處理代碼。就可擴展性而言,若是稍候須要實現另外一種客戶端錯誤類型,咱們能夠相信它將會在那被正確處理。
此外,這並不妨礙咱們在調用棧前面實現針對特定客戶端錯誤的額外特殊處理,或修改沿途相同的異常對象:
# app/controller/pseudo_controller.rbdef authenticate_user! fail AuthenticationError if token_invalid? || token_expired? User.find_by(authentication_token: token)rescue AuthenticationError => e report_suspicious_activity if token_invalid? raise eenddef show authenticate_user! show_private_stuff!(params[:id])rescue ClientError => e render_error(e)end
正如你看到那樣,拋出這個特定異常並無妨礙咱們在不一樣級別上對它的處理能力、修改它、以及容許父類處理器解決它。
這裏須要注意的兩件事:
+ 並非所有的語言都支持在異常處理器中拋出異常。
+ 在大多數語言裏,在異常處理器中拋出一個新的 異常會致使異常源永遠丟失,因此最好是從新拋出一樣的 異常對象(正如上面的示例那樣)以免丟失對錯誤引發的根源追蹤。(除非你正在有目的地作這個)
rescue Exception
這就是,不要嘗試爲基礎異常類型實現一個捕捉-所有(catch-all)處理器。在任何語言中無論三七二十一就rescue或捕捉所有異常 歷來都不是一個好主意,無論是全局地在基礎應用級別,抑或是在某個僅使用一次的隱藏小方法裏。咱們不想rescueException
是由於這樣會混淆到底發生了什麼,破壞了可維護性及可擴展性。咱們可能會浪費大量的時間在於調試實際的問題究竟是什麼, 而它可能只是一個簡單的語法錯誤:
# main.rbdef bad_example i_might_raise_exception!rescue Exception nah_i_will_always_be_here_for_youend# elsewhere.rbdef i_might_raise_exception! retrun do_a_lot_of_work!end
你可能已經注意到了前面這個示例中的錯誤;retrun
拼寫錯了。忙乎現代編輯器針對這樣指定語法錯誤拼寫 提供了一些保護措施,這個示例說明了rescue Exception
如何對咱們的代碼形成確切的傷害。對於所定位的 實際異常類型(這裏是NoMethodError
)毫無頭緒,也歷來沒有暴露給開發人員,可能會致使咱們浪費大量 的時間在一次又一次的運行上。
rescue
過多比你須要的異常前面這點是這個規則的一個特定狀況:咱們應該老是當心翼翼的以避免過分生成了異常處理器。理由是同樣的:無論 什麼時候rescue了過多的異常,咱們最終從應用的更高層次隱藏了應用邏輯的部分,更不用說抑制了開發人員他/她本身 處理異常的能力。這點嚴重影響了代碼的可擴展性和可維護性。
若是咱們真的打算在相同的處理器中處理不一樣的異常類型,就會引入擁有過多職責的胖代碼塊。例如,若是 正在構建一個消費遠程接口的類庫,處理一個MethodNotAllowedError
(HTTP 405),一般與處理一個UnauthorizedError
(HTTP 401) 是不一樣的,儘管他們二者都是ResponseError
。
正如咱們會看到的那樣,應用常常會存在一個不一樣的部分更適合於經過更加DRY的方式來處理特定異常。
因此,定義類或方法的單一職責,而且處理知足職責需求的最低限度的異常。例如,若是一個方法負責從遠程接口獲取庫存信息, 那麼它應該只處理從獲取那些信息拋出的異常,而且讓特別設計負責這些異常的不一樣方法來處理其餘的錯誤:
def get_info begin response = HTTP.get(STOCKS_URL + "#{@symbol}/info") fail AuthenticationError if response.code == 401 fail StockNotFoundError, @symbol if response.code == 404 return JSON.parse response.body rescue JSON::ParserError retry endend
這裏咱們定義了此方法的契約只是爲咱們獲取關於庫存的信息。它處理了終端特定錯誤,例如一個不完整或者 異常的JSON響應。它沒有處理認證失敗或者過時,或者是庫存不存在的狀況。這些是別人的職責,而且應明確地傳 給調用棧 -- 以DRY方式處理這些錯誤的更好的地方。
這是最後一點的補充。一個異常能夠在調用棧的任何點上處理,也能夠在類繼承體系的任意一點上處理,因此確切地知道 在哪裏處理它多是迷惑的。爲了解決這個難題,不少開發人員選擇在異常一旦拋出就儘快處理,但投資一些時間 完全地思考這點一般會找到一個更合適的地方來處理特定異常。
在Rails應用(尤爲那些僅暴露JSON的接口)中咱們看到的一個通用模式是如下這個控制器方法:
# app/controllers/client_controller.rbdef create @client = Client.new(params[:client]) if @client.save render json: @client else render json: @client.errors endend
(請注意儘管技術上這不是一個異常處理器,但在功能上它服務了相同的目的,由於當@client.save
遇到異常時只會返回false。)
然而,在這種狀況下,在每一個控制器重複相同的錯誤處理器違反了DRY,而且破壞了可維護性和可擴展性。相反, 咱們可使用異常傳播的特殊性,而且只在父類控制器ApplicationController
處理一次:
# app/controllers/client_controller.rbdef create @client = Client.create!(params[:client]) render json: @clientend
# app/controller/application_controller.rbrescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entitydef render_unprocessable_entity(e) render \ json: { errors: e.record.errors }, status: 422end
這種方式,咱們能夠確保所有ActiveRecord::RecordInvalid
錯誤都會正確地而且DRY地在一個地方處理,即 在ApplicationController
這一基礎級別。這樣給了咱們自由去搗鼓他們若是咱們想在更低的級別處理特定情 況,或者簡單地讓他們優雅地傳播。
當在開發一個gem或者一個類庫時,不少開發人員都會嘗試封裝功能而且不容許向外界傳播任何異常。但有時候, 如何處理一個異常並不明朗直到特定應用實現時。
讓咱們拿ActiveRecord
做爲理想解決方案 的一個示例。這個類庫爲開發人員提供了兩種完整性的途徑。save
方法處理了異常而沒有傳播他們,只是簡 單地返回了false
,而save!
則在失敗時拋出一個異常。這給予了開發人員處理特定不一樣錯誤狀況的選擇, 或者簡單地以通用的方式來處理所有失敗。
可是若是你沒有時間或者資源提供如此完整的實現的話該怎麼辦?那樣的話,若是存在不肯定的,最好是暴露這些異常, 放生它。
這就是爲何:咱們幾乎一直在和變化的需求工做,而且做出的異常應該老是以特定的方式來處理的這一決定可能 實際上損害了咱們的實現,破壞了可擴展性與可維護性,而且可能增長了技術債務, 尤爲在開發類庫時。
之前面庫存接口消費者獲取庫存價格爲例。咱們選擇了馬上處理不完整或者格式錯誤的響應,而且咱們選擇了再次 重試相同的請求直到得到一個有效的響應。可是後面,需求可能會變,例如咱們須要回滾到已保存的歷史庫存數據, 而不是重試請求。
這個時候,咱們將會強制修改類庫自己,更新如何處理這個異常,由於那些獨立的項目不會處理這個異常。 (他們怎麼處理得了?以前歷來沒有暴露過給他們。)咱們還要提醒依賴於咱們類庫的項目主人。若是有不少這樣 的項目的話,這可能會變成一場惡夢,由於他們極可能已經在這個錯誤將會以特定的方式處理這一假設上進行構建。
如今,咱們能夠看到咱們正在與依賴管理一塊兒邁進。前景並很差。這種狀況常有發生,並且每每不是,它會下降 類庫的有用性,可擴展性和靈活性。
因此底線就是:若是不清楚如何處理某個異常,那就讓它優雅地傳播。有不少狀況是存在一個清晰的地方內部 處理此異常,可是還有不少狀況是暴露此異常更好。因此在你選擇處理異常前,三思然後行。經驗法則是僅當你 直接與終端用戶交互時才堅持 處理異常。
Ruby的實現,甚至乎Rails,遵循了一些命名的慣例,例如使用「bang」區分method_names
和method_names!
。
在Ruby,bang意味着這個方法稍候會修改調用的對象,而在Rails,它意味着這個方法若是執行預期的行爲失敗則拋出 一個異常。試着遵照相同的慣例,尤爲當你準備把類庫開源的話。
若是咱們在Rails應用中寫了一個帶bang的新method!
,必定要考慮到這些慣例。沒有什麼東西強制當這個方法 失敗時拋出一個異常,但偏離了慣例,這個方法可能會誤導程序員相信他們會有機會本身處理異常,然而,實際上沒有。
另外一個Ruby慣例,歸功於Jim Weirich,使用了fail
暗示方法失敗,而且只用raise
若是你正在從新拋出這個異常。
「順便說一句,由於我使用異常暗示失敗,我幾乎老是在Ruby中使用「
fail
」關鍵字而不是「raise
」關鍵字。 fail和raise是同義詞因此除了「fail」更加明確地傳達了此方法已失敗外沒有其餘區別了。我使用「raise」的惟一時機
是當我在捕促某個異常而且從新拋出它時,由於這裏我不是失敗,而是明確且故意拋出一個異常。這是一個我遵循 的風格問題,但我懷疑不少其餘人作的。」
不少其餘語言社區已經採納了像這些圍繞如何對待異常的慣例,而忽略這些慣例會破壞咱們代碼的可讀性和可維護性。
固然,這個實踐不光適用於異常,但若是有同樣東西應該老是 被紀錄的話,那就是異常。
日誌紀錄是至關重要的(重要到足以讓Ruby發佈了一個帶logger的標準版本)。 它是咱們應用的日記,而且甚至比保持一份應用如何成功的紀錄更爲重要的是,紀錄如何及什麼時候失敗。
日誌類庫或者基於日誌的服務和設計模式並不缺少。 保持對異常的追蹤很關鍵以便咱們能夠回顧發生了什麼而且若是某些東西看起來不對勁的話進行調查。合適的日誌 消息能把開發人員直接指向到問題發生的緣由,節省他們不可計量的時間。
乾淨的異常處理會把你的代碼質量發送到月球!
異常是每一門編程語言的一個基礎部分。他們特殊且至關強大,咱們必須充分利用他們的力量來提高代碼質量, 而不是忙於和他們做鬥爭。
在這篇文章中,咱們潛入了一些組織異常樹結構的好實踐,以及它是如何有益於可讀性和邏輯結構他們的質量。 咱們看過了對於處理異常的不一樣途徑,或是在一個地方或是在多個層級。
咱們看到「捕獲所有異常」是很差的,而讓他們漂浮和冒出來是能夠的。
咱們看過了在哪裏以DRY方式來處理異常,以及學習了在他們首次拋出時就處理並非咱們的義務。
咱們討論了究竟什麼時候處理是個好主意,什麼時候不是,以及爲何,當困惑時,讓他們傳播是個好主意。
最後,咱們討論了其餘能夠幫助最大化異經常使用處的點,例如遵循慣例和紀錄每件事。
經過這些基本的準則,咱們能夠感受獲得在代碼中處理錯誤狀況時會更加溫馨以及自信,而且使異常真正的無與倫比!
特別感謝Avdi Grimm以及他超級棒的演講異常的Ruby,此演講極大幫助了此文章。
------------------------
本做品採用知識共享署名-非商業性使用-相同方式共享 3.0 未本地化版本許可協議進行許可。
本文翻譯做者爲:dogstar,發表於艾翻譯(itran.cc);歡迎轉載,但請註明出處,謝謝!