1. 程式人生 > >探索 Go 中的錯誤處理模式

探索 Go 中的錯誤處理模式

當你學習一種新的程式語言時,可能會存在一個挫敗期,就是當你無法使用更熟悉的語言來表達想法的時候。你很自然的想知道為什麼語言要設計成這樣,很容易誤認為(當表達想法遇到困難時)這是語言設計者的失誤。這種推理可能會導致你以一種非慣用的方法使用一種語言。

一個挑戰我自己觀念的內容是如何在 Go 中處理錯誤。概括如下:

  • Go 中的錯誤是一個實現了 error 介面(實現了 Error() 函式)的任意型別。
  • 函式返回錯和返回其它型別沒有區別。使用多返回值將錯誤和正常區分開。
  • 通過檢查函式返回值來處理錯誤,並通過返回值傳遞到更高層抽象來處理(可以向錯誤訊息追加詳細內容)。

例如,考慮一個解析主機地址並偵聽 TCP 連線的函式。有兩種出錯的可能,因此需要有兩個錯誤檢查:

func Listen(host string, port uint16) (net.Listener, error) {
    addr, addrErr := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", host, port))
    if addrErr != nil {
        return nil, fmt.Errorf("Listen: %s", addrErr)
    }

    listener, listenError := net.ListenTCP("tcp", addr)
    if listenError != nil {
        return nil, fmt.Errorf("Listen: %s", listenError)
    }

    return listener, nil
}

這個主題在 Go 常見問題 中有自己的條目,社群對此提出了廣泛的意見。根據你過去的經驗,你可能會傾向於認為:

  • Go 應該實現 某種形式的異常處理,允許編寫 try/catch 塊 , 將生成錯誤的程式碼組織在一起,並將其與錯誤處理的程式碼分開來。
  • Go 應該實現 某種形式的模式匹配,可以供了一種簡潔的方法來包裝錯誤,並對值和錯誤使用不同的形式,等等。

雖然這些在其他語言中是有用的功能,但它們短期內不太可能在 Go 中實現。相反,讓我們來看看使用現有功能編寫 Go 程式碼時慣用的幾種寫法。

一個稍長的例子

和前面的例子相比可能沒有太大的改變,沒有更短或更簡單,但每次函式呼叫後都編寫 if 語句,可能會感覺它正在失控:

func (router HttpRouter) parse(reader *bufio.Reader) (Request, Response) {
    requestText, err := readCRLFLine(reader) //string, err Response
    if err != nil {
        //No input, or it doesn't end in CRLF
        return nil, err
    }

    requestLine, err := parseRequestLine(requestText) //RequestLine, err Response
    if err != nil {
        //Not a well-formed HTTP request line with {method, target, version}
        return nil, err
    }

    if request := router.routeRequest(requestLine); request != nil {
        //Well-formed, executable Request to a known route
        return request, nil
    }

    //Valid request, but no route to handle it
    return nil, requestLine.NotImplemented()
}

這種寫法還有一些不足之處:

  1. 從整個函式來看可以提取一些輔助函式。
  2. 從兩個錯誤情況的中間返回成功值有一些難閱讀。
  3. 搞不清楚第二個 err 是新分配的變數還是第一個 err 的重新賦值。這和單變數形式使用 := 並不完一致,單變數形式是禁止重新分配變數的。

第一種選擇:接受這種寫法

雖然這種寫法讓我感覺不好,但每個可能出現錯誤的位置使用 if 進行處理是 Go 語言中的慣用方法。我們將探索一些其它方來進行重構,但請注意,此程式碼在不濫用語言功能的情況下完成了所需的功能。Go 的斯巴達式的哲學有一個優點:只要有一種明確的方法,你就可以接受它並繼續前進(即使你對標準並不贊同)

根據 Go 的官方程式碼風格,禁止出現未使用的變數和匯入。我可能不同意一些程式碼格式,並認為那裡應該允許未使用的變數,但是使用 goimports 工具能夠很容易遵循標準,而且編譯器也沒有給你更多的選擇。用於選擇程式碼格式的時間現在可以用來重新關注其他更重要的程式碼。

回到程式碼的問題,我們可以探索不同的結構使控制流程更清楚,但缺乏通用的方式,Go 中的高階函式限制了我們的選項。

非慣用方式

