如何優雅的在Golang中進行錯誤處理
如何優雅的在Golang
中進行錯誤處理?
答案是:沒有……(本文完)
開個玩笑,Golang
中的錯誤處理方式一直是社群熱烈討論的話題,有力挺者,有抱怨者,但不論如何,自2009年Golang
正式釋出以來,關於錯誤處理就一直是現在這種狀況。
隨著Golang
愈加的火爆,原本是Java
、Node
、C#
等語言擅長的應用級開發領域也逐漸出現Golang
的身影。Golang
自身其實更加擅長做基礎設施級開發,例如docker
,例如k8s
,再如etcd
,它友好的記憶體管理和簡單到粗暴的語法(25個關鍵字),特別適合過去C
和C++
這些語言所擅長的部分場景。我們有理由相信,Golang
下一個大的引爆點將也許會在IoT
上,因為它天然的適合。
當一門語言火起來,就會出現各式各樣的應用,於是MVC
框架有了,音視訊處理庫有了,各種資料庫驅動有了,甚至服務框架也出現了,遊戲、Machine Learning
都不在話下,還要啥自行車?組合一下做應用級開發妥妥的沒毛病。
但是,成也這25個關鍵字,敗也這25個關鍵字,究其根本原因,都是因為它背後簡單的哲學。
做應用級開發可不是那麼簡單的,這涉及到很多的細節處理,例如本文將要討論的錯誤處理。如果只是寫一個庫,那麼這個話題相對比較簡單,因為與API
打交道的都是開發者,你只管開心的往外扔error
就好了,總會有倒黴的程式設計師在使用你的程式碼時DEBUG到白頭,最後,以最嚴謹的方式,小心使用你的庫;可是有人出現的地方就會有么蛾子,一個常見的誤區就是將業務錯誤、執行時錯誤、程式錯誤一股腦的當成相同的error
來處理。
你是還沒在
error
上栽跟頭,當你栽了跟斗時才會哭著想起來,當年為什麼沒好好思考和反省錯誤處理這麼一個巨集大的話題
那麼,如何在現有的語言支援下,用一種相對優雅的方式進行錯誤處理呢?我們通過本文的思考和討論,嘗試予以解決。雖說主要討論的是Golang
,但是這背後的思考其實適合大部分語言。
語言級別的錯誤處理
Golang
是原生支援鴨子型別(duck typing)的,所以error
可以理解成一個“鴨子”的定義,它是這樣的:
// golang
type error interface {
Error() string
}
換句話講,一切實現了Error() string
方法的struct
,都可以當成error
往外扔,神不神奇?不神奇……把它看成介面也無礙,反正其它語言也長的類似,比如:
// csharp
public class SystemException : Exception {}
public class InvalidOperationException : SystemException
問題來了,Golang
是沒有繼承這一說的,所以如果你想把錯誤規劃成層級結構是行不通的,而且也不是Golang
的調調。不過定義多種錯誤終歸是可以的:
// golang
import "errors"
var (
ErrNotAuthenticated = errors.New("not authenticated")
ErrNotAuthorized = errors.New("not authorized")
ErrNoPermission = errors.New("no permission")
)
鴨子型別在不使用繼承的情況下變相支援了多型,所以是可以認為error
是個介面,error
的消費方可以不用關心背後是具體什麼結構,只需要滿足error
契約就行,這就是所謂的多型。
那麼到這裡為止,我們有了具體的error
,然後呢?總是得有一個地方去處理。從這裡開始,Golang
與別的語言區分開了。
本來想解釋一下什麼是錯誤(error),什麼是異常(exceptional),但是貌似太多的語言在混搭使用這兩個術語,所以我們乾脆放棄解釋錯誤和異常,而使用可恢復和不可恢復來說明。同時,我個人實名點贊Golang
和Rust
在這兩個概念上的區分。
不可恢復故障
Golang
和Rust
都有panic
的概念,也就是指不可恢復的故障,一般遇到panic
時基本就不用再救了,大部分的時候都是直接以-1
為返回值退出程式就好,除非你覺得我行我可以我還想再試試,那麼使用recover
手段,比如:
// golang
fun horrible() {
panic("some bad things happened")
}
func business() {
defer func() {
if p := recover(); p != nil {
// give me another chance
}
}()
horrible()
}
如果不處理的話,程式就會自動退出,並列印出錯誤資訊以及錯誤堆疊。Rust
的方式幾乎一模一樣,只不過有兩點不同:Rust
中對應panic
的是panic!
巨集,和recover
類似的功能是std::panic::catch_unwind
;另外就是退出後預設不列印堆疊,需要的話得手動設定RUST_BACKTRACE=1
環境變數。
這個非常好理解,比如陣列越界了,記憶體滿了,堆疊爆了,幾乎碰到panic
就很少有恢復的可能。
panic
背後其實是一種短路(或者叫快捷方式)哲學,任何層級的流程在執行過程中,通過panic
都可以直接讓程式跳到結束或者有recover
的地方。這與大多資料的高階語言的Exception
不謀而合,舉個例子:
// csharp
public void DoSometingIntresting() {
throw new InvalidOperationException("not allowed");
}
只要碰到Exception
就一定會中斷正常的執行順序。稍顯遺憾的是,這些語言中的Exception
不完全能把程式打死,因為它們大多都提供了try-catch
語言構造,讓你可以在任何想處理的地方,或處理或加工,總之手法多樣。打不死的原因也正是因為,一個簡單的catch (Exception ex) {}
就足夠吃掉所有的故障。
這也是為什麼在開頭那部分裡不用錯誤和異常的原因,因為:
大多數支援
try-catch-exception
機制的語言裡,可恢復和不可恢復的故障都用Exception來表示,這加劇了開發者的心智負擔,因為這需要仔細的處理Exception的型別。例如,C#裡的不可恢復錯誤往往都有特定的繼承鏈,比如SystemException
,使用時需要小心處理。
更好的理解方式是,把try-catch-exception
這種機制,主要作為處理可恢復故障的手段,而把少量不可恢復的故障,在充分思考的情況下處理或放任。換句話講,catch一定儘可能的按下游方法可能出現的Exception
型別去匹配,不要隨意通吃。
可恢復故障
與panic
有所區別的可恢復故障,Golang
也有約定的方式。這就是error
。
所謂可恢復,就是雖然無法順利的將當前的流程執行完畢,但是不影響大局,消費方可以按自己的意願去安排接下來的邏輯,或中斷執行某個業務,或檢查是否自己使用的方式有問題,或有備用的流程替換等等。
實踐中經常碰到的可恢復故障有幾大類:
- 前置檢查失敗,大多是指引數沒有按約定提供,例如引數不可空校驗失敗的錯誤,引數數值範圍不正確等等,這是呼叫方的bug
- 程式錯誤,例如通過
req.(sometype)
進行型別轉換,到執行時發現轉不過去,這是自身的bug - 依賴服務呼叫錯誤,比如查詢資料庫時發生了異常,往往都是第三方產生執行時錯誤,是最經常處理的錯誤
- 業務執行錯誤,例如一個傳送驗證碼的函式,在執行過程中發現某個使用者的傳送頻率超過閾值,那這是一個特定業務的失敗
基本所有在開發過程中碰到的錯誤都能歸入以上4類。而往往需著重關注的,是後兩類。前兩類實屬於bug,需要在上線前就清理完畢的。
可恢復故障的丟擲方式
我們來做一個思考。在一門語言中,如果一個方法有可能出錯,通常會通過什麼途徑把錯誤資訊告訴呼叫者呢?換句話講,正常的方法返回資料,不正常的方法需要有途徑“帶貨”,把錯誤資訊以某種方式帶出去。
單值函式的方式
如果這門語言只支援單值函式,也就是返回值只能是一個,那麼就需要有一個容器來儲存正常的值和出錯時需要返回的錯誤資訊,就像:
// fsharp
type Result<'T> = {
Data: 'T
Error: string
}
此時,每個使用該方法的地方,只需要簡單判斷一下res.Error
就能知道有沒有錯誤發生。
像不像是很多Restful
介面返回資料的模樣?是的,完全是一個模式:
{
"data": {
// ...
},
"errmsg": "",
"errcode": "610100"
}
這裡先忽略這個錯誤碼,後面的內容我們會涉及到。
多值函式的方式
那如果語言支援多值返回(其實還是單值,大多是引入元組(Tuple)
來處理,例如Python
),概念上和如下的方式相同:
// csharp
public Tuple<int, string> Multiple() {
return Tuple.Create(0, "something wrong");
}
好了,該Golang
出場了,既然我支援多值返回,那麼應該不用明顯的包裝型別就可以做到了吧:
// golang
func multiple() (int, string) {
return 0, "something wrong"
}
等等,錯誤用string
表示有點醜是不是,沒關係,Golang
幫你抽象出一個error
介面來,最終就變成了func multiple() (int, error) {}
這樣子了,
和定義一個type res struct { Data int; Err error}
相比,好像沒進步太多?
函式式的方式
那還有沒有更好的方式了呢?如果有接觸過Functional Programming
的東西,就會想到,通過Generic
+ Discriminated Unions
+ partten matching
的方式更加優雅。
核心在Discriminated Unions
上,也叫做Enum
,Union
,Tagged Union
,variant
,variant record
,choice type
,disjoint union
,sum type
,coproduct
……它是一種可以儲存多種(但是數量固定)型別值的結構,同一時間只可以使用其中的一種型別。舉個例子,如下的DUs
可以避免null
的顯式使用:
// fsharp
type Option<'T> =
| None
| Some of 'T
這個Option<'T>
(也有叫Maybe
的)要麼只有None
值,要麼只有一個包含'T
的Some
值,於是,當函式返回一個Option
型別的值時,消費方就可以不再寫諸如Golang
中的if err != nil {}
了,而是使用更加高階的模式匹配完成:
// fsharp
let res = somemethod() // will return an Option value
match res with
| None -> // data is empty, like null
| Some d -> // d is data
等等,這不像是在做錯誤處理?沒關係,稍微變換一下:
// fsharp
type Result<'T, 'TError> =
| Ok of 'T
| Error of 'TError
現在的使用方式變成了:
// fsharp
let res = somemethod() // will return an Option value
match res with
| Ok t -> // t is normal result
| Error err -> // err is error
好像還是沒什麼用?那是因為沒有接觸過FP
中的Warpper
型別的概念,基本上有了Warpper
型別,就可以bind
或者lift
等等了。Rust
走的就是這種路子,並有配套的函式支援。由於Option
和Result
如此常用,以至於很多語言核心庫都內建了對應的結構,有興趣可以參考我很早之前寫過的一點東西。
那麼,Golang
為什麼不使用這種方式呢?因為,第一缺乏泛型支援,Warpper
如果沒有泛型支援的話就無法泛化,會導致很多的模板程式碼,進而還不如直白的處理error
;第二沒有Discriminated Unions
,多個型別無法聯合起來並在同一時間只使用其中一種,也就快速區分彼此;第三沒有模式匹配,也就無法更進一步的簡化程式碼,不如還是使用if err != nil {}
。
上面諸多方式仍然停留在呼叫-返回-處理
這個流程上,頂多也就是程式碼簡潔與否的問題。我個人是認可Golang
的錯誤處理方式的,雖然會出現很多的模板程式碼,但是在寫程式碼的每一步都能清晰的並強迫性的讓開發者處理潛在的錯誤,也是一種提高質量的不錯手段。
實踐中使用最多的方式,是隔空傳送Exception
,雖然有很多的文章在指導大家如何去花式處理Exception
,但是仍然值得大家留意其中的陷阱。畢竟,異常是一種中斷當前執行流程的手段,並且會穿透呼叫棧,所以需要格外留意捕獲到的異常究竟代表了什麼含義,而不是一股腦的全部捕獲。這一點要贊一下Java
,Java
中的方法簽名會強制列出有可能丟擲的異常型別,以供開發者快速處理可能出現的異常。
有關Exception
設計和使用的話題,我們將來有機會再來聊。
Golang中將來可能的方式
在Go 2
的草案中,我們看到了有關於error
相關的一些提案,那就是check/handle
函式。
我們也許在下一個大版本的Golang
可以像下面這樣處理錯誤:
// golang
import "fmt"
func game() error {
handle err {
return fmt.Errorf("dependencies error: %v", err)
}
resource := check findResource() // return resource, error
defer func() {
resource.Release()
}()
profile := check loadProfile() // return profile, error
defer func() {
profile.Close()
}
// ...
}
有興趣的同學請關注這個提案。題外話,還有一個try
提案正式被否了。
所以,在Golang
中我們目前可以使用的方式,就是以error
介面為基礎,通過不同的錯誤型別,來向消費方提供有價值的資訊。
可恢復故障具體該怎麼拋
重點來了,說了這麼多,錯誤終歸是要扔出去的,雖然都是統一的error
介面,但是手法卻應該仔細斟酌。
錯誤應該包含的資訊
錯誤最主要包含的,就是錯誤資訊,是給人類閱讀使用的,更確切的講,是給開發者閱讀的。所以error
介面裡的Error() string
直接將這個資訊返回。那為什麼要返回error
,而不是直接返回string
呢?因為在開發過程中,我們往往需要一些額外的資訊。
首先,如果只有錯誤的文字,我們很難定位到具體的出錯地點。雖然通過在程式碼中搜尋錯誤文字也是有可能找到出錯地點的,但是資訊有限。所以,在實踐中,我們往往會將出錯時的呼叫棧資訊也附加上去。呼叫棧對消費方是沒有意義的,從隔離和自治的角度來看,消費方唯一需要關心的就是錯誤文字和錯誤型別。呼叫棧對實現者自身才是是有價值的。所以,如果一個方法需要返回錯誤,我們一般會使用errors.WithStack(err)
或者errors.Wrap(err, "custom message")
的方式,把此刻的呼叫棧加到error
裡去,並且在某個統一地方記錄日誌,方便開發者快速定位問題。
舉個例子:
// golang
import "github.com/pkg/errors"
func FindUser(userId string) (*User, error) {
if userId == "" {
return fmt.Errorf("userId is required")
}
user, err := db.FindUserById(userId)
if err != nil {
return nil, errors.Wrapf(err, "query user %s failed", userId)
}
return user, nil
}
如此,在記錄日誌的地方通過使用%+v
格式化佔位符就可以把堆疊資訊完整的記錄下來。
其次,如果是業務執行時的錯誤,只有錯誤訊息的話,往往是不夠的,因為呼叫方更加關心錯誤背後業務上的原因,例如,提交訂單介面返回了提交訂單失敗的錯誤,為什麼失敗?這個時候就需要某種機制來告訴呼叫者一些業務上的原因。顯然,如果通過錯誤訊息告訴的話,呼叫方就不得不對錯誤文字進行判斷,這很不優雅,所以我們往往通過其它兩種方式來處理。
1. 特定錯誤型別,例如:
// golang
var (
ErrInventoryInsufficient = errors.New("product inventory insufficient")
ErrProductSalesTerritoryLimit = errors.New("product sales torritory limit")
)
func Ordering(userId string, preOrder *PreOrder) (*model.Order, error) {
order := &model.Order{}
shippingAddress := preOrder.Shipping
for _, item := range preOrder.Items {
if findInventory(item.Product.Id) <= 0 {
return nil, ErrInventoryInsufficient
}
if !isValidSalesTerritory(item.Product.Id, shippingAddress) {
return nil, ErrProductSalesTerritoryLimit
}
order.AddItem(item)
}
// other processing
return order, nil
}
這樣,消費方拿到錯誤後,可以很簡單的判斷一下就能知道具體發生了什麼:
// golang
func UserOrderController(ctx context.Context, preOrder *PreOrder) {
// some preparing
user := FromContext(ctx)
order, err := service.Ordering(user.userId, preOrder)
if err != nil {
switch err {
case service.ErrInventoryInsufficient: // handling
case service.ErrProductSalesTerritoryLimit: // handling
}
}
// ...
}
這也是很多元件向外部提供錯誤的首選方式,例如,mongo.ErrNoDocuments
但是遺憾的是,如果是跨邊界的RPC
呼叫的話(假如剛才的Ordering
是個微服務),那麼就不能採用這種方式了,因為錯誤型別是無法有效序列化的,即使序列化了也失去了型別判斷的能力。所以,我們在整合有邊界的服務時,往往會採用另一種方式。
2. 錯誤標記,也就是通過某種約定好的標記,用於表示某種型別的業務錯誤。客戶端呼叫遠端的Restful
服務也是邊界與邊界間的呼叫,所以我們經常可以在API
的文件中看到這樣的模式:
返回碼 | 錯誤碼描述 | 說明 |
40001 | invalid credential | 不合法的呼叫憑證 |
40002 | invalid grant_type | 不合法的grant_type |
這裡的返回碼就是一種約定好的標記,也叫業務碼。所謂跨邊界呼叫,也可以換個說法,叫做程式間通訊,如果只在程式內通訊,那使用特定錯誤型別就足夠了,但是一旦出了程式,就需要某種標記手段了。
Golang
在實踐中也可以採用這種方式,尤其是在邊界間傳遞錯誤的時候:
// golang
import (
"fmt"
"regexp"
)
type BusinessError struct {
Code string `json:"code"`
Msg string `json:"msg"`
}
// error interface
func (be BusinessError) Error() string {
return fmt.Printf("[%s] %s", be.Code, be.Msg)
}
var codeReg = regexp.MustCompile("^\\d{6}$")
// factory method
func NewBusinessError(code string, msg string) *BusinessError {
if !codeReg.MatchString(code) {
panic("code can only contain 6 numbers")
}
if msg == "" {
panic("msg is required")
}
return &BusinessError{ code, msg }
}
var (
ErrInventoryInsufficient = NewBusinessError("301001", "product inventory insufficient")
ErrProductSalesTerritoryLimit = NewBusinessError("301002", "product sales torritory limit")
)
注意NewBusinessError
內部使用的是panic
,這背後的思考是,如果程式初始化時連錯誤碼的定義都能出現問題,我傾向於讓程式跑不起來,這樣便在開發階段就能妥善處理。
消費方拿到反序列化後的錯誤時,裡面已經包含了標記,查詢文件分別做處理就好。不管是Restful
,還是GRPC
、GraphQL
,都可以使用這種模式來處理。甚至更大好處是,客戶端不必判斷錯誤文字並設法解析出使用者友好的提示,服務不再提供使用者提示(想想看,如果要對錯誤文字提供i18n
支援的話,得多難看……),一切都交給客戶端去自主選擇。
錯誤資訊應該暴露多少
暴露多少錯誤細節,取決於對這個錯誤感興趣的一方是誰。 暴露多少錯誤細節,取決於對這個錯誤感興趣的一方是誰。 暴露多少錯誤細節,取決於對這個錯誤感興趣的一方是誰。
如果感興趣一方是其他開發者,那麼事情就會變的愉快很多,因為,開發者感興趣的錯誤,一般都是bug或者缺陷,我們不必把所有的細節都解釋給開發者,但是必要的資訊是要提供的,比如一個簡單的錯誤文字。
舉個例子,我們正在寫一個包,其中有一個用於傳送(大陸)簡訊的方法:
// golang
import (
"regexp"
"github.com/pkg/errors"
)
var (
phoneRegexp = regexp.MustCompile("^((\\+86)|(86))?\\d{11}$")
ErrPhoneSmsExceedLimit = errors.New("target phone exceed send limits")
)
func SendSms(phone string, content string) error {
if phone == "" {
return errors.New("phone is required")
}
if content == "" {
return errors.New("content is required")
}
if !phoneRegexp.MatchString(phone) {
return errors.New("phone format incorrect")
}
if exceedLimits(phone) {
return ErrPhoneSmsExceedLimit
}
// ...
}
由於使用SendSms
的人只可能是開發者,所以簡單的將錯誤資訊返回就可以了,無須再多做處理。
這裡需要插一句,一切的錯誤都會影響消費方的執行(除非消費方總是忽略錯誤),所以總在某個地方將我們返回的錯誤展示給開發者。
在上面這個例子中,我們已經要求了phone
和content
不應該為空字串,那麼消費方為什麼還要給我空字串呢?這是bug。
另外,如果手機號超過了每日傳送的條數限制,這不是bug,而是業務錯誤,所以我們用ErrPhoneSmsExceedLimit
提醒開發者,需要額外留意和處理一下,必要的時候用一些友好資訊告訴使用者。在該例子中是假定SendSms
和消費方處於同一程式,所以只需要通過判斷err == sms.ErrPhoneSmsExceedLimit
就可以準確的捕獲到業務錯誤。那如果這個發簡訊的方法在一個微服務之後呢?上面我們也提到了,這時候需要有某種標記:
// golang
var ErrPhoneSmsExceedLimit = NewBusinessError("310001", "target phone exceed send limits")
func SendSms(phone string, content string) error {
// ...
if exceedLimits(phone) {
return ErrPhoneSmsExceedLimit
}
// ...
}
是不是殊途同歸了?當然了,這其中還涉及到一些邊界上對錯誤的包裝與轉換,我們在後面會提到。
那麼接下來,如果這個方法還需要呼叫一些別的RPC
(這裡假定是個Restful
服務)才能完成最終的傳送,並且呼叫有可能會有錯誤,該怎麼處理呢?我們會包裝它:
// golang
func SendSms(phone string, content string) error {
// ...
provider := service.NewSmsProvider("appid", "appsecret")
res, err := provider.Send(phone, content)
if err != nil {
return errors.Wrapf(err, "send sms to phone %s failed", phone)
}
// ...
}
如此,消費方看到的只是send sms to phone xxx failed
(包裝進去的低層err
會在邊界處切掉),不過不影響我們服務本身列印出呼叫棧,方便我們知道是我們使用RPC
的姿勢有問題,還是網路出現故障了,還是……總之,我們進行不下去了。我們不必告訴消費方這些低層的錯誤細節,但是我們需要保留這些細節方便自己。
我們繼續思考,如果呼叫RPC
成功返回了,就一定代表成功了嗎?當然不是,沒有err
很可能只是說明整個RPC
成功完成,但沒說業務一定是成功的呀,所以我們還得對res
進一步分析:
// golang
func SendSms(phone string, content string) error {
// ...
res, err := provider.Send(phone, content)
if err != nil {
// ...
}
switch res.Code {
case "0000":
return nil
case "1001":
log.Printf("sms provider report [%s] insufficient balance", res.code)
default:
log.Printf("sms provider report [%s] %s", res.Code, res.Msg)
}
return errors.New("send sms failed")
}
我們已知的業務碼只有0000
代表成功,所以返回nil
表示本次呼叫成功;1001
代表餘額不足,其它的我們可能並不關心,那麼在簡單的記錄日誌之後,返回給呼叫方的只有send sms failed
。這是因為,我的錯誤我知道,我依賴服務的錯誤我也應該知道,但是,依賴我的服務如果不是使用姿勢不對,或者業務不正確的話,沒有理由瞭解這背後發生的過多細節,唯一需要讓消費方知道的就是沒成功。與此同時,我們記錄了所有的細節,不管是顯式的log.Printf
還是在邊界上列印的呼叫棧,都將進一步幫助我們分析和修復錯誤,或者改善實現細節。
那麼,如果此時SendSms
方法還需要呼叫並處理另一個內部的方法darkMagic(phone string) error
返回的錯誤呢?沒關係,仍然errors.Wrap(err, "cannot perform such operation")
就好了。這不僅僅是給呼叫方看,更重要的是,這說明了在darkMagic
裡可能有一個bug,需要我們自己處理,因為,我們是最清楚這些邏輯的,如果一切檢查(引數的,業務的)都沒問題,還會在內部出錯,那麼就可能是我們的實現有問題了。好在,這一類的缺陷通過單元測試一般都可以檢測出來。
一個小問題,
darkMagic()
裡如果呼叫spellForce()
又得到error
了怎麼辦? 答案是,直接return err
堆疊資訊在spellForce()
扔出的error
裡就有了,錯誤資訊也很明確,著實不用再包裝一層。 也就是說,程式內遇到的error
,只在離邊界最近的地方才需要errors.Wrap()
成對呼叫方友好(和隱藏細節)的error
,其它的都直白的往上return err
就好
總結一下:
- 你使用我的姿勢不對,例如空字串,會造成我的錯誤,直接返回
errors.New()
,這是bug,你去處理 - 你使用的姿勢是對的,我定睛一看是業務上問題,給你一個讓你有機會通過錯誤型別或者錯誤碼知道的原因,你酌情處理
- 你使用的姿勢是對的,我檢查發現業務也沒毛病,但是我依賴的一些服務(例如資料庫)出么蛾子了,那麼我會
Wrap
成一個既方便我調查原因,同時在不讓你關注過多細節的前提下告訴你:失敗了,你酌情處理,例如重試或者告訴終端使用者“我們的服務開了會小差,請稍後重試”等 - 如果我覺得這一定是個很嚴重的問題,並且我也無法解決,同時認為你也不該嘗試解決,那麼就
panic
吧。這一點在線上業務上幾乎遇不到,除了“記憶體滿了”、“堆疊爆了”這些無法抗拒的原因,panic
的很少會有
可恢復故障如何處理
我們在“錯誤資訊應該暴露多少”一節裡已經展示過一些處理方式,尤其是對跨越多層邊界的錯誤,程式內遇到錯誤的情形等。非邊界處的錯誤處理很直白,上一節也做出瞭解釋和示例,這一節我們討論一下在邊界處如何處理遇到的error
。
所謂邊界,就是離呼叫方最近的地方,呼叫方可以是某個服務,也可以是使用者使用的某種客戶端,總之是在消費你在邊界處提供的服務。邊界以內,只有程式內可見。
所以,我們可以認為,一個使用者微服務的GetUserById()
在邊界上,一個beego.Get("/",func(ctx *context.Context){})
用MVC
實現的方法也在邊界上。
通常情況下,在邊界處,我們就需要對下游產生的錯誤做出判斷,同時,對一些非業務錯誤一些包裝,隱藏錯誤細節。如果邊界不是面向終端使用者的,那麼也會提供一些開發者友好的錯誤文字。
我們分別來這其中處理錯誤的不同。
面向非使用者的邊界
對於一個使用者微服務的GetUserById()
,它的消費方一般不會是終端使用者,而是某種聚合閘道器或者其它微服務,所以它藏匿在整個安全壁壘之後。我們通常會這麼處理:
// golang
import (
"context"
"github.com/pkg/errors"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
var ErrUserNotValid = NewBusinessError("500213", "user is not valid")
func GetUserById(userId string) (*model.User, error) {
if userId == "" {
return errors.New("userId is required")
}
uid, err := primitive.ObjectIDFromHex(userId)
if err != nil {
return nil, errors.Wrap(err, "userId format incorrect")
}
user := &model.User{}
coll := db.Collection("users")
if err := coll.FindOne(context.TODO(), bson.M{"_id": uid}).Decode(user); err != nil {
if err == mongo.ErrNoDocuments {
// maybe return nil, nil is fine
// but, depends on design, be careful
}
return nil, errors.Wrap(err, "cannot perform such operation")
}
// maybe do local business check
if localBusinessCheck(user) {
return nil, ErrUserNotValid
}
// maybe call RPC to do business action
fine, err := rpc.BusinessAction(user)
if err != nil {
// err usually wrapped in rpc particular message type
// so we need abstract real error from wrapper type
rpcStatus := rpc.Convert(err)
if rpcStatus.Type == rpc.Status_Business_Error {
code := rpcStatus.GetMeta("code")
msg := rpcStatus.GetMeta("msg")
return nil, NewBusinessError(code, msg)
}
cause := rpcStatus.Error()
return nil, errors.Wrap(cause, "service unavailable")
}
if !fine {
return nil, ErrUserNotValid
}
return user, nil
}
這段示例很有意思。首先,如何處理下游支撐服務返回的異常?支撐服務(例如資料庫、快取、中介軟體等等)往往沒有業務,它們返回的錯誤就是單純的錯誤,需要開發者每時每刻關注和處理。所以,在這裡直接包裝並返回。於此同時,GetUserById()
的消費方得到了只應該它們關注的cannot perform such operation
,而在使用者微服務裡,我們得到了完整的呼叫棧和錯誤資訊。
其次,本地的業務檢查如果失敗,我們將直接返回一個預定義好的ErrUserNotValid
,表示一個業務上的失敗。
最後,如果涉及進一步的遠端RPC
呼叫,事情會變的稍微麻煩一些。遠端的RPC
呼叫可能有錯誤,但是錯誤型別比較複雜。通過RPC
的方式傳遞錯誤不如程式內呼叫那麼簡單直白,為了能夠順利序列化,很多的RPC
框架都會將錯誤資訊打包成為某種專有的結構,所以,我們需要一些手段從這些專有結構中提取出我們需要的資訊出來。
GRPC會將錯誤打包成為
google.golang.org/genproto/googleapis/rpc/status
包中的status.Status
結構,status.Status
裡包含了Code
、Message
、Details
,我們通常可以約定Code
為10
代表業務錯誤(10代表Aborted),同時將業務碼打包進Details
裡。GraphQL也有類似的方式,在返回的資料中,除了包含正常資料的
data
欄位外,還有一個errors
陣列欄位。一般發生錯誤時,會通過errors.[].message
提供錯誤資訊供客戶端使用,但當我們需要提供業務碼資訊時,這個欄位顯然不太適合使用。不過好在,除了errors.[].message
,GraphQL還提供了errors.[].extensions
結構用於擴充套件錯誤資訊。於是乎,可以和消費方約定一個業務碼所使用的具體欄位,例如errors.[].extensions.code
,如此便很好的解決了問題。Restful的方式其實很像是GraphQL的方式,由於
http
上不提供額外的序列化通道,能用的只有body
這一個選項(用header
?不能夠!),所以看起來只能提供{ "data": {}, "err_code": "", "err_msg": "" }
這樣的萬能包裝。其實大可不必,沒有錯誤的情況下,正常把資料寫入body
,當出現業務錯誤時,只要返回{ "err_code": "", "err_msg": "" }
,同時把status code設定為400即可,這樣就能把萬能的data
欄位解放出來了。如果是一般的錯誤,例如少引數、引數不允許為空等,這時候不用提供err_code
,只提供err_msg
,同時把status code設定為500即可。一股腦的200
真的不是什麼好設計。
通過rpc.Convert()
類似的工具函式,我們能從RPC
的error
中拿到原始的結構資料,然後通過判斷,確定是否為業務上的錯誤(所代表的型別),進而將原始的業務錯誤重新向外扔出,不需要做額外的處理。如果不是業務上的錯誤,那麼就是bug、缺陷或者傳輸級別的故障,我們仍舊可以通過包裝扔出,留下堆疊和詳細資訊在微服務內。
這或多或少的需要一種統一的設計和約定,例如將RPC
錯誤的型別欄位的某個特定key,約定好專門用於存放業務錯誤碼,否則的話將無法區分“業務錯誤”和“其它錯誤”。
示例中關於RPC
錯誤的程式碼稍顯囉嗦,我們其實可以稍微重構一下:
// golang
func handleRpcError(err error, wrapMsg string) error {
if err == nil {
return nil
}
rpcStatus := rpc.Convert(err)
if rpcStatus.Type == rpc.Status_Business_Error {
code := rpcStatus.GetMeta("code")
msg := rpcStatus.GetMeta("msg")
return nil, NewBusinessError(code, msg)
}
cause := rpcStatus.Error()
return nil, errors.Wrap(cause, wrapMsg)
}
// in pratice
func FindUserById(userId string) error {
// ...
fine, err := rpc.BusinessAction(user)
if err != nil {
return nil, handleRpcError(err, "service unavailable")
}
}
那麼,如果是更靠近終端使用者的“邊界”,又該如何處理呢?
面向使用者的邊界
很明確的就是,首先使用者很大程度上是關心業務碼的,至少使用者使用的客戶端是關心的;其次,使用者是不關心什麼連線字串錯誤、userId is required
等等這些錯誤的。所以,業務錯誤需要明確給出,前置檢查錯誤只給開發者,其它不可預料的錯誤全部簡單轉換為“服務當前不可用”。
有幾個簡單的觀點:
- 有業務碼錯誤的才需要對使用者顯示資訊,其它的一律可顯示為視為出錯了,請稍後重試
- 有業務碼的,說明是非技術的錯誤,其他一切要麼是bug,需要開發人員在上線前處理完畢,要麼是執行錯誤,比如資料庫異常。需要告訴使用者的只有出錯了,請稍後重試,不會也不能再告訴更多
- 身份證號格式不對,電話號格式不對,這種錯誤在嚴格意義上算是bug,應該在呼叫
API
前就檢驗好的。如果設計不那麼嚴格,可以適當的返回業務碼幫助一下,但也只是友情幫助,該客戶端做的驗證還是得做的
我們來看最後一個例子:
// golang
import (
"github.com/pkg/errors"
)
var ServiceUnavailableMessage = "service unavailable"
type LoginReq struct {
Username string
Password string
}
func Login(ctx context.Context, req LoginReq) (*model.Credential, error) {
if req.Username == "" {
return nil, errors.New("username is required")
}
if req.Password == "" {
return nil, errors.New("password is required")
}
// FindByUsername
// maybe got business error: '[10011] user doesn't exists'
user, err := rpc.UserService.FindByUsername(req.Username)
if err != nil {
return handleRpcError(err, ServiceUnavailableMessage)
}
// SignIn
// maybe got business error: '[20001] account is disabled'
// maybe got business error: '[20002] password is incorrect'
// maybe got business error: '[20003] login place abnormal'
cred, err := rpc.AccountService.SignIn(user.Id, req.Password)
if err != nil {
return handleRpcError(err, ServiceUnavailableMessage)
}
credential := &model.Credential{}
if err := credential.Load(cred); err != nil {
return errors.Wrap(err, ServiceUnavailableMessage)
}
return credential, nil
}
這是非常常見的一種API
服務的寫法,我省去了一些不必要的細節,例如Routing
或者Response
相關的東西。其實和普通的微服務實現沒有什麼兩樣,除了幾個小細節:
- 對引數的校驗還是必要的,不能因為微服務校驗過引數,消費方就不做校驗了
- 除了引數校驗的錯誤,仍然需要對下游服務返回的業務錯誤同步的向上返回
- 除了引數錯誤和業務錯誤,其它的錯誤會包裝成
service unavailable
,不向使用者洩露任何的技術細節
通常,在這種型別的服務中,會有一個類似中介軟體的東西,統一的處理一切的錯誤(或者,建議自己實現一個),或者叫全域性的錯誤處理函式、生命週期鉤子等等,總之在我們的Login()
函式返回錯誤後,能夠以統一的方式響應給使用者端,那具體會是什麼樣呢?
// golang
type UserError struct {
ErrCode string `json:"err_code"`
ErrMsg string `json:"err_msg"`
}
func handleGlobalError(ctx HttpContext, err error) {
if err != nil {
if e, ok := err.(*BusinessError); ok {
ue := &UserError{
ErrCode: e.Code,
ErrMsg: e.Msg,
}
ctx.Response.WriteJson(ue)
ctx.Response.SetStatus(400)
} else {
ue := &UserError{
ErrMsg: err.Error(),
}
ctx.Response.WriteJson(ue)
ctx.Response.SetStatus(500)
}
}
}
當然,這個函式只是概念上的解釋,具體到每一個不同的場景會有不同的API
和方式。實際上,如果能夠支援這種全域性錯誤處理,那麼credential.Load(cred)
產生的錯誤實際都不用Wrap
,只需在處理全域性錯誤的時候,直接將非業務錯誤的UserError
的ErrMsg
設定成service unavailable
就可以了,這也避免了處處都errors.Wrap(err, ServiceUnavailableMessage)
,讓簡潔性更進一步。
如此,世界得以清靜。
(完)
相關文章
- 如何在 Go 中優雅的處理和返回錯誤(1)——函式內部的錯誤處理Go函式
- 教你如何優雅處理Golang中的異常Golang
- async/await 優雅的錯誤處理方法AI
- 關於laravel的錯誤頁面處理大家都是如何優雅的處理的呢?Laravel
- 淺析Node是如何進行錯誤處理的
- EMQX 在 Kubernetes 中如何進行優雅升級MQ
- SpringBoot進行優雅的全域性異常處理Spring Boot
- 在 Laravel 中優雅處理 Form 表單LaravelORM
- Golang通脈之錯誤處理Golang
- 如何優雅的處理異常
- 程式中的敏感資訊如何優雅的處理?
- Redux 進階 — 優雅的處理 async actionRedux
- Redux 進階 -- 優雅的處理 async actionRedux
- grpc中的錯誤處理RPC
- 【翻譯】在Spring WebFlux中處理錯誤SpringWebUX
- 錯誤處理:如何通過 error、deferred、panic 等處理錯誤?Error
- Golang 學習——error 錯誤處理淺談GolangError
- 如何優雅處理前端異常?前端
- Restful API 中的錯誤處理RESTAPI
- 【譯】RxJava 中的錯誤處理RxJava
- [譯]Go如何優雅的處理異常Go
- eclipse在使用中彈出這個錯誤框,該如何處理?Eclipse
- 在大型軟體專案中如何處理錯誤和異常
- 這樣也行,在lambda表示式中優雅的處理checked exceptionException
- 如何優雅的入門golangGolang
- java優雅的處理程式中的異常Java
- 如何優雅地處理前端異常?前端
- 談談RxSwift中的錯誤處理Swift
- 應用中的錯誤處理概述
- Bash 指令碼中的錯誤處理指令碼
- 如何優雅的在 Kubernetes Pod 內進行網路抓包
- Go語言(golang)的錯誤(error)處理的推薦方案GolangError
- 錯誤處理
- 【故障處理】如何避免在執行impdp後出現ORA-00001錯誤
- 如何處理錯誤訊息PleaseinstalltheLinuxkernelheaderfilesLinuxHeader
- 「Golang成長之路」錯誤處理與資源管理Golang
- 如何優雅地檢視 JS 錯誤堆疊?JS
- go的錯誤處理Go