头图

1 ProtoBuf入门

1.1 简介

ProtoBuf全称ProtoBuffer,是微服务框架中使用的一种数据交换协议工具,用于网络通信和数据交换等场景。

与json和xml相比,json和xml是基于文本格式,而ProtoBuf是基于二进制格式,所以在ProtoBuf比json和xml更小(3-10倍),更快(20—100倍),更简单。

1.2 安装

1.2.1 安装ProtoBuf

官网下载地址:https://github.com/protocolbuffers/ProtoBuf/releases

下载之后需要将protoc所在的bin目录添加到电脑的环境变量中。

Mac电脑可以直接使用brew安装。

1.2.2 安装go语言工具包

默认安装的protoc可以将proto文件编译为java,C++等语言的代码文件,但是需要编译为go语言的,需要下载go语言的工具包。

// 下载go语言工具包命令
go get github.com/golang/protobuf/protoc-gen-go

1.3 使用

1.3.1 编写proto代码

syntax = "proto3";    // 定义proto版本号

package hello;    // 定义包名,用于proto

option go_package = "./;hello";      // 定义go包名,用于生成.pd.go文件

message Say{    // 定义消息体
  int64           id    = 1;
  string          hello = 2;
  repeated string word  = 3;
}

1.3.2 生成go代码

proto代码可以直接生成对应的go代码,go代码的后缀对应为pb.go,切换到proto所在目录使用以下命令即可生成go代码。

protoc --go_out=. hello.proto

生成go代码之后目录如下所示:
image.png

2 ProtoBuf进阶

ProtoBuf和go语言最大的区别在于ProtoBuf语句需要在结尾添加分号。

2.1 关键字

  • syntax:是必须写的,而且要定义在第一行;目前proto3是主流,不写默认使用proto2
  • package:定义我们proto文件的包名
  • option go_package:定义生成的pb.go的包名,我们通常在proto文件中定义。如果不在proto文件中定义,也可以在使用protoc生成代码时指定pb.go文件的包名
  • message:非常重要,用于定义消息结构体。

2.2 消息体编译

写完ProtoBuf代码之后,可以通过protoc命令将ProtoBuf编译成各种语言版本的代码。

编译器命令如下:

protoc [OPTION] PROTO_FILES

OPTION:命令选项,常用的OPTION选项如下:

  --go_out=OUT_DIR            指定代码生成目录,生成 Go 代码
  --cpp_out=OUT_DIR           指定代码生成目录,生成 C++ 代码
  --csharp_out=OUT_DIR        指定代码生成目录,生成 C# 代码
  --java_out=OUT_DIR          指定代码生成目录,生成 java 代码
  --js_out=OUT_DIR            指定代码生成目录,生成 javascript 代码
  --objc_out=OUT_DIR          指定代码生成目录,生成 Objective C 代码
  --php_out=OUT_DIR           指定代码生成目录,生成 php 代码
  --python_out=OUT_DIR        指定代码生成目录,生成 python 代码
  --ruby_out=OUT_DIR          指定代码生成目录,生成 ruby 代码

PROTO_FILES:要编译的proto消息定义文件,支持多个

2.3 消息message

message(消息),在ProtoBuf中表示定义的数据结构,类似于go中定义的结构体。

2.3.1 使用

语法:

message 消息名 {
    消息体
}

示例:

message Request {
  string query = 1;
  int32  page = 2;
  int32  limit = 3;
}

示例中定义了一个Request类型的消息,其中有三个字段,字符串类型的query、int32类型的page和limit。

2.3.2 字段类型

ProtoBuf支持多种数据类型,与go语言类型对应如下:

.proto TypeGo Type
doublefloat64
floatfloat32
int32int32
uint32uint32
uint64uint64
sint32int32
sint64int64
fixed32uint32
fixed64uint64
sfixed32int32
sfixed64int64
boolbool
stringstring
bytes[]byte

2.3.3 分配标识号

分配标识号就是在消息体定义中每个字段后面的唯一的数字,这个就是标识号,在同一个消息体中,每个字段的标识号都是唯一的,不同消息体可以拥有相同的标识号。

标识号的主要作用是用来在消息的二进制格式中标识各个字段的,一旦指定之后就不能修改。

2.3.4 保留标识号

