5分鐘學會 gRPC

crossoverJie發表於2022-03-10

介紹

我猜測大部分長期使用 Java 的開發者應該較少會接觸 gRPC,畢竟在 Java 圈子裡大部分使用的還是 Dubbo/SpringClound 這兩類服務框架。

我也是近段時間有機會從零開始重構業務才接觸到 gRPC 的,當時選擇 gRPC 時也有幾個原因:

  • 基於雲原生的思路開發部署專案,而在雲原生中 gRPC 幾乎已經是標準的通訊協議了。
  • 開發語言選擇了 Go,在 Go 圈子中 gRPC 顯然是更好的選擇。
  • 公司內部有部分業務使用的是 Python 開發,在多語言相容性上 gRPC 支援的非常好。

經過線上一年多的平穩執行,可以看出 gRPC 還是非常穩定高效的;rpc 框架中最核心的幾個要點:

  • 序列化
  • 通訊協議
  • IDL(介面描述語言)

這些在 gRPC 中分別對應的是:

  • 基於 Protocol Buffer 序列化協議,效能高效。
  • 基於 HTTP/2 標準協議開發,自帶 stream、多路複用等特性;同時由於是標準協議,第三方工具的相容性會更好(比如負載均衡、監控等)
  • 編寫一份 .proto 介面檔案,便可生成常用語言程式碼。

HTTP/2

學習 gRPC 之前首先得知道它是通過什麼協議通訊的,我們日常不管是開發還是應用基本上接觸到最多的還是 HTTP/1.1 協議。

由於 HTTP/1.1 是一個文字協議,對人類非常友好,相反的對機器效能就比較低。

需要反覆對文字進行解析,效率自然就低了;要對機器更友好就得采用二進位制,HTTP/2 自然做到了。

除此之外還有其他優點:

  • 多路複用:可以並行的收發訊息,互不影響
  • HPACK 節省 header 空間,避免 HTTP1.1 對相同的 header 反覆傳送。

Protocol

gRPC 採用的是 Protocol 序列化,釋出時間比 gRPC 早一些,所以也不僅只用於 gRPC,任何需要序列化 IO 操作的場景都可以使用它。

它會更加的省空間、高效能;之前在開發 https://github.com/crossoverJie/cim 時就使用它來做資料互動。

package order.v1;

service OrderService{

  rpc Create(OrderApiCreate) returns (Order) {}

  rpc Close(CloseApiCreate) returns (Order) {}

  // 服務端推送
  rpc ServerStream(OrderApiCreate) returns (stream Order) {}

  // 客戶端推送
  rpc ClientStream(stream OrderApiCreate) returns (Order) {}
  
  // 雙向推送
  rpc BdStream(stream OrderApiCreate) returns (stream Order) {}
}

message OrderApiCreate{
  int64 order_id = 1;
  repeated int64 user_id = 2;
  string remark = 3;
  repeated int32 reason_id = 4;
}

使用起來也是非常簡單的,只需要定義自己的 .proto 檔案,便可用命令列工具生成對應語言的 SDK。

具體可以參考官方文件:
https://grpc.io/docs/languages/go/generated-code/

呼叫

    protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    test.proto


生成程式碼之後編寫服務端就非常簡單了,只需要實現生成的介面即可。

func (o *Order) Create(ctx context.Context, in *v1.OrderApiCreate) (*v1.Order, error) {
    // 獲取 metadata
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, status.Errorf(codes.DataLoss, "failed to get metadata")
    }
    fmt.Println(md)
    fmt.Println(in.OrderId)
    return &v1.Order{
        OrderId: in.OrderId,
        Reason:  nil,
    }, nil
}

客戶端也非常簡單,只需要依賴服務端程式碼,建立一個 connection 然後就和呼叫本地方法一樣了。

這是經典的 unary(一元)呼叫,類似於 http 的請求響應模式,一個請求對應一次響應。

Server stream

gRPC 除了常規的 unary 呼叫之外還支援服務端推送,在一些特定場景下還是很有用的。

func (o *Order) ServerStream(in *v1.OrderApiCreate, rs v1.OrderService_ServerStreamServer) error {
    for i := 0; i < 5; i++ {
        rs.Send(&v1.Order{
            OrderId: in.OrderId,
            Reason:  nil,
        })
    }
    return nil
}

服務端的推送如上所示,呼叫 Send 函式便可向客戶端推送。

    for {
        msg, err := rpc.RecvMsg()
        if err == io.EOF {
            marshalIndent, _ := json.MarshalIndent(msgs, "", "\t")
            fmt.Println(msg)
            return
        }
    }

客戶端則通過一個迴圈判斷當前接收到的資料包是否已經截止來獲取服務端訊息。

為了能更直觀的展示這個過程,優化了之前開發的一個 gRPC 客戶端,可以直觀的除錯 stream 呼叫。

上圖便是一個服務端推送示例。

Client Stream

