gRPC(四)基礎:gRPC流

lin鍾一發表於2022-11-09

這章的內容需要安裝好外掛和protoc,建議閱讀我的上一篇 grpc使用篇
個人網站:linzyblog.netlify.app/
示例程式碼已經上傳到github:點選跳轉
gRPC官方文件:點選跳轉

gRPC 有兩種型別的請求模型:

  • 一元 - 直接的請求響應對映在 HTTP/2 請求響應之上。
    • 簡單來說一元就是一個簡單的 RPC,其中客戶端使用存根向伺服器傳送請求並等待響應返回,就像正常的函式呼叫一樣。
rpc SayHi(Request) returns (Response);
  • 流式傳輸——多個請求和響應透過長壽命 HTTP/2 流進行交換,可以是單向或雙向的。
    服務端流式 RPC
    • 其中許多程式可以透過 HTTP/2 的多路複用能力(透過單個 TCP 連線一起傳送多個響應或接收多個請求)在單個請求中發生。
    • Server-side streaming RPC—— 客戶端向伺服器傳送單個請求並接收回資料序列流(讀回一系列訊息)。客戶端從返回的流中讀取,直到沒有更多訊息為止。
    • Client-side streaming RPC—— 客戶端向伺服器傳送資料序列流(寫入一系列訊息),一旦客戶端完成了訊息的寫入,它會等待伺服器讀取所有訊息並返回其響應結果。
    • Bidirectional streaming RPC—— 它是雙向流式傳輸,客戶端和伺服器使用讀寫流傳送一系列訊息。兩個流獨立執行;因此,因此客戶端和伺服器可以按照他們喜歡的任何順序讀取和寫入。保留每個流中訊息的順序。例如,伺服器可以在寫入響應之前等待接收所有客戶端訊息,或者它可以交替讀取訊息然後寫入訊息,或其他一些讀取和寫入的組合。

Server-side streaming RPC:伺服器端流式 RPC
Client-side streaming RPC:客戶端流式 RPC
Bidirectional streaming RPC:雙向流式 RPC
stream可以透過將關鍵字放在請求型別之前來指定流式處理方法。

gRPC 是基於HTTP/2開發的,該協議於 2015 年釋出,以克服 HTTP/1.1 的限制。在相容 HTTP/1.1 的同時,我們來了解一下HTTP/2 帶來了許多高階功能,例如:

  • 二進位制分幀層 —— 與 HTTP/1.1 不同,HTTP/2 請求/響應分為小訊息並以二進位制格式分幀,使訊息傳輸高效。透過二進位制幀,HTTP/2 協議使請求/響應多路複用成為可能,而不會阻塞網路資源。
  • 流式傳輸 —— 客戶端可以請求並且伺服器可以同時響應的雙向全雙工流式傳輸
  • 流控制 —— HTTP/2 中使用流控制機制,可以對用於緩衝動態訊息的記憶體進行詳細控制。
  • 標頭壓縮 —— HTTP/2 中的所有內容,包括標頭,都在傳送前進行編碼,顯著提高了整體效能。使用 HPACK 壓縮方式,HTTP/2 只共享與之前的 HTTP 頭包不同的值。
  • 處理 —— 使用 HTTP/2,gRPC 支援同步和非同步處理,可用於執行不同型別的互動和流式 RPC。

在這裡插入圖片描述

HTTP/2 的所有這些特性使 gRPC 能夠使用更少的資源,從而減少在雲中執行的應用程式和服務之間的響應時間,並延長執行移動裝置的客戶端的電池壽命。

1、為什麼我們要用流式傳輸,簡單的一元RPC不行麼?

流式為什麼要存在呢?我們在使用一元請求的時候可能會遇到以下問題:

  • 資料包過大會造成的瞬時壓力。
  • 接收資料包時,需要所有資料包都接受成功且正確後,才能夠回撥響應,進行業務處理(無法客戶端邊傳送,服務端邊處理)

