注:本文是在作者本人的同意下进行翻译的,如无本人同意,不支持任何商业的和非商业的转载【授权见文末】。

15236357753546

这是一篇介绍我们是如何在 Go 语言中使用 gRPC (和 Protobuf) 从而构建稳定的 CS 系统的技术文章。

在这里不会介绍为什么选择 gRPC 作为客户端与服务器端之间的主要通信协议,确实已经有不少很不错的的文章在讲这些东西了,例如这些:

从大方面来说,我们正在使用 Golang 构建我们的 C/S 系统,同时需要他足够快,足够可靠,足够具有伸缩性(这也是为什么我们选择gRPC)。我们希望客户端与服务端之间的通信内容越少越好,越安全越好,并且客户端和服务器使用的模式要一致(这也是为什么选择 Protobuf )。

此外,我们同时也希望能够在服务端暴露其他类型的接口,因为有些客户端无法使用 gRPC:例如暴露传统的 REST 接口,我们希望这个需求(几乎)不需额外的代价。

概述

我们将开始使用 Go 构建一个非常简单的C/S系统,系统将会在客户端、服务端之间交换虚拟的消息。在完成第一步,客户端、服务端之间理解了对方的消息后,我们将会加入其它的特性,例如 TLS 支持,认证,以及一个 REST API。

文章的接下去部分假设你具有基本的Go语言编程能力。同时,也假设你已经安装了 protobuf 包,protoc 命令是可用的(再次说明,已经有很多文章涵盖了介绍如何安装的主题,同时,这里也有一份 官方文档)。

你也需要安装Go依赖库,例如 protobuf的go实现,还有 gRPC网关

这篇文章中的所有代码在:https://gitlab.com/pantomath-io/demo-grpc。你可以随意使用这个仓库,并通过标签来导航、定位。这个仓库应当放置在你 $GOPATHsrc 目录:

$ cd $GOPATH/src mkdir pantomath-io git clone https://gitlab.com/pantomath-io/demo-grpc go get -v -d gitlab.com/pantomath-io/demo-grpc   $ cd $GOPATH/src/gitlab.com/pantomath-io/demo-grpc

协议文件

15236369982537

首先,我们需要定义协议。也就是定义在客户端和服务端我们能交互些什么,以及如何交互。这就是 Protobuf 发挥作用的地方。它让你定义两类东西:服务( Service )和消息( Message )。一个service是服务端的一组动作集合,服务端针对客户端的请求执行并产生不同的响应。一个 message 就是一个客户端请求的内容。简单来说,你可以认为 service 定义了动作,而 message 定义了对象

api/api.proto 中写入如下内容:

syntax = "proto3";package api;message PingMessage {  string greeting = 1;}service Ping {  rpc SayHello(PingMessage) returns (PingMessage) {}}

可以看到,这里定义了两个结构:一个是名为 Pingservice,暴露了一个叫做 SayHellomethod,这个方法接收了一个叫做 PingMessage 的输入参数,并返回一个结果 PingMessage ;另外还有一个叫做 PingMessagemessage,这个 message 只有一个单一的字段 greeting,这个字段的类型是 string

同时,从文档中也说明了,这里使用的是 proto3 的规范,它和 proto2 有所区别(详见文档

事实上,这个文件其实现在是不能用的 —— 它需要被编译。所谓的编译 proto 文件,其实就是生成你想要的目标语言的代码,也就是你项目中所使用的编程语言。

在 shell 中,切换到你的项目目录,并执行以下命令:

protoc -I api/ \
    -I${PROTO_PATH} \
    --go_out=plugins=grpc:api \
    api/api.proto

这条命令会自动生成文件 api/api.pb.go,它内部已经实现了应用所需要的关于 gRPC 的 Go 代码,你可以阅读并且使用它,但切记不要去人工修改(每次你执行protoc命令,他都将被覆盖掉)。

同时您还需要定义由 service Ping 所调用的函数,因此请创建一个名为 api/handler.go 的文件:

// Server represents the gRPC servertype Server struct {}// SayHello generates response to a Ping requestfunc (s *Server) SayHello(ctx context.Context, in *PingMessage) (*PingMessage, error)  log.Printf("Receive message %s", in.Greeting)  return &PingMessage{Greeting: "bar"}, nil}

  • Server struct 只是服务器的抽象,它允许"附加" 一些资源到你的服务器中,从而使它们在 RPC 调用期间可用。
  • SayHello 函数是 Protobuf 文件中定义的那个函数,作为 RPC 调用的 Ping service。如果你没有定义它,你将无法创建 gRPC 服务器。
  • SayHello 需要 PingMessage 作为参数,并返回 PingMessagePingMessage struct 定义在从api.proto 自动生成的 api.pb.go 文件中。这个函数还有一个 Context 参数(请参阅官方博客文章中的进一步介绍)。后续你会知道通过 Context 我们能做什么。另外,如果发生了不良情况,这个函数还返回一个error

简单的服务器

15236372394602

现在你已经有了一个协议,是时候创建一个简单的服务器来实现 service 和理解 message 了,就拿起你最喜欢的编辑器来创建文件 server/main.go

package mainimport (  "fmt"  "log"  "net"// main start a gRPC server and waits for connection  lis, err := net.Listen("tcp", fmt.Sprintf(":%d", 7777))  if err != nil     log.Fatalf("failed to listen: %v", err)  }  // create a server instances := api.Server{}  // create a gRPC server object  grpcServer := grpc.NewServer()  // attach the Ping service to the server  api.RegisterPingServer(grpcServer, &s)  // start the server  if err := grpcServer.Serve(lis); err != nil {    log.Fatalf("failed to serve: %s", err)  }}

让我来分解一下代码,让你能够理解得更清晰一些:

  • 注意你要导入 api 包,以便 Protobuf service 处理程序和 Server struct 可用;
  • main 函数首先要在绑定 gRPC 服务器的端口上创建一个 TCP 监听器;
  • 那么剩下的就非常简单了:您创建一个实例 Server,创建一个 gRPC 服务器实例,注册 service 并启动 gRPC 服务器。

然后你就可以通过编译您的代码来获取服务器二进制文件了:

$ go build -i -v -o bin/server gitlab.com/pantomath-io/demo-grpc/server

简单的客户端

15236373738350

客户端也需要导入 api 包,以便 messageservice 可用,创建文件client/main.go:

15236374400564

这次代码的分解就简单多了:

  • main 函数在服务器绑定的 TCP 端口上实例化客户端连接;
  • 注意 defer 函数返回时正确关闭连接的调用;
  • c 变量是服务的客户端 Ping,它调用 SayHello 函数并传递 PingMessage 给它。

你现在可以通过编译您的代码以获取客户端二进制文件:

$ go build -i -v -o bin/client gitlab.com/pantomath-io/demo-grpc/client

客户端-服务器交互

您刚刚构建了一个客户端和一个服务器,是时候在两个终端中对它们进行了测试了:

$ bin/server
2006/01/02 15:04:05 Receive message foo

$ bin/client
2006/01/02 15:04:05 Response from server: bar

减轻你工作的工具

现在 API、客户端和服务器都可以工作了,您可能更喜欢使用 Makefile 来编译代码、清理文件夹和管理依赖关系等。

在项目文件夹的根目录下创建一个 Makefile。解释这个文件超出了这篇文章的范围,它主要使用你之前已经产生的编译命令。

要使用Makefile ,请尝试调用以下内容:

$ make help
api                            Auto-generate grpc go sources
build_client                   Build the binary file for client
build_server                   Build the binary file for server
clean                          Remove previous builds
dep                            Get the dependencies
help                           Display this help screen

加密通信

15236374928452

客户端和服务器是通过 HTTP/2gRPC上的传输层)相互通信。这些消息是二进制数据(感谢 Protobuf),但通信是纯文本的。幸运的是,gRPC 具有 SSL/TLS 集成功能,可用于从客户端角度对服务器进行身份验证,并对消息交换进行加密。

你不需要改变任何协议:它仍然是一样的。这些更改发生在客户端和服务器端的 gRPC 对象创建中。请注意,如果您仅更改一侧,则连接将不会起作用。

在更改代码中的任何内容之前,您需要创建一个自签名SSL证书。这个帖子的目的不是为了解释如何做到这一点,但OpenSSL官方文档(genrsa,req,x509)可以回答你的问题(DigitalOcean 也有一个很好的和完整的教程)。同时,您可以使用该文件 cert 夹中提供的文件。以下命令已用于生成文件:

$ openssl genrsa -out cert / server.key 2048 
$ openssl req -new -x509 -sha256 -key cert / server.key -out cert / server.crt -days 3650 
$ openssl req -new -sha256 -key cert / server。 key -out cert / server.csr 
$ openssl x509 -req -sha256 -in cert / server.csr -signkey cert / server.key -out cert / server.crt -days 3650

您可以继续并更新服务器定义以使用证书和密钥:

15236375978707

那么改变了什么?

  • 您从证书和密钥文件创建了一个 credentials(creds);
  • 您创建了一个 grpc.ServerOption 数组并将您的凭证对象放入其中;
  • 当创建grpc服务器时,您向构造函数提供了 grpc.ServerOption 数组;
  • 您必须注意到您需要精确地指定将您的服务器绑定到的IP,以便IP与证书中使用的FQDN相匹配。

请注意,这 grpc.NewServer() 是一个可变参数函数,所以您可以传递任意数量的结尾参数,这里您创建了一系列选项,以便稍后添加其他选项。

如果你现在已经编译好了你的服务端程序,并使用之前的客户端程序,那他们两者之间的连接将无法工作,他们两边都会抛出error。

  • 服务器报告客户端没有进行TLS握手:
2006/01/02 15:04:05 grpc: Server.Serve failed to complete security handshake from "localhost:64018": tls: first record does not look like a TLS handshake
  • 客户端在在执行任何操作之前关闭其连接:
2006/01/02 15:04:05 transport: http2Client.notifyError got notified that the client transport was broken read tcp localhost:64018->127.0.0.1:7777: read: connection reset by peer.
2006/01/02 15:04:05 Error when calling SayHello: rpc error: code = Internal desc = transport is closing

您需要在客户端使用完全相同的证书文件。所以编辑 client/main.go 文件:

15236376900834

客户端的更改与服务器上的更改几乎相同:

  • 您使用证书文件创建了 credentials对象,需要注意的是,客户端不使用证书密钥,key 对服务器来说是私有的;
  • grpc.Dial() 使用凭证对象向该函数添加了一个选项:请注意,grpc.Dial() 函数也是一个可变参数函数,因此它可以接受任意数量的选项;
  • 相同的服务器说明适用于客户端:您需要使用与证书中使用的FQDN相同的FQDN来连接服务器,否则传输认证握手将失败。

两边都使用了 credentials,所以他们应该能够像以前一样说话,但是要以加密方式。现在再重新编译代码::

$ make

并在两个独立的终端中运行双方:

$ bin/server
2006/01/02 15:04:05 Receive message foo

$ bin/client
2006/01/02 15:04:05 Response from server: bar

客户端标识

15236377235163

gRPC 服务器的另一个有趣功能是拦截来自客户端的请求。客户端可以在传输层上注入信息。您可以使用该功能来识别您的客户端,因为 SSL 实现通过证书验证服务器,但不验证客户端(所有客户端都使用相同证书)。

因此,您需要更新客户端,以便在每个调用上注入元数据(如登录名和密码),并在服务器端为每个调用检查这些凭据。

在客户端,你只需在你的 grpc.Dial() 调用中指定一个 DialOption,但是这个 DialOption 有一些限制,编辑你的 client/main.go(链接:file) 文件:

  • 您可以定义一个结构来保存您想要在你 rcp 调用中注入的字段集合。在我们的例子中,只需要一个登录名和密码,但你可以定义任何你想要的字段;
  • auth变量保存您将使用的值;
  • 可以使用 grpc.WithPerRPCCredentials() 函数为 grpc.Dial() 函数创建一个使用的 DialOption 对象;
  • 请注意,该 grpc.WithPerRPCCredentials() 函数将接口作为参数,所以您的 Authentication 结构应符合该接口。从文档中,你知道你应该在你的结构上实现两种方法:GetRequestMetadataRequireTransportSecurity
  • 所以你定义的 GetRequestMetadata 函数只是返回你的 Authentication 结构的 map ;
  • 最后,你定义了一个 RequireTransportSecurity 函数,告诉你的 grpc 客户端它是否应该在传输级别注入元数据。在我们这里的情况中,它总是返回 true,但你可以让它返回指定布尔值的值。

客户端在调用服务器时需要额外的数据,但服务器现在不知道,所以你需要告诉他检查这些元数据,打开 server/main.go 并更新它:

译者注:不希望堆代码,可以新 Tab 打开看 Code

再次,让我为你分解这件事:

  • 你给你之前创建的阵列 grpc.UnaryInterceptor 添加一个新的 grpc.ServerOption (现在知道为什么它是一个数组了?)。并且您将一个函数的引用传递给该函数,以便知道该调用谁。代码的其余部分不会改变;
  • 你必须要定义 unaryInterceptor 函数,他会接收一堆参数:
    1. 一个context.Context对象,包含了你的数据,他在整个请求生命周期内都存在
    2. 一个interface{},他是rpc调用的入参接口
    3. 一个UnaryServerInfo结构,他包含了若干关于本地调用的信息(例如,Server抽象对象,以及客户端调用的具体方法)
    4. 一个UnaryHandler结构,他被UnaryServerInterceptor调用用于完成正常的一元RPC调用(例如:在UnaryInterceptor返回前进行一些处理)
  • unaryInterceptor 函数确保 grpc.UnaryServerInfo 拥有正确的 server 抽象,并且会调用认证函数authenticateClient
  • 你定义了包含你认证逻辑的认证函数:authenticateClient函数–在这个示例中非常非常简单。你可以看到,他接收一个context.Context参数,并从该参数中抽取元数据信息。他校验用户信息,并且返回他的ID(以string的形式,和一个错误,如果有的话)
  • 如果unaryInterceptorauthenticateClient调用中返回一切正常,他会将clientID信息写入到context.Context对象,这样的话,后续的调用链上的函数都能够使用它(记得吗,handler都以context.Context作为入参)
  • 请注意,您已创建 typeconst 用来在你的 context.Context map 中引用 clientID,但这只是避免命名冲突,并且持续引用的一种便捷方式。

现在你可以编译代码了:

$ make

并在两个独立的终端中运行双方:

15236850148222

显然,你的认证逻辑可以是更加聪明的,可以使用数据库,而不是使用凭证。方便的做法是,你的认真该函数获取到你的Server对象,而在你的这个Server结构中,能够保存了你数据库的句柄。

提供 REST

最后一件事:你有一个漂亮的服务器,客户端和协议; 序列化,加密和认证。但是有一个重要的限制:您的客户端需要符合gRPC标准,即在 受支持的平台列表 中。为了避免这种限制,我们可以将服务器打开到REST网关,以允许REST客户端执行请求。幸运的是,有一个 gRPC protoc 插件 用于生成将 RESTful JSON API 转换为 gRPC 的反向代理服务器。我们可以使用几行纯 Go 代码作为反向代理服务:

让我们在你的 api/api.proto 文件中加入一些额外信息

15236868636435

引入的 annotations.proto 能够让 protoc 理解在文件后面设置的 optionoption 则定义了这个方法指定调用路径:

更新你的 Makefile文件从而加入新的编译目标:

+++++++ API_REST_OUT:=“api / api.pb.gw.go” 
+++++++ api: api/api.pb.go api/api.pb.gw.go ## Auto-generate grpc go sources

+++++++ api / api.pb.go:api / api.proto 
+++++++   @protoc -I api / \ 
+++++++     -I $ {GOPATH} / src \ 
+++++++     -I $ {GOPATH} /src/github.com/grpc-ecosystem/grpc-gateway/

生成为gateway准备的Go代码(和api/api.pb.go类似,将会生成api/api.pb.gw.go文件 – 不要编辑它,在编译的时候他会自动更新)

$ make api

服务端的改变更重要。grpc.Server()是一个阻塞型调用,他只会在发生错误时返回(或者是被信号kill掉的时候)。因为我们需要启动另外一个服务端(提供REST服务),因此我们需要是的这个调用是非阻塞的。幸运的是,我们可以使用goroutines来达到这个目的。并且在认证的时候,也有一些小技巧。因为,REST gateway仅仅是一个反向代理,在gRPC的角度来看,他实际上是一个gRPC的客户端,因此,当他与服务端建立链接的时候,也需要使用WithPerRPCCredentials选项。

这里是你的 server/main.go(点击可查看)

那这里究竟发生了什么呢?

  • 你将gRPC服务端创建需要的所有代码一到了一个goroutine中,并使用一个函数包装(startGRPCServer),这样他就不会阻塞main
  • 你也创建了一个goroutine,使用了一个包装函数(startRESTServer),在这个函数里面,你创建了一个HTTP/1.1服务
  • 在你创建的REST gateway的startRESTServer函数中,你首先获得了一个context.Context对象(例如,context树的根)。接着你创建了一个请求复用器对象,mux,并使用了一个参数:runtime.WithIncomingHeaderMatcher。这个参数已一个函数引用作为参数,credMatch,这个函数将在每一个进入的请求针对HTTP头被调用。这个函数用于判断对应的HTTP头信息是否应该装换成gRPC的上下文context
  • 你定义了一个credMatch函数,用于匹配证书头,允许他们成为gRPC上下文中的元数据。这也是为什么你的认证能工作的原因,因为反向代理在于后端gRPC服务连接时,使用HTTP头转换成gRPC的上线文元数据,
  • 你也创建了一个credentials.NewClientTLSFromFile,用于grpc.DialOption,正如你之前在客户端做的一样
  • 你注册你的api访问路径,例如,你让你的请求复用器和你的gRPC服务通过上下文context和gRPC选项给连接起来
  • 最后,你启动你了的HTTP服务,并等待有连接请求进来
  • 除了使用goroutine,你还是用了一个阻塞的select调用,这样做可以防止你的程序立马退出

现在构建整个项目,以便测试 REST 接口:

$ make

并在两个独立的终端中运行双方:

15236870164270

还剩一个 swag…

REST 形式的网关是很 cool 的,但是,如果能直接从他生成文档,岂不是更 cool,对吗?

通过使用 protoc插件 来生成 swagger json文件,你可以很容易的做到:

protoc -I api/ \
  -I${GOPATH}/src \
  -I${GOPATH}/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
  --swagger_out=logtostderr=true:api \
  api/api.proto

这将会生成api/api.swagger.json文件。像其他有Protobuf编译生成的文件一样,你不应该手动编辑它,但你可以使用它,同时,你也可以通过修改你的定义文件来编译更新他。

你可以把上述编译命令放入到 Makefile 中。

总结

你已经拥有了一个完整功能的gRPC客户端和服务端,他具有SSL加密、身份认证、客户端标识,以及REST网关(并包含swagger文件)等功能。那接下来,应该干什么呢?

你可以在REST网关上再添加一些新的功能,让他支持HTTPS,而不是HTTP。显然,你还可以在你的Protobuf上添加更加复杂的数据结构,增加更多的service。你也可以从HTTP/2的特性中获益,例如从客户端到服务端,或者从服务端到客户端,甚至是双向的流式特性。(当然,这个特性是仅仅针对gRPC的,REST是基于HTTP/1.1,无此特性)

非常感谢 Charles Francoise,他和我一同完成这篇文章,并编写了示例代码:https://gitlab.com/pantomath-io/demo-grpc.

译者声明