Golang 中更好的錯誤處理:理論和實踐技巧
編程語言應該如何處理錯誤?例如,打開具有給定名稱的文件并將其讀取到緩沖區的函數可能由于多種原因而失敗:文件可能不存在,打開的程序可能沒有打開它的權限,或者它可能太大而無法容納 緩沖區;大多數語言都使用異常:拋出的異常通過調用堆棧傳播直到在 try-catch 塊中處理的級別。異常模型將錯誤視為特殊情況,與程序的常規返回值流程分開處理。
這種方法有幾個缺點。首先,它可以對程序員隱藏錯誤處理路徑,特別是在捕獲異常不是強制性的情況下,例如在 Python 中。即使在具有必須處理的 Java 風格的檢查異常的語言中,如果在與原始調用不同的級別上處理錯誤,也并不總是很明顯錯誤是從哪里引發的。
我們都見過長長的代碼塊包裝在一個 try-catch 塊中。在這種情況下,catch 塊實際上充當 goto 語句,這通常被認為是有害的(奇怪的是,C 中的關鍵字被認為可以接受的少數用例之一是錯誤后清理,因為該語言沒有 Golang- 樣式延遲語句)。
如果你確實從源頭捕獲異常,你會得到一個不太優雅的 Go 錯誤模式版本。這可能會解決混淆代碼的問題,但會遇到另一個問題:性能。在諸如 Java 之類的語言中,拋出異常可能比函數的常規返回慢數百倍。
Java 中最大的性能成本是由打印異常的堆棧跟蹤造成的,這是昂貴的,因為運行的程序必須檢查編譯它的源代碼。僅僅進入一個 try 塊也不是空閑的,因為需要保存 CPU 內存寄存器的先前狀態,因為它們可能需要在拋出異常的情況下恢復。
如果您將異常視為通常不會發生的異常情況,那么異常的缺點并不重要。這可能是傳統的單體應用程序的情況,其中大部分代碼庫不必進行網絡調用——一個操作格式良好的數據的函數不太可能遇到錯誤(除了錯誤的情況)。一旦您在代碼中添加 I/O,無錯誤代碼的夢想就會破滅:您可以忽略錯誤,但不能假裝它們不存在!
try {
doSometing()
} catch (IOException e) {
// ignore it
}
與大多數其他編程語言不同,Golang 接受錯誤是不可避免的。如果在單體架構時代還不是這樣,那么在今天的模塊化后端服務中,服務通常和外部 API 調用、數據庫讀取和寫入以及與其他服務通信。
以上所有方法都可能失敗,解析或驗證從它們接收到的數據(通常在無模式 JSON 中)也可能失敗。Golang 使可以從這些調用返回的錯誤顯式化,與普通返回值的等級相同。從函數調用返回多個值的能力支持這一點,這在大多數語言中通常是不可能的。Golang 的錯誤處理系統不僅僅是一種語言怪癖,它是一種將錯誤視為替代返回值的完全不同的方式!
重復 if err != nil
對 Go 錯誤處理的一個常見批評是被迫重復以下代碼塊:
res, err := doSomething()
if err != nil {
// Handle error
}
對于新用戶來說,這可能會覺得沒用而且浪費行數:在其他語言中需要 3 行的函數很可能會增長到 12 行:
這么多行代碼!這么低效!如果您認為上述內容不優雅或浪費代碼,您可能忽略了我們檢查代碼中的錯誤的全部原因:我們需要能夠以不同的方式處理它們!對 API 或數據庫的調用可能會被重試。
有時事件的順序很重要:調用外部 API 之前發生的錯誤可能不是什么大問題(因為數據從未通過發送),而 API 調用和寫入本地數據庫之間的錯誤可能需要立即注意,因為 這可能意味著系統最終處于不一致的狀態。即使我們只想將錯誤傳播給調用者,我們也可能希望用失敗的解釋來包裝它們,或者為每個錯誤返回一個自定義錯誤類型。
并非所有錯誤都是相同的,并且向調用者返回適當的錯誤是 API 設計的重要部分,無論是對于內部包還是 REST API。
不必擔心在你的代碼中重復 if err != nil ——這就是 Go 中的代碼應該看起來的樣子。
自定義錯誤類型和錯誤包裝
從導出的方法返回錯誤時,請考慮指定自定義錯誤類型,而不是單獨使用錯誤字符串。字符串在意外代碼中是可以的,但在導出的函數中,它們成為函數公共 API 的一部分。更改錯誤字符串將是一項重大更改——如果沒有明確的錯誤類型,需要檢查返回錯誤類型的單元測試將不得不依賴原始字符串值!事實上,基于字符串的錯誤也使得在私有方法中測試不同的錯誤案例變得困難,因此您也應該考慮在包中使用它們。回到錯誤與異常的爭論,返回錯誤也使代碼比拋出異常更容易測試,因為錯誤只是要檢查的返回值。不需要測試框架或在測試中捕獲異常。
可以在 database/sql 包中找到簡單自定義錯誤類型的一個很好的示例。它定義了一個導出常量列表,表示包可以返回的錯誤類型,最著名的是 sql.ErrNoRows。雖然從 API 設計的角度來看,這種特定的錯誤類型有點問題(您可能會爭辯說 API 應該返回一個空結構而不是錯誤),但任何需要檢查空行的應用程序都可以導入該常量并在代碼中使用它不必擔心錯誤消息本身會改變和破壞代碼。
對于更復雜的錯誤處理,您可以通過實現返回錯誤字符串的 Error() 方法來定義自定義錯誤類型。自定義錯誤可以包括元數據,例如錯誤代碼或原始請求參數。如果您想表示錯誤類別,它們很有用。DigitalOcean 的本教程展示了如何使用自定義錯誤類型來表示可以重試的一類臨時錯誤。
通常,錯誤會通過將低級錯誤與更高級別的解釋包裝起來,從而在程序的調用堆棧中傳播。例如,數據庫錯誤可能會以下列格式記錄在 API 調用處理程序中:調用 CreateUser 端點時出錯:查詢數據庫時出錯:pq:檢測到死鎖。這很有用,因為它可以幫助我們跟蹤錯誤在系統中傳播的過程,向我們展示根本原因(數據庫事務引擎中的死鎖)以及它對更廣泛系統的影響(調用者無法創建新用戶)。
自 Go 1.13 以來,此模式具有特殊的語言支持,并帶有錯誤包裝。通過在創建字符串錯誤時使用 %w 動詞,可以使用 Unwrap() 方法訪問底層錯誤。除了比較錯誤相等性的函數 errors.Is() 和 errors.As() 外,程序還可以獲取包裝錯誤的原始類型或標識。這在某些情況下可能很有用,盡管我認為在確定如何處理所述錯誤時最好使用頂級錯誤的類型。
Panics
不要 panic()!長時間運行的應用程序應該優雅地處理錯誤而不是panic。即使在無法恢復的情況下(例如在啟動時驗證配置),最好記錄一個錯誤并優雅地退出。panic比錯誤消息更難診斷,并且可能會跳過被推遲的重要關閉代碼。
Logging
我還想簡要介紹一下日志記錄,因為它是處理錯誤的關鍵部分。通常你能做的最好的事情就是記錄收到的錯誤并繼續下一個請求。
除非您正在構建簡單的命令行工具或個人項目,否則您的應用程序應該使用結構化的日志庫,該庫可以為日志添加時間戳,并提供對日志級別的控制。最后一部分特別重要,因為它將允許您突出顯示應用程序記錄的所有錯誤和警告。通過幫助將它們與信息級日志分開,這將為您節省無數時間。
微服務架構還應該在日志行中包含服務的名稱以及機器實例的名稱。默認情況下記錄這些時,程序代碼不必擔心包含它們。您也可以在日志的結構化部分中記錄其他字段,例如收到的錯誤(如果您不想將其嵌入日志消息本身)或有問題的請求或響應。只需確保您的日志沒有泄露任何敏感數據,例如密碼、API 密鑰或用戶的個人數據!
對于日志庫,我過去使用過 logrus 和 zerolog,但您也可以選擇其他結構化日志庫。如果您想了解更多信息,互聯網上有許多關于如何使用這些的指南。如果您將應用程序部署到云中,您可能需要日志庫上的適配器來根據您的云平臺的日志 API 格式化日志 - 沒有它,云平臺可能無法檢測到日志級別等某些功能。
如果您在應用程序中使用調試級別日志(默認情況下通常不記錄),請確保您的應用程序可以輕松更改日志級別,而無需更改代碼。更改日志級別還可以暫時使信息級別甚至警告級別的日志靜音,以防它們突然變得過于嘈雜并開始淹沒錯誤。您可以使用在啟動時檢查以設置日志級別的環境變量來實現這一點。
原文:https://levelup.gitconnected.com/better-error-handling-in-golang-theory-and-practical-tips-758b90d3f6b4