基础教程

关于 gRPC in Go 的基础教程入门。

基础教程

关于 gRPC in Go 的基础教程入门。

本教程为 Go 程序员提供了一个关于使用 gRPC 的基础介绍。

通过此示例,您将学习如何

  • .proto 文件中定义服务。
  • 使用协议缓冲区编译器生成服务器和客户端代码。
  • 使用 Go gRPC API 为你的服务编写一个简单的客户端和服务器。

本教程假设你已阅读 gRPC 简介 并且熟悉 Protocol Buffers。请注意,本教程中的示例使用了 Protocol Buffers 的 proto3 版本:你可以在 proto3 语言指南Go 生成代码指南 中了解更多信息。

为什么使用 gRPC?

我们的示例是一个简单的路线映射应用程序,它允许客户端获取其路线上特征的信息,创建其路线摘要,并与服务器及其他客户端交换路线信息(例如交通更新)。

使用 gRPC,我们可以一次在 .proto 文件中定义服务,并生成 gRPC 支持的任何语言的客户端和服务器。这些客户端和服务器可以在各种环境中运行,从大型数据中心内的服务器到您的平板电脑,应有尽有——不同语言和环境之间通信的所有复杂性都由 gRPC 为您处理。我们还可以获得使用 Protocol Buffers 的所有优势,包括高效的序列化、简单的 IDL 和易于更新的接口。

设置

你应该已经安装了生成客户端和服务器接口代码所需的工具 —— 如果还没有,请参阅 快速开始 中的 前提条件 部分以获取安装说明。

获取示例代码

示例代码属于 grpc-go 仓库。

  1. 将仓库下载为 zip 文件 并解压,或者克隆该仓库

    git clone -b v1.81.1 --depth 1 https://github.com/grpc/grpc-go
    
  2. 切换到示例目录

    cd grpc-go/examples/route_guide
    

定义服务

我们的第一步(正如你从 gRPC 简介 中所知)是使用 Protocol Buffers 来定义 gRPC 服务 以及方法 请求响应 类型。关于完整的 .proto 文件,请参阅 routeguide/route_guide.proto

要定义服务,请在 .proto 文件中指定一个命名的 service

service RouteGuide {
   ...
}

然后,您在服务定义中定义 rpc 方法,并指定它们的请求和响应类型。gRPC 允许您定义四种服务方法,所有这些方法都在 RouteGuide 服务中使用

  • 简单 RPC:客户端使用存根向服务器发送请求,并等待响应返回,就像普通的函数调用一样。

    // Obtains the feature at a given position.
    rpc GetFeature(Point) returns (Feature) {}
    
  • 服务端流式 RPC:客户端向服务器发送请求,并获得一个用于读取一系列消息的流。客户端会读取返回的流,直到没有更多消息为止。正如您在我们的示例中所见,通过在响应类型前放置 stream 关键字,可以指定服务端流式方法。

    // Obtains the Features available within the given Rectangle.  Results are
    // streamed rather than returned at once (e.g. in a response message with a
    // repeated field), as the rectangle may cover a large area and contain a
    // huge number of features.
    rpc ListFeatures(Rectangle) returns (stream Feature) {}
    
  • 客户端流式 RPC:客户端写入一系列消息并通过提供的流发送给服务器。一旦客户端完成了消息写入,它将等待服务器读取所有消息并返回其响应。通过在请求类型前放置 stream 关键字,可以指定客户端流式方法。

    // Accepts a stream of Points on a route being traversed, returning a
    // RouteSummary when traversal is completed.
    rpc RecordRoute(stream Point) returns (RouteSummary) {}
    
  • 双向流式 RPC:双方都使用读写流发送一系列消息。这两个流独立操作,因此客户端和服务器可以按任何他们喜欢的顺序读取和写入:例如,服务器可以等待接收所有客户端消息后再写入其响应,或者交替读取一条消息然后写入一条消息,亦或是其他读写组合。每条流中消息的顺序都会得到保留。通过在请求和响应之前都放置 stream 关键字,可以指定这种类型的方法。

    // Accepts a stream of RouteNotes sent while a route is being traversed,
    // while receiving other RouteNotes (e.g. from other users).
    rpc RouteChat(stream RouteNote) returns (stream RouteNote) {}
    

