1. 前因:为什么我会用到 proto
我最近刚写完一个项目,项目中用到了 Protobuf,也就是大家常说的 proto。之前我没接触过它,所以一开始并不清楚它到底是什么。借这次机会,我和 AI 聊了聊,算是有了一点初步认知,也在这里分享一下。
我之所以会用到 proto,是因为当前项目是一个微服务架构的项目。简单说,就是把不同的服务拆开,比如 AI 服务、数据库服务、用户服务,各自作为独立模块来开发和部署。
为什么要拆开,而不是全部写在一个进程里?
假设所有服务都跑在同一个进程中,那这个进程一旦崩溃,整个项目都会挂掉,用户服务也会跟着不可用。拆成多个服务后,AI 服务崩溃通常不会直接拖垮用户服务、数据库服务,影响范围会被隔离。当然,和 AI 服务有依赖关系的模块还是会受影响。
这其实就是分布式的思路:不同服务可以部署在不同机器上,也可以用不同语言实现。
但问题也随之而来:服务之间怎么通信?数据怎么保持一致?
2. proto 出场:一份跨语言契约
不同服务可以部署在不同机器,使用不同语言实现。那么,接口和数据结构怎么对齐?
举个例子:
我用 C++ 实现了一个服务接口 UserService:
参数是
GetUserRequest返回是
User对象
这时,邮件服务想获取用户信息,但它是 Java 写的,而且和 UserService 不在同一个进程里。
于是就出现了一个问题:
C++ 服务端和 Java 客户端之间,数据怎么传递?
C++ 对象 -> 字节流 -> Java 对象
这个过程怎么保证不出错?
答案就是 proto。
proto 可以理解成一份契约。它规定好:不管你是 Java、C++ 还是 Go,大家看到的对象逻辑结构必须一致。
比如 User 对象定义了三个字段:
id
username
password_hash
那么无论生成的是 C++ 类、Java 类还是 Go 结构体,它们都只包含这三个逻辑字段。只是不同语言里的表达方式不同。
更准确地说,proto 的作用不是"直接帮你把 C++ 对象转成 Java 对象",而是:
用
.proto文件定义数据结构和接口;用
protoc工具为不同语言生成代码;各语言再借助 Protobuf 运行时库,把本地对象序列化成字节流,或从字节流反序列化成对象。
也就是说:
proto 的核心作用,就是通过一份契约文件,让不同语言之间能够互相理解对方的数据。
.proto 文件 = 契约 protoc = 代码生成器 Protobuf 运行时库 = 负责对象 <-> 字节流 gRPC = 负责远程调用
3. 一个具体示例
先定义一个 user.proto:
bashproto syntax = "proto3"; package user.v1; message GetUserRequest { int64 id = 1; } message User { int64 id = 1; string username = 2; string password_hash = 3; // 真实项目不要明文返回密码 } service UserService { rpc GetUser(GetUserRequest) returns (User); }
这里有几个关键点:
message定义数据结构;
service定义远程接口;
rpc定义具体方法;
= 1、= 2、= 3是字段编号,不是值。
字段编号非常重要。线上传输时,proto 认的是编号,不是字段名。
比如:
proto
int64 id = 1; string username = 2;
Java 里字段可能叫 id、username,C++ 里也可能叫 id、username,Go 里可能生成 Id、Username。名字不完全一样没关系,只要编号一致,双方就能对上。
4. 生成代码:C++ 服务端,Java 客户端
用 protoc 生成代码后:
C++ 会生成
User、GetUserRequest、UserService服务端基类等;Java 会生成
User、GetUserRequest、UserServiceGrpc.UserServiceBlockingStub等。
C++ 服务端实现业务逻辑,伪代码大概是这样:
cpp
cpp
class UserServiceImpl final : public user::v1::UserService::Service {
grpc::Status GetUser(grpc::ServerContext* context,
const user::v1::GetUserRequest* req,
user::v1::User* resp) override {
resp->set_id(req->id());
resp->set_username("alice");
resp->set_password_hash("...");
return grpc::Status::OK;
}
};
Java 邮件服务作为客户端,只需要调用生成的 Stub:
java
java
ManagedChannel channel = ManagedChannelBuilder
.forAddress("user-service", 50051)
.usePlaintext()
.build();
UserServiceGrpc.UserServiceBlockingStub stub =
UserServiceGrpc.newBlockingStub(channel);
User user = stub.getUser(
GetUserRequest.newBuilder().setId(123).build()
);
System.out.println(user.getUsername());
Java 这边不需要知道 C++ 对象长什么样,也不需要自己手写序列化逻辑。它只调用 Java 版 Stub,底层由 Protobuf 和 gRPC 处理。
5. 数据到底是怎么过去的
以 Java 调用 C++ 服务为例:
Java 客户端构造
GetUserRequest对象;Java 调用 proto 生成的序列化方法,把对象变成字节流;
字节流通过网络发送到 C++ 服务端;
C++ 服务端收到字节流,用 C++ 版 Protobuf 代码反序列化成 C++ 对象;
C++ 执行
GetUser业务逻辑;C++ 把返回的
User对象序列化成字节流;字节流返回给 Java 客户端;
Java 客户端反序列化成 Java 的
User对象。
整个过程中,双方传的是字节流,不是对方的语言对象。
proto 保证的是:
字段编号一致;
字段类型一致;
序列化规则一致;
逻辑结构一致。
至于各语言里的对象叫法、内存布局、方法名,可以不同。
所以可以这样理解:
proto 的核心思想,就是通过一份契约,让不同语言之间能够互相理解对方的数据。
物理结构不同,逻辑结构一致; 表达形式不同,数据语义一致。
6. 我最终的理解
proto 最大的价值,不是单纯做序列化,而是提供了一份跨语言的接口契约。
它让不同服务、不同语言、不同进程之间可以按同一套规则通信。
收益主要有:
接口和数据结构统一定义,减少扯皮;
自动生成多语言代码,减少手写 DTO;
跨进程、跨语言调用时,数据逻辑保持一致;
字段编号支持版本演进,新增字段更安全;
二进制序列化通常比 JSON 更小、更快。
当然,proto 也不是万能的。它保证的是结构和类型一致,不保证业务语义一定一致。比如 price 到底是分还是元,status = 1 代表什么,这些还需要文档、注释、测试和团队约定。
7. 总结
在微服务项目里,服务往往跨进程、跨机器、跨语言。直接调用接口不现实,只能通过网络传数据。
proto 的作用就是:
用一份
.proto契约,定义数据结构和接口;用
protoc生成各语言代码;用 Protobuf 运行时库完成对象和字节流的互转;
用 gRPC 等框架完成远程调用。
最终实现:
一份契约,多语言生成;
物理结构不同,逻辑结构一致;
跨进程调用,数据不跑偏。