而流式傳輸卻可以:

  • HTTP2 透過長期 TCP 連線多路複用流,因此新請求沒有 TCP 連線開銷。HTTP2 成幀允許在單個 TCP 資料包中傳送多個 gRPC 訊息。

  • 對於長期連線,流式請求應該在每條訊息的基礎上具有最佳效能。一元請求需要為每個請求建立一個新的 HTTP2 流,包括透過網路傳送的附加標頭幀。一旦建立,透過流式請求傳送的每條新訊息只需要透過連線傳送訊息的資料幀。

2、目錄結構

go-grpc-example
├── client
│   └──hello_client
│   │   └── client.go
│   └── stream_client
│       └── client.go
├── proto
│   └──hello
│   │   └── hello.proto
│   └──stream
│   │   └── stream.proto
├── server
│   └──hello_server
│   │   └── server.go
│   └──stream_server
│   │   └── server.go
├── Makefile

增加 stream_server、stream_client 存放服務端和客戶端檔案,proto/stream/stream.proto 用於編寫 IDL

3、編寫IDL

在 proto/stream 資料夾下的 stream.proto 檔案中,寫入如下內容:

syntax = "proto3";

option go_package="./proto/stream;stream";
package proto;

service StreamService {
  //List:伺服器端流式 RPC
  rpc List(StreamRequest) returns (stream StreamResponse) {};
  //Record:客戶端流式 RPC
  rpc Record(stream StreamRequest) returns (StreamResponse) {};
  //Route:雙向流式 RPC
  rpc Route(stream StreamRequest) returns (stream StreamResponse) {};
}


message StreamPoint {
  string name = 1;
  int32 value = 2;
}

message StreamRequest {
  StreamPoint pt = 1;
}

message StreamResponse {
  StreamPoint pt = 1;
}

注意關鍵字 stream,宣告其為一個流方法。這裡共涉及三個方法,對應關係為

  • List:伺服器端流式 RPC
  • Record:客戶端流式 RPC
  • Route:雙向流式 RPC

4、Makefile

這是我拖了很久的關於Makefile的用法,感覺Makefile更適合在專案使用中穿插講解一下。

有一篇很不錯的Makefile文件:點選跳轉

作用:Makefile 用於幫助決定大型程式的哪些部分需要重新編譯。

這裡我們用make gen指令代替proto外掛從我們的.proto 服務定義中生成 gRPC 客戶端和伺服器介面。

在Makefile檔案中寫入:

