注:本文是在作者本人的同意下进行翻译的,如无本人同意,不支持任何商业的和非商业的转载【授权见文末】。
这是一篇介绍我们是如何在 Go 语言中使用 gRPC (和 Protobuf) 从而构建稳定的 CS 系统的技术文章。
在这里不会介绍为什么选择 gRPC 作为客户端与服务器端之间的主要通信协议,确实已经有不少很不错的的文章在讲这些东西了,例如这些:
- Why we have decided to move our APIs to gRPC
- How is GRPC different from REST?
- Google’s gRPC: A Lean and Mean Communication Protocol for Microservices
从大方面来说,我们正在使用 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。你可以随意使用这个仓库,并通过标签来导航、定位。这个仓库应当放置在你 $GOPATH
的 src
目录:
协议文件
首先,我们需要定义协议。也就是定义在客户端和服务端我们能交互些什么,以及如何交互。这就是 Protobuf 发挥作用的地方。它让你定义两类东西:服务( Service )和消息( Message )。一个service
是服务端的一组动作集合,服务端针对客户端的请求执行并产生不同的响应。一个 message
就是一个客户端请求的内容。简单来说,你可以认为 service
定义了动作,而 message
定义了对象。
在 api/api.proto 中写入如下内容:
可以看到,这里定义了两个结构:一个是名为 Ping 的 service
,暴露了一个叫做 SayHello
的 method,这个方法接收了一个叫做 PingMessage
的输入参数,并返回一个结果 PingMessage
;另外还有一个叫做 PingMessage
的 message,这个 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 struct
只是服务器的抽象,它允许"附加" 一些资源到你的服务器中,从而使它们在 RPC 调用期间可用。- 该
SayHello
函数是 Protobuf 文件中定义的那个函数,作为 RPC 调用的Ping service
。如果你没有定义它,你将无法创建 gRPC 服务器。 SayHello
需要PingMessage
作为参数,并返回PingMessage
。PingMessage struct
定义在从api.proto
自动生成的api.pb.go
文件中。这个函数还有一个Context
参数(请参阅官方博客文章中的进一步介绍)。后续你会知道通过Context
我们能做什么。另外,如果发生了不良情况,这个函数还返回一个error
。
简单的服务器
现在你已经有了一个协议,是时候创建一个简单的服务器来实现 service
和理解 message
了,就拿起你最喜欢的编辑器来创建文件 server/main.go
:
让我来分解一下代码,让你能够理解得更清晰一些:
- 注意你要导入 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
简单的客户端
客户端也需要导入 api 包,以便 message 和 service 可用,创建文件client/main.go:
这次代码的分解就简单多了:
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
加密通信
客户端和服务器是通过 HTTP/2(gRPC上的传输层)相互通信。这些消息是二进制数据(感谢 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
您可以继续并更新服务器定义以使用证书和密钥:
那么改变了什么?
- 您从证书和密钥文件创建了一个
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
文件:
客户端的更改与服务器上的更改几乎相同:
- 您使用证书文件创建了
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
客户端标识
gRPC 服务器的另一个有趣功能是拦截来自客户端的请求。客户端可以在传输层上注入信息。您可以使用该功能来识别您的客户端,因为 SSL 实现通过证书验证服务器,但不验证客户端(所有客户端都使用相同证书)。
因此,您需要更新客户端,以便在每个调用上注入元数据(如登录名和密码),并在服务器端为每个调用检查这些凭据。
在客户端,你只需在你的 grpc.Dial()
调用中指定一个 DialOption
,但是这个 DialOption
有一些限制,编辑你的 client/main.go(链接:file) 文件:
- 您可以定义一个结构来保存您想要在你 rcp 调用中注入的字段集合。在我们的例子中,只需要一个登录名和密码,但你可以定义任何你想要的字段;
- auth变量保存您将使用的值;
- 可以使用
grpc.WithPerRPCCredentials()
函数为grpc.Dial()
函数创建一个使用的DialOption
对象; - 请注意,该
grpc.WithPerRPCCredentials()
函数将接口作为参数,所以您的Authentication
结构应符合该接口。从文档中,你知道你应该在你的结构上实现两种方法:GetRequestMetadata
和RequireTransportSecurity
。 - 所以你定义的
GetRequestMetadata
函数只是返回你的Authentication
结构的 map ; - 最后,你定义了一个
RequireTransportSecurity
函数,告诉你的 grpc 客户端它是否应该在传输级别注入元数据。在我们这里的情况中,它总是返回true
,但你可以让它返回指定布尔值的值。
客户端在调用服务器时需要额外的数据,但服务器现在不知道,所以你需要告诉他检查这些元数据,打开 server/main.go 并更新它:
译者注:不希望堆代码,可以新 Tab 打开看 Code
再次,让我为你分解这件事:
- 你给你之前创建的阵列 grpc.UnaryInterceptor 添加一个新的
grpc.ServerOption
(现在知道为什么它是一个数组了?)。并且您将一个函数的引用传递给该函数,以便知道该调用谁。代码的其余部分不会改变; - 你必须要定义
unaryInterceptor
函数,他会接收一堆参数:- 一个
context.Context
对象,包含了你的数据,他在整个请求生命周期内都存在 - 一个
interface{}
,他是rpc调用的入参接口 - 一个
UnaryServerInfo
结构,他包含了若干关于本地调用的信息(例如,Server
抽象对象,以及客户端调用的具体方法) - 一个
UnaryHandler
结构,他被UnaryServerInterceptor
调用用于完成正常的一元RPC调用(例如:在UnaryInterceptor
返回前进行一些处理)
- 一个
unaryInterceptor
函数确保grpc.UnaryServerInfo
拥有正确的 server 抽象,并且会调用认证函数authenticateClient
- 你定义了包含你认证逻辑的认证函数:
authenticateClient
函数–在这个示例中非常非常简单。你可以看到,他接收一个context.Context
参数,并从该参数中抽取元数据信息。他校验用户信息,并且返回他的ID(以string
的形式,和一个错误,如果有的话) - 如果
unaryInterceptor
从authenticateClient
调用中返回一切正常,他会将clientID
信息写入到context.Context
对象,这样的话,后续的调用链上的函数都能够使用它(记得吗,handler
都以context.Context
作为入参) - 请注意,您已创建
type
并const
用来在你的context.Context
map 中引用 clientID,但这只是避免命名冲突,并且持续引用的一种便捷方式。
现在你可以编译代码了:
$ make
并在两个独立的终端中运行双方:
显然,你的认证逻辑可以是更加聪明的,可以使用数据库,而不是使用凭证。方便的做法是,你的认真该函数获取到你的Server
对象,而在你的这个Server
结构中,能够保存了你数据库的句柄。
提供 REST
最后一件事:你有一个漂亮的服务器,客户端和协议; 序列化,加密和认证。但是有一个重要的限制:您的客户端需要符合gRPC标准,即在 受支持的平台列表 中。为了避免这种限制,我们可以将服务器打开到REST网关,以允许REST客户端执行请求。幸运的是,有一个 gRPC protoc 插件 用于生成将 RESTful JSON API 转换为 gRPC 的反向代理服务器。我们可以使用几行纯 Go 代码作为反向代理服务:
让我们在你的 api/api.proto
文件中加入一些额外信息
引入的 annotations.proto
能够让 protoc
理解在文件后面设置的 option
。option
则定义了这个方法指定调用路径:
更新你的 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
并在两个独立的终端中运行双方:
还剩一个 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.
译者声明
- Original URL:How we use gRPC to build a client/server system in Go
- Original Author:Julien Andrieux
- 翻译转载授权: