Protobuf是Protocol Buffers的简称,它是Google公司开发的⼀种数据描述语⾔,并于2008年对外开 源。Protobuf刚开源时的定位类似于XML、JSON等数据描述语⾔,通过附带⼯具⽣成代码并实现将结 构化数据序列化的功能。但是我们更关注的是Protobuf作为接⼝规范的描述语⾔,可以作为设计安全的 跨语⾔PRC接⼝的基础⼯具。
Protobuf的编译器叫做:protoc(protobuf compiler)
/bin/protoc.exe
添加到环境变量里
protoc --version
Protobuf核⼼的⼯具集是C++语⾔开发的,在官⽅的protoc编译器中并不⽀持Go语⾔。要想基于 .proto⽂件⽣成相应的Go代码,需要安装相应的插件。
cmd输入:
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
这个插件会自动下载到你的go path的bin目录下。(编译器会自动到这里找这个插件)
创建 myProto.proto
文件
syntax = "proto3";
package main;
option go_package = "./";
message String {
string name = 1;
int64 age = 2;
}
注意:如果在上面
protoc --version
成功,而这里提示无法识别protoc命令的话,重启一下goland就好了。
首先看下下面这个proto文件,我们后面的proto基本用法都是基于这个proto进行讲解
syntax = "proto3";
package pkgName;
option go_package = "./";
message mmData {
optional int32 num = 1;
optional int32 def_num = 2 [default=10];
required string str = 3;
repeated string rep_str = 4;
}
(可看完·3. Protobuf的基本用法·,在来看这部分)
---my_project
|---06-protocol_buffers
|---pbrpc
|---service
|---service.proto
pbrpc/service/service.proto
:
syntax = "proto3";
package hello;
// go module = MicroServiceStudy01
option go_package = "MicroServiceStudy01/06-protocol_buffers/pbrpc/service";
message Request{
string value = 1;
}
使用protoc:
cd 06-protocol_buffers/pbrpc/service
protoc -I . --go_out=. hello.proto
如果这样执行的话,他的结果是在你go_out目录(这里是当前目录)存放,并且按照你定义的go_package的名称,在你go_out目录下创建一个目录结构:
如果你不想让他帮你生成一个go_package的目录结构,那么就需要指定一个前缀:
protoc -I . --go_out=. --go_opt=module="MicroServiceStudy01/06-protocol_buffers/pbrpc/service" hello.proto
这样就没有再根据go_pacakage生成目录结构,而是直接存放在了go_out目录:
我不理解,如果目的是存放在当前目录,为什么不把
go_package="./"
,如果想存放在当前目录下的子目录,就go_package=“./subpkg “
,上面这种做法,我无法理解,暂时就当做学了个参数用法吧,有大佬明白的可以留言。
--go_out=./
:proto-gen-go插件编译产物的存放目录,这里是存放到当前目录,注意生成 的.pb.go
文件的最终位置是你的--go_out=?
位置+go_package=?
位置,后者是在--go_out
位置之后,进一步指定生成的.pb.go
文件的存放路径。
-I ../
:--proto_path=PATH
的缩写
表示引入文件的目录路径,这里有坑。(这里如果看不懂,看到下面的import就明白了)
-I
参数简单来说,就是如果多个proto文件之间有互相依赖,生成某个proto文件时,需要import其他几个proto文件,这时候就要用-I
来指定搜索目录。如果没有指定-I
参数,则在当前目录进行搜索。(这里的例子命令便是)
每个-I
参数都引入一个目录,proto文件中引入了几个外部proto文件理论来说就需要多少个-I
(同一目录的可以一次性引入),再加上待编译的proto也需要引入,所以上面这里就用了两个-I
来引入目录文件。
--go_opt=moudle=....
:protoc—gen-go插件的opt参数,采用go moudle模式.
hello.proto
:proto文件路径。
表明使用proto3语法;如果你没有指定这个,编译器会使用proto2语法;这个指定语法行必须是文件的非空非注释的第一个行
proto文件使用关键字package指定当前包名,类似于模块,定义proto包名,可以为.proto文件新增一个可选的package声明符作为生成语言的namespace,用来防止不同的消息类型有命名冲突.
在定义.proto文件时能够标注一系列的options。Options并不改变整个文件声明的含义,但却能够影响特定环境下处理方式。完整的可用选项可以在google/protobuf/descriptor.proto找到。
在消息定义之前,可以通过option来进行配置,常用的option:
option go_package = "path;name";
Protobuf中定义一个消息类型是通过关键字message字段指定的,这个关键字可以理解为Go语言的stuct关键字,用protobuf编译器将proto编译成Go代码之后,每个message都会生成一个名字与之对应的stuct结构体。
如上面的,就会生成一个名字为mmData
的结构体。
变量(字段)的定义格式为:
[修饰符(可选)][数据类型][变量名(字段名)] = [唯一标识符] ;
其中唯一标识符是用来标识字段的,同一个message中字段的标识符不能相同。
message
中的字段规则有三种。
required
: 字段属性为必填字段。若不设置,则会导致编解码异常,导致消息被丢弃。
optional
: 字段属性为可选字段。发送方可以选择性根据需要进行设置;
对于optional属性的字段,可以通过default关键字为字段设置默认值,即当发送方没有对该字段进行设置的时候,将使用默认值。
如果没有对字段设置默认值,就会根据特定的类型给字段赋予特定的默认值。
对于bool类型,默认值为false;对于string类型,默认值为空字符串;对于数值类型,默认值为0;对于枚举类型,默认值是枚举类型中的第一个值。
repeated
: 字段属性为可重复字段,该字段可以包含[0,n]个元素,字段中的元素顺序被保留。类似于go的切片。
注意:
在消息体的定义中,每个字段都必须要有一个唯一的标识号。
这些标识号是用来在消息的二进制格式中识别各个字段的,一旦使用就不能再改变,否则会导致原有消息编解码出现异常。
标识号是[0,2^29 - 1]范围内的一个整数,其中**[19000,19999)之间的标识号在protobuf协议的实现中被预留了**,所以特写注意不要使用这个范围内的标识号,若使用进行编译的时候也会告警:
Field numbers 19000 through 19999 are reserved for the protocol buffer library implementation.
注意: [1,15]内的标识号在编码的时候占用一个字节,[16,2047]之内的标识符占用两个字节,所以尽量为频繁使用的字段分配[1,15]内的标识号,另外预留出来一部分给未来可能频繁使用的字段。
关于字段的默认值: string类型的变量,默认值是空字符串 bytes类型的变量,默认值是空byte数组 bool类型的变量,默认值是false 数字类型的变量,默认值是0 枚举类型的变量,默认值是第一个枚举值,而且这个第一个枚举值的数字值必须是0
字段类型除了上述基本的字段类型之外,也可以是枚举类型。
syntax = "proto3";
package main;
option go_package = "./";
// 定义枚举类型
enum DayName {
Sun = 0;
Mon = 1;
Tues = 2;
Wed = 3;
Thur = 4;
Fri = 5;
Sat = 6;
}
message workDay {
// 消息类型使用枚举类型
optional DayName day = 1;
}
protoc --go_out=./ hello.proto
生成的go文件里对应为const:
// 定义枚举类型
type DayName int32
const (
DayName_Sun DayName = 0
DayName_Mon DayName = 1
DayName_Tues DayName = 2
DayName_Wed DayName = 3
DayName_Thur DayName = 4
DayName_Fri DayName = 5
DayName_Sat DayName = 6
)
....
type WorkDay struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// 消息类型使用枚举类型
Day *DayName `protobuf:"varint,1,opt,name=day,proto3,enum=main.DayName,oneof" json:"day,omitempty"`
}
枚举常量的值必须在32位整数范围内,因为enum值是使用可编码方式存储的,对负数存储不够高效,因此不推荐在enum中使用负数。
枚举类型可以定义在message内,也可以定义在message外,若定义在message内,其他message要使用则需要通过messageType.enumType来进行引用。
默认情况下,枚举类型中的字段值不可重复,但是通过对enum添加option allow_alias = true;
来达到对同一个枚举值起一个别名的目的,若不添加allow_alise并且有重复的枚举值编译的时候会报错。
syntax = "proto3";
package pkgName;
option go_package = "./";
// 定义枚举类型
enum DayName {
// 若不添加该option,会报错:
// "pkgName.Test" uses the same enum value as "pkgName.Sat".
// If this is intended, set 'option allow_alias = true;' to the enum definition.
option allow_alias = true;
Sun = 0;
Mon = 1;
Tues = 2;
Wed = 3;
Thur = 4;
Fri = 5;
Sat = 6;
Test = 6; // Test与Sat字段值重名
}
除了上述类型之外,message还支持map<Type,Type>类型。
syntax = "proto3";
package pkgName;
option go_package = "./";
message TData {
map<int32, string> data = 1;
}
在生成的go文件对应map类型:
type TData struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Data map[int32]string `protobuf:"bytes,1,rep,name=data,proto3" json:"data,omitempty" protobuf_key:"varint,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
}
注意:
protobuf允许将其他消息类型用作字段类型。
如下面userData中存在一个workDay类型的数据:
syntax = "proto3";
package pkgName;
option go_package = "./";
message workDay {
int day = 1;
}
message userData {
workDay userDays = 1;
}
message可以无限嵌套
syntax = "proto3";
package pkgName;
option go_package = "./";
message OuterData1 {
// 嵌套消息定义
message TData {
int32 a = 1;
}
// 引用嵌套消息
TData data1 = 1;
OuterData2.TData data2 = 2;
}
message OuterData2 {
// 嵌套消息定义
message TData {
int32 a = 1;
}
}
在生成的hello.pb.go
中对应:
type OuterData1 struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// 引用嵌套消息
Data1 *OuterData1_TData `protobuf:"bytes,1,opt,name=data1,proto3" json:"data1,omitempty"`
Data2 *OuterData2_TData `protobuf:"bytes,2,opt,name=data2,proto3" json:"data2,omitempty"`
}
....
type OuterData2 struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
syntax = "proto3";
import "google/protobuf/any.proto";
package pkgName;
option go_package = "./";
message ErrorStatus {
string message = 1;
repeated google.protobuf.Any details = 21;
}
如果你的 message 包含许多可选字段,并且最多只能同时设置其中一个字段,则可以使用 oneof 功能强制执行此行为并节省内存。
Oneof 共享内存中的所有字段,并且最多只能同时设置一个字段。设置 oneof 的任何成员会自动清除所有其他成员。你可以使用特殊的 case() 或 WhichOneof() 方法检查 oneof 字段中当前是哪个值(如果有)被设置,具体方法取决于你选择的语言。
使用案例:
要在 .proto 中定义 oneof,请使用 oneof 关键字,后跟你的 oneof 名称,在本例中为 test_oneof:
syntax = "proto3";
import "google/protobuf/any.proto";
package pkgName;
option go_package = "./";
message SampleMessage {
oneof test_oneof {
string name = 1;
string nike_name = 2;
}
}
然后,将 oneof 字段添加到test_oneof的定义中。
你可以在test_oneof添加任何类型的字段,但不能使用 required,optional 或 repeated 关键字。如果需要向 oneof 添加重复字段,可以使用包含重复字段的 message。
在生成的代码中,oneof 字段与常规 optional 方法具有相同的 getter 和 setter。你还可以使用特殊方法检查 oneof 中的值(如果有)。
在生成的hello.pb.go
中为:
type SampleMessage struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Types that are assignable to TestOneof:
// *SampleMessage_Name
// *SampleMessage_NikeName
TestOneof isSampleMessage_TestOneof `protobuf_oneof:"test_oneof"`
}
....
type SampleMessage_Name struct {
Name string `protobuf:"bytes,1,opt,name=name,proto3,oneof"`
}
type SampleMessage_NikeName struct {
NikeName string `protobuf:"bytes,2,opt,name=nike_name,json=nikeName,proto3,oneof"`
}
....
如果要将 message 类型与 RPC(远程过程调用)系统一起使用,则可以在 .proto 文件中定义 RPC 服务接口,protocol buffer 编译器将以你选择的语言生成服务接口和stub(桩)。
因此,例如,如果要定义一个 RPC 服务,其中包含一个根据 SearchRequest 返回 SearchResponse 的方法,可以在 .proto
文件中定义它,如下所示:
syntax = "proto3";
package pkgName;
option go_package = "./";
message SearchRequest {
string query = 1;
int32 page_number = 2;
int32 result_per_page = 3;
}
message SearchResponse {
string result = 1;
}
service SearchService {
rpc Search (SearchRequest) returns (SearchResponse);
}
与 ProtoBuf 直接搭配使用的 RPC 系统是 gRPC :一个 Google 开发的平台无关语言无关的开源 RPC 系统。gRPC 和 ProtoBuf 能够非常完美的配合,你可以使用专门的 ProtoBuf 编译插件直接从.proto 文件生成相关 RPC 代码。
我们可以通过import导入其他proto文件,并使用该proto文件中的定义的消息类型。
---my_project
|---protocol
|---aaa
| |---aaa.proto
|---bbb
|---bbb.proto
aaa/aaa.proto
syntax = "proto3";
package aaa;
option go_package = "./";
message Something {
string msg = 1;
}
bbb/bbb.proto
syntax = "proto3";
package bbb;
option go_package = "./";
import "aaa/aaa.proto";
message Something2 {
aaa.Something something = 1;
}
虽然会报红但是不用管,生成 pb.go 的时候,假设当前在 my_project/protocol/bbb 目录下,则执行:
protoc -I ../ -I ./ --go_out=./ bbb.proto
# -I ../ : 在上一层目录中寻找引入的proto文件
# -I ./ : 在本层文件中找待编译的proto文件(顺序无所谓)
protoc有一个参数
-I
,表示引入文件的目录路径,这里有坑。-I
参数简单来说,就是如果多个proto文件之间有互相依赖,生成某个proto文件时,需要import其他几个proto文件,这时候就要用-I
来指定搜索目录。如果没有指定-I
参数,则在当前目录进行搜索。 每个-I
参数都引入一个目录,proto文件中引入了几个外部proto文件理论来说就需要多少个-I
(同一目录的可以一次性引入),再加上待编译的proto也需要引入,所以上面这里就用了两个-I
来引入目录文件。
这样 protoc 可以在 -I path + import path => “./…/aaa/aaa.proto”
路径下找到 aaa.proto 这个文件。
# 当然也可以 import “aaa.proto”,-I=./…/aaa,同样可以执行成功。
protoc -I ../aaa -I ./ --go_out=./ bbb.proto
默认情况下,proto只允许引用直接import的文件中定义的数据类型。
如b.proto中导入了a.proto,c.proto中导入了b.proto;默认情况下,c.proto中只能引用b.proto中定义的数据类型,而引用不到a.proto中的数据类型。若c.proto要使用a.proto中定义的数据类型,则b.proto引用a.proto的时候要使用import public。
---my_project
|---protocol
|---aaa
| |---aaa.proto
|---bbb
|---bbb.proto
|---ccc
|---ccc.proto
aaa/aaa.proto
syntax = "proto3";
package aaa;
option go_package = "./";
message Something {
string msg = 1;
}
bbb/bbb.proto
syntax = "proto3";
package bbb;
option go_package = "./";
// import "aaa/aaa.proto"; 不加会报错
import public "aaa/aaa.proto";
message Something2 {
aaa.Something something = 1;
}
ccc/ccc.proto
syntax = "proto3";
package ccc;
option go_package = "./";
import "bbb/bbb.proto";
message Something3 {
aaa.Something something = 1;
}
执行:
protoc -I ../ -I ../ -I ./ --go_out=./ ccc.proto
这种用法在迁移proto文件到新的位置的时候十分有用,如Message类要从old.proto迁移到new.proto文件中,这个时候如果要在不修改对old.proto的文件的情况下,直接将Message移动到new.proto中,然后在old.proto中import public new.proto即可。
为了达到前后消息类型兼容的目的,扩展Message消息类型的时候需要注意一下几点:
对于没有⽤过Protobuf的读者,建议先从官⽹了解下基本⽤法。这⾥我们尝试将Protobuf和RPC结合在 ⼀起使⽤,通过Protobuf来最终保证RPC的接⼝规范和安全。Protobuf中最基本的数据单元是 message,是类似Go语⾔中结构体的存在。在message中可以嵌套message或其它的基础数据类型的 成员。
07-pbrpc/service/service.proto
syntax = "proto3";
package hello;
// go module = MicroServiceStudy01
option go_package = "MicroServiceStudy01/07-pbrpc/service";
message Request{
string value = 1;
}
message Response{
string value = 1;
}
生成go语言结构:
$ cd 07-pbrpc
$ protoc -I ./service --go_out=./service --go_opt=module="MicroServiceStudy01/07-pbrpc/service" service/service.prot
o
基于 生成的数据结构,定义接口:
07-pbrpc/service/interface.go
package service
const HelloServiceName = "HelloService"
type HelloService interface {
// Hello
// 这里的 Request 和 Response 是基于protobuf生成的service.pb.go里的结构
Hello(request *Request, response *Response) error
}
这个接口时为了约束参数,详见2.更安全的RPC接口
向之前没有联合protobuf使用的时候,我们这里的接口方法的参数类型是我们自己写的结构体类型,而使用了protobuf之后,这里的参数类型就需要引用我们通过protobuf生成的.pb.go
文件里的结构体类型。
我们定义的接口要放在一个 独立的文件里类似于当前的service
包,他就相当于一个契约包,用来 约束服务端server(提供RPC服务)和我们的客户端client(调用RPC服务)。
07-pbrpc/server/server.go
type HelloService struct{}
func (hs *HelloService) Hello(req *service.Request, resp *service.Response) error {
resp.Value = "hello:" + req.Value
return nil
}
// 通过接口约束 Server 端
var _ service.HelloService = (*HelloService)(nil)
func main() {
rpc.RegisterName(service.HelloServiceName, new(HelloService))
listen, err := net.Listen("tcp", ":1234")
if err != nil {
log.Fatal("Listen TCP err:", err)
}
for {
conn, err := listen.Accept()
if err != nil {
log.Fatal("Accept err:", err)
}
// 这里使用的还是json,先忽略 往下看
go rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
}
}
07-pbrpc/client/client.go
type HelloServiceClient struct {
*rpc.Client
}
func (hsc HelloServiceClient) Hello(req *service.Request, resp *service.Response) error {
return hsc.Client.Call(service.HelloServiceName+".Hello", req, resp)
}
// 通过接口约束 Client 端
var _ service.HelloService = (*HelloServiceClient)(nil)
func DialHelloService(network, address string) (*HelloServiceClient, error) {
conn, err := net.Dial(network, address)
if err != nil {
log.Fatal("net.Dail err: ", err)
}
client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))
return &HelloServiceClient{client}, nil
}
func main() {
client, err := DialHelloService("tcp", "localhost:1234")
if err != nil {
log.Fatal("Dial err: ", err)
}
resp := &service.Response{}
err = client.Hello(&service.Request{Value: "world"}, resp)
if err != nil {
log.Fatal(err)
}
fmt.Println(resp)
}
此时我们只是Hello方法的参数使用的是protobuf生成的service.pb.go中的结构体,但是其他逻辑依然没有改变,使用的还是json-rpc
,所以这里会发现,我们这次虽然定义了相关的protobuf,但是我们和protobuf还没有半毛钱关系,只是用到了他为我们生成的结构体;
那么我们如何将json编码换成protobuf编码呢?
将07-pbrpc/server/server.go
里的go rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
修改成go rpc.ServeCodec(server.NewServerCodec(conn))
即可。
这是我们4.基于Protobuf的RPC
的重点,官方的net/rpc包里是没有protoc的插件
我看的视频的发布者仿照net/rpc/jsonrpc
自己写了个关于Proto Codec 编解码的包,但是视频中没有放出来,而这里的NewServerCodec
就用到了那个包里的方法,大家不用深究,逻辑就是这么个逻辑,重在理解。