用 Golang 構建 gRPC 服務

KevinYan發表於2019-11-17

Golang

本教程提供了Go使用gRPC的基礎教程

在教程中你將會學到如何:

  • .proto檔案中定義一個服務。
  • 使用protocol buffer編譯器生成客戶端和服務端程式碼。
  • 使用gRPC的Go API為你的服務寫一個客戶端和伺服器。

繼續之前,請確保你已經對gRPC概念有所瞭解,並且熟悉protocol buffer。需要注意的是教程中的示例使用的是proto3版本的protocol buffer:你可以在Protobuf語言指南Protobuf生成Go程式碼指南中瞭解到更多相關知識。

為什麼使用gRPC

我們的示例是一個簡單的路線圖應用,客戶端可以獲取路線特徵資訊、建立他們的路線摘要,還可以與伺服器或者其他客戶端交換比如交通狀態更新這樣的路線資訊。

藉助gRPC,我們可以在.proto檔案中定義我們的服務,並以gRPC支援的任何語言來實現客戶端和伺服器,客戶端和伺服器又可以在從伺服器到你自己的平板電腦的各種環境中執行-gRPC還會為你解決所有不同語言和環境之間通訊的複雜性。我們還獲得了使用protocol buffer的所有優點,包括有效的序列化(速度和體積兩方面都比JSON更有效率),簡單的IDL(介面定義語言)和輕鬆的介面更新。

安裝

安裝grpc包

首先需要安裝gRPC golang版本的軟體包,同時官方軟體包的examples目錄裡就包含了教程中示例路線圖應用的程式碼。

$ go get google.golang.org/grpc

然後切換到`grpc-go/examples/route_guide:`目錄:

$ cd $GOPATH/src/google.golang.org/grpc/examples/route_guide

安裝相關工具和外掛

  • 安裝protocol buffer編譯器

安裝編譯器最簡單的方式是去https://github.com/protocolbuffers/protobuf/releases 下載預編譯好的protoc二進位制檔案,倉庫中可以找到每個平臺對應的編譯器二進位制檔案。這裡我們以Mac Os為例,從https://github.com/protocolbuffers/protobuf/releases/download/v3.6.0/protoc-3.6.0-osx-x86_64.zip 下載並解壓檔案。

更新PATH系統變數,或者確保protoc放在了PATH包含的目錄中了。

  • 安裝protoc編譯器外掛
$ go get -u github.com/golang/protobuf/protoc-gen-go

編譯器外掛protoc-gen-go將安裝在$GOBIN中,預設位於​$GOPATH/bin。編譯器protoc必須在$PATH中能找到它:

$ export PATH=$PATH:$GOPATH/bin

定義服務

首先第一步是使用protocol buffer定義gRPC服務還有方法的請求和響應型別,你可以在下載的示例程式碼examples/route_guide/routeguide/route_guide.proto中看到完整的.proto檔案。

要定義服務,你需要在.proto檔案中指定一個具名的service

service RouteGuide {
   ...
}

然後在服務定義中再來定義rpc方法,指定他們的請求和響應型別。gRPC允許定義四種型別的服務方法,這四種服務方法都會應用到我們的RouteGuide服務中。

  • 一個簡單的RPC,客戶端使用存根將請求傳送到伺服器,然後等待響應返回,就像普通的函式呼叫一樣。
// 獲得給定位置的特徵
rpc GetFeature(Point) returns (Feature) {}
  • 伺服器端流式RPC,客戶端向伺服器傳送請求,並獲取流以讀取回一系列訊息。客戶端從返回的流中讀取,直到沒有更多訊息為止。如我們的示例所示,可以通過將stream關鍵字放在響應型別之前來指定伺服器端流方法。
//獲得給定Rectangle中可用的特徵。結果是
//流式傳輸而不是立即返回
//因為矩形可能會覆蓋較大的區域幷包含大量特徵。
rpc ListFeatures(Rectangle) returns (stream Feature) {}
  • 客戶端流式RPC,其中客戶端使用gRPC提供的流寫入一系列訊息並將其傳送到伺服器。客戶端寫完訊息後,它將等待伺服器讀取所有訊息並返回其響應。通過將stream關鍵字放在請求型別之前,可以指定客戶端流方法。
