gRPC 的錯誤處理實踐
0 背景
我們內部系統全部統一採用gRPC
協議和protobuf
編解碼。統一的好處在於不需要在做任何協議、編解碼轉換,這樣就可以使我們所有業務採用同一個protobuf
倉庫,基於CI/CD
工具實現許多自動化功能。
我們要求所有服務提供者提前在獨立的路徑下定義好接口和錯誤碼的protobuf
文件,然後提交到GitLab
,我們通過GitLab CI
的check
階段對變更的protobuf
文件做format
、lint
、breaking
檢查。然後在build
階段,會基於protobuf
文件中的註釋自動產生文檔,並推送至內部的微服務管理系統接口平臺中,還會根據protobuf
文件自動構建Go/PHP/Node/Java
等多種語言的樁代碼和錯誤碼,並推送到指定對應的中心化倉庫。推送到倉庫後,我們就可以通過各語言的包管理工具拉取客戶端、服務端的 gRPC 和錯誤碼的依賴,不需要口頭約定對接數據的定義,也不需要通過IM
工具傳遞對接數據的定義文件,極大的簡化了對接成本。
1 判斷 Error 的錯誤原理
要了解怎麼處理gRPC
的error
之前,我們首先來看下Go
普通的error
是怎麼處理的。
我們在判斷一個error
的根因,需要根因error
是一個固定地址的指針類型,這樣我們才能夠使用官方的errors.Is
方法判斷他是否爲根因。以下是一個代碼示例:
我們先看這個代碼errors.Is(wrapNewPointerError(), fmt.Errorf("i am error"))
的執行步驟,首先構造了一個error
,然後使用官方%w
的方式將error
進行了包裝,我們在使用errors.Is
方法判斷的時候,底層函數會將error
解包來判斷兩個error
的地址是否一致。
因此我們第一個errors.Is
執行的是個false
。在使用這個代碼errors.Is(wrapConstantPointerError(), sentinelErr)
,因爲是固定地址的error
,所以判斷根因錯誤的時候,執行的是true
。
2 gRPC 網絡傳輸的 Error
我們客戶端在獲取到gRPC
的error
的時候,是否可以使用上文說的官方errors.Is
進行判斷呢。如果我們直接使用該方法,通過判斷 error 地址是否相等,是無法做到的。原因是因爲我們在使用gRPC
的時候,在遠程調用過程中,客戶端獲取的服務端返回的error
,在tcp
傳遞的時候實際上是一串文本。客戶端拿到這個文本,是要將其反序列化轉換爲error
,在這個反序列化的過程中,其實是new
了一個新的error
地址,這樣就無法判斷error
地址是否相等。
爲了更好的解釋gRPC
網絡傳輸的error
,以下描述了整個error
的處理流程。
-
客戶端通過
invoker
方法將請求發送到服務端。 -
服務端通過
processUnaryRPC
方法,獲取到用戶代碼的error
信息。 -
服務端通過
status.FromError
方法,將error
轉化爲status.Status
。 -
服務端通過
WriteStatus
方法將status.Status
裏的數據,寫入到grpc-status
、grpc-message
、grpc-status-details-bin
的header
頭裏。 -
客戶端通過網絡獲取到這些
header
頭,使用strconv.ParseInt
解析到grpc-status
信息、decodeGrpcMessage
解析到grpc-message
信息、decodeGRPCStatusDetails
解析爲grpc-status-details-bin
信息。 -
客戶端通過
a.Status().Err()
獲取到用戶代碼的錯誤。
爲了方便理解,我們抓個包,看下error
具體的報文情況。
3 檢查 gRPC 的 error 信息第一版本
通過上文描述,我們已經瞭解了gRPC
在網絡中如何傳輸error
,可以看到new
出來的error
是無法判等的。所以我們就想到,使用工具提前生成好error
,這樣error
的地址是不會改變的。這樣我們就可以使用errors.Is
的方法去檢查根因error
。
首先我們可以將錯誤碼編寫在proto
裏,註釋,如下所示:
syntax = "proto3";
package engineering.helloworld;
option go_package = "engineering/helloworld;helloworld";
// @plugins=protoc-gen-go-errors
// 錯誤
enum Error {
// 未知類型
// @code=UNKNOWN
RESOURCE_ERR_UNKNOWN = 0;
// 找不到資源
// @code=NOT_FOUND
RESOURCE_ERR_NOT_FOUND = 1;
// 獲取列表數據出錯
// @code=INTERNAL
RESOURCE_ERR_LIST_MYSQL = 2;
// 獲取詳情數據出錯
// @code=INTERNAL
RESOURCE_ERR_INFO_MYSQL = 3;
}
然後我們可以通過執行proto
錯誤插件,生成固定地址的error
,將error
註冊到全局map
裏,同時我們還可以根據@code
的註釋,生成gRPC
的狀態碼。
func init() {
resourceErrUnknown = eerrors.New(int(codes.Unknown), "engineering.helloworld.RESOURCE_ERR_UNKNOWN", Error_RESOURCE_ERR_UNKNOWN.String())
eerrors.Register(resourceErrUnknown)
resourceErrNotFound = eerrors.New(int(codes.NotFound), "engineering.helloworld.RESOURCE_ERR_NOT_FOUND", Error_RESOURCE_ERR_NOT_FOUND.String())
eerrors.Register(resourceErrNotFound)
resourceErrListMysql = eerrors.New(int(codes.Internal), "engineering.helloworld.RESOURCE_ERR_LIST_MYSQL", Error_RESOURCE_ERR_LIST_MYSQL.String())
eerrors.Register(resourceErrListMysql)
resourceErrInfoMysql = eerrors.New(int(codes.Internal), "engineering.helloworld.RESOURCE_ERR_INFO_MYSQL", Error_RESOURCE_ERR_INFO_MYSQL.String())
eerrors.Register(resourceErrInfoMysql)
}
func ResourceErrUnknown() eerrors.Error {
return resourceErrUnknown
}
....
接着我們在獲取gRPC error
後,需要使用FromError
方法,轉換爲我們proto
生成的error
。在這個轉換過程中,我們會從之前註冊的全局error map
裏,通過reason
方法,找到對應的error
,返回給用戶。用戶這個時候就可以通過errors.Is
來判斷根因。
4 檢查 gRPC 的 Error 信息第二版本
按以上方案,確實可以解決根因問題,但該error
,無法攜帶message
,metadata
信息。這就導致我們,很難準確定位一些問題。所以這個時候,我們需要在error
裏做一些擴展,增加兩個方法。
這種方式可以讓我們攜帶信息,但是他會對原有的error
錯誤做一次克隆,導致了error
的地址變化,無法在通過error
判等的方式進行校驗是否是根因。
這個時候,我們只能通過errors.Is
中的(interface{ Is(error) bool })
斷言方式,在我們自定義的error
中,增加一個Is
方法來判斷。
通過這種方式,我們不僅可以判斷根因,並且還可以將error
裏攜帶更多排查有用的信息。
5 演示 gRPC 的 Error 的處理
爲了更好的演示 error,我們將 error 處理的方式做成了工具,通過執行腳本,我們就可以下載到對應的工具
bash <(curl -L https://raw.githubusercontent.com/gotomicro/egoctl/main/getlatest.sh)
通過該工具,就可以執行我們ego error
的演示代碼
5.1 生成 error、grpc 的 pb 文件
我們在該演示代碼目錄下執行make gen
,可以生成對應的error
、grpc
的pb
文件,如下所示。
這些error
爲了防止其他人不小心篡改,獲取error
的時候,都是用方法來獲取,如下所示。
func ResourceErrUnknown() eerrors.Error {
return resourceErrUnknown
}
我們在server
里根據客戶端發送的error
,返回我們proto
生成的error
信息。
我們在client
裏,判斷是否是這個error
,並記錄error
裏的錯誤信息。
5.2 執行指令
在目錄下執行make svc
,我們可以啓動服務端 然後在目錄下,我們在執行make cli
,我們可以啓動客戶端 執行完後,可以看到如下日誌:
服務端展示:
客戶端展示:
可以看到客戶端紅框裏,就是我們業務代碼裏記錄的日誌。我們通過官方的 errors.Is 判斷,能夠很優雅的做一些業務邏輯處理。
5.3 錯誤碼查看
錯誤碼,我們可以全部放在proto
裏管理。那麼我們就可以很方便在proto
裏查看錯誤碼,或者做的更好一點,將proto
生成更好看的錯誤碼文檔。
自此我們將錯誤碼進行了詳細的介紹,下次我們會介紹gRPC
如何做單元測試和mock
服務的實踐,如何通過proto
文件生成單元測試代碼。
6 鳴謝
感謝kratos
的error
的處理和生成工具,通過學習它的代碼和思想,我們將框架Ego
基於error
處理做了更多的改進,例如通過proto
的註解生成grpc
錯誤碼,生成固定地址的error
。並且我們做了更多的proto
工具,可以通過proto
文件生成單元測試代碼、API 文檔等。
7 相關鏈接
-
項目演示代碼:https://github.com/gotomicro/go-engineering/tree/main/chapter_grpc_error/egoerror
-
項目框架:https://github.com/gotomicro/ego
-
proto 生成插 error 件:https://github.com/gotomicro/ego/tree/master/cmd/protoc-gen-go-errors
-
框架對 error 的處理:https://github.com/gotomicro/ego/blob/master/core/eerrors/errors.go
-
常量 error:https://dave.cheney.net/2016/04/07/constant-errors
-
Go1.13Error Wrapping 分析:https://www.flysnow.org/2019/09/06/go1.13-error-wrapping.html
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/3XLmGAlGDHfarbLyQoAh7Q