寫給 go 開發者的 gRPC 教程 - 錯誤處理

基本錯誤處理

首先回顧下 pb 文件和生成出來的 client 與 server 端的接口

service OrderManagement {
    rpc getOrder(google.protobuf.StringValue) returns (Order);
}
type OrderManagementClient interface {
 GetOrder(ctx context.Context, 
           in *wrapperspb.StringValue, opts ...grpc.CallOption) (*Order, error)
}
type OrderManagementServer interface {
 GetOrder(context.Context, *wrapperspb.StringValue) (*Order, error)
 mustEmbedUnimplementedOrderManagementServer()
}

可以看到,雖然我們沒有在 pb 文件中的接口定義設置error返回值,但生成出來的 go 代碼是包含error返回值的

這非常符合 Go 語言的使用習慣:通常情況下我們定義多個error變量,並且在函數內返回,調用方可以使用errors.Is()或者errors.As()對函數的error進行判斷

var (
 ParamsErr = errors.New("params err")
 BizErr    = errors.New("biz err")
)

func Invoke(i bool) error {
 if i {
  return ParamsErr
 } else {
  return BizErr
 }
}

func main() {
 err := Invoke(true)

 if err != nil {
  switch {
  case errors.Is(err, ParamsErr):
   log.Println("params error")
  case errors.Is(err, BizErr):
   log.Println("biz error")
  }
 }
}

🌿 但,在 RPC 場景下,我們還能進行 error 的值判斷麼?

// common/errors.go
var ParamsErr = errors.New("params is not valid")
// server/main.go
func (s *OrderManagementImpl) GetOrder(ctx context.Context, orderId *wrapperspb.StringValue) (*pb.Order, error) {
 return nil, common.ParamsErr
}
// client/main.go
retrievedOrder, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: "101"})

if err != nil && errors.Is(err, common.ParamsErr) {
  // 不會走到這裏,因爲err和common.ParamsErr不相等
  panic(err)
}

很明顯,serverclient並不在同一個進程甚至都不在同一個臺機器上,所以errors.Is()或者errors.As()是沒有辦法做判斷的

業務錯誤碼

那麼如何做?在 http 的服務中,我們會使用錯誤碼的方式來區分不同錯誤,通過判斷errno來區分不同錯誤

{
    "errno": 0,
    "msg": "ok",
    "data": {}
}

{
    "errno": 1000,
    "msg": "params error",
    "data": {}
}

類似的,我們調整下我們 pb 定義:在返回值裏攜帶錯誤信息

service OrderManagement {
    rpc getOrder(google.protobuf.StringValue) returns (GetOrderResp);
}

message GetOrderResp{
    BizErrno errno = 1;
    string msg = 2;
    Order data = 3;
}

enum BizErrno {
    Ok = 0;
    ParamsErr = 1;
    BizErr = 2;
}

message Order {
    string id = 1;
    repeated string items = 2;
    string description = 3;
    float price = 4;
    string destination = 5;
}

於是在服務端實現的時候,我們可以返回對應數據或者錯誤狀態碼

func (s *OrderManagementImpl) GetOrder(ctx context.Context, orderId *wrapperspb.StringValue) (*pb.GetOrderResp, error) {
 ord, exists := orders[orderId.Value]
 if exists {
  return &pb.GetOrderResp{
   Errno: pb.BizErrno_Ok,
   Msg:   "Ok",
   Data:  &ord,
  }, nil
 }

 return &pb.GetOrderResp{
  Errno: pb.BizErrno_ParamsErr,
  Msg:   "Order does not exist",
 }, nil
}

在客戶端可以判斷返回值的錯誤碼來區分錯誤,這是我們在常規 RPC 的常見做法

// Get Order
resp, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: ""})
if err != nil {
  panic(err)
}

if resp.Errno != pb.BizErrno_Ok {
  panic(resp.Msg)
}

log.Print("GetOrder Response -> : ", resp.Data)

🌿 但,這麼做有什麼問題麼?

很明顯,對於 clinet 側來說,本身就可能遇到網絡失敗等錯誤,所以返回值(*GetOrderResp, error)包含error並不會非常突兀

但再看一眼 server 側的實現,我們把錯誤枚舉放在GetOrderResp中,此時返回的另一個error就變得非常尷尬了,該繼續返回一個error呢,還是直接都返回nil呢?兩者的功能極度重合

那有什麼辦法既能利用上error這個返回值,又能讓client端枚舉出不同錯誤麼?一個非常直觀的想法:讓error裏記錄枚舉值就可以了!

但我們都知道 Go 裏的error是隻有一個string的,可以攜帶的信息相當有限,如何傳遞足夠多的信息呢?gRPC官方提供了google.golang.org/grpc/status的解決方案

使用 Status處理錯誤

gRPC 提供了google.golang.org/grpc/status來表示錯誤,這個結構包含了 codemessage 兩個字段

🌲 code是類似於http status code的一系列錯誤類型的枚舉,所有語言 sdk 都會內置這個枚舉列表

雖然總共預定義了 16 個code,但gRPC框架並不是用到了每一個 code,有些 code 僅提供給業務邏輯使用

b6MTTw

🌲 message就是服務端需要告知客戶端的一些錯誤詳情信息

package main

import (
 "errors"
 "fmt"
 "log"

 "google.golang.org/grpc/codes"
 "google.golang.org/grpc/status"
)

