如何優雅的在Golang中進行錯誤處理

Ninja_Lu發表於2019-07-26

如何優雅的在Golang中進行錯誤處理?

答案是:沒有……(本文完)


開個玩笑,Golang中的錯誤處理方式一直是社群熱烈討論的話題,有力挺者,有抱怨者,但不論如何,自2009年Golang正式釋出以來,關於錯誤處理就一直是現在這種狀況。

隨著Golang愈加的火爆,原本是JavaNodeC#等語言擅長的應用級開發領域也逐漸出現Golang的身影。Golang自身其實更加擅長做基礎設施級開發,例如docker,例如k8s,再如etcd,它友好的記憶體管理和簡單到粗暴的語法(25個關鍵字),特別適合過去CC++這些語言所擅長的部分場景。我們有理由相信,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),但是貌似太多的語言在混搭使用這兩個術語,所以我們乾脆放棄解釋錯誤和異常,而使用可恢復和不可恢復來說明。同時,我個人實名點贊GolangRust在這兩個概念上的區分。

不可恢復故障

GolangRust都有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上,也叫做EnumUnionTagged Unionvariantvariant recordchoice typedisjoint unionsum typecoproduct……它是一種可以儲存多種(但是數量固定)型別值的結構,同一時間只可以使用其中的一種型別。舉個例子,如下的DUs可以避免null的顯式使用:

// fsharp
type Option<'T> =
    | None
    | Some of 'T

這個Option<'T>(也有叫Maybe的)要麼只有None值,要麼只有一個包含'TSome值,於是,當函式返回一個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走的就是這種路子,並有配套的函式支援。由於OptionResult如此常用,以至於很多語言核心庫都內建了對應的結構,有興趣可以參考我很早之前寫過的一點東西

那麼,Golang為什麼不使用這種方式呢?因為,第一缺乏泛型支援,Warpper如果沒有泛型支援的話就無法泛化,會導致很多的模板程式碼,進而還不如直白的處理error;第二沒有Discriminated Unions,多個型別無法聯合起來並在同一時間只使用其中一種,也就快速區分彼此;第三沒有模式匹配,也就無法更進一步的簡化程式碼,不如還是使用if err != nil {}

上面諸多方式仍然停留在呼叫-返回-處理這個流程上,頂多也就是程式碼簡潔與否的問題。我個人是認可Golang的錯誤處理方式的,雖然會出現很多的模板程式碼,但是在寫程式碼的每一步都能清晰的並強迫性的讓開發者處理潛在的錯誤,也是一種提高質量的不錯手段。

實踐中使用最多的方式,是隔空傳送Exception,雖然有很多的文章在指導大家如何去花式處理Exception,但是仍然值得大家留意其中的陷阱。畢竟,異常是一種中斷當前執行流程的手段,並且會穿透呼叫棧,所以需要格外留意捕獲到的異常究竟代表了什麼含義,而不是一股腦的全部捕獲。這一點要贊一下JavaJava中的方法簽名會強制列出有可能丟擲的異常型別,以供開發者快速處理可能出現的異常。

有關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,還是GRPCGraphQL,都可以使用這種模式來處理。甚至更大好處是,客戶端不必判斷錯誤文字並設法解析出使用者友好的提示,服務不再提供使用者提示(想想看,如果要對錯誤文字提供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的人只可能是開發者,所以簡單的將錯誤資訊返回就可以了,無須再多做處理。

這裡需要插一句,一切的錯誤都會影響消費方的執行(除非消費方總是忽略錯誤),所以總在某個地方將我們返回的錯誤展示給開發者。

在上面這個例子中,我們已經要求了phonecontent不應該為空字串,那麼消費方為什麼還要給我空字串呢?這是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裡包含了CodeMessageDetails,我們通常可以約定Code10代表業務錯誤(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()類似的工具函式,我們能從RPCerror中拿到原始的結構資料,然後通過判斷,確定是否為業務上的錯誤(所代表的型別),進而將原始的業務錯誤重新向外扔出,不需要做額外的處理。如果不是業務上的錯誤,那麼就是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,只需在處理全域性錯誤的時候,直接將非業務錯誤的UserErrorErrMsg設定成service unavailable就可以了,這也避免了處處都errors.Wrap(err, ServiceUnavailableMessage),讓簡潔性更進一步。

如此,世界得以清靜。

(完)

相關文章