我们的 .proto 文件还包含我们服务方法中使用的所有请求和响应类型的协议缓冲区消息类型定义——例如,这是 Point 消息类型

// Points are represented as latitude-longitude pairs in the E7 representation
// (degrees multiplied by 10**7 and rounded to the nearest integer).
// Latitudes should be in the range +/- 90 degrees and longitude should be in
// the range +/- 180 degrees (inclusive).
message Point {
  int32 latitude = 1;
  int32 longitude = 2;
}

生成客户端和服务器代码

接下来,我们需要从 .proto 服务定义中生成 gRPC 客户端和服务器接口。我们使用带有特殊 gRPC Go 插件的 Protocol Buffer 编译器 protoc 来完成此操作。这与我们在 快速开始 中所做的一样。

examples/route_guide 目录下,运行以下命令

protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    routeguide/route_guide.proto

运行此命令会在 routeguide 目录中生成以下文件

  • route_guide.pb.go,其中包含所有用于填充、序列化和检索请求及响应消息类型的 Protocol Buffer 代码。
  • route_guide_grpc.pb.go,其中包含以下内容
    • 一个客户端接口类型(或 桩/stub),供客户端调用 RouteGuide 服务中定义的方法。
    • 一个服务器接口类型,供服务器实现,同样包含 RouteGuide 服务中定义的方法。

创建服务器

首先让我们看看如何创建一个 RouteGuide 服务器。如果您只对创建 gRPC 客户端感兴趣,您可以跳过此部分并直接跳到创建客户端(尽管您可能仍然会觉得它很有趣!)。

使我们的 RouteGuide 服务正常工作需要两部分

  • 实现从我们的服务定义生成的服务接口:完成我们服务的实际“工作”。
  • 运行 gRPC 服务器以监听来自客户端的请求,并将它们分发到正确的服务实现。

你可以在 server/server.go 中找到我们的示例 RouteGuide 服务器。让我们仔细看看它是如何工作的。

实现 RouteGuide

正如你所见,我们的服务器有一个 routeGuideServer 结构体类型,它实现了生成的 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 {
        ...
}
...
简单 RPC

routeGuideServer 实现了我们所有的服务方法。让我们先看最简单的类型 GetFeature,它只是从客户端获取一个 Point,并从其数据库中返回相应的特征信息作为 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{Location: point}, nil
}

该方法接收一个 RPC 上下文对象和客户端的 Point Protocol Buffer 请求。它返回一个带有响应信息的 Feature Protocol Buffer 对象和一个 error。在方法中,我们用相关信息填充 Feature,然后将其与一个 nil 错误一起 return,以告知 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 对象,并使用 RouteGuide_ListFeaturesServerSend() 方法将其写入。最后,像我们简单的 RPC 一样,我们返回一个 nil 错误来告知 gRPC 我们已经完成了响应的写入。如果在此调用中发生任何错误,我们返回一个非 nil 的错误;gRPC 层会将其转换为适当的 RPC 状态发送出去。

客户端流式 RPC

现在让我们看一个稍微复杂一点的情况:客户端流式方法 RecordRoute,我们从客户端获取一个 Point 流,并返回一个包含其旅程信息的单个 RouteSummary。正如你所见,这次该方法根本没有请求参数。相反,它获得了一个 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)
                ... // look for notes to be sent to client
    for _, note := range s.routeNotes[key] {
      if err := stream.Send(note); err != nil {
        return err
      }
    }
  }
}

这次我们得到了一个 RouteGuide_RouteChatServer 流,就像我们的客户端流示例一样,它可以用于读取和写入消息。但是,这次我们通过方法的流返回值,而客户端仍在向 他们的 消息流中写入消息。

此处的读写语法与我们的客户端流方法非常相似,区别在于服务器使用流的 Send() 方法而不是 SendAndClose(),因为它在写入多个响应。尽管双方总是会以写入的顺序收到对方的消息,但客户端和服务器都可以按任何顺序读取和写入——流是完全独立运行的。

启动服务器

一旦我们实现了所有方法,我们还需要启动一个 gRPC 服务器,以便客户端能够实际使用我们的服务。以下代码片段展示了我们如何为 RouteGuide 服务执行此操作

