0%

Protobuf 协议,从入门到从容



一、前言

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";                            // 必须指定,不写默认为 proto2

package user.v1; // 包名,防止不同项目类型冲突

option go_package = "user/v1;userv1"; // Go 代码的包路径和包名

// 用户基本信息
message User {
uint64 id = 1; // 字段编号 1
string nickname = 2;
string email = 3;
int32 age = 4;
Gender gender = 5;
repeated string tags = 6; // repeated = 列表/数组
Address address = 7; // 嵌套 message
}

enum Gender {
GENDER_UNKNOWN = 0; // proto3 枚举必须从 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; // 没传就是 0
string host = 2; // 没传就是 ""
bool enable = 3; // 没传就是 false
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; // 包装类型,可为 null
}

但不要滥用——大部分场景下零值语义就足够了。

四、编译与使用

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") # 大约 35 bytes

# 反序列化
u2 = user_pb2.User()
u2.ParseFromString(data)
print(u2.nickname) # "Monster"

同样的数据用 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"

# Protobuf → JSON
j = json_format.MessageToJson(u)

# JSON → Protobuf
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" # 这会自动清空 alipay_token
p.amount = 5000

print(p.WhichOneof("method")) # "credit_card_id"

5.2 map 字段

1
2
3
4
5
message Product {
string sku = 1;
map<string, string> attributes = 2; // key: value
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;
// email 字段已废弃,编号 2 被保留
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,从这一刻起,你的服务之间说的就是同一种语言了。