protobuf+grpc安装和入门

584 阅读25分钟

Protobuf

安装Protoc

Go

参考: 1.windows下安装golang使用protobuf - Go语言中文网 - Golang中文社区 (studygolang.com) 2.【转】windows 下 goprotobuf 的安装与使用 - 苍洱 - 博客园 (cnblogs.com)

1.安装 protoc

  • 该链接下下 载protoc-3.3.0-win32.zip的包
  • 将文件解压到某一文件夹
  • 将解压出来的文件夹下的 /bin 路径添加到环境变量path中

2、下载protobuf模块以及插件

# protoc-gen-go是用来将protobuf的的代码转换成go语言代码的一个插件
$ go get -u github.com/golang/protobuf/protoc-gen-go
# proto是protobuf在golang中的接口模块
$ go get -u github.com/golang/protobuf/proto

3.进入protobuf文件所在目录下,执行:

protoc -I . --go_out=plugins=grpc:. *.proto 即可生成相关go 文件(详情查看编译protobuf部分

注意:如果出现protoc-gen-go: unable to determine Go import path for "myproto.proto" 解决:在myproto.proto文件中的代码package pb;下面加入option go_package = ".;proto";详情见go_package

python

1.下载依赖

pip install grpcio
pip install grpcio-tools  # python的grpc tools包含了protoc及其插件,用来生成客户端和服务端代码

2.在protobuf文件所在目录下执行 python -m grpc_tools.protoc --python_out=. --grpc_python_out=. -I . *.proto 即可生成相关py文件

注意:会生成两个py文件,如果运行报错,要修改xxx_pb2_grpc.py中的import user_pb2 as user__pb2改成from . import user_pb2 as user__pb2

注意事项

  • package主要是用于避免命名冲突的,不同的项目(project)需要指定不同的package。同一package下msg名称必须唯一。
  • import,如果proto文件需要使用在其他proto文件中已经定义的结构,可以使用import引入。
  • option go_package = "github.com/protocolbuffers/protobuf/examples/go/tutorialpb"; go_packge有两层意思,一层是表明如果要引用这个proto生成的文件的时候import后面的路径;一层是如果不指定--go_opt(默认值),生成的go文件存放的路径。
  • message成员编号,可以不从1开始,但不能重复,且数字19000-19999不能用,若在 .proto 文件中使用了这些预留的编号 protocol buffer 编译器会发出警告。(最小的字段编号为 1,最大的为 2^29 - 1,或 536,870,911。)
  • 可以使用message嵌套
  • 定义数组、切片用repeated关键字
  • 可以使用枚举enum
  • 可以使用联合体。oneof关键字,成员编号,不能重复。

示例:

//  指定版本号,默认是proto2
syntax = "proto3";
//  指定所在包包名
package pb;
//定义枚举
enum Week {
  Monday = 0;//枚举值必须从0开始
  Turesday = 1;
}
//  定义消息体
message Student {
  //  =1 =2 是标识该字段在二进制编码中的唯一"标记"
  int32 age = 1;  // 可以不从1开始,但是不能重复,也不能用19000-19999,不同message下的可以重复
  string name = 2;
  People p = 3;
  repeated int32 score = 4;//数组或切片
  //枚举
  Week w = 5;
  //联合体
  oneof data {
    string teacher = 6;
    string class = 7;
  }
}
// 消息体可以嵌套
message People {
  int32 weight = 1;
}

编译protobuf

Go

go语言中编译命令,进入probuf文件所在目录下,执行: protoc -I (1) (2) --go_out=plugins=grpc:(3) ---> 生成xxx.pb.go 文件

(1)proto文件所在目录,如果是当前目录则为.

(2)编译哪些proto文件,*.proto表示全部proto文件

(3)将生成的文件存放到的位置,.表示当前目录,目录必须存在

注意:如果出现protoc-gen-go: unable to determine Go import path for "myproto.proto" 解决:在myproto.proto文件中的代码package pb;下面加入option go_package = "../pb";

Python

在protobuf文件所在目录下执行: python -m grpc_tools.protoc -I . *.proto --python_out=. --grpc_python_out=. 即可生成相关py文件

注意:会生成两个py文件,如果运行报错,要修改xxx_pb2_grpc.py中的import user_pb2 as user__pb2改成from . import user_pb2 as user__pb2

protobuf中添加rpc服务

  • 语法:

    service 服务名称 {
        rpc 函数名(参数:消息体) returns (返回值:信息体)
    }
    message People {
        string name = 1;
    }
    message Student {
        int32 age = 2;
    }
    例:
    service hello {
        rpc HelloWorld(People) returns (Student);
    }
    
  • 知识点:

    • 默认,protobuf在编译期间,不编译服务。要想使之编译,需要使用gRPC。

    • go使用的编译指令为:

      • protoc --go_out=plugins=grpc:. *.proto
      • protoc --go_out=plugins=grpc:生成go文件的位置 proto文件位置

protobuf类型

一个标量消息字段可以含有一个如下的类型——该表格展示了定义于.proto文件中的类型,以及与之对应的、在自动生成的访问类中定义的类型:

.proto TypeNotesPython TypeGo Type
doublefloatfloat64
floatfloatfloat32
int32使用变长编码,对于负值的效率很低,如果你的域有可能有负值,请使用sint64替代intint32
uint32使用变长编码intuint32
uint64使用变长编码intuint64
sint32使用变长编码,这些编码在负值时比int32高效的多intint32
sint64使用变长编码,有符号的整型值。编码时比通常的int64高效。intint64
fixed32总是4个字节,如果数值总是比总是比228大的话,这个类型会比uint32高效。intuint32
fixed64总是8个字节,如果数值总是比总是比256大的话,这个类型会比uint64高效。intuint64
sfixed32总是4个字节intint32
sfixed64总是8个字节intint64
boolboolbool
string一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本。strstring
bytes可能包含任意顺序的字节数据。str[]byte

数组表示:repeated 类型 变量名 = 编号;

你可以在文章Protocol Buffer 编码中,找到更多“序列化消息时各种类型如何编码”的信息

类型默认值

当一个消息被解析的时候,如果被编码的信息不包含一个特定的singular元素,被解析的对象锁对应的域被设置位一个默认值,对于不同类型指定如下:

  • 对于string,默认是一个空string
  • 对于bytes,默认是一个空的bytes
  • 对于bool,默认是false
  • 对于数值类型,默认是0
  • 对于枚举,默认是第一个定义的枚举值,必须为0;
  • 对于消息类型(message),域没有被设置,确切的消息是根据语言确定的,详见generated code guide 对于可重复域的默认值是空(通常情况下是对应语言中空列表)。 注:对于标量消息域,一旦消息被解析,就无法判断域释放被设置为默认值(例如,例如boolean值是否被设置为false)还是根本没有被设置。你应该在定义你的消息类型时非常注意。例如,比如你不应该定义boolean的默认值false作为任何行为的触发方式。也应该注意如果一个标量消息域被设置为标志位,这个值不应该被序列化传输。 查看generated code guide选择你的语言的默认值的工作细节。

go_package

option go_package=".;proto"; //前一个参数用于指定生成文件的位置,后一个参数指定生成的 .go 文件的 package(如果第二个参数不写默认对应位置下的package)

option go_package="../../common/stream/proto/v1"; //指定生成的.go文件位置

使用go_package了就不用package了,并且go_package不会影响到其他语言的生成

java也有java_package

protobuf引用其他protobuf文件

//base.proto
syntax = "proto3";
​
option go_package = ".;proto";
message Pong{
    string id = 1;
}
//hello.proto 与base同级
syntax = "proto3";
import "base.proto";    //引入base中定义的
import "google/protobuf/empty.proto";//引入公共内置的
option go_package = ".;proto";
service Greeter {
    rpc SayHello (HelloRequest) returns (HelloReply);
    rpc Ping(google.protobuf.Empty) returns (Pong);
}
​
message HelloRequest {
    string url = 1;
    string name = 2;
}
​
message HelloReply {
    string message = 1;
​
    message Result {
        string name = 1;
        string url = 2;
    }
​
    repeated Result data = 2;
}

message嵌套

message Hello {
    string name = 1;
    message Result{
        string r = 1;
    }
}

枚举类型

enum Gender {
    MALE = 0;
    FEMALE = 1;
}

map类型

message H{
    string name = 1;
    string url  = 2;
    Gender g = 3;
    map<string,string> mp = 4;
}

数组类型

// 只需要在相关类型前面加repeat
message H{
    repeat int32 data = 1;
}

protobuf内置timestamp类型

import "google/protobuf/timestamp.proto"
​
message H{
    string name = 1;
    string url  = 2;
    Gender g = 3;
    map<string,string> mp = 4;
    google.protobuf.Timestamp addTime = 5;
}

grpc

www.topgoer.cn/docs/grpc/g…

调试工具

  • 命令行:

    # 查看所有的服务
    $ grpc_cli ls localhost:50051# 查看 Greeter 服务的详细信息
    $ grpc_cli ls localhost:50051 helloworld.Greeter -l# 查看 Greeter.SayHello 方法的详细信息
    $ grpc_cli ls localhost:50051 helloworld.Greeter.SayHello -l# 远程调用
    $ grpc_cli call localhost:50051 SayHello "name: 'gRPC CLI'"
    
  • 界面:github.com/fullstoryde…

    go install github.com/fullstorydev/grpcui/cmd/grpcui@latest安装好后,调用grpcui -help查看是否安装成功,安装成功后命令行执行grpcui -plaintext grpc服务的地址(ip:port)后,会打开浏览器进入调试页面

  • go和python使用grpc调试的前置条件

    go

    1. 执行go install github.com/fullstorydev/grpcui/cmd/grpcui@latest
    2. 会在环境变量$GOPATHbin目录下生成一个grpcui.exe,只需要把$GOPATH/bin添加到环境变量PATH中即可。
    3. 控制台执行grpcui -help,查看是否安装成功
    4. 注册反射:在grpc服务器代码中添加reflection.Register(server),这样就不需要指定proto文件位置了
    5. 启动grpc服务
    6. 控制台执行grpcui -plaintext 被调试的grpc地址:被调试的grpc端口,会在浏览器打开一个调试页面

    python

    • 需要手动安装grpc reflection:pip install grpcio-reflection

    • grpc服务端代码引入安装的reflection包,实例:

      # 生成grpc服务器实例
      server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
      # 注册本地服务到服务器中
      goods_pb2_grpc.add_GoodsServicer_to_server(GoodsServicer(), server)
      # 使用grpc调试只需要添加以下代码
      from grpc_reflection.v1alpha import reflection
      reflection.enable_server_reflection([header.service_name() for header in server._state.generic_handlers], server)
      # 启动grpc服务器
      
    • 控制台执行grpcui -plaintext 被调试的grpc地址:被调试的grpc端口,会在浏览器打开一个调试页面

安装

python:

  1. pip install grpcio
  2. pip install grpcio-tools googleapis-common-protos

go:

四种通信模式及其应用场景选型

gRPC有四种通信方式,分别是:简单 RPC(Unary RPC)、服务端流式 RPC (Server streaming RPC)、客户端流式 RPC (Client streaming RPC)、双向流式 RPC(Bi-directional streaming RPC)。它们主要有以下特点:

服务类型特点
简单 RPC一般的rpc调用,传入一个请求对象,返回一个返回对象
服务端流式 RPC传入一个请求对象,服务端可以返回多个结果对象
客户端流式 RPC客户端传入多个请求对象,服务端返回一个结果对象
双向流式 RPC结合客户端流式RPC和服务端流式RPC,可以传入多个请求对象,返回多个结果对象

简单 RPC

一般的rpc调用,传入一个请求对象,返回一个返回对象

proto语法:

rpc simpleHello(Person) returns (Result) {}

客户端发起一次请求,服务端响应一个数据,即标准RPC通信。 这种模式,一个每一次都是发起一个独立的tcp连接,走一次三次握手和四次挥手!

服务端流式 RPC

传入一个请求对象,服务端可以返回多个结果对象

proto语法 :

rpc serverStreamHello(Person) returns (stream Result) {}

服务端流 RPC 下,客户端发出一个请求,但不会立即得到一个响应,而是在服务端与客户端之间建立一个单向的流,服务端可以随时向流中写入多个响应消息,最后主动关闭流,而客户端需要监听这个流,不断获取响应直到流关闭

应用场景举例: 典型的例子是客户端向服务端发送一个股票代码,服务端就把该股票的实时数据源源不断的返回给客户端。

客户端流式 RPC

客户端传入多个请求对象,服务端返回一个结果对象

proto语法 :

rpc clientStreamHello(stream Person) returns (Result) {}

应用场景: 物联网终端向服务器报送数据。

双向流式 RPC

结合客户端流式RPC和服务端流式RPC,可以传入多个请求对象,返回多个结果对象

proto语法 :

rpc biStreamHello(stream Person) returns (stream Result) {}

应用场景:聊天应用。

server

func (*greeter) SayHelloStream(stream proto.Greeter_SayHelloStreamServer) error {
    for {
        args, err := stream.Recv()
        if err != nil {
            if err == io.EOF {
                return nil
            }
            return err
        }
        fmt.Println("Recv: " + args.Name)
        reply := &proto.HelloReply{Message: "hi " + args.Name}
        err = stream.Send(reply)
        if err != nil {
            return err
        }
    }
}

client

通过一个 goroutine 发送消息,主程序的 for 循环接收消息。

func main() {
    ....
    client := proto.NewGreeterClient(conn)
    // 流处理
    stream, err := client.SayHelloStream(context.Background())
    if err != nil {
        log.Fatal(err)
    }
​
    // 发送消息
    go func() {
        for {
            if err := stream.Send(&proto.HelloRequest{Name: "zhangsan"}); err != nil {
                log.Fatal(err)
            }
            time.Sleep(time.Second)
        }
    }()
​
    // 接收消息
    for {
        reply, err := stream.Recv()
        if err != nil {
            if err == io.EOF {
                break
            }
            log.Fatal(err)
        }
        fmt.Println(reply.Message)
    }
}

metadata机制

类似于http的header

新建metadata

MD 类型实际上是map,key是string,value是string类型的slice。

type MD map[string][]string

创建的时候可以像创建普通的map类型一样使用new关键字进行创建:

// 第一种方式,通过给New方法传入一个map
// 由于map的key不能重复,因为默认key会被转成小写,所以如果要kv一对多需要使用不同大小写的key,比较麻烦,所以这种一般只用于kv一对一的时候
md := metadata.New(map[string]string{"key1": "val1", "key2": "val2"})
// 最终变成:
//  name: []string{"bobo"}
//  password: []string{"123456"}
// 源码:
func New(m map[string]string) MD {
    md := make(MD, len(m))
    for k, val := range m {
        key := strings.ToLower(k)
        md[key] = append(md[key], val)
    }
    return md
}
​
//第二种方式 key不区分大小写,会被默认统一转成小写。
md := metadata.Pairs(
    "key1", "val1",
    "key1", "val1-2", // "key1" will have map value []string{"val1", "val1-2"}
    "key2", "val2",
)
// 源码
func Pairs(kv ...string) MD {
    if len(kv)%2 == 1 {
        panic(fmt.Sprintf("metadata: Pairs got the odd number of input pairs for metadata: %d", len(kv)))
    }
    md := make(MD, len(kv)/2)
    for i := 0; i < len(kv); i += 2 {
        key := strings.ToLower(kv[i])
        md[key] = append(md[key], kv[i+1])
    }
    return md
}

发送metadata

  • NewOutgoingContext:创建一个附加了传出 md 的新上下文,可供外部的 gRPC 客户端、服务端使用
// 两种构建方式,参考上面
md := metadata.Pairs("key", "val")
​
// 新建一个有 metadata 的 context
ctx := metadata.NewOutgoingContext(context.Background(), md)
// 需要注意一点,在新增 metadata 信息时,务必使用 Append 类别的方法,否则如果直接 New 一个全新的 md,将会导致原有的 metadata 信息丢失(除非你确定你希望得到这样的结果)。
newCtx := metadata.AppendToOutgoingContext(ctx, "eddycjy", "Go 语言编程之旅")
// 单向 RPC
response, err := client.SomeRPC(ctx, someRequest)

接收metadata

  • NewIncomingContext:创建一个附加了所传入的 md 新上下文,仅供自身的 gRPC 服务端内部使用。
func (s *server) SomeRPC(ctx context.Context, in *pb.SomeRequest) (*pb.SomeResponse, err) {
    // 从上下文中通过FromIncomingContext接收
    md, ok := metadata.FromIncomingContext(ctx)
    // do something with metadata
}

拦截器机制

interceptor

server端(可以实现验证token等)

// 1.先实现这样一个函数 相当于中间件
interceptor := func(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
    }
// 2.使用,
opt := grpc.UnaryInterceptor(interceptor)
g := grpc.NewServer(opt)
proto.RegisterGreeterServer(g, &Server{})
lis, err := net.Listen("tcp", "0.0.0.0:50051")
if err != nil {
    panic("failed to listen:" + err.Error())
}
err = g.Serve(lis)
if err != nil {
    panic("failed to start grpc:" + err.Error())
}

client端

interceptor := func(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
    }
var opts []grpc.DialOption
opts = append(opts, grpc.WithInsecure())
opts = append(opts, grpc.WithUnaryInterceptor(interceptor))//还有grpc.WithPerRPCCredentials()方法,也是对interceptor的一种封装
conn, err := grpc.Dial("127.0.0.1:50051", 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)

自定义认证

实现Token认证

先改造服务端

有了上文验证器的经验,那么可以采用同样的方式,写一个拦截器,然后在初始化 server 时候注入。(基于metadata+拦截器+token)

  1. 实现认证函数:

    func Auth(ctx context.Context) error {
        // metadata.FromIncomingContext 从上下文读取token,然后判断是否通过认证。
        md, ok := metadata.FromIncomingContext(ctx)
        if !ok {
            return fmt.Errorf("missing credentials")
        }
    ​
        var token string
        if val, ok := md["x-token"]; ok {
            token = val[0]
        }
    ​
        if !ValidateToken(token) {
            return grpc.Errorf(codes.Unauthenticated, "invalid token")
        }
        return nil
    }
    
  2. 构造拦截器:

    var authInterceptor grpc.UnaryServerInterceptor
    authInterceptor = func(
        ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
    ) (resp interface{}, err error) {
        //拦截普通方法请求,验证 Token
        err = Auth(ctx)
        if err != nil {
            return
        }
        // 继续处理请求
        return handler(ctx, req)
    }
    
  3. 初始化:

    server := grpc.NewServer(
        grpc.UnaryInterceptor(
            grpc_middleware.ChainUnaryServer(
                authInterceptor,
                grpc_validator.UnaryServerInterceptor(),//下面的验证器
            ),
        ),
        grpc.StreamInterceptor(
            grpc_middleware.ChainStreamServer(
                grpc_validator.StreamServerInterceptor(),
            ),
        ),
    )
    
最后是客户端改造
  1. 客户端需要实现 PerRPCCredentials 接口。

    在 gRPC 中所提供的 PerRPCCredentials,是 gRPC 默认提供用于自定义认证 Token 的接口,它的作用是将所需的安全认证信息添加到每个 RPC 方法的上下文中。其包含两个接口方法,如下:

    • GetRequestMetadata:获取当前请求认证所需的元数据(metadata)。
    • RequireTransportSecurity:是否需要基于 TLS 认证进行安全传输。
    type PerRPCCredentials interface {
        GetRequestMetadata(ctx context.Context, uri ...string) (
            map[string]string, error,
        )
        RequireTransportSecurity() bool
    }
    

    GetRequestMetadata 方法返回认证需要的必要信息,RequireTransportSecurity 方法表示是否启用安全链接,在生产环境中,一般都是启用的,但为了测试方便,暂时这里不启用了。

    实现接口:

    type Authentication struct {
        Token     string
    }
    ​
    

func (a *Authentication) GetRequestMetadata(context.Context, ...string) ( map[string]string, error, ) { return map[string]string{"x-token": a.Token}, nil } func (a *Authentication) RequireTransportSecurity() bool { return false } ```

  1. 连接:

    conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure(), grpc.WithPerRPCCredentials(&auth))
    

    好了,现在我们的服务就有 Token 认证功能了。如果token验证错误,客户端就会收到:

    2021/10/11 20:39:35 rpc error: code = Unauthenticated desc = invalid token
    exit status 1
    

    如果token正确,则可以正常返回。

证书认证

证书认证分两种方式:单向认证,双向认证

单向证书认证

先看一下单向认证方式:

生成证书

首先通过 openssl 工具生成自签名的 SSL 证书。

1、生成私钥:

openssl genrsa -des3 -out server.pass.key 2048

2、去除私钥中密码:

openssl rsa -in server.pass.key -out server.key

3、生成 csr 文件:

openssl req -new -key server.key -out server.csr -subj "/C=CN/ST=beijing/L=beijing/O=grpcdev/OU=grpcdev/CN=example.grpcdev.cn"

4、生成证书:

openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt

gRPC 代码

证书有了之后,剩下的就是改造程序了,首先是服务端代码。

// 证书认证-单向认证
creds, err := credentials.NewServerTLSFromFile("keys/server.crt", "keys/server.key")
if err != nil {
    log.Fatal(err)
    return
}
server := grpc.NewServer(grpc.Creds(creds))

只有几行代码需要修改,很简单,接下来是客户端。

由于是单向认证,不需要为客户端单独生成证书,只需要把服务端的 crt 文件拷贝到客户端对应目录下即可。

// 证书认证-单向认证
creds, err := credentials.NewClientTLSFromFile("keys/server.crt", "example.grpcdev.cn")
if err != nil {
    log.Fatal(err)
    return
}
conn, err := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(creds))

好了,现在我们的服务就支持单向证书认证了。

但是还没完,这里可能会遇到一个问题:

2021/10/11 21:32:37 rpc error: code = Unavailable desc = connection error: desc = "transport: authentication handshake failed: x509: certificate relies on legacy Common Name field, use SANs or temporarily enable Common Name matching with GODEBUG=x509ignoreCN=0"
exit status 1

原因是 Go 1.15 开始废弃了 CommonName,推荐使用 SAN 证书。如果想要兼容之前的方式,可以通过设置环境变量的方式支持,如下:

export GODEBUG="x509ignoreCN=0"

但是需要注意,从 Go 1.17 开始,环境变量就不再生效了,必须通过 SAN 方式才行。所以,为了后续的 Go 版本升级,还是早日支持为好。

双向证书认证

最后来看看双向证书认证。

还是先生成证书,但这次有一点不一样,我们需要生成带 SAN 扩展的证书。 什么是 SAN?SAN(Subject Alternative Name)是 SSL 标准 x509 中定义的一个扩展。使用了 SAN 字段的 SSL 证书,可以扩展此证书支持的域名,使得一个证书可以支持多个不同域名的解析。

将默认的 OpenSSL 配置文件拷贝到当前目录。

Linux 系统在:

/etc/pki/tls/openssl.cnf

Mac 系统在:

/System/Library/OpenSSL/openssl.cnf

修改临时配置文件,找到 [ req ] 段落,然后将下面语句的注释去掉。

req_extensions = v3_req # The extensions to add to a certificate request

接着添加以下配置:

[ v3_req ]
# Extensions to add to a certificate requestbasicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
​
[ alt_names ]
DNS.1 = www.example.grpcdev.cn

[ alt_names ] 位置可以配置多个域名,比如:

[ alt_names ]
DNS.1 = www.example.grpcdev.cn
DNS.2 = www.test.grpcdev.cn

为了测试方便,这里只配置一个域名。

1、生成 ca 证书:

openssl genrsa -out ca.key 2048
​
openssl req -x509 -new -nodes -key ca.key -subj "/CN=example.grpcdev.com" -days 5000 -out ca.pem

2、生成服务端证书:

# 生成证书
openssl req -new -nodes \
    -subj "/C=CN/ST=Beijing/L=Beijing/O=grpcdev/OU=grpcdev/CN=www.example.grpcdev.cn" \
    -config <(cat openssl.cnf \
        <(printf "[SAN]\nsubjectAltName=DNS:www.example.grpcdev.cn")) \
    -keyout server.key \
    -out server.csr
​
# 签名证书
openssl x509 -req -days 365000 \
    -in server.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
    -extfile <(printf "subjectAltName=DNS:www.example.grpcdev.cn") \
    -out server.pem

3、生成客户端证书:

# 生成证书
openssl req -new -nodes \
    -subj "/C=CN/ST=Beijing/L=Beijing/O=grpcdev/OU=grpcdev/CN=www.example.grpcdev.cn" \
    -config <(cat openssl.cnf \
        <(printf "[SAN]\nsubjectAltName=DNS:www.example.grpcdev.cn")) \
    -keyout client.key \
    -out client.csr
​
# 签名证书
openssl x509 -req -days 365000 \
    -in client.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
    -extfile <(printf "subjectAltName=DNS:www.example.grpcdev.cn") \
    -out client.pem

gRPC 代码

接下来开始修改代码,先看服务端:

// 证书认证-双向认证
// 从证书相关文件中读取和解析信息,得到证书公钥、密钥对
cert, _ := tls.LoadX509KeyPair("cert/server.pem", "cert/server.key")
// 创建一个新的、空的 CertPool
certPool := x509.NewCertPool()
ca, _ := ioutil.ReadFile("cert/ca.pem")
// 尝试解析所传入的 PEM 编码的证书。如果解析成功会将其加到 CertPool 中,便于后面的使用
certPool.AppendCertsFromPEM(ca)
// 构建基于 TLS 的 TransportCredentials 选项
creds := credentials.NewTLS(&tls.Config{
    // 设置证书链,允许包含一个或多个
    Certificates: []tls.Certificate{cert},
    // 要求必须校验客户端的证书。可以根据实际情况选用以下参数
    ClientAuth: tls.RequireAndVerifyClientCert,
    // 设置根证书的集合,校验方式使用 ClientAuth 中设定的模式
    ClientCAs: certPool,
})

再看客户端:

// 证书认证-双向认证
// 从证书相关文件中读取和解析信息,得到证书公钥、密钥对
cert, _ := tls.LoadX509KeyPair("cert/client.pem", "cert/client.key")
// 创建一个新的、空的 CertPool
certPool := x509.NewCertPool()
ca, _ := ioutil.ReadFile("cert/ca.pem")
// 尝试解析所传入的 PEM 编码的证书。如果解析成功会将其加到 CertPool 中,便于后面的使用
certPool.AppendCertsFromPEM(ca)
// 构建基于 TLS 的 TransportCredentials 选项
creds := credentials.NewTLS(&tls.Config{
    // 设置证书链,允许包含一个或多个
    Certificates: []tls.Certificate{cert},
    // 要求必须校验客户端的证书。可以根据实际情况选用以下参数
    ServerName: "www.example.grpcdev.cn",
    RootCAs:    certPool,
})

大功告成。

验证器机制

地址:(grpc实战:跨语言的rpc框架到底好不好用,试试就知道 - 知乎 (zhihu.com)

这个需求是很自然会想到的,因为涉及到接口之间的请求,那么对参数进行适当的校验是很有必要的。 如果是内部的一些可以不用验证,毕竟验证也耗费资源

在这里我们使用 protoc-gen-govalidatorsgo-grpc-middleware 来实现。

  1. 先安装:

    go get github.com/mwitkow/go-proto-validators/protoc-gen-govalidators
    go get github.com/grpc-ecosystem/go-grpc-middleware
    
  2. 接下来修改 proto 文件:

    proto

    import "github.com/mwitkow/go-proto-validators@v0.3.2/validator.proto";
    ​
    message HelloRequest {
        string name = 1 [
            (validator.field) = {regex: "^[z]{2,5}$"}
        ];
    }
    

    在这里对 name 参数进行校验,需要符合正则的要求才可以正常请求。

    还有其他验证规则,比如对数字大小进行验证等,这里不做过多介绍。

  3. 接下来生成 *.pb.go 文件:

    protoc  \
        --proto_path=${GOPATH}/pkg/mod \
        --proto_path=${GOPATH}/pkg/mod/github.com/gogo/protobuf@v1.3.2 \
        --proto_path=. \
        --govalidators_out=. --go_out=plugins=grpc:.\
        *.proto
    

    执行成功之后,目录下会多一个 helloworld.validator.pb.go 文件。

    这里需要特别注意一下,使用之前的简单命令是不行的,需要使用多个 proto_path 参数指定导入 proto 文件的目录。

    官方给了两种依赖情况,一个是 google protobuf,一个是 gogo protobuf。我这里使用的是第二种。

    注意:即使使用上面的命令,也有可能会遇到这个报错:

    Import "github.com/mwitkow/go-proto-validators/validator.proto" was not found or had errors
    

    但不要慌,大概率是引用路径的问题,一定要看好自己的安装版本,以及在 GOPATH 中的具体路径。

  4. 最后是服务端代码改造:

    • 引入包:

      grpc_middleware "github.com/grpc-ecosystem/go-grpc-middleware"
      grpc_validator "github.com/grpc-ecosystem/go-grpc-middleware/validator"
      
    • 然后在初始化的时候增加验证器功能(即添加到拦截器中):

      server := grpc.NewServer(
          grpc.UnaryInterceptor(
              grpc_middleware.ChainUnaryServer(
                  grpc_validator.UnaryServerInterceptor(),
              ),
          ),
          grpc.StreamInterceptor(
              grpc_middleware.ChainStreamServer(
                  grpc_validator.StreamServerInterceptor(),
              ),
          ),
      )
      
  5. 启动程序之后,我们再用之前的客户端代码来请求,会收到报错:

    2021/10/11 18:32:59 rpc error: code = InvalidArgument desc = invalid field Name: value 'zhangsan' must be a string conforming to regex "^[z]{2,5}$"
    exit status 1
    

    因为 name: zhangsan 是不符合服务端正则要求的,但是如果传参 name: zzz,就可以正常返回了。

错误处理机制

地址:zhuanlan.zhihu.com/p/435011704

判断Error的错误原理

要了解怎么处理gRPCerror之前,我们首先来看下Go普通的error是怎么处理的。

我们在判断一个error的根因,需要根因error是一个固定地址的指针类型,这样我们才能够使用官方的errors.Is方法判断他是否为根因。

我们先看这个代码errors.Is(wrapNewPointerError(), fmt.Errorf("i am error"))的执行步骤,首先构造了一个error,然后使用官方%w的方式将error进行了包装,我们在使用errors.Is方法判断的时候,底层函数会将error解包来判断两个error的地址是否一致。

gRPC网络传输的Error

grpc网络传输的error不能简单通过官方提供的errors.Is()来进行判断。 我们客户端在获取到gRPCerror的时候,是否可以使用上文说的官方errors.Is进行判断呢。如果我们直接使用该方法,通过判断error地址是否相等,是无法做到的。原因是因为我们在使用gRPC的时候,在远程调用过程中,客户端获取的服务端返回的error,在tcp传递的时候实际上是一串文本。客户端拿到这个文本,是要将其反序列化转换为error,在这个反序列化的过程中,其实是new了一个新的error地址,这样就无法判断error地址是否相等。

grpc与http对比:

  1. grpc的meta对应http的请求头,code对应http的状态码,error对应http的具体报错信息。
  2. grpc客户端远程调用服务端方法时,得到的是(resp,error),我们需要通过status.FromError将error转化为status,继而通过status获取响应的code和error。
  3. grpc服务端向客户端返回响应的时候,返回的格式也是(resp, status.Error(code,err.Error()))。

为了更好的解释gRPC网络传输的error,以下描述了整个error的处理流程。

  • 客户端通过invoker方法将请求发送到服务端。
  • 服务端通过processUnaryRPC方法,获取到用户代码的error信息。
  • 服务端通过status.FromError方法,将error转化为status.Status
  • 服务端通过WriteStatus方法将status.Status里的数据,写入到grpc-statusgrpc-messagegrpc-status-details-binheader头里。
  • 客户端通过网络获取到这些header头,使用strconv.ParseInt解析到grpc-status信息、decodeGrpcMessage解析到grpc-message信息、decodeGRPCStatusDetails解析为grpc-status-details-bin信息。
  • 客户端通过a.Status().Err()获取到用户代码的错误。

img

超时机制

进行超时控制。网络抖动,网络断开

    ctx, _ := context.WithTimeout(context.Background(), time.Second*3)
    _, err = c.SayHello(ctx, &proto.HelloRequest{Name: "bobby"})

负载均衡

Demo

  1. 创建并且编写proto文件

  2. 执行插件生成相关语言类型的文件

  3. 实习服务端和客户端代码逻辑

    • 服务器

      const (
          port = ":50051"
      )
      ​
      type server struct{} //服务对象// SayHello 实现服务的接口 在proto中定义的所有服务都是接口
      func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
          return &pb.HelloReply{Message: "Hello " + in.Name}, nil
      }
      ​
      func main() {
          lis, err := net.Listen("tcp", port)
          if err != nil {
              log.Fatalf("failed to listen: %v", err)
          }
          s := grpc.NewServer() //起一个服务 
          pb.RegisterGreeterServer(s, &server{})
          
          // 注册反射服务 这个服务是CLI使用的 跟服务本身没有关系
          reflection.Register(s)
          
          if err := s.Serve(lis); err != nil {
              log.Fatalf("failed to serve: %v", err)
          }
      }
      
    • 客户端

      const (
          address     = "localhost:50051"
          defaultName = "world"
      )
      ​
      func main() {
          //建立链接
          conn, err := grpc.Dial(address, grpc.WithInsecure())
          if err != nil {
              log.Fatalf("did not connect: %v", err)
          }
          defer conn.Close()
          c := pb.NewGreeterClient(conn)
      ​
          // Contact the server and print out its response.
          name := defaultName
          if len(os.Args) > 1 {
              name = os.Args[1]
          }
          // 1秒的上下文
          ctx, cancel := context.WithTimeout(context.Background(), time.Second)
          defer cancel()
          r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
          if err != nil {
              log.Fatalf("could not greet: %v", err)
          }
          log.Printf("Greeting: %s", r.Message)
      }
      

连接consul

注册到consul中后,consul会根据注册信息定期向服务器发送健康检查请求。 如果没有设置健康检查,注册后会因为健康检查机制立马下线。

一般用HTTP或者GRPC方式,一般HTTP项目用HTTP方式,GRPC项目用GRPC方式

HTTP方式

// 开放一个 /health 接口,用于服务注册中心向此接口发送健康检查请求
r.GET("/health", func(ctx *gin.Context) {
    ctx.JSON(http.StatusOK, gin.H{
    "code": http.StatusOK,
    "msg":  "",
    })
})

然后注册到consul的时候配置是HTTP方式,及其健康检查请求url

type Registry struct {
   Host string
   Port int
}
type RegistryClient interface {
   Register(address string, port int, name string, tags []string, id string) error
   DeRegister(serviceId string) error
}
​
func NewRegistryClient(host string, port int) RegistryClient {
   return &Registry{Host: host, Port: port}
}
​
func (r *Registry) Register(address string, port int, name string, tags []string, id string) error {
   cfg := api.DefaultConfig()
   cfg.Address = fmt.Sprintf("%s:%d", r.Host, r.Port)
​
   client, err := api.NewClient(cfg)
   if err != nil {
      panic(err)
   }
   //生成对应grpc的检查对象
   check := &api.AgentServiceCheck{
      //HTTP方式
      HTTP:                           fmt.Sprintf("http://%s:%d/health", address, port),
      Timeout:                        "5s",
      Interval:                       "5s",
      DeregisterCriticalServiceAfter: "15s",
   }
​
   //生成注册对象
   registration := new(api.AgentServiceRegistration)
   registration.Name = name
   registration.ID = id
   registration.Port = port
   registration.Tags = tags
   registration.Address = address
   registration.Check = check
​
   err = client.Agent().ServiceRegister(registration)
   if err != nil {
      panic(err)
   }
   return nil
}
​
func (r *Registry) DeRegister(serviceId string) error {
   cfg := api.DefaultConfig()
   cfg.Address = fmt.Sprintf("%s:%d", r.Host, r.Port)
​
   client, err := api.NewClient(cfg)
   if err != nil {
      return err
   }
   err = client.Agent().ServiceDeregister(serviceId)
   return err
}

GRPC方式

比较简单,官方直接提供了,只需要注册到服务器实例中就行

// 6.1 生成grpc服务器实例
l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", initialize.ServerConfig.Host, initialize.ServerConfig.Port))
if err != nil {
    zap.S().Panic(err)
}
server := grpc.NewServer()
zap.S().Debugf("启动服务器,端口:%d", initialize.ServerConfig.Port)
// 6.2 本地rpc服务注册到服务器
pb.RegisterInventoryServer(server, &api.InventoryServer{})
// 6.3 健康检查注册注册到服务器
hsrv := health.NewServer()
hsrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
grpc_health_v1.RegisterHealthServer(server, hsrv)
// 6.4 启动实例
go func(){
    if err := server.Serve(l); err != nil {
        log.Fatal(err)
    }
}
​
// 相关信息注册到consul中
registerClient := NewRegistryClient(initialize.ServerConfig.ConsulConfig.Host, initialize.ServerConfig.ConsulConfig.Port)
serviceId := uuid.NewV4().String()
if err := registerClient.Register(initialize.ServerConfig.Host, initialize.ServerConfig.Port, initialize.ServerConfig.Name, initialize.ServerConfig.Tags, serviceId); err != nil {
    zap.S().Panic("服务注册失败:", err.Error())
}

consul部分只需要把HTTP修改为GRPC相关

check := &api.AgentServiceCheck{
    //启用GRPC
    GRPC:                           fmt.Sprintf("%s:%d", address, port),
    GRPCUseTLS:                     false,
    Timeout:                        "5s",
    Interval:                       "5s",
    DeregisterCriticalServiceAfter: "15s",
}

源码分析

gRPC 源码分析 (一) : 概述

gRPC 源码分析(二): gRPC Server 的 RPC 连接阶段

gRPC 源码分析(三): gRPC Server 的 RPC 交互阶段

gRPC 源码分析(四): gRPC server 中 frame 的处理

gRPC 源码分析(五): gRPC server 的流量控制 - 采样流量控制

gRPC 源码分析(六): gRPC server 的流量控制 - connection 和 stream level 的流量控制

gRPC 流量控制详解

custom

客户端流程浅析

DialContext

  1. --> cc.parseTargetAndFindResolver ,

    1. 通过Scheme获取对应resolver(是一个resolver.Builder接口)

      --> cc.getResolver,先从初始化的配置中找是否存在该resolver(即先从grpc.WithResolvers()方法传递进来的resolvers中找),如果不存在再使用resolver.Get方法去全局中找(即再去通过使用resolver.Register方法注册进来的resolvers中找)。

    2. 都获取不到则使用默认的passthrough方案,不使用该功能。

  2. 调用newCCBalancerWrapper方法去初始化grpc.ClientConn的属性balancerWrapper。该方法先将传入的grpc.ClientConn对象包装成实现了balancer.ClientConn接口的ccBalancerWrapper对象(该结构体对象所在位置:google.golang.org\grpc@v1.55.0\balancer_conn_wrappers.go),该对象有一个属性updateCh,其本质是一个长度为1的chan interface{},然后会通过goroutine启动其watch方法用来监听updateCh中的数据,通过断言收到的数据类型,从而根据不同的数据类型做出不同的处理,例如收到的数据是ccStateUpdate类型(该类型包含了resolver.State类型,即resolver.ClientConn接口的UpdateState方法的参数),则会调用该对象的handleClientConnStateChange方法,可能过滤数据,该方法最终调用其内部属性balancer的UpdateClientConnState方法(内部调用balToUpdate.UpdateClientConnState --> UpdateSubConnState --> bw.Balancer.UpdateSubConnState --..... / ),并且将结果发送到resultCh中。(此刻可以猜想resolver模块与balancer模块如何联系,但还需找到resolver.ClientConn接口的UpdateState方法最终向updateCh发送ccStateUpdate类型数据的地方,才能确认)。

  3. 调用newCCResolverWrapper方法去初始化grpc.ClientConn的属性resolverWrapper。该方法先将传入的grpc.ClientConn对象包装成实现了resolver.ClientConn接口的grpc.ccResolverWrapper对象(该结构体对象所在位置:google.golang.org\grpc@v1.55.0\resolver_conn_wrapper.go),最后调用前面找到的resolver的Build()方法,将该对象作为参数之一传进去。

    查看grpc.ccResolverWrapper对象实现的UpdateState方法,可知其调用了grpc.ClientConn的updateResolverState方法,此方法内部最终调用了gprc.ClientConn的属性balancerWrapper的updateClientConnState方法,并且将类型resolver.State包装成了类型balancer.ClientConnState,然后传入进去,而updateClientConnState方法内部又将类型balancer.ClientConnState包装成了类型*ccStateUpdate,然后发送到balancerWrapper的updateCh中(跟2中连起来了,找到并且验证了balancer模块和resolver通信的全过程)。

  4. *ccStateUpdate类型

resover.Builder接口的Build方法有一个参数是resolver.ClientConn接口类型,此接口的UpdateState方法要求传入更新的值,此方法具体实现可在grpc.ccResolverWrapper结构体的UpdateState方法中查看,位置:google.golang.org\grpc@v1.55.0\resolver_conn_wrapper.go,查看后可知内部调用了grpc.ClientConn的updateResolverState方法,此方法内部最终调用了gprc.ClientConn的属性balancerWrapper的updateClientConnState方法