乾淨的代碼和異常處理的藝術

已發表: 2022-03-11

異常與編程本身一樣古老。 早在使用硬件或通過低級編程語言進行編程的日子裡,異常被用來改變程序的流程,並避免硬件故障。 今天,維基百科將例外定義為:

需要特殊處理的異常或異常情況——經常改變程序執行的正常流程……

處理它們需要:

專門的編程語言結構或計算機硬件機制。

因此,異常需要特殊處理,未處理的異常可能會導致意外行為。 結果往往是驚人的。 1996 年,著名的阿麗亞娜 5 號火箭發射失敗歸咎於未處理的溢出異常。 歷史上最糟糕的軟件錯誤包含一些其他錯誤,這些錯誤可能歸因於未處理或未處理的異常。

隨著時間的推移,這些錯誤以及無數其他錯誤(可能沒有那麼嚴重,但對相關人員來說仍然是災難性的)造成了異常是壞的印象。

但是異常是現代編程的基本要素。 它們的存在是為了讓我們的軟件變得更好。 與其害怕例外,我們應該擁抱它們並學習如何從中受益。 在本文中,我們將討論如何優雅地管理異常,並使用它們編寫更易於維護的干淨代碼。

異常處理:這是一件好事

隨著面向對象編程 (OOP) 的興起,異常支持已成為現代編程語言的關鍵要素。 如今,大多數語言都內置了強大的異常處理系統。 例如,Ruby 提供了以下典型模式:

 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_executed end

前面的代碼沒有任何問題。 但是過度使用這些模式會導致代碼異味,而且不一定是有益的。 同樣,濫用它們實際上會對您的代碼庫造成很大傷害,使其變得脆弱,或者混淆錯誤的原因。

圍繞異常的恥辱常常讓程序員感到無所適從。 例外是不可避免的,這是生活中的一個事實,但我們經常被教導必須迅速果斷地處理它們。 正如我們將看到的,這不一定是真的。 相反,我們應該學習優雅地處理異常的藝術,使它們與我們的其餘代碼和諧相處。

以下是一些推薦的做法,它們將幫助您接受異常並利用它們以及它們的能力來保持代碼的可維護性可擴展性可讀性

  • 可維護性:使我們能夠輕鬆地發現和修復新的錯誤,而不必擔心破壞當前功能、引入更多錯誤或由於隨著時間的推移增加複雜性而不得不完全放棄代碼。
  • 可擴展性:允許我們輕鬆地添加到我們的代碼庫,實現新的或更改的需求,而不會破壞現有的功能。 可擴展性提供了靈活性,並為我們的代碼庫實現了高度的可重用性。
  • 可讀性:允許我們輕鬆閱讀代碼並發現其用途,而無需花費太多時間挖掘。 這對於有效地發現錯誤和未經測試的代碼至關重要。

這些元素是我們所謂的清潔度質量的主要因素,這本身並不是一個直接的衡量標準,而是前面幾點的綜合效果,如這部漫畫所示:

OSNews 的 Thom Holwerda 的“WTFs/m”

話雖如此,讓我們深入研究這些實踐,看看它們中的每一個如何影響這三個措施。

注意:我們將展示來自 Ruby 的示例,但這裡演示的所有結構在最常見的 OOP 語言中都有等效項。

始終創建自己的ApplicationError層次結構

大多數語言都帶有各種異常類,它們以繼承層次結構組織,就像任何其他 OOP 類一樣。 為了保持代碼的可讀性、可維護性和可擴展性,創建我們自己的應用程序特定異常子樹以擴展基本異常類是一個好主意。 花一些時間在邏輯上構建這個層次結構是非常有益的。 例如:

 class ApplicationError < StandardError; end # Validation Errors class ValidationError < ApplicationError; end class RequiredFieldError < ValidationError; end class UniqueFieldError < ValidationError; end # HTTP 4XX Response Errors class ResponseError < ApplicationError; end class BadRequestError < ResponseError; end class UnauthorizedError < ResponseError; end # ... 