// 接收路線上被穿過的一系列點位, 當行程結束時
// 服務端會返回一個RouteSummary型別的訊息.
rpc RecordRoute(stream Point) returns (RouteSummary) {}
  • 雙向流式RPC,雙方都使用讀寫流傳送一系列訊息。這兩個流是獨立執行的,因此客戶端和伺服器可以按照自己喜歡的順序進行讀寫:例如,伺服器可以在寫響應之前等待接收所有客戶端訊息,或者可以先讀取訊息再寫入訊息,或其他一些讀寫組合。每個流中的訊息順序都會保留。您可以通過在請求和響應之前都放置stream關鍵字來指定這種型別的方法。
//接收路線行進中傳送過來的一系列RouteNotes型別的訊息,同時也接收其他RouteNotes(例如:來自其他使用者)
rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}

我們的.proto檔案中也需要所有請求和響應型別的protocol buffer訊息型別定義。比如說下面的Point訊息型別:

// Points被表示為E7表示形式中的經度-緯度對。
//(度數乘以10 ** 7並四捨五入為最接近的整數)。
// 緯度應在+/- 90度範圍內,而經度應在
// 範圍+/- 180度(含)
message Point {
  int32 latitude = 1;
  int32 longitude = 2;
}

生成客戶端和服務端程式碼

接下來要從我們的.proto服務定義生成gRPC客戶端和服務端的介面。我們使用protoc編譯器和上面安裝的編譯器外掛來完成這些工作:

在示例route_guide的目錄下執行:

 protoc -I routeguide/ routeguide/route_guide.proto --go_out=plugins=grpc:routeguide

執行命令後會在示例route_guide目錄的routeguide目錄下生成route_guide.pb.go檔案。

pb.go檔案裡面包含:

  • 用於填充、序列化和檢索我們定義的請求和響應訊息型別的所有protocol buffer程式碼。
  • 一個客戶端存根用來讓客戶端呼叫RouteGuide服務中定義的方法。
  • 一個需要服務端實現的介面型別RouteGuideServer,介面型別中包含了RouteGuide服務中定義的所有方法。

建立gRPC服務端

首先讓我們看一下怎麼建立RouteGuide伺服器。有兩種方法來讓我們的RouteGuide服務工作:

  • 實現我們從服務定義生成的服務介面:做服務實際要做的事情。
  • 執行一個gRPC伺服器監聽客戶端的請求然後把請求派發給正確的服務實現。

你可以在剛才安裝的gPRC包的grpc-go/examples/route_guide/server/server.go找到我們示例中RouteGuide`服務的實現程式碼。下面讓我們看看他是怎麼工作的。

實現RouteGuide

如你所見,實現程式碼中有一個routeGuideServer結構體型別,它實現了protoc編譯器生成的pb.go檔案中定義的RouteGuideServer介面。

type routeGuideServer struct {
        ...
}
...

func (s *routeGuideServer) GetFeature(ctx context.Context, point *pb.Point) (*pb.Feature, error) {
        ...
}
...

func (s *routeGuideServer) ListFeatures(rect *pb.Rectangle, stream pb.RouteGuide_ListFeaturesServer) error {
        ...
}
...

func (s *routeGuideServer) RecordRoute(stream pb.RouteGuide_RecordRouteServer) error {
        ...
}
...

func (s *routeGuideServer) RouteChat(stream pb.RouteGuide_RouteChatServer) error {
        ...
}
...

普通PRC

routeGuideServer實現我們所有的服務方法。首先,讓我們看一下最簡單的型別GetFeature,它只是從客戶端獲取一個Point,並從其Feature資料庫中返回相應的Feature資訊。

func (s *routeGuideServer) GetFeature(ctx context.Context, point *pb.Point) (*pb.Feature, error) {
    for _, feature := range s.savedFeatures {
        if proto.Equal(feature.Location, point) {
            return feature, nil
        }
    }
    // No feature was found, return an unnamed feature
    return &pb.Feature{"", point}, nil
}

這個方法傳遞了RPC上下文物件和客戶端的Point protocol buffer請求訊息,它在響應資訊中返回一個Feature型別的protocol buffer訊息和錯誤。在該方法中,我們使用適當的資訊填充Feature,然後將其返回並返回nil錯誤,以告知gRPC我們已經完成了RPC的處理,並且可以將`Feature返回給客戶端。

服務端流式RPC

現在,讓我們看一下服務方法中的一個流式RPC。 ListFeatures是伺服器端流式RPC,因此我們需要將多個Feature傳送回客戶端。