在标识号的使用中,[1,15]之间的标识号在编码的时候占用一个字节,[16,2047]之间的标识号占用两个字节,所以在使用的时候应该为将来有可能添加的、频繁出现的字段预留一些标识号。

想要预留一些标识号,留给以后使用,可以使用关键字reserved:

message Test {
  reserved 2, 5, 7 to 10; // 保留2,5,7到10这些标识号
}

如果在代码中使用了这些预留的标识号,那么编译器无法编译通过。
image.png

2.4 数组类型

在ProtoBuf中定义数组类型,是通过在字段前面增加repeated关键字实现的,标记之后就代表当前字段是一个数组。

示例:

message Msg {
  // 整形数组
  repeated int64 arrays = 1;
  // 字符串数组
  repeated string names = 2;
}

2.5 枚举类型

枚举在Java中是很常见的一种类型,但是go语言是不直接支持枚举的,而且也没有Enum关键字。

枚举的应用场景:当定义一个消息类型时,有时候某字段只能是某一种类型的某一个值,这时候就需要使用到枚举,例如定义一个性别字段,那么这个字段就只能为性别类型中的男或者女。

代码示例:

syntax = "proto3";//指定版本信息,非注释的第一行

enum SexType //枚举消息类型,使用enum关键词定义,一个性别类型的枚举类型
{
    UNKONW = 0; //proto3版本中,首成员必须为0,成员不应有相同的值
    MALE = 1;  //1男
    FEMALE = 2; //2女
}

// 定义一个用户消息
message UserInfo
{
    string name = 1; // 姓名字段
    SexType sex = 2; // 性别字段,使用SexType枚举类型,表示该字段只能为SexType中的0,1,2三个中的一个
}

2.6 消息嵌套

在实际的开发过程中,可能需要定义很多的proto文件,如果需要做到消息的复用,则需要使用消息嵌套。

在go语言的开发中,经常会使用到结构体的嵌套,例如一个结构体内有另外一个结构体的属性,在ProtoBuf中同样支持消息的嵌套,可以在一个消息中嵌套另外一个消息,字段类型可以是另外一个消息类型。

2.6.1 引用其他消息类型

// 定义Article消息
message Article {
  string url = 1;
  string title = 2;
  repeated string tags = 3; // 字符串数组类型
}

// 定义ListArticle消息
message ListArticle {
  // 引用上面定义的Article消息类型,作为articles字段的类型
  repeated Article articles = 1; // repeated关键词标记,说明articles字段是一个数组
}

2.6.2 消息嵌套

message ListArticle {
  // 嵌套消息定义
  message Article {
    string url = 1;
    string title = 2;
    repeated string tags = 3;
  }
  // 引用嵌套的消息定义
  repeated Article articles = 1;
}

2.6.3 import

在实际开发中,可能需要定义很多消息,如果都写在一个proto文件中,不方便维护。可以将消息写在不同的proto文件中,需要的时候通过import关键字导入其他proto文件中定义的消息。

article.proto

syntax = "proto3";

package nesting;

option go_package = "./;article";

message Article {
  string          url   = 1;
  string          title = 2;
  repeated string tags  = 3; // 字符串数组类型
}

list_article.proto

syntax = "proto3";
// 导入Article消息定义
import "article.proto";

package nesting;

option go_package = "./;article";

// 定义ListArticle消息
message ListArticle {
  // 使用导入的Result消息
  repeated Article articles = 1;
}

2.7 Map类型

在go语言开发中,Map是常用的一种类型,在ProtoBuf中同样是支持Map类型的。

2.7.1 Map语法

map<key_type, value_type> map_field = N;
  1. key_type可以是任何整数或字符串类型(除浮点类型和字节之外的任何标量类型)。
  2. 注意:枚举不是有效的key_type
  3. value_type 可以是除另一个映射之外的任何类型。
  4. Map 字段不能使用repeated关键字修饰。

2.7.2 map的例子

syntax = "proto3";

package map;

option go_package = "./;score";

message Student{
  int64              id    = 1; //id
  string             name  = 2; //学生姓名
  map<string, int32> score = 3; //学科 分数的map
}

2.3实战

2.3.1 定义proto文件

student_info.proto

syntax = "proto3";