應用程序異常層次結構示例:StandardError 位於頂部。 ApplicationError 繼承自它。 ValidationError 和 ResponseError 都繼承自它。 RequiredFieldError 和 UniqueFieldError 繼承自 ValidationError,而 BadRequestError 和 UnauthorizedError 繼承自 ResponseError。

為我們的應用程序提供一個可擴展的、全面的異常包可以更輕鬆地處理這些特定於應用程序的情況。 例如,我們可以決定以更自然的方式處理哪些異常。 這不僅提高了我們代碼的可讀性,還提高了我們的應用程序和庫(gems)的可維護性。

從可讀性的角度來看,它更容易閱讀:

 rescue ValidationError => e

比閱讀:

 rescue RequiredFieldError, UniqueFieldError, ... => e

例如,從可維護性的角度來看,我們正在實現一個 JSON API,並且我們已經定義了我們自己的ClientError具有幾個子類型,以便在客戶端發送錯誤請求時使用。 如果出現其中任何一項,應用程序應在其響應中呈現錯誤的 JSON 表示。 將更容易修復或添加邏輯到處理ClientError的單個塊,而不是遍歷每個可能的客戶端錯誤並為每個錯誤實現相同的處理程序代碼。 在可擴展性方面,如果我們稍後必須實現另一種類型的客戶端錯誤,我們可以相信它已經在這裡得到了妥善處理。

此外,這並不妨礙我們在調用堆棧的早期對特定客戶端錯誤實施額外的特殊處理,或者在此過程中更改相同的異常對象:

 # app/controller/pseudo_controller.rb def 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 e end def show authenticate_user! show_private_stuff!(params[:id]) rescue ClientError => e render_error(e) end

正如你所看到的,引發這個特定的異常並沒有阻止我們能夠在不同的級別上處理它,改變它,重新引發它,並允許父類處理程序解決它。

這裡需要注意兩點:

  • 並非所有語言都支持從異常處理程序中引發異常。
  • 在大多數語言中,從處理程序中引發異常將導致原始異常永遠丟失,因此最好重新引發相同的異常對象(如上例所示)以避免丟失原始異常的原因錯誤。 (除非你是故意這樣做的)。

永遠不要rescue Exception

也就是說,永遠不要嘗試為基本異常類型實現包羅萬象的處理程序。 拯救或捕獲所有異常在任何語言中都不是一個好主意,無論是在全局基礎應用程序級別上,還是在僅使用一次的小型隱藏方法中。 我們不想拯救Exception ,因為它會混淆真正發生的一切,損害可維護性和可擴展性。 我們可能會浪費大量時間來調試實際問題,因為它可能像語法錯誤一樣簡單:

 # main.rb def bad_example i_might_raise_exception! rescue Exception nah_i_will_always_be_here_for_you end # elsewhere.rb def i_might_raise_exception! retrun do_a_lot_of_work! end

您可能已經註意到前面示例中的錯誤; return輸入錯誤。 儘管現代編輯器針對這種特定類型的語法錯誤提供了一些保護,但此示例說明了rescue Exception如何損害我們的代碼。 任何時候都沒有處理異常的實際類型(在本例中為NoMethodError ),也從未向開發人員公開,這可能會導致我們浪費大量時間在循環中運行。

永遠不要rescue比你需要的更多的異常

前一點是這條規則的一個具體案例:我們應該始終注意不要過度概括我們的異常處理程序。 原因是一樣的; 每當我們挽救的異常多於應有的異常時,我們最終都會對應用程序的更高級別隱藏部分應用程序邏輯,更不用說抑制開發人員自己處理異常的能力了。 這嚴重影響了代碼的可擴展性和可維護性。

如果我們確實試圖在同一個處理程序中處理不同的異常子類型,我們就會引入具有太多職責的胖代碼塊。 例如,如果我們正在構建一個使用遠程 API 的庫,則處理MethodNotAllowedError (HTTP 405) 通常與處理UnauthorizedError (HTTP 401) 不同,即使它們都是ResponseError