lis, err := net.Listen("tcp", fmt.Sprintf("localhost:%d", port))
if err != nil {
  log.Fatalf("failed to listen: %v", err)
}
var opts []grpc.ServerOption
...
grpcServer := grpc.NewServer(opts...)
pb.RegisterRouteGuideServer(grpcServer, newServer())
grpcServer.Serve(lis)

要构建并启动服务器,我们

  1. 使用以下代码指定我们要监听客户端请求的端口
    lis, err := net.Listen(...).
  2. 使用 grpc.NewServer(...) 创建一个 gRPC 服务器实例。
  3. 将我们的服务实现注册到 gRPC 服务器。
  4. 在服务器上调用 Serve() 并传入端口详细信息,以进行阻塞等待,直到进程被杀死或调用了 Stop()

创建客户端

在本节中,我们将介绍如何为 RouteGuide 服务创建一个 Go 客户端。你可以在 grpc-go/examples/route_guide/client/client.go 中查看我们完整的示例客户端代码。

创建存根

要调用服务方法,我们首先需要创建一个 gRPC 通道 以与服务器通信。我们通过将服务器地址和端口号传递给 grpc.NewClient() 来创建此通道,如下所示

var opts []grpc.DialOption
...
conn, err := grpc.NewClient(*serverAddr, opts...)
if err != nil {
  ...
}
defer conn.Close()

当服务需要身份验证凭据时,你可以在 grpc.NewClient 中使用 DialOptions 来设置这些凭据(例如 TLS、GCE 凭据或 JWT 凭据)。RouteGuide 服务不需要任何凭据。

一旦 gRPC 通道 设置完成,我们需要一个客户端 桩/stub 来执行 RPC。我们通过使用示例 .proto 文件生成的 pb 包提供的 NewRouteGuideClient 方法来获得它。

client := pb.NewRouteGuideClient(conn)

调用服务方法

现在让我们看看如何调用我们的服务方法。请注意,在 gRPC-Go 中,RPC 以阻塞/同步模式运行,这意味着 RPC 调用会等待服务器响应,并会返回响应或错误。

简单 RPC

调用简单的 RPC GetFeature 几乎与调用本地方法一样简单。

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

正如你所见,我们在之前获得的桩上调用该方法。在方法参数中,我们创建并填充一个请求 Protocol Buffer 对象(在我们的例子中是 Point)。我们还传递了一个 context.Context 对象,如果需要,这使我们可以更改 RPC 的行为,例如在 RPC 运行期间超时/取消它。如果调用没有返回错误,那么我们可以从第一个返回值中读取来自服务器的响应信息。

log.Println(feature)
服务器端流式 RPC

这里是我们调用服务器端流式方法 ListFeatures 的地方,它返回一个地理 Feature 流。如果你已经阅读了 创建服务器,那么其中一些内容可能看起来很熟悉——流式 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 流,我们可以使用它来写入 读取消息。

// Create a random number of random 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())
if err != nil {
  log.Fatalf("%v.RecordRoute(_) = _, %v", client, err)
}
for _, point := range points {
  if err := stream.Send(point); err != nil {
    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() 方法。尽管双方总是会以写入的顺序收到对方的消息,但客户端和服务器都可以按任何顺序读取和写入——流是完全独立运行的。

尝试一下!

examples/route_guide 目录下执行以下命令

  1. 运行服务器

    go run server/server.go
    
  2. 在另一个终端中,运行客户端

    go run client/client.go
    

你将看到如下输出

Getting feature for point (409146138, -746188906)
name:"Berkshire Valley Management Area Trail, Jefferson, NJ, USA" location:<latitude:409146138 longitude:-746188906 >
Getting feature for point (0, 0)
location:<>
Looking for features within lo:<latitude:400000000 longitude:-750000000 > hi:<latitude:420000000 longitude:-730000000 >
name:"Patriots Path, Mendham, NJ 07945, USA" location:<latitude:407838351 longitude:-746143763 >
...
name:"3 Hasta Way, Newton, NJ 07860, USA" location:<latitude:410248224 longitude:-747127767 >
Traversing 56 points.
Route summary: point_count:56 distance:497013163
Got message First message at point(0, 1)
Got message Second message at point(0, 2)
Got message Third message at point(0, 3)
Got message First message at point(0, 1)
Got message Fourth message at point(0, 1)
Got message Second message at point(0, 2)
Got message Fifth message at point(0, 2)
Got message Third message at point(0, 3)
Got message Sixth message at point(0, 3)