gen:
    protoc --go_out=. --go-grpc_out=. ./proto/stream/*.proto

make gen指令生成Go程式碼:

➜ make gen
protoc --go_out=. --go-grpc_out=. ./proto/stream/*.proto

在這裡插入圖片描述

注意使用Makefile生成的時候,要注意.proto檔案 go_package 指定生成的位置。

5、寫出基礎模板和空定義

我們先把基礎的模板和空定義寫出來在進行完善,不太懂的看我上一篇文章

1)server.go

type StreamService struct {
    pb.UnimplementedStreamServiceServer
}

const PORT = "8888"

func main() {
    server := grpc.NewServer() //建立 gRPC Server 物件
    pb.RegisterStreamServiceServer(server, &StreamService{})

    lis, err := net.Listen("tcp", ":"+PORT)
    if err != nil {
        log.Fatalf("net.Listen err: %v", err)
    }

    server.Serve(lis)
}

//服務端流式RPC,Server是Stream,Client為普通RPC請求
//客戶端傳送一次普通的RPC請求,服務端透過流式響應多次傳送資料集
func (s *StreamService) List(r *pb.StreamRequest, stream pb.StreamService_ListServer) error {
    return nil
}

//客戶端流式RPC,單向流
//客戶端透過流式多次傳送RPC請求給服務端,服務端傳送一次普通的RPC請求給客戶端
func (s *StreamService) Record(stream pb.StreamService_RecordServer) error {
    return nil
}

//雙向流,由客戶端發起流式的RPC方法請求,服務端以同樣的流式RPC方法響應請求
//首個請求一定是client發起,具體互動方法(誰先誰後,一次發多少,響應多少,什麼時候關閉)根據程式編寫方式來確定(可以結合協程)
func (s *StreamService) Route(stream pb.StreamService_RouteServer) error {
    return nil
}

2)client.go

const PORT = "8888"

func main() {
    conn, err := grpc.Dial(":"+PORT, grpc.WithInsecure())
    if err != nil {
        log.Fatalf("grpc.Dial err: %v", err)
    }
    defer conn.Close()

    client := pb.NewStreamServiceClient(conn)

    err = printLists(client, &pb.StreamRequest{Pt: &pb.StreamPoint{Name: "gRPC Stream Client: List", Value: 1234}})
    if err != nil {
        log.Fatalf("printLists.err: %v", err)
    }

    err = printRecord(client, &pb.StreamRequest{Pt: &pb.StreamPoint{Name: "gRPC Stream Client: Record", Value: 9999}})
    if err != nil {
        log.Fatalf("printRecord.err: %v", err)
    }

    err = printRoute(client, &pb.StreamRequest{Pt: &pb.StreamPoint{Name: "gRPC Stream Client: Route", Value: 1111}})
    if err != nil {
        log.Fatalf("printRoute.err: %v", err)
    }
}

func printLists(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    return nil
}

func printRecord(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    return nil
}

func printRoute(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    return nil
}

6、Server-side streaming RPC:伺服器端流式 RPC

服務端流式RPC,Server是Stream,Client為普通RPC請求,客戶端傳送一次普通的RPC請求,服務端透過流式響應多次傳送資料集。

在這裡插入圖片描述

1)server

/*
1. 建立連線 獲取client
2. 透過 client 獲取stream
3. for迴圈中透過stream.Recv()依次獲取服務端推送的訊息
4. err==io.EOF則表示服務端關閉stream了
*/
func (s *StreamService) List(r *pb.StreamRequest, stream pb.StreamService_ListServer) error {
    // 具體返回多少個response根據業務邏輯調整
    for n := 0; n <= 6; n++ {
        // 透過 send 方法不斷推送資料
        err := stream.Send(&pb.StreamResponse{
            Pt: &pb.StreamPoint{
                Name:  r.Pt.Name,
                Value: r.Pt.Value + int32(n),
            },
        })
        if err != nil {
            return err
        }
        time.Sleep(time.Second)
    }
    // 返回nil表示已經完成響應
    return nil
}

在 Server,主要留意 stream.Send 方法。它看上去能傳送 N 次?有沒有大小限制?

type StreamService_ListServer interface {
    Send(*StreamResponse) error
    grpc.ServerStream
}

func (x *streamServiceListServer) Send(m *StreamResponse) error {
    return x.ServerStream.SendMsg(m)
}

透過閱讀原始碼,可得知是 protoc 在生成時,根據定義生成了各式各樣符合標準的介面方法。最終再統一排程內部的 SendMsg 方法,該方法涉及以下過程:

  • 訊息體(物件)序列化
  • 壓縮序列化後的訊息體
  • 對正在傳輸的訊息體增加 5 個位元組的 header
  • 判斷壓縮+序列化後的訊息體總位元組長度是否大於預設的 maxSendMessageSize(預設值為 math.MaxInt32),若超出則提示錯誤
  • 寫入給流的資料集

    2)client

/*
1. 建立連線 獲取client
2. 透過 client 獲取stream
3. for迴圈中透過stream.Recv()依次獲取服務端推送的訊息
4. err==io.EOF則表示服務端關閉stream了
*/
func printLists(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    // 呼叫獲取stream
    stream, err := client.List(context.Background(), r)
    if err != nil {
        return err
    }
    // for迴圈獲取服務端推送的訊息
    for {
        // 透過 Recv() 不斷獲取服務端send()推送的訊息
        resp, err := stream.Recv()
        // err==io.EOF則表示服務端關閉stream了
        if err == io.EOF {
            break
        }
        if err != nil {
            return err
        }
        log.Printf("resp: pj.name: %s, pt.value: %d", resp.Pt.Name, resp.Pt.Value)
    }
    return nil
}

在 Client,主要留意 stream.Recv() 方法。什麼情況下 io.EOF ?什麼情況下存在錯誤資訊呢?

type StreamService_ListClient interface {
    Recv() (*StreamResponse, error)
    grpc.ClientStream
}