你可能熟悉其他語言中使用的控制流的方法,可以嘗試將您喜歡的技術應用於 Go。 讓我們簡單地考慮一些常見的方式(不考慮不常見的情況),這些方式可能會引起 Go 社群的關注。

Defer, Panic, Recover

第一個方式被稱為 DeferPanic 和 RecoverPanic 和 Recove 類似於其他語言的 throw 和 catch。這裡有幾點值得注意:

  • Go 作者確實在 Go 標準庫中使用了一個案例,但他們也一直小心翼翼地避免 panic 暴露在外部。在大多數情況下,panic 是為真正的災難性錯誤準備的(非常類似於 Java 中的 Error 類用於不可恢復的錯誤)。
  • 異常使用的虛擬變數破壞了函式的引用透明:Scala 中的函式程式設計很好地描述了這一點。總結一下:可以丟擲異常的程式碼,根據它是否包含在 try/catch 塊中,可以求得不同的值,因此程式設計師必須知道全域性上下文以避免錯誤。在 GitHub 上有一個很好的例子,並對這個結論有一個簡潔的解釋
  • 實用性的考慮:基於異常的程式碼很難區分正確和錯誤

Go 作者傾向於區分控制流程中的預期分支(例如有效和無效輸入)和威脅整個過程的大規模事件。如果你要保持對 Go 社群的青睞,你應該努力為今後保留 panic

高階函式和包裝型別

Go 作者 Rob Pike 一遍又一遍地說只是寫一個 for 迴圈,但很難拒絕將示例中的問題視為一系列轉換。

bufio.Reader -> string -> RequestLine -> Request

我們不應該為此寫一個對映函式嗎?

Go 是一種沒有泛型的靜態型別語言,因此你可以在使用領域內宣告特定型別的型別,或者完全放棄型別安全。

想象一下,如果你試圖寫的對映函式會是什麼樣子:

// Sure, we can declare a few commonly used variations...
func mapIntToInt(value int, func(int) int) int { ... }
func mapStringToInt(value string, func(string) int) int { ... }

// ...but how does this help?
type any interface{}
func mapFn(value any, mapper func(any) any) any {
    return mapper(value)
}

有一些放棄型別安全的選項,比如 Go Promise 庫。仔細研究示例程式碼可以看到,示例中通過使用接受任何型別的輸出函式(` fmt.Println ` 最終使用反射確定引數型別)來仔細迴避型別安全問題。

var p = promise.New(...)
p.Then(func(data interface{}) {
    fmt.Println("The result is:", data)
})

編寫類似 Scala 的 Either 型別的包裝器也不能真正解決問題,因為它需要具有型別安全的函式來轉換 happy-path 和 sad-path 的值。能夠像這樣編寫示例函式會更好:

func (router HttpRouter) parse(reader *bufio.Reader) (Request, Response) {
    request, response := newStringOrResponse(readCRLFLine(reader)).
        Map(parseRequestLine).
        Map(router.routeRequest)

    if response != nil {
        return nil, response
    } else if request == nil {
        //Technically, this doesn't work because we now lack the intermediate value
        return nil, requested.NotImplemented()
    } else {
        return request, nil
    }
}

但是看看你需要編寫多少一次性程式碼才能支援這種寫法:

func newStringOrResponse(data string, err Response) *StringOrResponse {
    return &StringOrResponse{data: data, err: err}
}

type StringOrResponse struct {
    data string
    err Response
}

type ParseRequestLine func(text string) (*RequestLine, Response)
func (either *StringOrResponse) Map(parse ParseRequestLine) *RequestLineOrResponse {
    if either.err != nil {
        return &RequestLineOrResponse{data: nil, err: either.err}
    }

    requestLine, err := parse(either.data)
    if err != nil {
        return &RequestLineOrResponse{data: nil, err: either.err}
    }

    return &RequestLineOrResponse{data: requestLine, err: nil}
}

type RequestLineOrResponse struct {
    data *RequestLine
    err Response
}

type RouteRequest func(requested *RequestLine) Request
func (either *RequestLineOrResponse) Map(route RouteRequest) (Request, Response) {
    if either.err != nil {
        return nil, either.err
    }

    return route(either.data), nil
}

因此,編寫高階函式的各種形式結果都是非慣用的,不切實際的,或兩者兼而有之。Go 不是一種函式程式語言。

迴歸本源

現在我們已經看到函式程式設計的形式並沒有什麼用處,這讓我們提醒自己