func (s *routeGuideServer) ListFeatures(rect *pb.Rectangle, stream pb.RouteGuide_ListFeaturesServer) error {
    for _, feature := range s.savedFeatures {
        if inRange(feature.Location, rect) {
            if err := stream.Send(feature); err != nil {
                return err
            }
        }
    }
    return nil
}

如你所見,這次我們沒有獲得簡單的請求和響應物件,而是獲得了一個請求物件(客戶端要在其中查詢FeatureRectangle)和一個特殊的RouteGuide_ListFeaturesServer物件來寫入響應。

在該方法中,我們填充了需要返回的所有Feature物件,並使用Send()方法將它們寫入RouteGuide_ListFeaturesServer。最後,就像在簡單的RPC中一樣,我們返回nil錯誤來告訴gRPC我們已經完成了響應的寫入。如果此呼叫中發生任何錯誤,我們將返回非nil錯誤; gRPC層會將其轉換為適當的RPC狀態,以線上上傳送。

客戶端流式RPC

現在,讓我們看一些更復雜的事情:客戶端流方法RecordRoute,從客戶端獲取點流,並返回一個包含行程資訊的RouteSummary。如你所見,這一次該方法根本沒有request引數。相反,它獲得一個RouteGuide_RecordRouteServer流,伺服器可以使用該流來讀取和寫入訊息-它可以使用Recv()方法接收客戶端訊息,並使用SendAndClose()方法返回其單個響應。

func (s *routeGuideServer) RecordRoute(stream pb.RouteGuide_RecordRouteServer) error {
    var pointCount, featureCount, distance int32
    var lastPoint *pb.Point
    startTime := time.Now()
    for {
        point, err := stream.Recv()
        if err == io.EOF {
            endTime := time.Now()
            return stream.SendAndClose(&pb.RouteSummary{
                PointCount:   pointCount,
                FeatureCount: featureCount,
                Distance:     distance,
                ElapsedTime:  int32(endTime.Sub(startTime).Seconds()),
            })
        }
        if err != nil {
            return err
        }
        pointCount++
        for _, feature := range s.savedFeatures {
            if proto.Equal(feature.Location, point) {
                featureCount++
            }
        }
        if lastPoint != nil {
            distance += calcDistance(lastPoint, point)
        }
        lastPoint = point
    }
}

在方法主體中,我們使用RouteGuide_RecordRouteServerRecv()方法不停地讀取客戶端的請求到一個請求物件中(在本例中為Point),直到沒有更多訊息為止:伺服器需要要在每次呼叫後檢查從Recv()返回的錯誤。如果為nil,則流仍然良好,並且可以繼續讀取;如果是io.EOF,則表示訊息流已結束,伺服器可以返回其RouteSummary。如果錯誤為其他值,我們將返回錯誤“原樣”,以便gRPC層將其轉換為RPC狀態。

雙向流式RPC

最後讓我們看一下雙向流式RPC方法RouteChat()

func (s *routeGuideServer) RouteChat(stream pb.RouteGuide_RouteChatServer) error {
    for {
        in, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }
        key := serialize(in.Location)

        s.mu.Lock()
        s.routeNotes[key] = append(s.routeNotes[key], in)
        // Note: this copy prevents blocking other clients while serving this one.
        // We don't need to do a deep copy, because elements in the slice are
        // insert-only and never modified.
        rn := make([]*pb.RouteNote, len(s.routeNotes[key]))
        copy(rn, s.routeNotes[key])
        s.mu.Unlock()

        for _, note := range rn {
            if err := stream.Send(note); err != nil {
                return err
            }
        }
    }
}

這次,我們得到一個RouteGuide_RouteChatServer流,就像在客戶端流示例中一樣,該流可用於讀取和寫入訊息。但是,這次,當客戶端仍在向其訊息流中寫入訊息時,我們會向流中寫入要返回的訊息。

此處的讀寫語法與我們的客戶端流式傳輸方法非常相似,不同之處在於伺服器使用流的Send()方法而不是SendAndClose(),因為伺服器會寫入多個響應。儘管雙方總是會按照對方的寫入順序來獲取對方的訊息,但是客戶端和伺服器都可以以任意順序進行讀取和寫入-流完全獨立地執行(意思是伺服器可以接受完請求後再寫流,也可以接收一條請求寫一條響應。同樣的客戶端可以寫完請求了再讀響應,也可以發一條請求讀一條響應)