func (x *streamServiceListClient) Recv() (*StreamResponse, error) {
    m := new(StreamResponse)
    if err := x.ClientStream.RecvMsg(m); err != nil {
        return nil, err
    }
    return m, nil
}

RecvMsg 會從流中讀取完整的 gRPC 訊息體,另外透過閱讀原始碼可得知:

(1)RecvMsg 是阻塞等待的

(2)RecvMsg 當流成功/結束(呼叫了 Close)時,會返回 io.EOF

(3)RecvMsg 當流出現任何錯誤時,流會被中止,錯誤資訊會包含 RPC 錯誤碼。而在 RecvMsg 中可能出現如下錯誤:

  • io.EOF
  • io.ErrUnexpectedEOF
  • transport.ConnectionError
  • google.golang.org/grpc/codes

    3)啟動 & 請求

# 啟動服務端
$ go run server.go
API server listening at: 127.0.0.1:55149

# 啟動客戶端
$ go run client.go 
API server listening at: 127.0.0.1:55158
2022/11/03 09:35:03 resp: pj.name: gRPC Stream Client: List, pt.value: 1234
2022/11/03 09:35:04 resp: pj.name: gRPC Stream Client: List, pt.value: 1235
2022/11/03 09:35:05 resp: pj.name: gRPC Stream Client: List, pt.value: 1236
2022/11/03 09:35:06 resp: pj.name: gRPC Stream Client: List, pt.value: 1237
2022/11/03 09:35:07 resp: pj.name: gRPC Stream Client: List, pt.value: 1238
2022/11/03 09:35:08 resp: pj.name: gRPC Stream Client: List, pt.value: 1239
2022/11/03 09:35:09 resp: pj.name: gRPC Stream Client: List, pt.value: 1240

伺服器流式 RPC 類似於一元 RPC,除了伺服器返回訊息流以響應客戶端的請求。傳送所有訊息後,伺服器的狀態詳細資訊(狀態程式碼和可選狀態訊息)和可選尾隨後設資料將傳送到客戶端。這樣就完成了伺服器端的處理。客戶端在擁有伺服器的所有訊息後完成。

7、Client-side streaming RPC:客戶端流式 RPC

客戶端透過流式多次傳送RPC請求給服務端,服務端傳送一次響應給客戶端。
在這裡插入圖片描述

1)server

/*
1. for迴圈中透過stream.Recv()不斷接收client傳來的資料
2. err == io.EOF表示客戶端已經傳送完畢關閉連線了,此時在等待服務端處理完並返回訊息
3. stream.SendAndClose() 傳送訊息並關閉連線(雖然在客戶端流裡伺服器這邊並不需要關閉 但是方法還是叫的這個名字,內部也只會呼叫Send())
*/
func (s *StreamService) Record(stream pb.StreamService_RecordServer) error {
    // for迴圈接收客戶端傳送的訊息
    for {
        // 透過 Recv() 不斷獲取客戶端 send()推送的訊息
        r, err := stream.Recv()
        // err == io.EOF表示已經獲取全部資料
        if err == io.EOF {
            // SendAndClose 返回並關閉連線
            // 在客戶端傳送完畢後服務端即可返回響應
            return stream.SendAndClose(&pb.StreamResponse{Pt: &pb.StreamPoint{Name: "gRPC Stream Server: Record", Value: 1}})
        }
        if err != nil {
            return err
        }
        log.Printf("stream.Recv pt.name: %s, pt.value: %d", r.Pt.Name, r.Pt.Value)
        time.Sleep(time.Second)
    }
    return nil
}

stream.SendAndClose:我們對每一個 Recv 都進行了處理,當發現 io.EOF (流關閉) 後,需要將最終的響應結果傳送給客戶端,同時關閉正在另外一側等待的 Recv

2)client