正如我們將看到的,通常存在應用程序的不同部分,它們更適合以更乾燥的方式處理特定異常。

因此,定義您的類或方法的單一職責,並處理滿足此職責要求的最少的異常。 例如,如果一個方法負責從遠程 API 獲取股票信息,那麼它應該只處理因獲取該信息而產生的異常,並將其他錯誤的處理留給專門為這些職責設計的不同方法:

 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 end end

在這裡,我們為這種方法定義了合約,只為我們獲取有關股票的信息。 它處理特定於端點的錯誤,例如不完整或格式錯誤的 JSON 響應。 它不處理身份驗證失敗或過期或庫存不存在的情況。 這些是其他人的責任,並且被明確地傳遞到調用堆棧,在那裡應該有更好的地方以 DRY 方式處理這些錯誤。

抵制立即處理異常的衝動

這是對最後一點的補充。 可以在調用堆棧中的任何點以及類層次結構中的任何點處理異常,因此確切地知道在哪里處理它可能會令人費解。 為了解決這個難題,許多開發人員選擇在出現任何異常時立即處理,但投入時間思考這一點通常會導致找到更合適的位置來處理特定異常。

我們在 Rails 應用程序(尤其是那些隻公開 JSON API 的應用程序)中看到的一種常見模式是以下控制器方法:

 # app/controllers/client_controller.rb def create @client = Client.new(params[:client]) if @client.save render json: @client else render json: @client.errors end end

(請注意,雖然這在技術上不是異常處理程序,但從功能上講,它具有相同的目的,因為@client.save僅在遇到異常時返回 false。)

然而,在這種情況下,在每個控制器操作中重複相同的錯誤處理程序與 DRY 是相反的,並且會損害可維護性和可擴展性。 相反,我們可以利用異常傳播的特殊性質,在父控制器類ApplicationController中只處理一次:

 # app/controllers/client_controller.rb def create @client = Client.create!(params[:client]) render json: @client end
 # app/controller/application_controller.rb rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity def render_unprocessable_entity(e) render \ json: { errors: e.record.errors }, status: 422 end

這樣,我們可以確保所有ActiveRecord::RecordInvalid錯誤都在一個地方,在基本ApplicationController級別上得到正確和 DRY 處理。 如果我們想在較低級別處理特定情況,或者只是讓它們優雅地傳播,這使我們可以自由地擺弄它們。

並非所有異常都需要處理

在開發 gem 或庫時,許多開發人員會嘗試封裝功能,並且不允許任何異常傳播到庫之外。 但有時,在實現特定應用程序之前,如何處理異常並不明顯。

讓我們以ActiveRecord作為理想解決方案的示例。 該庫為開發人員提供了兩種完整性方法。 save方法處理異常而不傳播它們,只是返回false ,同時save! 失敗時引發異常。 這使開發人員可以選擇以不同的方式處理特定的錯誤情況,或者以一般方式簡單地處理任何故障。

但是,如果您沒有時間或資源來提供如此完整的實現怎麼辦? 在這種情況下,如果有任何不確定性,最好將異常暴露出來,並將其釋放到野外。

原因如下:我們幾乎一直在處理移動需求,並且做出始終以特定方式處理異常的決定實際上可能會損害我們的實現,破壞可擴展性和可維護性,並可能增加巨大的技術債務,尤其是在開發時圖書館。

以股票 API 消費者獲取股票價格的較早示例為例。 我們選擇當場處理不完整和格式錯誤的響應,並且我們選擇再次重試相同的請求,直到我們得到有效的響應。 但後來,需求可能會發生變化,因此我們必須回退到保存的歷史股票數據,而不是重試請求。

此時,我們將被迫更改庫本身,更新處理此異常的方式,因為依賴項目不會處理此異常。 (他們怎麼可能?他們以前從未接觸過它。)我們還必須通知依賴我們庫的項目的所有者。 如果有很多這樣的項目,這可能會成為一場噩夢,因為它們很可能是建立在假設這個錯誤將以特定方式處理的基礎上的。