func Invoke() {
 ok := status.New(codes.OK, "ok")
 fmt.Println(ok)

 invalidArgument := status.New(codes.InvalidArgument, "invalid args")
 fmt.Println(invalidArgument)
}

Status 和語言 Error 的互轉

上文提到無論是serverclient返回的都是error,如果我們返回Status那肯定是不行的

Status 提供了和Error互轉的方法

所以在服務端可以利用.Err()Status轉換成error並返回

或者直接創建一個Statuserrorstatus.Errorf(codes.InvalidArgument, "invalid args")返回

func (s *OrderManagementImpl) GetOrder(ctx context.Context, orderId *wrapperspb.StringValue) (*pb.Order, error) {
 ord, exists := orders[orderId.Value]
 if exists {
  return &ord, status.New(codes.OK, "ok").Err()
 }

 return nil, status.New(codes.InvalidArgument,
  "Order does not exist. order id: "+orderId.Value).Err()
}

到客戶端這裏我們再利用status.FromError(err)error轉回Status

order, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: ""})
if err != nil {
  // 轉換有可能失敗
  st, ok := status.FromError(err)
  if ok && st.Code() == codes.InvalidArgument {
    log.Println(st.Code(), st.Message())
  } else {
    log.Println(err)
  }

  return
}

log.Print("GetOrder Response -> : ", order)

🌿 但,status真的夠用麼?

類似於 HTTP 狀態碼code的個數也是有限的。有個很大的問題就是 表達能力非常有限

所以我們需要一個能夠額外傳遞業務錯誤信息字段的功能

Richer error model

Google 基於自身業務, 有了一套錯誤擴展 https://cloud.google.com/apis/design/errors#error_model

// The `Status` type defines a logical error model that is suitable for
// different programming environments, including REST APIs and RPC APIs.
message Status {
  // A simple error code that can be easily handled by the client. The
  // actual error code is defined by `google.rpc.Code`.
  int32 code = 1;

  // A developer-facing human-readable error message in English. It should
  // both explain the error and offer an actionable resolution to it.
  string message = 2;

  // Additional error information that the client code can use to handle
  // the error, such as retry info or a help link.
  repeated google.protobuf.Any details = 3;
}

可以看到比標準錯誤多了一個 details 數組字段, 而且這個字段是 Any 類型, 支持我們自行擴展

使用示例

由於 Golang 支持了這個擴展, 所以可以看到 Status 直接就是有 details 字段的.

所以使用 WithDetails 附加自己擴展的錯誤類型, 該方法會自動將我們的擴展類型轉換爲 Any 類型

WithDetails 返回一個新的 Status 其包含我們提供的 details

WithDetails 如果遇到錯誤會返回nil 和第一個錯誤

func InvokRPC() error {
 st := status.New(codes.InvalidArgument, "invalid args")

 if details, err := st.WithDetails(&pb.BizError{}); err == nil {
  return details.Err()
 }

 return st.Err()
}

前面提到details 數組字段, 而且這個字段是 Any 類型, 支持我們自行擴展。

同時,Google API 爲錯誤詳細信息定義了一組標準錯誤負載,您可在 google/rpc/error_details.proto 中找到這些錯誤負載

它們涵蓋了對於 API 錯誤的最常見需求,例如配額失敗和無效參數。與錯誤代碼一樣,開發者應儘可能使用這些標準載荷

下面是一些示例 error_details 載荷:

服務端

package main

import (
 "fmt"

 pb "github.com/liangwt/note/grpc/error_handling/error"
 epb "google.golang.org/genproto/googleapis/rpc/errdetails"
 "google.golang.org/grpc/codes"
 "google.golang.org/grpc/status"
)

func (s *OrderManagementImpl) GetOrder(ctx context.Context, orderId *wrapperspb.StringValue) (*pb.Order, error) {
 ord, exists := orders[orderId.Value]
 if exists {
  return &ord, status.New(codes.OK, "ok").Err()
 }

 st := status.New(codes.InvalidArgument,
  "Order does not exist. order id: "+orderId.Value)

 details, err := st.WithDetails(
  &epb.BadRequest_FieldViolation{
   Field:       "ID",
   Description: fmt.Sprintf("Order ID received is not valid"),
  },
 )
 if err == nil {
  return nil, details.Err()
 }

 return nil, st.Err()
}

客戶端

// Get Order
order, err := client.GetOrder(ctx, &wrapperspb.StringValue{Value: ""})
if err != nil {
 st, ok := status.FromError(err)
 if !ok {
  log.Println(err)
   return
 }

 switch st.Code() {
 case codes.InvalidArgument:
  for _, d := range st.Details() {
   switch info := d.(type) {
   case *epb.BadRequest_FieldViolation:
    log.Printf("Request Field Invalid: %s", info)
   default:
    log.Printf("Unexpected error type: %s", info)
   }
  }
 default:
  log.Printf("Unhandled error : %s ", st.String())
 }

 return
}

log.Print("GetOrder Response -> : ", order)

引申問題

如何傳遞這個非標準的錯誤擴展消息呢?或許可以在下一章可以找到答案。

總結

我們先介紹了 gRPC 最基本的錯誤處理方式:返回error

之後我們又介紹了一種能夠攜帶更多錯誤信息的方式:Status,它包含codemessagedetails等信息,通過Statuserror的互相轉換,利用error來傳輸錯誤

參考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/EaTO3tr6_1c3yrtZzYEBrA