關鍵的一課是錯誤是一種值型別,並且 Go 語言的全部功能都可用於處理它們。

另一個好訊息是你無法在不知不覺中忽略返回的錯誤,就像未經檢查的異常一樣。編譯器會強制你至少將錯誤宣告為 _,而像 errcheck 這樣的工具可以很好地保證你的正確。

函式組

回顧一下示例程式碼,有兩個明確的錯誤(輸入沒有以 CRLF 結束和 HTTP 請求格式不正確),一個清楚的成功響應和一個預設響應。為什麼我們不將這些情況進行分組?

func (router HttpRouter) parse(reader *bufio.Reader) (Request, Response) {
    requested, err := readRequestLine(reader)
    if err != nil {
        //No input, not ending in CRLF, or not a well-formed request
        return nil, err
    }

    return router.requestOr501(requested)
}

func readRequestLine(reader *bufio.Reader) (*RequestLine, Response) {
    requestLineText, err := readCRLFLine(reader)
    if err == nil {
        return parseRequestLine(requestLineText)
    } else {
        return nil, err
    }
}

func (router HttpRouter) requestOr501(line *RequestLine) (Request, Response) {
    if request := router.routeRequest(line); request != nil {
        //Well-formed, executable Request to a known route
        return request, nil
    }

    //Valid request, but no route to handle it
    return nil, line.NotImplemented()
}

在這裡,我們可以通過一些額外的函式使解析功能更小。你可以決定是選擇更多的函式還是更大的函式。

正確和錯誤的路徑並行

也可以重構現有的功能,同時處理正確和錯誤的路徑。

func (router HttpRouter) parse(reader *bufio.Reader) (Request, Response) {
    return router.route(parseRequestLine(readCRLFLine(reader)))
}

//Same as before
func readCRLFLine(reader *bufio.Reader) (string, Response) { ... }

func parseRequestLine(text string, prevErr Response) (*RequestLine, Response) {
    if prevErr != nil {
        //New
        return nil, prevErr
    }

    fields := strings.Split(text, " ")
    if len(fields) != 3 {
        return nil, &clienterror.BadRequest{
            DisplayText: "incorrectly formatted or missing request-line",
        }
    }

    return &RequestLine{
        Method: fields[0],
        Target: fields[1],
    }, nil
}

func (router HttpRouter) route(line *RequestLine, prevErr Response) (Request, Response) {
    if prevErr != nil {
        //New
        return nil, prevErr
    }

    for _, route := range router.routes {
        request := route.Route(line)
        if request != nil {
            //Valid request to a known route
            return request, nil
        }
    }

    //Valid request, but unknown route
    return nil, &servererror.NotImplemented{Method: line.Method}
}

相比原始示例中的四個函式,我們將函式減少到了三個,但是必須從內到外閱讀頂級 parse 函式。

錯誤閉包

你還可以在遇到第一個錯誤後建立一個閉包。文章中的示例程式碼如下所示:

_, err = fd.Write(p0[a:b])
if err != nil {
    return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
    return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
    return err
}

作者編寫了一個函式只要沒有遇到錯誤,就會繼續進行下一步操作

var err error
write := func(buf []byte) {
    if err != nil {
        return
    }
    _, err = w.Write(buf)
}
write(p0[a:b])
write(p1[c:d])
write(p2[e:f])
if err != nil {
    return err
}

當你在處理過程中將每個步驟傳遞給閉包時,這種方法很有效,建議在每個步驟中應用相同的型別。在某些情況下,這可以很好地工作。

在本部落格的示例中,你必須建立一個結構來處理錯誤,併為工作流中的每個步驟編寫單獨的 applyParseText / applyParseRequest / applyRoute 函式,這可能會比它帶來的價值更麻煩。

總結

雖然 Go 在錯誤處理方面的設計選擇起初可能看起來很陌生,但從各種部落格和會談中可以清楚地看出,作者給出的這些選擇不是隨意的。就個人而言,我試提醒自己缺乏經驗的麻煩在於我,而不是 Go 作者,而且我可以學會以新的方式思考老的問題。

當我開始撰寫本文時,我認為可以從其他更多功能語言借鑑經驗,使用更多的函式來使我的 Go 程式碼更簡單。這段經歷一直很好地提醒我 Go 的作者一直在強調的是:有時候編寫一些你自己特定用途的函式並繼續前進是有幫助的。