gRPC进阶学习

Keywords: #技术 #Golang #gRPC
Release Date: 2025-02-28
Table of Contents

gRPC 进阶学习知识点笔记

protobuf 进阶知识

官方文档: https://developers.google.com/protocol-buffers/docs/proto3

基础类型与默认值

以下是 protobuf 与 Go 的基础类型对照表:

proto TypeNotesGo Type
doublefloat 64
floatfloat 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
boolbool
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.protohello.proto 文件生成的 base.pb.gobase_grpc.pb.go) 和 hello.pb.gohello_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 是一个方法集),以及 HelloRequestHelloReply 两个 message。

那么当我们使用 protoc 根据这个 proto 文件生成对应的 Go 文件,它们会转换成什么呢?

事实上,message 会变为 Go 的结构体,方法集会变为 Go 的接口,而 rpc 方法会称为该接口中的方法。

我们使用以下命令来生成 helloworld.pb.gohelloworld_grpc.pb.go 文件。

protoc -I . --go_out=. --go-grpc_out=require_unimplemented_servers=false:. .\helloworld.proto

helloworld.pb.go 中,可以找到 HelloRequestHelloReply 结构体:

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 中,可以找到 GreeterServerGreeterClient 接口和 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 的根目录 GOPATHbin 目录下。

使用以下命令,可以将所需要的 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. protovalidate. 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. gohelloworld_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