啟動伺服器

一旦實現了所有方法,我們還需要啟動gRPC伺服器,以便客戶端可以實際使用我們的服務。以下程式碼段顯示瞭如何啟動RouteGuide服務。

flag.Parse()
lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))
if err != nil {
        log.Fatalf("failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
pb.RegisterRouteGuideServer(grpcServer, &routeGuideServer{})
... // determine whether to use TLS
grpcServer.Serve(lis)

為了構建和啟動伺服器我們需要:

  • 指定要監聽客戶端請求的介面lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port))。
  • 使用grpc.NewServer()建立一個gRPC server的例項。
  • 使用gRPC server註冊我們的服務實現。
  • 使用我們的埠詳細資訊在伺服器上呼叫Serve()進行阻塞等待,直到程式被殺死或呼叫Stop()為止。

建立客戶端

在這一部分中我們將為RouteGuide服務建立Go客戶端,你可以在grpc-go/examples/route_guide/client/client.go 看到完整的客戶端程式碼。

Creating stub

要呼叫服務的方法,我們首先需要建立一個gRPC通道與伺服器通訊。我們通過把伺服器地址和埠號傳遞給grpc.Dial()來建立通道,像下面這樣:

conn, err := grpc.Dial(*serverAddr)
if err != nil {
    ...
}
defer conn.Close()

如果你請求的服務需要認證,你可以在grpc.Dial中使用DialOptions設定認證憑證(比如:TLS,GCE憑證,JWT憑證)--不過我們的RouteGuide服務不需要這些。

設定gRPC通道後,我們需要一個客戶端存根來執行RPC。我們使用從.proto生成的pb包中提供的NewRouteGuideClient方法獲取客戶端存根。

client := pb.NewRouteGuideClient(conn)

生成的pb.go檔案定義了客戶端介面型別RouteGuideClient並用客戶端存根的結構體型別實現了介面中的方法,所以通過上面獲取到的客戶端存根client可以直接呼叫下面介面型別中列出的方法。

type RouteGuideClient interface {
    GetFeature(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Feature, error)

    ListFeatures(ctx context.Context, in *Rectangle, opts ...grpc.CallOption) (RouteGuide_ListFeaturesClient, error)

    RecordRoute(ctx context.Context, opts ...grpc.CallOption) (RouteGuide_RecordRouteClient, error)
    RouteChat(ctx context.Context, opts ...grpc.CallOption) (RouteGuide_RouteChatClient, error)
}

每個實現方法會再去請求gRPC服務端相對應的方法獲取服務端的響應,比如:

func (c *routeGuideClient) GetFeature(ctx context.Context, in *Point, opts ...grpc.CallOption) (*Feature, error) {
    out := new(Feature)
    err := c.cc.Invoke(ctx, "/routeguide.RouteGuide/GetFeature", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

RouteGuideClient介面的完整實現可以在生成的pb.go檔案裡找到。

呼叫服務的方法

現在讓我們看看如何呼叫服務的方法。注意在gRPC-Go中,PRC是在阻塞/同步模式下的執行的,也就是說RPC呼叫會等待服務端響應,服務端將返回響應或者是錯誤。

普通RPC

呼叫普通RPC方法GetFeature如同直接呼叫本地的方法。

feature, err := client.GetFeature(context.Background(), &pb.Point{409146138, -746188906})
if err != nil {
        ...
}

如你所見,我們在之前獲得的存根上呼叫該方法。在我們的方法引數中,我們建立並填充一個protocol buffer物件(在本例中為Point物件)。我們還會傳遞一個context.Context物件,該物件可讓我們在必要時更改RPC的行為,例如超時/取消正在呼叫的RPC(cancel an RPC in flight)。如果呼叫沒有返回錯誤,則我們可以從第一個返回值中讀取伺服器的響應資訊。

服務端流式RPC

這裡我們會呼叫服務端流式方法ListFeatures,方法返回的流中包含了地理特徵資訊。如果你讀過上面的建立客戶端的章節,這裡有些東西看起來會很熟悉--流式RPC在兩端實現的方式很類似。

rect := &pb.Rectangle{ ... }  // initialize a pb.Rectangle
stream, err := client.ListFeatures(context.Background(), rect)
if err != nil {
    ...
}
for {
    feature, err := stream.Recv()
    if err == io.EOF {
        break
    }
    if err != nil {
        log.Fatalf("%v.ListFeatures(_) = _, %v", client, err)
    }
    log.Println(feature)
}

和簡單RPC呼叫一樣,呼叫時傳遞了一個方法的上下文和一個請求。但是我們取回的是一個RouteGuide_ListFeaturesClient例項而不是一個響應物件。客戶端可以使用RouteGuide_ListFeaturesClient流讀取伺服器的響應。

我們使用RouteGuide_ListFeaturesClientRecv()方法不停地將伺服器的響應讀入到一個protocol buffer響應物件中(本例中的Feature物件),直到沒有更多訊息為止:客戶端需要在每次呼叫後檢查從Recv()返回的錯誤err。如果為nil,則流仍然良好,並且可以繼續讀取;如果是io.EOF,則訊息流已結束;否則就是一定RPC錯誤,該錯誤會通過err傳遞給呼叫程式。

客戶端流式RPC

客戶端流方法RecordRoute與伺服器端方法相似,不同之處在於,我們僅向該方法傳遞一個上下文並獲得一個RouteGuide_RecordRouteClient流,該流可用於寫入和讀取訊息。

// 隨機的建立一些Points
r := rand.New(rand.NewSource(time.Now().UnixNano()))
pointCount := int(r.Int31n(100)) + 2 // Traverse at least two points
var points []*pb.Point
for i := 0; i < pointCount; i++ {
    points = append(points, randomPoint(r))
}
log.Printf("Traversing %d points.", len(points))
stream, err := client.RecordRoute(context.Background())// 呼叫服務中定義的客戶端流式RPC方法
if err != nil {
    log.Fatalf("%v.RecordRoute(_) = _, %v", client, err)
}
for _, point := range points {
    if err := stream.Send(point); err != nil {// 向流中寫入多個請求訊息
        if err == io.EOF {
            break
        }
        log.Fatalf("%v.Send(%v) = %v", stream, point, err)
    }
}
reply, err := stream.CloseAndRecv()// 從流中取回伺服器的響應
if err != nil {
    log.Fatalf("%v.CloseAndRecv() got error %v, want %v", stream, err, nil)
}
log.Printf("Route summary: %v", reply)

RouteGuide_RecordRouteClient有一個Send()。我們可以使用它傳送請求給服務端。一旦我們使用Send()寫入流完成後,我們需要在流上呼叫CloseAndRecv()方法讓gRPC知道我們已經完成了請求的寫入並且期望得到一個響應。我們從CloseAndRecv()方法返回的err中可以獲得RPC狀態。如果狀態是nil,CloseAndRecv()`的第一個返回值就是一個有效的伺服器響應。

雙向流式RPC

最後,讓我們看一下雙向流式RPC RouteChat()。與RecordRoute一樣,我們只向方法傳遞一個上下文物件,然後獲取一個可用於寫入和讀取訊息的流。但是,這一次我們在伺服器仍將訊息寫入訊息流的同時,通過方法的流返回值。

stream, err := client.RouteChat(context.Background())
waitc := make(chan struct{})
go func() {
    for {
        in, err := stream.Recv()
        if err == io.EOF {
            // read done.
            close(waitc)
            return
        }
        if err != nil {
            log.Fatalf("Failed to receive a note : %v", err)
        }
        log.Printf("Got message %s at point(%d, %d)", in.Message, in.Location.Latitude, in.Location.Longitude)
    }
}()
for _, note := range notes {
    if err := stream.Send(note); err != nil {
        log.Fatalf("Failed to send a note: %v", err)
    }
}
stream.CloseSend()
<-waitc

除了在完成呼叫後使用流的CloseSend()方法外,此處的讀寫語法與我們的客戶端流方法非常相似。儘管雙方總是會按照對方的寫入順序來獲取對方的訊息,但是客戶端和伺服器都可以以任意順序進行讀取和寫入-兩端的流完全獨立地執行。

啟動應用

要編譯和執行伺服器,假設你位於$ GOPATH/src/google.golang.org/grpc/examples/route_guide資料夾中,只需:

$ go run server/server.go

同樣,執行客戶端:

$ go run client/client.go

WX20191117-152623@2x.png

課程推薦:簡明高效的Go語言入門和實戰指南

公眾號:網管叨bi叨 | Golang、PHP、Laravel、Docker等學習經驗分享

相關文章