gRPC 服務的響應設計

1. 服務端響應的現狀

做後端服務的開發人員對錯誤處理總是很敏感的,因此在做服務的響應 (response/reply) 設計時總是會很慎重。

如果後端服務選擇的是 HTTP API(rest api),比如 json over http,API 響應 (Response)中大多會包含如下信息:

{
 "code": 0,
 "msg": "ok",
 "payload" : {
        ... ...
 }
}

在這個 http api 的響應設計中,前兩個狀態標識這個請求的響應狀態。這個狀態由一個狀態代碼 (code) 與狀態信息 (msg) 組成。狀態信息是對狀態代碼所對應錯誤原因的詳細詮釋。只有當狀態爲正常時(code = 0),後面的 payload 才具有意義。payload 顯然是在響應中意圖傳給客戶端的業務信息。

這樣的服務響應設計是目前比較常用且成熟的方案,理解起來也十分容易。

好,現在我們看看另外一大類服務:採用 RPC 方式提供的服務。我們還是以使用最爲廣泛的 gRPC 爲例。在 gRPC 中,一個 service 的定義如下 (我們借用一下 grpc-go 提供的 helloworld 示例 [1]):

// https://github.com/grpc/grpc-go/blob/master/examples/helloworld/helloworld/helloworld.proto
package helloworld;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

grpc 對於每個 rpc 方法 (比如 SayHello) 都有約束,只能有一個輸入參數和一個返回值。這個. proto 定義通過 protoc 生成的 go 代碼變成了這樣:

// https://github.com/grpc/grpc-go/blob/master/examples/helloworld/helloworld/helloworld_grpc.pb.go
type GreeterServer interface {
 // Sends a greeting
    SayHello(context.Context, *HelloRequest) (*HelloReply, error)
    ... ...
}

我們看到對於 SayHello RPC 方法,protoc 生成的 go 代碼中,SayHello 方法的返回值列表中多了一個 Gopher 們熟悉的 error 返回值。對於已經習慣了 HTTP API 那套響應設計的 gopher 來說,現在問題來了! http api 響應中表示響應狀態的 code 與 msg 究竟是定義在 HelloReply 這個業務響應數據中,還是通過 error 來返回的呢?這個 grpc 官方文檔似乎也沒有明確說明(如果各位看官找到位置,可以告訴我哦)。

2. gRPC 服務端響應設計思路

我們先不急着下結論!我們繼續借用 helloworld 這個示例程序來測試一下當 error 返回值不爲 nil 時客戶端的反應!先改一下 greeter_server[2] 的代碼:

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) { 
    log.Printf("Received: %v", in.GetName())
    return &pb.HelloReply{Message: "Hello " + in.GetName()}, errors.New("test grpc error")
}

在上面代碼中,我們故意構造一個錯誤並返回給調用該方法的客戶端。我們來運行一下這個服務並啓動 greeter_client[3] 來訪問該服務,在客戶端側,我們得到的結果如下:

2021/09/20 17:04:35 could not greet: rpc error: code = Unknown desc = test grpc error

從客戶端的輸出結果中,我們看到了我們自定義的錯誤的內容 (test grpc error)。但我們還發現錯誤輸出的內容中還有一個 "code = Unknown" 的輸出,這個 code 是從何而來呢?似乎 grpc 期待的 error 形式是包含 code 與 desc 的形式。

這時候就不得不查看一下 gprc-go(v1.40.0) 的參考文檔 [4] 了!在 grpc-go 的文檔中我們發現幾個被 DEPRECATED 的與 Error 有關的函數:

在這幾個作廢的函數的文檔中都提到了用 status 包的同名函數替代。那麼這個 status 包又是何方神聖?我們翻看 grpc-go 的源碼,終於找到了 status 包,在包說明的第一句中我們就找到了答案:

Package status implements errors returned by gRPC.

原來 status 包實現了上面 grpc 客戶端所期望的 error 類型。那麼這個類型是什麼樣的呢?我們逐步跟蹤代碼:

