gRPC进阶学习
Table of Contents
gRPC 进阶学习知识点笔记
protobuf 进阶知识
官方文档: https://developers.google.com/protocol-buffers/docs/proto3
基础类型与默认值
以下是 protobuf 与 Go 的基础类型对照表:
proto Type | Notes | Go Type |
---|---|---|
double | float 64 | |
float | float 32 | |
int 32 | 使用变长编码,对于负值的效率很低,如果你的域有可能有负值,请使用 sint 64 替代 | int 32 |
uint 32 | 使用变长编码 | uint 32 |
uint 64 | 使用变长编码 | uint 64 |
sint 32 | 使用变长编码,这些编码在负值时比 int 32 高效的多 | int 32 |
sint 64 | 使用变长编码,有符号的整型值。编码时比通常的 int 64 高效。 | int 64 |
fixed 32 | 总是 4 个字节,如果数值总是比总是比 228 大的话,这个类型会比 uint 32 高效。 | uint 32 |
fixed 64 | 总是 8 个字节,如果数值总是比总是比 256 大的话,这个类型会比 uint 64 高效。 | uint 64 |
sfixed 32 | 总是 4 个字节 | int 32 |
sfixed 64 | 总是 8 个字节 | int 64 |
bool | bool | |
string | 一个字符串必须是 UTF-8 编码或者 7-bit ASCII 编码的文本。 | string |
bytes | 可能包含任意顺序的字节数据。 | []byte |
其对应的默认值为:
- 对于 strings,默认是一个空 string
- 对于 bytes,默认是一个空的 bytes
- 对于 bools,默认是 false
- 对于数值类型,默认是 0
- 对于枚举,默认是第一个定义的枚举值,必须为 0;- 对于消息类型(message),域没有被设置,确切的消息是根据语言确定的,详见 generated code guide
- 对于可重复域的默认值是空(通常情况下是对应语言中空列表)。
注意:对于标量消息域,一旦消息被解析,就无法判断域释放被设置为默认值(例如,例如 boolean 值是否被设置为 false)还是根本没有被设置。你应该在定义你的消息类型时非常注意。
option package 的作用
对于 proto 文件中 option go_package=".;proto";
作用,它指定了生成的 Go 语言文件的存放路径(.
代表当前目录)与包名(proto
代表 package proto
)。
比如,我想在 ./common/stream/proto/v1
生成对应的 Go 语言文件,则可以编写:
option go_package="./common/stream/proto/v1;proto";
注意:如果没有指定包名,则默认使用最近一级的目录名称。比如:
option go_package="./common/stream/proto/v1";
其生成的 Go 语言文件的包名为:
package v1
proto 文件同步的问题
当我们的一个项目比较大时,有好多服务需要使用到 proto 生成的接口,就像上方的例子中的那样,服务端和客户端都需要使用到这个接口,那么我们应该如何处理?
在上面的处理方式中,我们是建立了一个单独的 proto 文件夹,让服务端和客户端都可以访问到这个文件夹。
但是如果项目比较大的话,比如服务端和客户端在两台不同的服务器上,它们需要使用相同的 proto 文件生成的接口,那么此时最好的办法就是将 proto 文件复制一份,就可以在两边都使用上相同的接口。
然而与此同时,就会出现了另外一个问题,proto 文件同步的问题。如果 proto 文件在复制的过程中,不小心被修改了,导致两个 proto 文件并不完全一致,比如以下这种情况
在服务端的 proto 文件中,name 字段序号为 1,url 字段序号为 2:
message HelloRequest {
string name = 1;
string url =2;
}
而在客户端的 proto 文件中,name 字段序号被改为 2,url 字段序号改为 1:
message HelloRequest {
string url =1;
string name = 2;
}
那么此时,运行两边的程序不会报错,但是服务端和客户端的相互通信就会出现问题,name 和 url 的值会被互换。
这是由于 protobuf 的编码顺序格式,一定要注意 proto 文件中字段 =
后方代表的是序号(编号),需要保证相同文件中字段序号的一致性。这与 Json 中的 key-value 模式有着明显不同,这是为了缩短编码长度。
proto 文件相互导入
在很大的项目中,一个 proto 文件中可能会有很多的 message,并且不同的 proto 文件中的 message 可能通用,因此就需要将另外通用的 message 通过导入的方式来使用,减少 proto 文件的代码量。
比如在项目中,我们常常需要一个接口用于检测服务是否可用,我们就可以在 proto 文件中定义一个 Ping 方法接口,返回 Pong 的 message。
所以,我们可以将所有公用的 message 都写在一个 proto 文件中,在其他需要该 message 的 proto 中引入该文件。
比如下面是一个 hello.proto
文件设置了一个 Ping 方法接口,需要使用到 Empty 和 Pong 的 message,这两个 message 存放在公用的 base.proto
文件中。
hello.proto
:
syntax = "proto3";
import "base.proto"; // 引入公用的Empty和Pong message
option go_package = ".;proto";
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
rpc Ping (Empty) returns (Pong);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
base.proto
:
syntax = "proto3";
message Pong {
string id = 1;
}
message Empty { }
由于方法必须要有一个参数传入,所以我们需要创建一个空的 message Empty
。
实际上官方也提供了一个 Empty 的 message,只需要通过 import "google/protobuf/empty.proto"
引入(会下载到本地 protoc 根目录的 include
文件夹中),通过 google.protobuf.Empty
在 proto 文件中使用。并且在 Go 语言文件中 import github.com/golang/protobuf/ptypes/empty
引入,通过 empty.Empty
在 Go 语言文件中使用该生成后的结构体。
google/protobuf
也提供了很多其他公用的 message,可以阅读源码得知。
这时其他服务中也需要一个 Ping 方法用于检测服务是否连通,则无需再重复写这两个 message,同样将上方的 base.proto
文件使用 import
引入其中即可。
值得注意的是,如果需要在 Go 语言代码中使用外部引入的 message,如上面例子中的 Pong
,则依旧需要通过 protoc 指令将 base.proto
生成 Go 语言代码,并在程序中引入这个文件。
如果
base.proto
和hello.proto
文件生成的base.pb.go
(base_grpc.pb.go
) 和hello.pb.go
(hello_grpc.pb.go
) 文件在同一个目录中,则需要保持其package
一致,同时在使用时只需要引入这个包,就可以同时访问到这两个 Go 语言文件,这是 Go 的包管理方式决定的。
如果需要导入的 proto 文件不在当前目录,则可以选择将其拷贝到当前目录中,这是常见的方式。另一种可行的方式,就是通过在 protoc 生成指令中使用 --proto_path
或 -I
参数来指定 proto 文件的目录,该参数可以有多个,比如常见方式为:
protoc -I . -I D://tmp/proto ……
但是,这样的方式,不利于项目的共享,因此大型项目,依旧推荐将所有用到的 proto 文件都放置在一个统一的文件夹中。
同时,通常建议的方式是将 --proto_path
/ -I
参数设置为项目的根目录,而 .proto
文件中的 import
则从根目录的次级目录开始。
嵌套 message 对象
proto 为了满足复杂的 message 类型,同时减少 message 的数量,可以支持 message 的嵌套,比如以下的例子:
syntax = "proto3";
import "base.proto" // 引入公用的Empty和Pong message
option go_package = ".;proto";
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
rpc Ping (Empty) returns (Pong);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
message Result { // 嵌套的message
string name = 1;
string url = 2;
}
repeated Result data = 2;
}
需要注意的是,嵌套的 message 的序号依旧是独立的,不会互相影响。
同时,在 Go 语言中调用和实例化 Result,则需要使用以下方式来访问。
proto.HelloReply_Result{}
enum 枚举类型
proto 也是支持枚举类型的,使用方式如下:
syntax = "proto3";
option go_package = ".;proto";
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
enum Gender {
MALE = 0;
FEMALE = 1;
}
message HelloRequest {
string name = 1;
string url = 2;
Gender g = 3;
}
message HelloReply {
string message = 1;
}
只需要使用 enum
关键字定义一个枚举类型,在 message 中使用即可。
使用 protoc 命令生成 Go 文件后,enum
枚举类型会变为一个 int32
的类型,并且其中的类别会变为一个常量,如上方的 proto 文件转成 Go 语言代码会变为:
type Gender int32
const (
Gender_MALE Gender = 0
Gender_FEMALE Gender = 1
)
因此,我们使用的时候可以使用以下的方式:
proto.Gender_MALE
proto.Gender_FEMALE
map 类型
proto 文件中的 map 类型使用方法如下:
syntax = "proto3";
option go_package = ".;proto";
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
string url = 2;
map<string, string> mp = 3;
}
message HelloReply {
string message = 1;
}
map 类型需要指定 key 和 value 的类型,这与其他静态语言类似。
在生成 Go 语言之后,map 类型会自动转换为 Go 中的 map 类型,如:
Mp map[string]string
在使用时,就与 Go 语言的 map 类型使用方式一样,传递时也只需要转递 map 类型:
rsp, _ := client.SayHello(context.Background(),&proto.HelloRequest{
Name: "bobby",
Url: "https://baidu.com",
Mp: map[string]string {
"name": "bobby",
"url": "https://baidu.com",
}
})
虽然 map 类型也可以存放像上方的 name 和 url 等信息,但是我们却并不推荐大量使用 map 类型,因为 proto 文件在项目开发中,大多扮演着开发文档的角色,以上 name 和 url 在 proto 文件中都有明确的定义,同时也可以写一些注释作为文档。但是对于 map 中的各种 key-value 在 proto 中并没有规定,可以随意书写,只需保证类型一致,开发者不知道这个 map 中会传什么值,这样不对的。
timestamp 类型
proto 中也扩展了 timestamp 时间戳类型,这样的类型并没有内置,而是需要通过引入官方提供的 proto 文件:
import "google/protobuf/timestamp.proto"
使用时同样需要像上方 proto 文件相互导入中讲解的那样,引入完整的路径:
syntax = "proto3";
import "google/protobuf/timestamp.proto"
option go_package = ".;proto";
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
string url = 2;
map<string, string> mp = 3;
google.protobuf.Timestamp addTime = 4;
}
message HelloReply {
string message = 1;
}
由于导入了外部的 proto 文件,因此在 Go 中使用 proto 中的内容,不仅需要引入上方这段代码生成的 Go 文件,还需要引入 "google/protobuf/timestamp.proto"
生成的 Go 文件,那么如何找到它生成的 Go 源码路径呢?
可以通过进入 google/protobuf/timestamp.proto
文件中,根据 option go_package
后的路径找到生成的源码路径。因此我们可以找到路径为 github.com/golang/protobuf/ptypes/timestamp
。
通过 github.com/golang/protobuf/ptypes/timestamp
打开源码 timestamp.pb.go
发现其中又引入了 google.golang.org/protobuf/types/known/timestamppb
:
import (
……
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
……
)
type Timestamp = timestamppb.Timestamp
因此再打开 timestamppb
的源码 timestamppb.pb.go
,发现其中有实例化 timestamp 的 New 方法:
// New constructs a new Timestamp from the provided time.Time.
func New(t time.Time) *Timestamp {
return &Timestamp{Seconds: int64(t.Unix()), Nanos: int32(t.Nanosecond())}
}
因此,在我们的 Go 文件中可以直接引入这个包,通过这个方法来使用 timestamp,使用方式如下:
import (
……
"time"
timestamppb "google.golang.org/protobuf/types/known/timestamppb"
……
)
……
rsp, _ := client.SayHello(context.Background(),&proto.HelloRequest{
Name: "bobby",
Url: "https://baidu.com",
Mp: map[string]string {
"name": "bobby",
"url": "https://baidu.com",
}
AddTime: timestamppb.New(time.Now())
})
protoc 生成的源码阅读
上述学习了许多关于 protobuf 的知识,最终我们的目标是希望通过 protobuf 这个工具,帮助我们快捷地生成各种语言,方便使用的代码。
那么具体 protoc 生成的 Go 源码中,到底写了些什么代码呢?
回顾 RPC 架构技术要点,server 端最好帮助我们生成好接口,我们只需要去每个接口中实现对应的业务逻辑即可。同时,client 端最好帮助我们生成对应的方法,并且将这个方法都绑定到一个结构体上,生成的时候
比如,我们编写了一个 helloworld.proto
文件,内容如下:
syntax = "proto3";
option go_package = ".;proto";
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
这其中定义了一个 SayHello
的 rpc 方法(Greeter
是一个方法集),以及 HelloRequest
和 HelloReply
两个 message。
那么当我们使用 protoc 根据这个 proto 文件生成对应的 Go 文件,它们会转换成什么呢?
事实上,message 会变为 Go 的结构体,方法集会变为 Go 的接口,而 rpc 方法会称为该接口中的方法。
我们使用以下命令来生成 helloworld.pb.go
和 helloworld_grpc.pb.go
文件。
protoc -I . --go_out=. --go-grpc_out=require_unimplemented_servers=false:. .\helloworld.proto
在 helloworld.pb.go
中,可以找到 HelloRequest
和 HelloReply
结构体:
type HelloRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
type HelloReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
在 helloworld_grpc.pb.go
中,可以找到 GreeterServer
和 GreeterClient
接口和 SayHello
方法:
type GreeterClient interface {
SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}
type GreeterServer interface {
SayHello(context.Context, *HelloRequest) (*HelloReply, error)
}
由于 client 端在调用
SayHello()
时可能会传递一些调用参数,因此与 server 端的接口方法并不一致。
为了完成 RPC 方法的注册,代码中还生成了一个 RegisterGreeterServer
注册方法,方便我们使用。
func RegisterGreeterServer(s grpc.ServiceRegistrar, srv GreeterServer) {
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&Greeter_ServiceDesc, srv)
}
在 helloworld_grpc.pb.go
这个代码中,依旧蕴含着 Go 语言最关键的设计理念:面向接口的编程,比如观察下面这段代码:
type GreeterClient interface {
SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}
type greeterClient struct {
cc grpc.ClientConnInterface
}
func NewGreeterClient(cc grpc.ClientConnInterface) GreeterClient {
return &greeterClient{cc}
}
func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(HelloReply)
err := c.cc.Invoke(ctx, Greeter_SayHello_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
在这段代码中,NewGreeterClient
用于新建一个 greeterClient
结构体对象,但是在标明返回值时,它却使用的是 GreeterClient
这一个接口类型,为什么呢?
因为,greeterClient
这个结构拥有一个 SayHello()
方法,即 GreeterClient
中定义的所有方法,因此这个结构体就实现了 GreeterClient
这也一个接口,所以 NewGreeterClient
函数中可以返回这个结构体实例。
这样面向接口的编程是为了实现多态,也可以说是让代码编写更加灵活。只要是实现了 GreeterClient
接口的所有结构体都可以通过 NewGreeterClient
方法来获得一个实例对象。如果只是写为:
func NewGreeterClient(cc grpc.ClientConnInterface) greeterClient {
return &greeterClient{cc}
}
那么只有这个结构体可以使用这个函数了。
gRPC 进阶知识
metadata 机制
gRPC 让我们可以像本地调用一样实现远程调用,对于每一次的 RPC 调用中,都可能会有一些有用的数据,而这些数据就可以通过 metadata 来传递。
metadata 是以 key-value 的形式存储数据的,其中 key 是 string
类型,而 value 是 []string
,即一个字符串数组类型。
metadata 使得 client 和 server 能够为对方提供关于本次调用的一些信息,就像一次http 请求的 RequestHeader 和 ResponseHeader 一样。http 中 header 的生命周周期是一次 http 请求,那么 metadata 的生命周期就是一次 RPC 调用。
所以,gRPC 中的 metadata 就如同 http 中的 header,message 就如同 http 中的 post 数据。
同时,由于在 Web 开发中通过 http 的 header 可以传递 token、cookie、session 等认证信息,因此 gRPC 也可以利用 metadata 机制来实现 RPC 的权限认证等功能。
那么在 gRPC 中如何使用 metadata 呢?
metadata 类型实际上是 Go 中的 map 类型,key 是 string,value 是 string 类型的 slice。
type MD map[string][]string
创建 metadata 时,可以像创建普通的 map 类型一样使用 new 关键字进行创建:
//第一种方式
md := metadata.New(map[string]string{"key1": "val1", "key2": "val2"})
//第二种方式 key不区分大小写,会被统一转成小写。
md := metadata.Pairs(
"key1", "val1",
// "key1" will have map value []string{"val1", "val1-2"}
"key1", "val1-2",
"key2", "val2",
)
发送 metadata 时可以使用以下方式:
md := metadata.Pairs("key", "val")
// 新建一个有 metadata 的 context
ctx := metadata.NewOutgoingContext(context.Background(), md)
// 单向 RPC
response, err := client.SomeRPC(ctx, someRequest)
接收 metadata 时可以使用以下方式:
func (s *server) SomeRPC(ctx context.Context, in *pb.SomeRequest) (*pb.SomeResponse, err) {
md, ok := metadata.FromIncomingContext(ctx)
// do something with metadata
}
接下来是 gRPC 的 metadata 的实际案例,首先同样在项目根目录创建三个文件夹,分别是 proto、client 和 server,对应三个模块功能。
我们依旧是以之前最简单的 gRPC 代码为例,加上 metadata 的使用。
首先创建一个 proto/metadata.proto
文件,编写以下内容:
syntax = "proto3";
option go_package = ".;proto";
service Greeter {
rpc SayHello (HelloRequest) returns (HelloReply);
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
执行以下命令,生成对应的 Go 文件:
protoc --go_out=. --go-grpc_out=require_unimplemented_servers=false:. metadata.proto
创建一个 client/client.go
文件,编写以下内容:
func main() {
conn,err := grpc.Dial("127.0.0.1:8088",grpc.WithInsecure())
if err!=nil{
panic(err)
}
defer conn.Close()
c := proto.NewGreeterClient(conn)
// 创建 metadata
md := metadata.New(map[string]string{
"name":"bobby",
"pasword":"123456",
})
// 新建一个有 metadata 的 context
ctx := metadata.NewOutgoingContext(context.Background(), md)
r, err := c.SayHello(ctx, &proto.HelloRequest{Name:"bobby"})
if err!=nil{
panic(err)
}
fmt.Println(r.Message)
}
最后创建一个 server/server.go
文件,编写以下内容:
type Server struct{}
func (s *Server) SayHello(ctx context.Context, request *proto.HelloRequest) (*proto.HelloReply,
error) {
// 接收 metadata
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
fmt.Println("get metadata error")
}
// 输出切片中的数据
if nameSlice, ok := md["name"]; ok {
fmt.Println(nameSlice)
for i, e := range nameSlice {
fmt.Println(i, e)
}
}
return &proto.HelloReply{
Message: "hello " + request.Name,
}, nil
}
func main() {
// 1. 实例化一个服务
g := grpc.NewServer()
// 2. 注册处理逻辑
proto.RegisterGreeterServer(g, &Server{})
// 3. 启动服务
lis, err := net.Listen("tcp", "0.0.0.0:8088")
if err != nil {
panic("failed to listen:" + err.Error())
}
err = g.Serve(lis) // 不会直接退出
if err != nil {
panic("failed to start grpc:" + err.Error())
}
}
以上就完成了一个简单的 metadata 的使用案例。服务端输出结果如下:
[bobby]
0 bobby
如果将上述 server 端的输出代码改为:
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
fmt.Println("get metadata error")
}
for key, val := range md {
fmt.Println(key, val)
}
那么输出结果可能就会如下所示:
name [bobby]
password [123456]
:authority [127.0.0.1:8088]
content-type [application/grpc]
user-agent [grpc-go/1.70.0]
我们观察以上输出格式,除了我们自行放置的 name 和 password 的字段值,还会同时传递 authority、content-type 和 user-agent 等重要的字段和值,这些都是系统框架默认生成的,这与 http 传输过程的 header 十分类似!
gRPC 拦截器
拦截器是 gRPC 的一个重要部分,在实际的项目开发中也会经常使用到,类似于在 Web 开发框架中,拦截器也是必不可少的一部分。
比如,在 Web 开发中,我们时常需要对用户的各种请求进行检测和预处理,比如反爬虫等等,这些逻辑并不适合直接写在 API 接口中,这样会污染 API 接口,因此需要一个专门的拦截器模块,微服务中也是同样的道理。
gRPC 框架中已经实现了拦截器的功能逻辑,我们只需要会配置和使用。
服务端的 gRPC 拦截器的使用实际上十分简单,我们只需要在原先 server.go
代码中实例化一个服务的 grpc.NewServer()
中传递一个拦截器选项,同时实现这个拦截器的配置函数逻辑即可,比如:
interceptor = func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
// 继续处理请求
……
}
opt := grpc.UnaryInterceptor(interceptor)
g := grpc.NewServer(opt)
除了服务端的拦截器,客户端也可以设置拦截器,类似的原理,我们只需要将 client.go
代码中启动监听的 grpc.Dial()
方法中传递一个 grpc.WithUnaryInterceptor()
拦截器选项,并且实现拦截器配置函数逻辑,比如:
interceptor := func (ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
……
}
opt := grpc.WithUnaryInterceptor(interceptor)
conn, err := grpc.Dial("localhost:8088", grpc.WithInsecure(), opt)
由于 grpc.Dial()
定义的参数是不定长的,因此下面传入多个参数的方式也十分常见:
interceptor := func (ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
……
}
var opts []grpc.DialOption
opts = append(opts, grpc.WithInsecure())
opts = append(opts, grpc.WithUnaryInterceptor(interceptor))
conn, err := grpc.Dial("localhost:50051", opts...)
当然,以上服务端和客户端中的 interceptor
都可以提取成一个公用的函数。
完成的示例代码如下,proto 文件代码如 metadata 机制 中的一样,无需修改。
client/client.go
的代码如下:
func interceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
start := time.Now()
err := invoker(ctx, method, req, reply, cc, opts...)
fmt.Printf("耗时:%s\n", time.Since(start))
return err
}
func main() {
var opts []grpc.DialOption
opts = append(opts, grpc.WithInsecure())
opts = append(opts, grpc.WithUnaryInterceptor(interceptor))
conn, err := grpc.Dial("127.0.0.1:8088", opts...)
if err!=nil{
panic (err)
}
defer conn.Close ()
c := proto.NewGreeterClient (conn)
r, err := c.SayHello (context.Background (), &proto. HelloRequest{
Name: "bobby"
})
if err!=nil{
panic (err)
}
fmt.Println (r.Message)
}
server/server. go
的代码如下:
type Server struct{}
func (s *Server) SayHello (ctx context. Context, request *proto. HelloRequest) (*proto. HelloReply,
error) {
return &proto. HelloReply{
Message: "hello " + request. Name,
}, nil
}
func interceptor (ctx context. Context, req interface{}, info *grpc. UnaryServerInfo, handler grpc. UnaryHandler) (resp interface{}, err error) {
fmt.Println ("接收到了一个新的请求")
res, err := handler (ctx, req)
fmt.Println ("请求已经完成")
return res, err
}
func main () {
opt := grpc.UnaryInterceptor (interceptor)
// 1. 实例化一个服务
g := grpc.NewServer (opt)
// 2. 注册处理逻辑
proto.RegisterGreeterServer (g, &Server{})
// 3. 启动服务
lis, err := net.Listen ("tcp", "0.0.0.0:8088")
if err != nil {
panic ("failed to listen: " + err.Error ())
}
err = g.Serve (lis) // 不会直接退出
if err != nil {
panic ("failed to start grpc: " + err.Error ())
}
}
在服务端的 interceptor
函数中,通过 handler
方法来实现原本服务端的调用逻辑。在客户端的 interceptor
函数中,通过 invoker
方法来实现原本客户端的调用逻辑。
对于拦截器的其他应用场景, go-grpc-middleware 这个项目十分不错,建议能够读懂源码。
metadata + 拦截器的应用
现在我们想完成一个功能:让我们的 gRPC 在提供服务时有一个验证机制,为了保证安全性。因此,我们可以使用 metadata 机制加上 gRPC 的拦截器来实现。
具体的实现逻辑十分简单,就是用户在调用 gRPC 服务时,需要通过 metadata 带上用户的验证信息(token), 再利用拦截器来处理用户的验证信息是否通过,这是一种常见的应用场景。
具体实现代码如下,同样以最开始的项目示例为例,proto 文件不需要改动。
client/client. go
代码如下:
func interceptor (ctx context. Context, method string, req, reply interface{}, cc *grpc. ClientConn, invoker grpc. UnaryInvoker, opts ... grpc. CallOption) error {
start := time.Now ()
// 通过 metadata 传递认证信息
md := metadata.New (map[string]string{
"ID": "123",
"key": "this is a key",
})
ctx = metadata.NewOutgoingContext (context.Background (), md)
err := invoker (ctx, method, req, reply, cc, opts...)
fmt.Printf ("耗时:%s\n", time.Since (start))
return err
}
func main () {
var opts []grpc. DialOption
opts = append (opts, grpc.WithInsecure ())
opts = append (opts, grpc.WithUnaryInterceptor (interceptor))
conn, err := grpc.Dial ("127.0.0.1:8088", opts...)
if err!=nil{
panic (err)
}
defer conn.Close ()
c := proto.NewGreeterClient (conn)
r, err := c.SayHello (context.Background (), &proto. HelloRequest{
Name: "bobby"
})
if err!=nil{
panic (err)
}
fmt.Println (r.Message)
}
server/server. go
代码如下:
type Server struct{}
func (s *Server) SayHello (ctx context. Context, request *proto. HelloRequest) (*proto. HelloReply,
error) {
return &proto. HelloReply{
Message: "hello " + request. Name,
}, nil
}
func interceptor (ctx context. Context, req interface{}, info *grpc. UnaryServerInfo, handler grpc. UnaryHandler) (resp interface{}, err error) {
fmt.Println ("接收到了一个新的请求")
// 接收 metadata 认证信息
if md, ok := metadata.FromIncomingContext (ctx); !ok {
return resp, status.Error (codes. Unauthenticated, "无 token 认证信息")
}
var (
ID string
key string
)
if va 1, ok := md["ID"]; ok {
id = va 1[0]
}
if va 1, ok := md["key"]; ok {
key = va 1[0]
}
// 模拟 token 信息验证
if id != "123" || key != "this is a key" {
return resp, status.Error (codes. Unauthenticated, "token 认证失败")
}
res, err := handler (ctx, req)
fmt.Println ("请求已经完成")
return res, err
}
func main () {
opt := grpc.UnaryInterceptor (interceptor)
// 1. 实例化一个服务
g := grpc.NewServer (opt)
// 2. 注册处理逻辑
proto.RegisterGreeterServer (g, &Server{})
// 3. 启动服务
lis, err := net.Listen ("tcp", "0.0.0.0:8088")
if err != nil {
panic ("failed to listen: " + err.Error ())
}
err = g.Serve (lis) // 不会直接退出
if err != nil {
panic ("failed to start grpc: " + err.Error ())
}
}
需要注意服务端代码中的 status.Error ()
是 gRPC 内部自带的错误处理方法,同时也内置了一些常用的错误信息提示码,如 codes. Unauthenticated
就是未认证错误。
诚然,以上在客户端代码的 interceptor
中设置 metadata 是正确的,但是它却不够好。事实上,gRPC 本身就提供了 grpc.WithPerRPCCredentials ()
方法和 PerRPCCredentials
接口,其中内置了拦截器和 metadata 的传递机制。我们只需要使用 grpc.WithPerRPCCredentials ()
这个方法,并实现 PerRPCCredentials
这个接口。
通过实现 PerRPCCredentials
这个接口的 GetRequestMetadata ()
和 RequireTransportSecurity ()
这两个方法,在 GetRequestMetadata ()
中返回 metadata 信息,而 RequireTransportSecurity ()
暂时使用不到。
这样就不需要自己实现拦截器 interceptor ()
这个函数,同时也不用考虑 metadata 信息该如何传递。
因此,更加简单优秀的 client/client. go
写法,如下所示。
type customCredential struct{}
func (c customCredential) GetRequestMetadata (ctx context. Context, uri ... string) (map[string]string, error) {
return map[string]string{
"ID": "123",
"key": "this is a key",
}, nil
}
func (c customCredential) RequireTransportSecurity () bool {
return false
}
func main () {
var opts []grpc. DialOption
opts = append (opts, grpc.WithInsecure ())
opts = append (opts, grpc.WithPerRPCCredentials (customCredential{}))
conn, err := grpc.Dial ("127.0.0.1:8088", opts...)
if err!=nil{
panic (err)
}
defer conn.Close ()
c := proto.NewGreeterClient (conn)
r, err := c.SayHello (context.Background (), &proto. HelloRequest{
Name: "bobby"
})
if err!=nil{
panic (err)
}
fmt.Println (r.Message)
}
gRPC 验证器
proto 文件虽然写起来比较麻烦,但是它实际上相当于文档的功能,在这个文件中,清晰的标识出了接口的参数与返回类型,同时也指明了客户端和服务端互相传递信息具体的字段类型。
但是,只有以上这些功能还不够,当客户端通过 API 接口请求服务端时,最好在响应之前对客户端的请求参数进行验证,如果参数不对(类型或数量不匹配)那么可以直接取消响应,这就如同 Web 开发框架中的表单验证功能。
这就是 gRPC 验证器所需要完成的功能。我们将使用 protoc-gen_validate 这个项目进行演示 (该项目只适合演示)。这个项目将通过编写 proto 文件代码,使项目具有一定的 message 验证功能,它同时支持 Go、Java、C 等语言。
在项目 Github 的 Release 页面下载对应版本的 protoc-gen_validate 可执行文件,最好将该文件移动到 Go 的根目录 GOPATH
的 bin
目录下。
使用以下命令,可以将所需要的 proto 与 go 代码下载到本地的 GOPATH\pkg\mod\github. com\envoyproxy\ protoc-gen-validate@v1.2.1 \
目录中:
go get -d github. com/envoyproxy/protoc-gen-validate
接着就可以在 validate
目录中找到 validate. proto
与 validate. pb. go
文件,这是我们编写 proto 文件和 Go 代码需要引入的文件。
最好将 validate. proto
拷贝一份放置在项目中的 proto
目录中,方便编写 proto 文件时引入使用。
或者也可以不拷贝,在使用 protoc 生成时代码时,通过 -I
或者 --proto_path
来指定 validate. proto
的存放路径。
这样就可以编写 proto 文件:
syntax = "proto 3";
import "validate. proto";
option go_package = ".; proto";
service Greeter {
rpc SayHello (Person) returns (Person);
}
message Person {
// id > 999
uint 64 id = 1 [(validate. rules). uint 64. gt = 999];
// 内置的 email 规则
string email = 2 [(validate. rules). string. email = true];
// 电话号码正则表达式
string mobile = 3 [(validate. rules). string = {
pattern: "^1[3-9]\\d{9}$";
}];
}
除了以上演示的规则,实际上还有很多种规则,可以参加官方的文档。
使用 protoc 命令来生成 Go 代码:
protoc -I . -I D:\Personal_Data\Go_file\pkg\mod\github. com\envoyproxy\ protoc-gen-validate@v1.2.1 \validate --go_out=. --go-grpc_out=require_unimplemented_servers=false:. --validate_out="lang=go:." helloworld. proto
这样在当前目录下就会生成三个文件,分别时 helloworld. pb. go
、helloworld_grpc. pb. go
以及 helloworld. pb. validate. go
。
使用这个验证器十分简单,通过 New ()
创建一个对象,调用 Validate ()
会返回一个 error,指明不符合 proto 文件中规定的情况:
p := new (Person)
err := p.Validate () // err: Id must be greater than 999
p.Id = 1000
err = p.Validate () // err: Email must be a valid email address
p.Email = " example@bufbuild.com "
err = p.Validate () // err: Mobile must match pattern '^1[3-9]\\d{9}$'
p.Mobile = "13577889900"
但是,如果在代码中使用这个验证器还需要结合拦截器。并定义一个 Validator
接口,方便拦截器进行调用。
接下来就可以在 server/server. go
中配合拦截器来验证客户端传递的值是否符合规范。
type Server struct{}
type Validator interface {
Validate () error
}
func (s *Server) SayHello (ctx context. Context, request *proto. HelloRequest) (*proto. HelloReply,
error) {
return &proto. Person{
Id: 32
}, nil
}
func interceptor (ctx context. Context, req interface{}, info *grpc. UnaryServerInfo, handler grpc. UnaryHandler) (resp interface{}, err error) {
if r, ok := req. (Validator); ok {
if err := r.Validate (); err != nil {
return nil, status.Error (codes. InvalidArygument, err.Error ())
}
}
res, err := handler (ctx, req)
return res, err
}
func main () {
opt := grpc.UnaryInterceptor (interceptor)
// 1. 实例化一个服务
g := grpc.NewServer (opt)
// 2. 注册处理逻辑
proto.RegisterGreeterServer (g, &Server{})
// 3. 启动服务
lis, err := net.Listen ("tcp", "0.0.0.0:8088")
if err != nil {
panic ("failed to listen: " + err.Error ())
}
err = g.Serve (lis) // 不会直接退出
if err != nil {
panic ("failed to start grpc: " + err.Error ())
}
}
而对于 client/client. go
的代码,也就是针对我们的业务代码,则不需要进行任何更改。
func main () {
var opts []grpc. DialOption
opts = append (opts, grpc.WithInsecure ())
// 不使用客户端的拦截器
// opts = append (opts, grpc.WithUnaryInterceptor (interceptor))
conn, err := grpc.Dial ("127.0.0.1:8088", opts...)
if err!=nil{
panic (err)
}
defer conn.Close ()
c := proto.NewGreeterClient (conn)
r, err := c.SayHello (context.Background (), &proto. Person{
id: "800", // 报错
email: " example@pie.fun " // 正确
mobile: "13566779900" // 正确
})
if err!=nil{
panic (err)
}
fmt.Println (r.Message)
}
gRPC 异常处理
gRPC 中自带了许多常见情况的状态码,可以直接非常方便地使用。具体见官方文档。
在实际开发中,很多时候我们会自己定义状态码,一些开发框架可能会提供一些简单的状态码,我们可以选择遵守这些规则,也可以选择不遵守。
遵守的好处在于,所有人都清楚这些状态码,迁移更加方便,迁移成本更低,对于爬虫也更加友好。然而对于一些大型公司,不在意百度爬虫,甚至不希望别人来爬取公司数据,因此它们会自定义一套状态码。
gRPC 中给出的状态码,方便我们开发者在多语言的环境中进行开发,它对于不同的编程语言都提供了一套通用完整的状态码,不需要我们单独维护。
那么我们如何使用 gRPC 提供的这个状态码和错误处理呢?
实际上很简单,只需要使用 gRPC 的 status 包来返回一个错误信息,利用 codes 包来使用定义好的状态码。
err := status.Error (codes. NotFound, "记录未找到")
err := status.Errorf (codes. NotFound, "记录未找到:%s", request. Name)
比如 server/server. go
通过 status.Errorf ()
方法,返回错误信息。
type Server struct{}
// 实现 helloworld_grpc. pb. go 中的 GreeterServer 接口
func (s *Server) SayHello (ctx context. Context, request *proto. HelloRequest) (*proto. HelloReply,
error) {
return nil, status.Errorf (codes. NotFound, "记录未找到:%s", request. Name)
}
func main () {
// 1. 实例化一个服务
g := grpc.NewServer ()
// 2. 注册处理逻辑
proto.RegisterGreeterServer (g, &Server{})
// 3. 启动服务
lis, err := net.Listen ("tcp", "0.0.0.0:8088")
if err != nil {
panic ("failed to listen: " + err.Error ())
}
err = g.Serve (lis) // 不会直接退出
if err != nil {
panic ("failed to start grpc: " + err.Error ())
}
}
而 client/client. go
通过 status.FromError ()
方法,将服务端发送的错误信息再转换为 status,并取出其中的状态码与提示文字。
func main () {
conn, err := grpc.Dial ("127.0.0.1:8088",grpc.WithInsecure ())
if err!=nil{
panic (err)
}
defer conn.Close ()
c := proto.NewGreeterClient (conn)
_, err := c.SayHello (context.Background (),&proto. HelloRequest{Name: "bobby"})
if err != nil {
st, ok := status.FromError (err)
if !ok {
// Error was not a status error
panic ("解析 error 失败")
}
fmt.Println (st.Message ())
fmt.Println (st.Code ())
}
}
gRPC 提供的这一套状态码与错误处理机制是跨语言通用的。
gRPC 超时机制
当一个客户端访问服务端时,可能会出现一些异常,比如网络拥塞/抖动、服务器压力大/处理慢等问题,可能会使客户端请求很慢,需要一直等待服务器返回,导致客户端后续的操作一直无法进行,执行队列被阻塞,引起一连串的反应与错误,因此客户端一定需要一个超时机制。
牵扯到 Go 语言的超时机制,那么一定绕不开的就是 Go 语言的 Context(十分重要!!!)。
客户端想要实现超时机制其实十分简单,利用 Context 设置超时时间,同时 gRPC 的错误处理会自动给其加上错误提示,具体方式如下:
func main () {
conn, err := grpc.Dial ("127.0.0.1:8088",grpc.WithInsecure ())
if err!=nil{
panic (err)
}
defer conn.Close ()
c := proto.NewGreeterClient (conn)
// 设置超时时间为 3 秒
ctx, _ = context.WithTimeout (context.Background (), time. Second * 3)
_, err := c.SayHello (ctx, &proto. HelloRequest{Name: "bobby"})
if err != nil {
st, ok := status.FromError (err)
if !ok {
// Error was not a status error
panic ("解析 error 失败")
}
fmt.Println (st.Message ())
fmt.Println (st.Code ())
}
}
如果超时,会返回错误信息为:
context deadline exceeded
DeadlineExceeded