用Golang構建gRPC服務

本教程提供了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/protocolbu... 下載預編譯好的protoc二進制文件,倉庫中可以找到每個平臺對應的編譯器二進制文件。這裏我們以Mac Os爲例,從https://github.com/protocolbu... 下載並解壓文件。

更新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,客戶端向服務器發送請求,並獲取流以讀取回一系列消息。客戶端從返回的流中讀取,直到沒有更多消息爲止。如我們的示例所示,可以通過將<font color="red">stream</font>關鍵字放在響應類型之前來指定服務器端流方法。
//獲得給定Rectangle中可用的特徵。結果是
//流式傳輸而不是立即返回
//因爲矩形可能會覆蓋較大的區域幷包含大量特徵。
rpc ListFeatures(Rectangle) returns (stream Feature) {}
  • 客戶端流式RPC,其中客戶端使用gRPC提供的流寫入一系列消息並將其發送到服務器。客戶端寫完消息後,它將等待服務器讀取所有消息並返回其響應。通過將<font color="red">stream</font>關鍵字放在請求類型之前,可以指定客戶端流方法。
// 接收路線上被穿過的一系列點位, 當行程結束時
// 服務端會返回一個RouteSummary類型的消息.
rpc RecordRoute(stream Point) returns (RouteSummary) {}
  • 雙向流式RPC,雙方都使用讀寫流發送一系列消息。這兩個流是獨立運行的,因此客戶端和服務器可以按照自己喜歡的順序進行讀寫:例如,服務器可以在寫響應之前等待接收所有客戶端消息,或者可以先讀取消息再寫入消息,或其他一些讀寫組合。每個流中的消息順序都會保留。您可以通過在請求和響應之前都放置<font color="red">stream</font>關鍵字來指定這種類型的方法。
//接收路線行進中發送過來的一系列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中使用<font color="red">DialOptions</font>設置認證憑證(比如: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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章