import "address.proto";

package stu;

option go_package = "./;student";

enum SexType {
  UNKNOWN = 0; //proto3版本中,首成员必须为0,成员不应有相同的值
  MALE = 1;  //1男
  FEMALE = 2; //2女
}

message StudentInfo{
  int64 id = 1;
  string name = 2;
  SexType sex = 3;
  repeated string hobby = 4;
  map<string, int32> score = 5;
  Address address = 6;
}

address.proto

syntax = "proto3";

package stu;

option go_package = "./;student";

message Address{
  reserved 3 to 8;
  string country = 1;
  string city = 2;
}

2.3.2 生成go代码

protoc --go_out=. student_info.proto address.proto

2.3.3 main代码运行

package main

import (
  student "example.com/m/proto"
  "fmt"
  "github.com/golang/protobuf/proto"
)

func main() {

  stu := student.StudentInfo{}

  stu.Id = 1
  stu.Name = "kun"
  stu.Sex = student.SexType(1)

  hobby := []string{"唱", "跳", "Rap", "篮球"}
  stu.Hobby = hobby

  score := map[string]int32{"语文": 80, "数学": 90}
  stu.Score = score

  stu.Address = &student.Address{Country: "中国", City: "北京"}

  fmt.Println("原结构体字符串:", stu.String())

  marshal, err := proto.Marshal(&stu)
  if err != nil {
    return
  }
  fmt.Println("结构体转二进制:", marshal)
  newStu := student.StudentInfo{}
  err = proto.Unmarshal(marshal, &newStu)
  if err != nil {
    return
  }
  fmt.Println("二进制转结构体:", newStu.String())

}

运行结果:

image.png

3 案例

3.1 安装插件

// 之前在proto中安装过protoc-gen-go,这里安装protoc-gen-go-grpc就行
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2

3.2 编写proto文件

新建一个go项目,新建proto文件夹,增加以下代码:

syntax = "proto3";

package hello;

option go_package = "../hello";

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

使用插件生成Go语言代码

protoc --go_out=./ --go-grpc_out=./ helloworld.proto

生成之后代码如下所示:
image.png

注意:生成代码之后如果代码爆红,可以使用go mod tidy更新依赖

3.3 实现接口

在自动生成的helloworld_grpc.pb.go文件中有这样一段代码:

// GreeterServer is the server API for Greeter service.
// All implementations must embed UnimplementedGreeterServer
// for forward compatibility
type GreeterServer interface {
  // Sends a greeting
  SayHello(context.Context, *HelloRequest) (*HelloReply, error)
  mustEmbedUnimplementedGreeterServer()
}

// UnimplementedGreeterServer must be embedded to have forward compatible implementations.
type UnimplementedGreeterServer struct {
}

func (UnimplementedGreeterServer) SayHello(context.Context, *HelloRequest) (*HelloReply, error) {
  return nil, status.Errorf(codes.Unimplemented, "method SayHello not implemented")
}
func (UnimplementedGreeterServer) mustEmbedUnimplementedGreeterServer() {}

这一段代码上面部分的GreeterServer接口中的SayHello就是我们之前在proto中定义的服务中的方法,而下面的代码就是针对上面这个接口的实现。

我们也需要实现这个接口,并重写方法,在hello文件夹下新建一个helloworld.go文件:

type HelloWorldServer struct{}

func (HelloWorldServer) SayHello(ctx context.Context, request *HelloRequest) (*HelloReply, error) {
   return &HelloReply{Message: "hello " + request.Name}, nil
}

func (HelloWorldServer) mustEmbedUnimplementedGreeterServer() {
   //TODO implement me
   panic("implement me")
}

3.4 服务端代码

新建server文件夹,在文件夹中新建server.go文件:

func main() {

   // tcp协议监听指定端口号
   listen, err := net.Listen("tcp", ":8888")
   if err != nil {
      log.Fatalln("listen error", err)
   }

   // 实例化gRPC服务
   server := grpc.NewServer()

   // 服务注册
   hello.RegisterGreeterServer(server, hello.HelloWorldServer{})

   // 启动服务
   err = server.Serve(listen)
   if err != nil {
      log.Fatalln("server error", err)
   }

}

3.5 客户端代码