/*
1. 建立連線並獲取client
2. 獲取 stream 並透過 Send 方法不斷推送資料到服務端
3. 傳送完成後透過stream.CloseAndRecv() 關閉stream並接收服務端返回結果
*/
func printRecord(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    // 獲取 stream
    stream, err := client.Record(context.Background())
    if err != nil {
        return err
    }

    for i := 0; i <= 6; i++ {
        // 透過 Send 方法不斷推送資料到服務端
        err := stream.Send(r)
        if err != nil {
            return err
        }
    }

    // 傳送完成後透過stream.CloseAndRecv() 關閉stream並接收服務端返回結果
    // (服務端則根據err==io.EOF來判斷client是否關閉stream)
    resp, err := stream.CloseAndRecv()
    if err != nil {
        return err
    }
    log.Printf("resp: pj.name: %s, pt.value: %d", resp.Pt.Name, resp.Pt.Value)
    return nil
}

stream.CloseAndRecv 和 stream.SendAndClose 是配套使用的流方法

3)啟動 & 請求

# 啟動服務端
$ go run server.go
API server listening at: 127.0.0.1:57789
2022/11/03 11:59:31 stream.Recv pt.name: gRPC Stream Client: Record, pt.value: 9999
2022/11/03 11:59:32 stream.Recv pt.name: gRPC Stream Client: Record, pt.value: 9999
2022/11/03 11:59:33 stream.Recv pt.name: gRPC Stream Client: Record, pt.value: 9999
2022/11/03 11:59:34 stream.Recv pt.name: gRPC Stream Client: Record, pt.value: 9999
2022/11/03 11:59:35 stream.Recv pt.name: gRPC Stream Client: Record, pt.value: 9999
2022/11/03 11:59:36 stream.Recv pt.name: gRPC Stream Client: Record, pt.value: 9999
2022/11/03 11:59:37 stream.Recv pt.name: gRPC Stream Client: Record, pt.value: 9999

# 啟動客戶端
$ go run client.go 
API server listening at: 127.0.0.1:57793
2022/11/03 11:59:38 resp: pj.name: gRPC Stream Server: Record, pt.value: 1

8、Bidirectional streaming RPC:雙向流式 RPC

雙向流,由客戶端發起流式的RPC方法請求,服務端以同樣的流式RPC方法響應請求 首個請求一定是client發起,具體互動方法(誰先誰後,一次發多少,響應多少,什麼時候關閉)根據程式編寫方式來確定(可以結合協程)。
在這裡插入圖片描述

1)server

一般是使用兩個 Goroutine,一個接收資料,一個推送資料。最後透過 return nil 表示已經完成響應。

/*
// 1. 建立連線 獲取client
// 2. 透過client呼叫方法獲取stream
// 3. 開兩個goroutine(使用 chan 傳遞資料) 分別用於Recv()和Send()
// 3.1 一直Recv()到err==io.EOF(即客戶端關閉stream)
// 3.2 Send()則自己控制什麼時候Close 服務端stream沒有close 只要跳出迴圈就算close了。 具體見https://github.com/grpc/grpc-go/issues/444
*/
func (s *StreamService) Route(stream pb.StreamService_RouteServer) error {
    var (
        wg    sync.WaitGroup //任務編排
        msgCh = make(chan *pb.StreamPoint)
    )
    wg.Add(1)
    go func() {
        n := 0
        defer wg.Done()
        for v := range msgCh {
            err := stream.Send(&pb.StreamResponse{
                Pt: &pb.StreamPoint{
                    Name:  v.GetName(),
                    Value: int32(n),
                },
            })
            if err != nil {
                fmt.Println("Send error :", err)
                continue
            }
            n++
        }
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()
        for {
            r, err := stream.Recv()
            if err == io.EOF {
                break
            }
            if err != nil {
                log.Fatalf("recv error :%v", err)
            }
            log.Printf("stream.Recv pt.name: %s, pt.value: %d", r.Pt.Name, r.Pt.Value)
            msgCh <- &pb.StreamPoint{
                Name: "gRPC Stream Server: Route",
            }
        }
        close(msgCh)
    }()

    wg.Wait() //等待任務結束

    return nil
}

2)client

和服務端類似,不過客戶端推送結束後需要主動呼叫 stream.CloseSend() 函式來關閉Stream。