在 grpc-go/status 包中我們看到如下代碼:

type Status = status.Status

// New returns a Status representing c and msg.
func New(c codes.Code, msg string) *Status {
    return status.New(c, msg)
}

status 包使用了 internal/status 包中的 Status,我們再來看 internal/status 包中 Status 結構的定義:

// internal/status
type Status struct {
    s *spb.Status
}

// New returns a Status representing c and msg.
func New(c codes.Code, msg string) *Status {
    return &Status{s: &spb.Status{Code: int32(c), Message: msg}}
}

internal/status 包的 Status 結構體組合了一個 * spb.Status 類型 (google.golang.org/genproto/googleapis/rpc/status 包中的類型) 的字段,繼續追蹤 spb.Status:

// https://pkg.go.dev/google.golang.org/genproto/googleapis/rpc/status
type Status struct {
 // The status code, which should be an enum value of [google.rpc.Code][google.rpc.Code].
 Code int32 `protobuf:"varint,1,opt,`
 // A developer-facing error message, which should be in English. Any
 // user-facing error message should be localized and sent in the
 // [google.rpc.Status.details][google.rpc.Status.details] field, or localized by the client.
 Message string `protobuf:"bytes,2,opt,`
 // A list of messages that carry the error details.  There is a common set of
 // message types for APIs to use.
 Details []*anypb.Any `protobuf:"bytes,3,rep,`
 // contains filtered or unexported fields
}

我們看到最後的這個 Status 結構包含了 Code 與 Message。這樣一來,grpc 的設計意圖就很明顯了,它期望開發者在 error 這個返回值中包含 rpc 方法的響應狀態,而自定義的響應結構體只需包含業務所需要的數據即可。我們用一幅示意圖來橫向建立一下 http api 與 rpc 響應的映射關係:

有了這幅圖,再面對如何設計 grpc 方法響應這個問題時,我們就胸有成竹了!

grpc-go 在 codes 包 [5] 中定義了 grpc 規範要求的 10 餘種錯誤碼:

const (
 // OK is returned on success.
 OK Code = 0

 // Canceled indicates the operation was canceled (typically by the caller).
 //
 // The gRPC framework will generate this error code when cancellation
 // is requested.
 Canceled Code = 1

 // Unknown error. An example of where this error may be returned is
 // if a Status value received from another address space belongs to
 // an error-space that is not known in this address space. Also
 // errors raised by APIs that do not return enough error information
 // may be converted to this error.
 //
 // The gRPC framework will generate this error code in the above two
 // mentioned cases.
 Unknown Code = 2

 // InvalidArgument indicates client specified an invalid argument.
 // Note that this differs from FailedPrecondition. It indicates arguments
 // that are problematic regardless of the state of the system
 // (e.g., a malformed file name).
 //
 // This error code will not be generated by the gRPC framework.
 InvalidArgument Code = 3

    ... ...

 // Unauthenticated indicates the request does not have valid
 // authentication credentials for the operation.
 //
 // The gRPC framework will generate this error code when the
 // authentication metadata is invalid or a Credentials callback fails,
 // but also expect authentication middleware to generate it.
 Unauthenticated Code = 16

在這些標準錯誤碼之外,我們還可以擴展定義自己的錯誤碼與錯誤描述。

3. 服務端如何構造 error 與客戶端如何解析 error

前面提到,gRPC 服務端採用 rpc 方法的最後一個返回值 error 來承載應答狀態。google.golang.org/grpc/status 包爲構建客戶端可解析的 error 提供了一些方便的函數,我們看下面示例(基於上面 helloworld 的 greeter_server[6] 改造):

func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
    log.Printf("Received: %v", in.GetName())
    return nil, status.Errorf(codes.InvalidArgument, "you have a wrong name: %s", in.GetName())
}