新建client文件夹,在文件夹中新建client.go文件:

func main() {
   // 通过gRPC.Dial()方法建立服务连接
   conn, err := grpc.Dial("localhost:8888", grpc.WithTransportCredentials(insecure.NewCredentials()))
   if err != nil {
      log.Fatalln("connection error", err)
   }
   // 连接关闭
   defer conn.Close()
   client := hello.NewGreeterClient(conn)
   // 客户端调用在proto中定义的SayHello()rpc方法,发起请求,接收服务端响应
   sayHello, err := client.SayHello(context.Background(), &hello.HelloRequest{Name: "lee"})
   if err != nil {
      log.Fatalln("say hello error", err)
   }
   fmt.Println(sayHello)
}

3.6 运行

首先运行server端代码,等启动没有报错之后再运行客户端代码,可以在客户端中输出以下内容则成功。
image.png

4 Stream

在HTTP/1.1时代,同一个时刻只能对一个请求进行处理或者响应,也就是说,下一个请求必须等当前请求处理完之后才能继续进行。

HTTP/1.1需要注意的是,在服务端没有response的时候,客户端是可以发起多个request的,但服务端依旧是顺序对请求进行处理,并按照收到的请求的次序予以返回。

HTTP/2时代,多路复用的特性让一次同时处理多个请求成为了现实,并且同一个TCP通道中的请求不分先后、不会堵塞,HTTP/2中引入了流(Stream)和帧(Frame)的概念,当TCP通道简历之后,后续的所有操作都是以流的方式发送的,而二进制帧则是组成流的最小单位,属于协议层上的流式传输。

HTTP/2在一个TCP连接的基础上虚拟出多个Stream,Stream之间可以并发的请求和处理,并且HTTP/2以二进制帧(Frame)的方式进行数据传送,并引入了头部压缩(HPACK),大大提升了交互效率。

Stream关键字,当该关键字修饰参数时,表示这是一个客户端流式的gRPC接口;当该参数修饰返回值时,表示这是一个服务端流式的gRPC接口;当该关键字同时修饰参数和返回值时,表示这是一个双向流式的gRPC接口。

// 普通 RPC
rpc Simple(Request) returns (Response);
// 客户端流式 RPC
rpc ClientStream(stream Request) returns (Response);
// 服务端流式 RPC
rpc Simple(Request) returns (stream Response);
// 双向流式 RPC
rpc Simple(stream Request) returns (stream Response);

4.1 客户端流

在之前的helloworld.proto文件中添加客户端流式 rpc 接口:

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
  rpc ClientStream (stream HelloRequest) returns (HelloReply){}
}

执行命令重新生成go文件:

protoc --go_out=./ --go-grpc_out=./ helloworld.proto

helloworld.go文件中实现ClientStream

func (HelloWorldServer) ClientStream(stream Greeter_ClientStreamServer) error {
  count := 0
  for {
    // 获取客户端流
    recv, err := stream.Recv()
    if err != nil {
      if err == io.EOF {
        return nil
      }
      return err
    }
    fmt.Println("接收:", recv, count)
    count++
    // 当次数大于10时,发送响应并关闭流
    if count > 10 {
      res := HelloReply{Message: "success " + strconv.Itoa(count)}
      err := stream.SendAndClose(&res)
      if err != nil {
        return err
      }
      return nil
    }
  }
}

修改客户端代码:

func main() {
   // 通过gRPC.Dial()方法建立服务连接
   conn, err := grpc.Dial("localhost:8888", grpc.WithTransportCredentials(insecure.NewCredentials()))
   if err != nil {
      log.Fatalln("connection error", err)
   }
   // 连接关闭
   defer conn.Close()
   // 实例化客户端连接
   client := hello.NewGreeterClient(conn)
   // 客户端调用SayHelloStream接口获取流
   stream, err := client.ClientStream(context.Background())
   if err != nil {
      log.Fatalln("connection error", err)
   }
   // 使用通道来判断是否接收到数据
   res := make(chan struct{}, 1)
   go request(stream, res)
   // 当通道中接收到数据则打印出服务端响应的内容
   select {
   case <-res:
      recv, err := stream.CloseAndRecv()
      if err != nil {
         log.Fatalln(err)
      }
      fmt.Println("response:", recv.Message)
   }

}

