protobuf+grpc安装和入门

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模块以及插件

shell 复制代码
# 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.下载依赖

arduino 复制代码
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关键字,成员编号,不能重复。

示例:

ini 复制代码
//  指定版本号,默认是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服务

  • 语法:

    ini 复制代码
    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 Type Notes Python Type Go Type
double float float64
float float float32
int32 使用变长编码,对于负值的效率很低,如果你的域有可能有负值,请使用sint64替代 int int32
uint32 使用变长编码 int uint32
uint64 使用变长编码 int uint64
sint32 使用变长编码,这些编码在负值时比int32高效的多 int int32
sint64 使用变长编码,有符号的整型值。编码时比通常的int64高效。 int int64
fixed32 总是4个字节,如果数值总是比总是比228大的话,这个类型会比uint32高效。 int uint32
fixed64 总是8个字节,如果数值总是比总是比256大的话,这个类型会比uint64高效。 int uint64
sfixed32 总是4个字节 int int32
sfixed64 总是8个字节 int int64
bool bool bool
string 一个字符串必须是UTF-8编码或者7-bit ASCII编码的文本。 str string
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文件

ini 复制代码
//base.proto
syntax = "proto3";
​
option go_package = ".;proto";
message Pong{
    string id = 1;
}
ini 复制代码
//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嵌套

ini 复制代码
message Hello {
    string name = 1;
    message Result{
        string r = 1;
    }
}

枚举类型

ini 复制代码
enum Gender {
    MALE = 0;
    FEMALE = 1;
}

map类型

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

数组类型

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

protobuf内置timestamp类型

ini 复制代码
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...

调试工具

  • 命令行:

    shell 复制代码
    # 查看所有的服务
    $ 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
    0. 执行go install github.com/fullstorydev/grpcui/cmd/grpcui@latest

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

    python

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

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

      ini 复制代码
      # 生成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语法:

scss 复制代码
rpc simpleHello(Person) returns (Result) {}

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

服务端流式 RPC

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

proto语法 :

scss 复制代码
rpc serverStreamHello(Person) returns (stream Result) {}

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

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

客户端流式 RPC

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

proto语法 :

scss 复制代码
rpc clientStreamHello(stream Person) returns (Result) {}

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

双向流式 RPC

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

proto语法 :

scss 复制代码
rpc biStreamHello(stream Person) returns (stream Result) {}

应用场景:聊天应用。

server

go 复制代码
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 循环接收消息。

go 复制代码
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。

go 复制代码
type MD map[string][]string

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

go 复制代码
// 第一种方式,通过给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 客户端、服务端使用
go 复制代码
// 两种构建方式,参考上面
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 服务端内部使用。
scss 复制代码
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等)

go 复制代码
// 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端

go 复制代码
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. 实现认证函数:

    go 复制代码
    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. 构造拦截器:

    go 复制代码
    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. 初始化:

    css 复制代码
    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 认证进行安全传输。
    go 复制代码
    type PerRPCCredentials interface {
        GetRequestMetadata(ctx context.Context, uri ...string) (
            map[string]string, error,
        )
        RequireTransportSecurity() bool
    }

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

    实现接口:

    go 复制代码
    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. 连接:

    css 复制代码
    conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure(), grpc.WithPerRPCCredentials(&auth))

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

    lua 复制代码
    2021/10/11 20:39:35 rpc error: code = Unauthenticated desc = invalid token
    exit status 1

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

证书认证

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

单向证书认证

先看一下单向认证方式:

生成证书

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

1、生成私钥:

csharp 复制代码
openssl genrsa -des3 -out server.pass.key 2048

2、去除私钥中密码:

vbnet 复制代码
openssl rsa -in server.pass.key -out server.key

3、生成 csr 文件:

vbnet 复制代码
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、生成证书:

vbscript 复制代码
openssl x509 -req -days 365 -in server.csr -signkey server.key -out server.crt

gRPC 代码

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

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

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

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

go 复制代码
// 证书认证-单向认证
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))

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

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

lua 复制代码
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 证书。如果想要兼容之前的方式,可以通过设置环境变量的方式支持,如下:

ini 复制代码
export GODEBUG="x509ignoreCN=0"

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

双向证书认证

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

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

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

Linux 系统在:

bash 复制代码
/etc/pki/tls/openssl.cnf

Mac 系统在:

bash 复制代码
/System/Library/OpenSSL/openssl.cnf

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

ini 复制代码
req_extensions = v3_req # The extensions to add to a certificate request

接着添加以下配置:

ini 复制代码
[ v3_req ]
# Extensions to add to a certificate request
​
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names
​
[ alt_names ]
DNS.1 = www.example.grpcdev.cn

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

ini 复制代码
[ alt_names ]
DNS.1 = www.example.grpcdev.cn
DNS.2 = www.test.grpcdev.cn

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

1、生成 ca 证书:

vbnet 复制代码
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、生成服务端证书:

bash 复制代码
# 生成证书
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、生成客户端证书:

bash 复制代码
# 生成证书
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 代码

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

go 复制代码
// 证书认证-双向认证
// 从证书相关文件中读取和解析信息,得到证书公钥、密钥对
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,
})

再看客户端:

go 复制代码
// 证书认证-双向认证
// 从证书相关文件中读取和解析信息,得到证书公钥、密钥对
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 复制代码
    go get github.com/mwitkow/go-proto-validators/protoc-gen-govalidators
    go get github.com/grpc-ecosystem/go-grpc-middleware
  2. 接下来修改 proto 文件:

    proto

    ini 复制代码
    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 文件:

    ini 复制代码
    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。我这里使用的是第二种。

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

    arduino 复制代码
    Import "github.com/mwitkow/go-proto-validators/validator.proto" was not found or had errors

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

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

    • 引入包:

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

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

    lua 复制代码
    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()获取到用户代码的错误。

超时机制

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

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

负载均衡

Demo

  1. 创建并且编写proto文件

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

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

    • 服务器

      go 复制代码
      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)
          }
      }
    • 客户端

      go 复制代码
      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方式

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

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

go 复制代码
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方式

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

scss 复制代码
// 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相关

css 复制代码
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方法

相关推荐
feilieren3 小时前
信创改造 - TongRDS 替换 Redis
java·spring boot·后端
hani19903 小时前
beikeshop 与swoole结合,让网站打开飞起
后端·swoole
knoci4 小时前
【Go】-go中的锁机制
后端·学习·golang
Mike_188702783514 小时前
深入探索Golang的GMP调度机制:源码解析与实现原理
开发语言·后端·golang
不7夜宵4 小时前
Golang 反射
开发语言·后端·golang
AskHarries4 小时前
Spring Cloud Consul实现选举机制
java·后端·spring cloud·consul
山山而川粤5 小时前
大连环保公益管理系统|Java|SSM|Vue| 前后端分离
java·开发语言·后端·学习·mysql
尘浮生5 小时前
Java项目实战II基于SpringBoot前后端分离的网吧管理系统(开发文档+数据库+源码)
java·开发语言·数据库·spring boot·后端·微信小程序·小程序
哎呦没5 小时前
企业OA管理系统:Spring Boot技术实现与案例研究
android·spring boot·后端
人才程序员6 小时前
详解Qt QStorageInfo 存储信息类
c语言·开发语言·c++·后端·qt·界面