基础教程
Go 语言中 gRPC 基础教程介绍。
基础教程
本教程为 Go 程序员提供了一个使用 gRPC 的基础入门介绍。
通过学习这个示例,你将了解如何:
- 在
.proto
文件中定义服务。 - 使用 protocol buffer 编译器生成服务端和客户端代码。
- 使用 Go gRPC API 为你的服务编写一个简单的客户端和服务端。
本教程假设你已经阅读了 gRPC 介绍 并熟悉 protocol buffers。请注意,本教程中的示例使用了 protocol buffers 语言的 proto3 版本:你可以在 proto3 语言指南 和 Go 生成代码指南 中找到更多信息。
为什么使用 gRPC?
我们的示例是一个简单的路线映射应用,允许客户端获取沿途特征信息,创建路线摘要,并与服务端及其他客户端交换路况更新等路线信息。
使用 gRPC,我们可以在 .proto
文件中一次性定义服务,并使用 gRPC 支持的任何语言生成客户端和服务端,这些客户端和服务端可以在从大型数据中心的服务器到你的个人平板电脑的各种环境中运行——不同语言和环境之间通信的所有复杂性都由 gRPC 处理。我们还获得了使用 protocol buffers 的所有优势,包括高效序列化、简单的 IDL 以及易于接口更新。
设置
你应该已经安装了生成客户端和服务端接口代码所需的工具——如果还没有,请参阅快速入门的前提条件部分获取设置说明。
获取示例代码
示例代码是 grpc-go 代码库的一部分。
下载代码库的 zip 文件 并解压,或者克隆代码库
git clone -b v1.72.0 --depth 1 https://github.com/grpc/grpc-go
切换到示例目录
cd grpc-go/examples/route_guide
定义服务
我们的第一步(正如你在 gRPC 介绍 中了解到的)是使用 protocol buffers 定义 gRPC 服务以及方法的请求和响应类型。完整的 .proto
文件请参见 routeguide/route_guide.proto。
要定义服务,请在 .proto
文件中指定一个具名的 service
:
service RouteGuide {
...
}
然后在服务定义中定义 rpc
方法,并指定其请求和响应类型。gRPC 允许你定义四种服务方法,所有这些方法都在 RouteGuide
服务中使用:
一种简单 RPC,客户端使用 stub 向服务端发送请求并等待响应返回,就像普通的函数调用一样。
// 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
文件还包含服务方法中使用的所有请求和响应类型的 protocol buffer 消息类型定义——例如,这是 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 客户端和服务端接口。我们使用 protocol buffer 编译器 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
,包含所有用于填充、序列化和获取请求及响应消息类型的 protocol buffer 代码。route_guide_grpc.pb.go
,包含以下内容:- 一个接口类型(或 stub),供客户端调用
RouteGuide
服务中定义的方法。 - 一个接口类型,供服务端实现,同样包含
RouteGuide
服务中定义的方法。
- 一个接口类型(或 stub),供客户端调用
创建服务端
首先,让我们看看如何创建一个 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 对象。在方法中,我们用适当的信息填充 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
}
如你所见,这次在方法参数中我们没有获得简单的请求和响应对象,而是获得了一个请求对象(客户端想在其中查找 Feature
的 Rectangle
)和一个特殊的 RouteGuide_ListFeaturesServer
对象来写入我们的响应。
在方法中,我们填充需要返回的 Feature
对象,并使用 RouteGuide_ListFeaturesServer
的 Send()
方法将它们写入。最后,就像在简单 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_RecordRouteServer
的 Recv()
方法重复读取客户端的请求到请求对象(在本例中为 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)
构建并启动服务端,我们:
- 使用以下方式指定用于监听客户端请求的端口:
lis, err := net.Listen(...)
. - 使用
grpc.NewServer(...)
创建一个 gRPC 服务端实例。 - 向 gRPC 服务端注册我们的服务实现。
- 使用端口详细信息调用服务端的
Serve()
方法进行阻塞等待,直到进程被终止或调用Stop()
。
创建客户端
在本节中,我们将介绍如何为我们的 RouteGuide
服务创建一个 Go 客户端。你可以在 grpc-go/examples/route_guide/client/client.go 中看到完整的示例客户端代码。
创建 stub
为了调用服务方法,我们首先需要创建一个 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 {
...
}
如你所见,我们在之前获得的 stub 上调用该方法。在我们的方法参数中,我们创建并填充了一个请求 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_ListFeaturesClient
的 Recv()
方法反复读取服务器的响应到一个响应协议缓冲区对象中(在此例中是一个 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
目录执行以下命令
运行服务器
go run server/server.go
从另一个终端运行客户端
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)