基础教程

Go 语言中 gRPC 的基本教程介绍。

基础教程

Go 语言中 gRPC 的基本教程介绍。

本教程为 Go 程序员提供了 gRPC 工作的基本介绍。

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

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

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

为什么使用 gRPC?

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

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

设置

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

获取示例代码

示例代码是 grpc-go 仓库的一部分。

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

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

    cd grpc-go/examples/route_guide
    

定义服务

我们的第一步(正如你从 gRPC 简介中了解到的)是使用 协议缓冲区 来定义 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 客户端和服务器接口。我们使用协议缓冲区编译器 protoc 和一个特殊的 gRPC Go 插件来完成此操作。这与我们在 快速入门中做的类似。

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,其中包含用于填充、序列化和检索请求和响应消息类型的所有协议缓冲区代码。
  • route_guide_grpc.pb.go,其中包含以下内容
    • 供客户端调用的接口类型(或存根),其中包含 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 协议缓冲区请求。它返回一个包含响应信息和 errorFeature 协议缓冲区对象。在方法中,我们用适当的信息填充 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 通道设置好后,我们需要一个客户端存根来执行 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 {
  ...
}

如你所见,我们在之前获得的存根上调用该方法。在我们的方法参数中,我们创建并填充了一个请求协议缓冲区对象(在本例中为 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() 方法反复读取服务器响应到响应协议缓冲区对象(在本例中为 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)