// 客户端朝服务端不停的发送消息
func request(stream hello.Greeter_ClientStreamClient, res chan struct{}) {
   count := 0
   for {
      // 通过流发送消息到服务端
      err := stream.Send(&hello.HelloRequest{Name: "lee"})
      if err != nil {
         log.Fatalln(err)
      }
      time.Sleep(time.Millisecond * 100)
      count++
      // 发送个数大于10时,停止发送,并写入内容到res通道中
      if count > 10 {
         res <- struct{}{}
         break
      }
   }
}

运行之后服务端和客户端效果如下:

image.png

image.png

4.2 服务端流

新增服务端流的rpc接口:

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
  rpc ClientStream (stream HelloRequest) returns (HelloReply){}
  rpc ServerStream (HelloRequest) returns (stream HelloReply){}
}

helloworld.go文件中实现ServerStream

func (HelloWorldServer) ServerStream(request *HelloRequest, stream Greeter_ServerStreamServer) error {
   count := 0
   // 不停发送数据给客户端
   for {
      err := stream.Send(&HelloReply{Message: "hello " + strconv.Itoa(count)})
      if err != nil {
         return err
      }
      count++
      time.Sleep(time.Millisecond * 100)
      if count > 10 {
         break
      }
   }
   return nil
}

客户端代码:

func main() {
   // 通过gRPC.Dial()方法建立服务连接
   conn, err := grpc.Dial("localhost:8888", grpc.WithTransportCredentials(insecure.NewCredentials()))
   if err != nil {
      log.Fatalln("connection error", err)
   }
   // 连接关闭
   defer conn.Close()
   // 实例化客户端连接
   client := hello.NewGreeterClient(conn)
   // 获取到服务端流
   stream, err := client.ServerStream(context.Background(), &hello.HelloRequest{Name: "lee"})
   if err != nil {
      log.Fatalln("stream err", err)
   }
   // 循环打印出服务端发送的消息
   for {
      recv, err := stream.Recv()
      if err != nil {
         if err == io.EOF {
            fmt.Println("客户端接收完成")
            err := stream.CloseSend()
            if err != nil {
               log.Fatalln("CloseSend err", err)
            }
            return
         }
         log.Fatalln("Recv err", err)
      }
      fmt.Println("客户端接收到数据:", recv.Message)
   }
}

4.3 双向流

新增双向流的rpc接口:

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
  rpc ClientStream (stream HelloRequest) returns (HelloReply){}
  rpc ServerStream (HelloRequest) returns (stream HelloReply){}
  rpc ClientAndServerStream (stream HelloRequest) returns (stream HelloReply){}
}

helloworld.go文件中实现ClientAndServerStream

func (HelloWorldServer) ClientAndServerStream(stream Greeter_ClientAndServerStreamServer) error {
   for {
      // 接收消息
      recv, err := stream.Recv()
      if err != nil {
         return err
      }
      fmt.Println("服务端接收到消息:", recv.Name)
      time.Sleep(time.Millisecond * 100)
      // 发送消息
      err = stream.Send(&HelloReply{Message: "hello"})
      if err != nil {
         return err
      }
   }
}

客户端代码:

func main() {
   // 通过gRPC.Dial()方法建立服务连接
   conn, err := grpc.Dial("localhost:8888", grpc.WithTransportCredentials(insecure.NewCredentials()))
   if err != nil {
      log.Fatalln("connection error", err)
   }
   // 连接关闭
   defer conn.Close()
   // 实例化客户端连接
   client := hello.NewGreeterClient(conn)
   // 调用接口获取服务端流
   stream, err := client.ClientAndServerStream(context.Background())
   if err != nil {
      log.Fatalln(err)
   }
   for {
       // 发送消息
      err := stream.Send(&hello.HelloRequest{Name: "lee"})
      if err != nil {
         log.Fatalln(err)
      }
      time.Sleep(time.Millisecond * 100)
      // 接收消息
      recv, err := stream.Recv()
      if err != nil {
         log.Fatalln(err)
      }
      fmt.Println("客户端接收消息:", recv.Message)
   }
}
本文参与了1024 程序员节活动,欢迎正在阅读的你也加入。

CodeJR
12 声望0 粉丝