除了支援服務端推送之外,客戶端也支援。

客戶端在同一個連線中一直向服務端傳送資料,服務端可以並行處理訊息。

// 服務端程式碼
func (o *Order) ClientStream(rs v1.OrderService_ClientStreamServer) error {
    var value []int64
    for {
        recv, err := rs.Recv()
        if err == io.EOF {
            rs.SendAndClose(&v1.Order{
                OrderId: 100,
                Reason:  nil,
            })
            log.Println(value)
            return nil
        }
        value = append(value, recv.OrderId)
        log.Printf("ClientStream receiv msg %v", recv.OrderId)
    }
    log.Println("ClientStream finish")
    return nil
}

    // 客戶端程式碼
    for i := 0; i < 5; i++ {
        messages, _ := GetMsg(data)
        rpc.SendMsg(messages[0])
    }
    receive, err := rpc.CloseAndReceive()

程式碼與服務端推送類似,只是角色互換了。

Bidirectional Stream

同理,當客戶端、服務端同時都在傳送訊息也是支援的。

// 服務端
func (o *Order) BdStream(rs v1.OrderService_BdStreamServer) error {
    var value []int64
    for {
        recv, err := rs.Recv()
        if err == io.EOF {
            log.Println(value)
            return nil
        }
        if err != nil {
            panic(err)
        }
        value = append(value, recv.OrderId)
        log.Printf("BdStream receiv msg %v", recv.OrderId)
        rs.SendMsg(&v1.Order{
            OrderId: recv.OrderId,
            Reason:  nil,
        })
    }
    return nil
}
// 客戶端
    for i := 0; i < 5; i++ {
        messages, _ := GetMsg(data)
        // 傳送訊息
        rpc.SendMsg(messages[0])
        // 接收訊息
        receive, _ := rpc.RecvMsg()
        marshalIndent, _ := json.MarshalIndent(receive, "", "\t")
        fmt.Println(string(marshalIndent))
    }
    rpc.CloseSend()

其實就是將上訴兩則合二為一。

通過呼叫示例很容易理解。

後設資料

gRPC 也支援後設資料傳輸,類似於 HTTP 中的 header

    // 客戶端寫入
    metaStr := `{"lang":"zh"}`
    var m map[string]string
    err := json.Unmarshal([]byte(metaStr), &m)
    md := metadata.New(m)
    // 呼叫時將 ctx 傳入即可
    ctx := metadata.NewOutgoingContext(context.Background(), md)
    
    // 服務端接收
    md, ok := metadata.FromIncomingContext(ctx)
    if !ok {
        return nil, status.Errorf(codes.DataLoss, "failed to get metadata")
    }
    fmt.Println(md)    

gRPC gateway

gRPC 雖然功能強大使用也很簡單,但對於瀏覽器、APP的支援還是不如 REST 應用廣泛(瀏覽器也支援,但應用非常少)。

為此社群便建立了 https://github.com/grpc-ecosystem/grpc-gateway 專案,可以將 gRPC 服務暴露為 RESTFUL API。

為了讓測試可以習慣用 postman 進行介面測試,我們也將 gRPC 服務代理出去,更方便的進行測試。

反射呼叫

作為一個 rpc 框架,泛化呼叫也是必須支援的,可以方便開發配套工具;gRPC 是通過反射支援的,通過拿到服務名稱、pb 檔案進行反射呼叫。

https://github.com/jhump/protoreflect 這個庫封裝了常見的反射操作。

上圖中看到的視覺化 stream 呼叫也是通過這個庫實現的。

負載均衡

由於 gRPC 是基於 HTTP/2 實現的,客戶端和服務端會保持長連線;這時做負載均衡就不像是 HTTP 那樣簡單了。

而我們使用 gRPC 想達到效果和 HTTP 是一樣的,需要對請求進行負載均衡而不是連線。

通常有兩種做法:

  • 客戶端負載均衡
  • 服務端負載均衡

客戶端負載均衡在 rpc 呼叫中應用廣泛,比如 Dubbo 就是使用的客戶端負載均衡。

gRPC 中也提供有相關介面,具體可以參考官方demo。

https://github.com/grpc/grpc-go/blob/87eb5b7502/examples/features/load_balancing/README.md

客戶端負載均衡相對來說對開發者更靈活(可以自定義適合自己的策略),但相對的也需要自己維護這塊邏輯,如果有多種語言那就得維護多份。

所以在雲原生這個大基調下,更推薦使用服務端負載均衡。

可選方案有:

  • istio
  • envoy
  • apix

這塊我們也在研究,大概率會使用 envoy/istio

總結

gRPC 內容還是非常多的,本文只是作為一份入門資料希望能讓不瞭解 gRPC 的能有一個基本認識;這在雲原生時代確實是一門必備技能。

對文中的 gRPC 客戶端感興趣的朋友,可以參考這裡的原始碼:
https://github.com/crossoverJie/ptg

相關文章