一、前言
2024 年,微服务通信的主流选择已经非常清晰——REST + JSON 仍然是"最容易上手"的方案,但一旦流量上来、接口多了、需要跨语言互通,JSON 的缺点就藏不住了:解析慢、体积大、没有类型约束、字段增减全靠文档约定。
Protobuf(Protocol Buffers)是 Google 开源的语言中立序列化协议,它的设计目标就是解决这些问题——更小、更快、强类型、自动生成代码。搭配 gRPC 使用时,一个 .proto 文件就能同时产出服务端和客户端的桩代码,覆盖 Go、Python、Java、C++、TypeScript 等十多种语言。
本文从 .proto 文件的写法开始,到 Python 和 Go 的跨语言对接,全程代码可跑,不掺杂无用的概念堆砌。
二、安装与基础
2.1 安装 protoc 编译器
Protobuf 的核心工具是 protoc 编译器,它把 .proto 文件编译成目标语言的代码:
1 2 3 4 5 6 7 8
| # macOS brew install protobuf
# Ubuntu / Debian apt install protobuf-compiler
# 验证安装 protoc --version # libprotoc 25.x
|
2.2 安装对应语言的代码生成插件
1 2 3 4 5 6 7 8
| # Python pip install grpcio-tools # 自带 protoc-gen-python
# Go go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
# TypeScript / Node npm install -g protoc-gen-ts
|
三、.proto 文件写法
3.1 一个完整示例
假设我们正在设计一个用户管理服务的通信协议:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| syntax = "proto3";
package user.v1;
option go_package = "user/v1;userv1";
message User { uint64 id = 1; string nickname = 2; string email = 3; int32 age = 4; Gender gender = 5; repeated string tags = 6; Address address = 7; }
enum Gender { GENDER_UNKNOWN = 0; GENDER_MALE = 1; GENDER_FEMALE = 2; }
message Address { string country = 1; string city = 2; string detail = 3; }
|
关键点:
| 语法要素 |
解释 |
syntax = "proto3" |
使用 proto3 语法,比 proto2 更简洁(去掉了 required/optional) |
message |
定义数据结构,字段类型 + 字段名 + 编号 |
uint64 / string / int32 |
标量类型,映射到各语言的对应类型 |
repeated |
数组/列表,proto3 中数组元素的默认值是空列表而不是 nil |
| 字段编号 1-15 |
占 1 个字节,高频字段优先分配小编号 |
| 字段编号 16-2047 |
占 2 个字节,低频字段分配大编号 |
3.2 字段类型对照表
| .proto 类型 |
Python |
Go |
Java |
double |
float |
float64 |
double |
float |
float |
float32 |
float |
int32 |
int |
int32 |
int |
int64 |
int |
int64 |
long |
uint32 |
int |
uint32 |
int |
uint64 |
int |
uint64 |
long |
bool |
bool |
bool |
boolean |
string |
str |
string |
String |
bytes |
bytes |
[]byte |
ByteString |
3.3 默认值与零值
proto3 的一个关键设计:所有字段没有"未设置"状态,只有零值。
1 2 3 4 5 6
| message Config { int32 timeout = 1; string host = 2; bool enable = 3; repeated int32 ids = 4; }
|
这意味你无法区分"用户设置了 0"和"用户没传"。如果非要区分,用 google.protobuf.wrappers:
1 2 3 4 5
| import "google/protobuf/wrappers.proto";
message Request { google.protobuf.Int32Value timeout = 1; }
|
但不要滥用——大部分场景下零值语义就足够了。
四、编译与使用
4.1 生成 Python 代码
1
| protoc --python_out=./gen --pyi_out=./gen user.proto
|
生成两个文件:
user_pb2.py — 序列化 / 反序列化的运行时代码
user_pb2.pyi — 类型提示文件,让 IDE 能补全字段名
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| from gen import user_pb2
u = user_pb2.User() u.id = 1001 u.nickname = "Monster" u.email = "monster@example.com" u.age = 28 u.gender = user_pb2.GENDER_MALE u.tags.extend(["developer", "blogger"])
data: bytes = u.SerializeToString() print(f"大小: {len(data)} bytes")
u2 = user_pb2.User() u2.ParseFromString(data) print(u2.nickname)
|
同样的数据用 JSON 序列化大概 90+ bytes,Protobuf 压缩了 60% 以上。
4.2 生成 Go 代码
1
| protoc --go_out=./gen user.proto
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| package main
import ( "log" pb "gen/user/v1" "google.golang.org/protobuf/proto" )
func main() { u := &pb.User{ Id: 1001, Nickname: "Monster", Email: "monster@example.com", Age: 28, Gender: pb.Gender_GENDER_MALE, Tags: []string{"developer", "blogger"}, }
data, _ := proto.Marshal(u) log.Printf("大小: %d bytes", len(data))
u2 := &pb.User{} proto.Unmarshal(data, u2) log.Println(u2.GetNickname()) }
|
4.3 JSON 互转
1 2 3 4 5 6 7 8 9 10 11 12
| from google.protobuf import json_format
u = user_pb2.User() u.id = 1001 u.nickname = "Monster"
j = json_format.MessageToJson(u)
u2 = user_pb2.User() json_format.Parse(j, u2)
|
微服务架构中经常遇到已有系统走 JSON、新系统走 Protobuf 的场景,json_format 就是那个粘合剂。
五、高级用法
5.1 oneof — 多选一
当某个字段只能是几种情况中的一种时,用 oneof 代替多个可选字段:
1 2 3 4 5 6 7 8
| message Payment { oneof method { string credit_card_id = 1; string alipay_token = 2; string wechat_openid = 3; } int64 amount = 4; }
|
在代码层,oneof 被编译成 HasField / WhichOneof 等方法,确保同时只有一个字段被设置:
1 2 3 4 5 6
| p = payment_pb2.Payment() p.alipay_token = "alipay_xxx" p.credit_card_id = "card_xxx" p.amount = 5000
print(p.WhichOneof("method"))
|
5.2 map 字段
1 2 3 4 5
| message Product { string sku = 1; map<string, string> attributes = 2; map<string, double> price_by_region = 3; }
|
编译后对应 Python 的 dict、Go 的 map、Java 的 HashMap。序列化时 key 必须为 string 类型。
5.3 向后兼容——字段编号的黄金法则
Protobuf 的向后兼容完全依赖 字段编号:
绝对不要重复使用已废弃的字段编号。
1 2 3 4 5 6 7
| message User { reserved 2, 5, 10 to 20; reserved "email", "phone"; string nickname = 1; int32 age = 3; }
|
不遵守这条规则,新老版本混部时会出现静默解析错误——数据错乱但没有任何报错。这是 Protobuf 生产环境中最容易踩的坑。
六、实战:跨语言 RPC 调用
6.1 定义服务
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| syntax = "proto3";
package user.v1;
service UserService { rpc GetUser(GetUserRequest) returns (User); rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); }
message GetUserRequest { uint64 user_id = 1; }
message ListUsersRequest { int32 page_size = 1; string page_token = 2; }
message ListUsersResponse { repeated User users = 1; string next_page_token = 2; }
|
6.2 Python 服务端
1 2
| pip install grpcio grpcio-tools protoc --python_out=./gen --grpc_python_out=./gen user.proto
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| from concurrent import futures import grpc from gen import user_pb2, user_pb2_grpc
class UserServiceServicer(user_pb2_grpc.UserServiceServicer): def GetUser(self, request, context): return user_pb2.User( id=request.user_id, nickname="Monster", email="monster@example.com", age=28, )
def ListUsers(self, request, context): return user_pb2.ListUsersResponse( users=[user_pb2.User(id=1, nickname="Alice")], next_page_token="", )
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) user_pb2_grpc.add_UserServiceServicer_to_server(UserServiceServicer(), server) server.add_insecure_port("[::]:50051") server.start() server.wait_for_termination()
|
6.3 Go 客户端
1
| protoc --go_out=./gen --go-grpc_out=./gen user.proto
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| package main
import ( "context" "log" pb "gen/user/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" )
func main() { conn, _ := grpc.Dial("localhost:50051", grpc.WithTransportCredentials(insecure.NewCredentials())) defer conn.Close()
client := pb.NewUserServiceClient(conn) resp, _ := client.GetUser(context.Background(), &pb.GetUserRequest{UserId: 1001})
log.Printf("用户: %s (%s)", resp.GetNickname(), resp.GetEmail()) }
|
在终端分别启动 Python 服务端和 Go 客户端,就能看到跨语言 RPC 调用成功。不需要约定 JSON 字段名大小写,不需要手写序列化代码,一切由 .proto 文件驱动。
七、总结
| 组件 |
用途 |
关键命令 / 方法 |
.proto 文件 |
定义数据结构 + 服务接口 |
syntax, message, service |
| protoc 编译器 |
生成目标语言代码 |
protoc --xxx_out=./gen |
| SerializeToString |
序列化为二进制 |
message.SerializeToString() |
| ParseFromString |
反序列化 |
message.ParseFromString(data) |
| json_format |
JSON ↔ Protobuf 互转 |
MessageToJson(), Parse() |
| gRPC |
基于 Protobuf 的 RPC 框架 |
grpc.server, NewXxxClient |
| oneof |
多选一字段 |
WhichOneof() |
| reserved |
保留已废弃的编号和名称 |
reserved 2, 5; |
回到开头的问题:为什么不用 JSON?
JSON 适合"人类可读、快速迭代、不需要跨语言协作"的场景。一旦你遇到以下任一情况,Protobuf 的价值就体现出来了:
- 接口需要同时服务 Go 后端和 Python 算法团队
- 消息体超过 10 KB,带宽敏感
- 字段变更频繁,需要严格的兼容性检查
- 使用 gRPC 做双向流通信
Protobuf 不是 JSON 的替代品,它是 JSON 力不从心时的升级选项。写一个 .proto 文件,跑一遍 protoc,从这一刻起,你的服务之间说的就是同一种语言了。