/*
1. 建立連線 獲取client
2. 透過client獲取stream
3. 開兩個goroutine 分別用於Recv()和Send()
    3.1 一直Recv()到err==io.EOF(即服務端關閉stream)
    3.2 Send()則由自己控制
4. 傳送完畢呼叫 stream.CloseSend()關閉stream 必須呼叫關閉 否則Server會一直嘗試接收資料 一直報錯...
*/
func printRoute(client pb.StreamServiceClient, r *pb.StreamRequest) error {
    var wg sync.WaitGroup
    // 呼叫方法獲取stream
    stream, err := client.Route(context.Background())
    if err != nil {
        return err
    }

    // 開兩個goroutine 分別用於Recv()和Send()
    wg.Add(1)
    go func() {
        defer wg.Done()
        for {
            resp, err := stream.Recv()
            if err == io.EOF {
                fmt.Println("Server Closed")
                break
            }
            if err != nil {
                continue
            }
            log.Printf("resp: pj.name: %s, pt.value: %d", resp.Pt.Name, resp.Pt.Value)
        }
    }()

    wg.Add(1)
    go func() {
        defer wg.Done()

        for n := 0; n <= 6; n++ {
            err := stream.Send(r)
            if err != nil {
                log.Printf("send error:%v\n", err)
            }
            time.Sleep(time.Second)
        }

        // 傳送完畢關閉stream
        err = stream.CloseSend()
        if err != nil {
            log.Printf("Send error:%v\n", err)
            return
        }
    }()

    wg.Wait()
    return nil
}

3)啟動 & 請求

# 啟動服務端
$ go run server.go
API server listening at: 127.0.0.1:55108
2022/11/03 12:29:35 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/03 12:29:36 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/03 12:29:37 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/03 12:29:38 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/03 12:29:39 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/03 12:29:40 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111
2022/11/03 12:29:41 stream.Recv pt.name: gRPC Stream Client: Route, pt.value: 1111

# 啟動客戶端
$ go run client.go 
API server listening at: 127.0.0.1:55113
2022/11/03 12:29:35 resp: pj.name: gRPC Stream Server: Route, pt.value: 0
2022/11/03 12:29:36 resp: pj.name: gRPC Stream Server: Route, pt.value: 1
2022/11/03 12:29:37 resp: pj.name: gRPC Stream Server: Route, pt.value: 2
2022/11/03 12:29:38 resp: pj.name: gRPC Stream Server: Route, pt.value: 3
2022/11/03 12:29:39 resp: pj.name: gRPC Stream Server: Route, pt.value: 4
2022/11/03 12:29:40 resp: pj.name: gRPC Stream Server: Route, pt.value: 5
2022/11/03 12:29:41 resp: pj.name: gRPC Stream Server: Route, pt.value: 6
Server Closed

客戶端或者服務端都有對應的 推送或者 接收物件,我們只要 不斷迴圈 Recv()或者 Send() 就能接收或者推送了!

gRPC Stream 和 goroutine 配合簡直完美。透過 Stream 我們可以更加靈活的實現自己的業務。如 訂閱,大資料傳輸等。

Client傳送完成後需要手動呼叫Close()或者CloseSend()方法關閉stream,Server端則return nil就會自動 Close。

1)ServerStream

  • 服務端處理完成後return nil代表響應完成
  • 客戶端透過 err == io.EOF判斷服務端是否響應完成

2)ClientStream

  • 客戶端傳送完畢透過CloseAndRecv關閉stream 並接收服務端響應
  • 服務端透過 err == io.EOF判斷客戶端是否傳送完畢,完畢後使用SendAndClose關閉 stream並返回響應。

3)BidirectionalStream

  • 客戶端服務端都透過stream向對方推送資料
  • 客戶端推送完成後透過CloseSend關閉流,透過err == io.EOF判斷服務端是否響應完成
  • 服務端透過err == io.EOF判斷客戶端是否響應完成,透過return nil表示已經完成響應
    透過err == io.EOF來判定是否把對方推送的資料全部獲取到了。

客戶端透過CloseAndRecv或者CloseSend關閉 Stream,服務端則透過SendAndClose或者直接 return nil來返回響應。

參考文章:
www.lixueduan.com/posts/grpc/03-st...

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章