現在,我們可以看到依賴管理的發展方向。 前景並不好。 這種情況經常發生,而且往往會降低庫的實用性、可擴展性和靈活性。

所以這是底線:如果不清楚應該如何處理異常,讓它優雅地傳播。 在許多情況下,存在明確的內部處理異常的地方,但在許多其他情況下,暴露異常更好。 因此,在您選擇處理異常之前,請三思而後行。 一個好的經驗法則是,僅在您直接與最終用戶交互時才堅持處理異常。

遵守約定

Ruby 以及 Rails 的實現遵循一些命名約定,例如區分method_namesmethod_names! “砰”的一聲。 在 Ruby 中,bang 表示該方法將更改調用它的對象,而在 Rails 中,這意味著該方法如果未能執行預期的行為將引發異常。 嘗試遵守相同的約定,特別是如果您要開源您的庫。

如果我們要寫一個新method! 在 Rails 應用程序中,我們必須考慮這些約定。 當這個方法失敗時,沒有什麼強迫我們引發異常,但是通過偏離約定,這個方法可能會誤導程序員相信他們將有機會自己處理異常,而實際上他們不會。

Jim Weirich 的另一個 Ruby 約定是使用fail來指示方法失敗,並且只有在重新引發異常時才使用raise

順便說一句,因為我使用異常來指示失敗,所以在 Ruby 中我幾乎總是使用fail關鍵字而不是raise關鍵字。 Fail 和 raise 是同義詞,所以除了 fail 更清楚地表明方法失敗之外沒有區別。 我唯一一次使用 raise 是當我捕獲異常並重新引發它時,因為在這裡我沒有失敗,而是明確且有目的地引發異常。 這是我遵循的風格問題,但我懷疑許多其他人會這樣做。

許多其他語言社區已經圍繞如何處理異常採用了類似的約定,而忽略這些約定將損害我們代碼的可讀性和可維護性。

Logger.log(一切)

當然,這種做法不僅僅適用於異常,但如果有一件事應該始終記錄,那就是異常。

日誌記錄非常重要(對於 Ruby 以標準版本發布記錄器非常重要)。 這是我們應用程序的日記,比記錄我們的應用程序如何成功更重要的是記錄它們如何以及何時失敗。

不乏日誌庫或基於日誌的服務和設計模式。 跟踪我們的異常情況至關重要,這樣我們就可以查看發生的情況並調查是否有問題。 適當的日誌消息可以將開發人員直接指出問題的原因,從而為他們節省大量時間。

清潔代碼的信心

乾淨的異常處理將把你的代碼質量提升到月球!
鳴叫

異常是每種編程語言的基本組成部分。 它們很特別而且非常強大,我們必須利用它們的力量來提升我們代碼的質量,而不是讓自己筋疲力盡地與它們戰鬥。

在本文中,我們深入探討了一些構建異常樹的良好實踐,以及邏輯地構建異常樹如何提高可讀性和質量。 我們研究了處理異常的不同方法,無論是在一個地方還是在多個級別。

我們看到“全部抓住”是不好的,讓它們四處漂浮並冒泡是可以的。

我們查看了在哪里以 DRY 方式處理異常,並了解到我們沒有義務在它們首次出現的時間或地點處理它們。

我們討論了什麼時候處理它們個好主意,什麼時候是個壞主意,以及為什麼在有疑問時讓它們傳播是個好主意。

最後,我們討論了有助於最大化異常有用性的其他要點,例如遵循約定和記錄所有內容。

有了這些基本的指導方針,我們在處理代碼中的錯誤情況時會感到更加自在和自信,並使我們的異常真正異常!

特別感謝 Avdi Grimm 和他精彩的演講 Exceptional Ruby,這對本文的製作有很大幫助。

相關: Ruby 開發人員的提示和最佳實踐