 Protocol Buffers基础)
# Go gRPC实战系列一Protocol Buffers基础从proto文件到Go代码生成摘要:本文从proto3语法入门讲解Protocol Buffers的消息定义、标量类型、枚举、嵌套消息、oneof和map用法演示protoc生成Go代码的完整流程分享字段编号复用的踩坑经验对比proto3与JSON在序列化效率和类型安全上的差异。开篇故事去年我们团队把一个订单服务从HTTPJSON迁移到gRPC。迁移之前我对Protocol Buffers的认知停留在听说过的层面觉得跟JSON差不多无非多了个编译步骤。真正上手写proto文件的时候才发现这东西的坑比我想象的多得多。第一周就出了事。我在一个已有的消息体里加了个字段顺手用了中间一个没被占用的编号。结果测试环境的老客户端解析新服务端返回的数据时某些字段直接变成了零值。排查了两个小时才搞明白原来那个编号之前被用过后来删掉了但老版本客户端的proto文件里还有定义新数据被当成了未知字段直接丢弃。那次事故之后我认真把proto3的规范从头到尾啃了一遍。这篇就当是我的学习笔记分享给同样刚接触gRPC的同学。一、proto3基础语法先看一个最简单的proto文件。假设我们要定义一个用户服务的消息格式。// user.proto - 用户服务的消息定义 syntax proto3; // 声明使用proto3语法必须放在第一行 // 指定Go代码生成的包路径 // 最后一段是Go包名前面的路径决定import时的路径 option go_package github.com/myproject/proto/user; // 消息定义类似Go里的struct message User { int64 id 1; // 字段编号1int64类型 string name 2; // 字段编号2string类型 string email 3; // 字段编号3邮箱地址 int32 age 4; // 字段编号4年龄用int32够用 bool active 5; // 字段编号5是否激活 repeated string tags 6; // 字段编号6标签列表对应Go的[]string }每个字段后面都有一个编号这个编号是Protocol Buffers的核心机制。序列化后的二进制数据里只有编号没有字段名。编号1到15只占1个字节16到2047占2个字节所以高频字段尽量用小编号。标量类型对照表我贴在这里写proto的时候照着选就行。proto类型Go类型说明int32/int64int32/int64变长编码负数效率低sint32/sint64int32/int64ZigZag编码负数友好fixed32/fixed64uint32/uint64固定4/8字节大数友好float/doublefloat32/float6432/64位浮点boolbool布尔值stringstringUTF-8字符串bytes[]byte任意字节序列二、枚举与嵌套消息实际业务里光有标量类型不够用经常需要枚举和嵌套结构。// user.proto - 枚举和嵌套消息 syntax proto3; option go_package github.com/myproject/proto/user; // 枚举定义第一个值必须是0 enum UserStatus { USER_STATUS_UNKNOWN 0; // 0值是默认值必须有 USER_STATUS_ACTIVE 1; // 激活状态 USER_STATUS_INACTIVE 2; // 未激活状态 USER_STATUS_BANNED 3; // 封禁状态 } // 地址消息嵌套在User里使用 message Address { string province 1; // 省份 string city 2; // 城市 string street 3; // 街道 string zip_code 4; // 邮编 } message User { int64 id 1; string name 2; string email 3; UserStatus status 4; // 枚举字段 Address address 5; // 嵌套消息 repeated string tags 6; // repeated表示列表对应Go的[]string }有个细节要注意proto3的枚举第一个值必须是0这是默认值。如果请求里没传这个字段反序列化出来的就是0也就是USER_STATUS_UNKNOWN。这个设计逼你在默认值上多想一步挺好的。三、oneof和map的用法oneof适合互斥字段的场景。比如用户的联系方式要么是手机号要么是邮箱二选一。// contact.proto - oneof和map用法 syntax proto3; option go_package github.com/myproject/proto/contact; message Contact { int64 user_id 1; // oneof里的字段同时只能有一个被赋值 // 设置一个会自动清除其他的 oneof contact_method { string phone 2; // 手机号 string email 3; // 邮箱 string wechat 4; // 微信号 } // map类型key必须是标量类型 // value可以是任意类型包括另一个message mapstring, string metadata 5; // 元数据key-value形式 // map的value也可以是复杂类型 mapstring, Address addresses 6; // 多个地址按名称索引 } message Address { string city 1; string street 2; }oneof在Go里生成的代码是一个接口类型通过类型断言来判断当前设置的是哪个字段。map在Go里直接生成map[string]string用起来很自然。四、生成Go代码proto文件写好了下一步是生成Go代码。需要安装两个工具。# 安装protoc编译器从GitHub release下载# https://github.com/protocolbuffers/protobuf/releases# 安装Go插件goinstallgoogle.golang.org/protobuf/cmd/protoc-gen-golatest goinstallgoogle.golang.org/grpc/cmd/protoc-gen-go-grpclatest# 生成Go代码protoc\--go_out.\# 消息代码输出目录--go_optpathssource_relative\# 路径跟proto文件一致--go-grpc_out.\# gRPC服务代码输出目录--go-grpc_optpathssource_relative\user.proto# 要编译的proto文件生成的代码里主要包含三部分消息的Go结构体、序列化反序列化方法、以及如果是service定义的话还有gRPC的接口和客户端桩代码。生成的文件不要手动改每次proto文件变动重新生成就行。来看一段使用生成代码的Go程序。// main.go - 使用生成的proto代码packagemainimport(fmtloggithub.com/myproject/proto/user// 导入生成的包google.golang.org/protobuf/proto)funcmain(){// 构造一个User消息u:user.User{Id:1001,// 用户IDName:张三,// 用户名Email:zhangsanexample.com,// 邮箱Age:28,// 年龄Active:true,// 激活状态Tags:[]string{vip,developer},// 标签列表}// 序列化为二进制data,err:proto.Marshal(u)iferr!nil{log.Fatal(序列化失败,err)}fmt.Printf(序列化后大小 %d 字节\n,len(data))// 反序列化varu2 user.User errproto.Unmarshal(data,u2)iferr!nil{log.Fatal(反序列化失败,err)}fmt.Printf(反序列化结果 %v\n,u2)}五、独家踩坑, 字段编号复用事故这个坑我踩过痛过记一辈子。一开始User消息里有个字段string password 6后来认证逻辑改了这个字段不需要了。我直接从proto文件里删掉了这行没加reserved关键字。过了两周同事在同一个消息里加了string avatar_url 6编号复用了。问题来了。老版本客户端还带着password字段的定义新版本服务端返回的数据里编号6的位置存的是avatar_url。老客户端收到数据后按照自己的proto定义把编号6的字段解析成了password。于是用户的头像URL被当成了密码字段虽然不会造成安全问题密码是hash过的但头像显示全部变成了空。修复方法很简单proto3有reserved关键字专门干这个。message User { int64 id 1; string name 2; // 删掉的字段编号必须用reserved保留 // 防止后续编号复用导致数据解析错乱 reserved 6; // 保留编号6 reserved password; // 同时保留字段名 string avatar_url 7; // 用新编号别贪图复用 }protoc编译时如果有人用了reserved的编号或名字会直接报错。这个机制保证了字段编号的唯一性一旦分配就终身属于那个字段哪怕删了也不能给别人用。六、proto3 vs JSON vs MessagePack最后做个序列化方案的对比。维度proto3JSONMessagePack序列化体积最小二进制紧凑最大字段名占空间中等二进制但带字段名解析速度最快直接内存映射最慢需要字符串解析较快二进制解析类型安全强类型编译时检查弱类型运行时出错中等有schema可选可读性不可读需要工具人类可读不可读Schema依赖必须有proto文件不需要可选跨语言支持主流语言都有所有语言主流语言都有我的经验是内部服务间通信选proto3需要调试或对外暴露API选JSONMessagePack用得少除非对体积敏感又不想引入proto的编译流程。总结这篇讲了proto3的基础语法和Go代码生成流程核心就一句话字段编号是Protocol Buffers的灵魂删了字段要加reserved编号一旦分配终身不可复用。下一篇我们进入gRPC服务端开发讲四种服务方法的实现包括一元调用、服务端流、客户端流和双向流以及怎么选合适的方法类型。专栏模块三的gRPC之旅才刚开始关注不迷路。