status 包提供了一個類似於 fmt.Errorf 的函數,我們可以很方便的構造一個帶有 code 與 msg 的 error 實例並返回給客戶端。

而客戶端同樣可以通過 status 包提供的函數將 error 中攜帶的信息解析出來,我們看下面代碼:

ctx, _ := context.WithTimeout(context.Background(), time.Second)
r, err := c.SayHello(ctx, &pb.HelloRequest{Name: "tony")})
if err != nil {
    errStatus := status.Convert(err)
    log.Printf("SayHello return error: code: %d, msg: %s\n", errStatus.Code(), errStatus.Message())
}
log.Printf("Greeting: %s", r.GetMessage())

我們看到:通過 status.Convert 函數可以很簡答地將 rpc 方法返回的不爲 nil 的 error 中攜帶的信息提取出來。

4. 空應答

gRPC 的 proto 文件規範要求每個 rpc 方法的定義中都必須包含一個返回值,返回值不能爲空,比如上面 helloworld 項目的. proto 文件中的 SayHello 方法:

rpc SayHello (HelloRequest) returns (HelloReply) {}

如果去掉 HelloReply 這個返回值,那麼 protoc 在生成代碼時會報錯!

但是有些方法本身不需要返回業務數據,那麼我們就需要爲其定義一個空應答消息,比如:

message Empty {

}

考慮到每個項目在遇到空應答時都要重複造上面 Empty message 定義的輪子,grpc 官方提供了一個可被複用的空 message:

// https://github.com/protocolbuffers/protobuf/blob/master/src/google/protobuf/empty.proto

// A generic empty message that you can re-use to avoid defining duplicated
// empty messages in your APIs. A typical example is to use it as the request
// or the response type of an API method. For instance:
//
//     service Foo {
//       rpc Bar(google.protobuf.Empty) returns (google.protobuf.Empty);
//     }
//
// The JSON representation for `Empty` is empty JSON object `{}`.
message Empty {}

我們只需在. proto 文件中導入該 empty.proto 並使用 Empty 即可,比如下面代碼:

// xxx.proto

syntax = "proto3";
  
import "google/protobuf/empty.proto";

service MyService {
 rpc MyRPCMethod(...) returns (google.protobuf.Empty);
}

當然 google.protobuf.Empty 不僅僅適用於空響應,也適合空請求,這個就留給大家可自行完成吧。

5. 小結

本文我們講述了 gRPC 服務端響應設計的相關內容,最主要想說的是直接使用 gRPC 生成的 rpc 方面的 error 返回值來表示 rpc 調用的響應狀態,不要再在自定義的 Message 結構中重複放入 code 與 msg 字段來表示響應狀態了。

btw,做 API 的錯誤設計,google 的這份 API 設計方面的參考資料 [7] 是十分好的。有時間一定要好好讀讀哦。


我的聯繫方式:

參考資料

[1]  helloworld 示例: https://github.com/grpc/grpc-go/tree/master/examples/helloworld

[2]  greeter_server: https://github.com/grpc/grpc-go/blob/master/examples/helloworld/greeter_server/main.go

[3]  greeter_client: https://github.com/grpc/grpc-go/tree/master/examples/helloworld/greeter_client

[4]  gprc-go(v1.40.0) 的參考文檔: https://pkg.go.dev/google.golang.org/grpc#section-readme

[5]  codes 包: https://pkg.go.dev/google.golang.org/grpc@v1.40.0/codes#Code

[6]  greeter_server: https://github.com/grpc/grpc-go/blob/master/examples/helloworld/greeter_server/main.go

[7]  google 的這份 API 設計方面的參考資料: https://cloud.google.com/apis/design/errors

[8]  改善 Go 語⾔編程質量的 50 個有效實踐: https://www.imooc.com/read/87

[9]  Kubernetes 實戰:高可用集羣搭建、配置、運維與應用: https://coding.imooc.com/class/284.html

[10]  鏈接地址: https://m.do.co/c/bff6eed92687

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