跳到主要内容

reserved 关键字

reserved 用于保留字段编号或字段名,防止未来不小心重用已删除的字段。这对于维护向后兼容性非常重要。

使用场景

  • 当你需要删除某个字段时,应该使用 reserved 标记该字段编号,而不是直接删除
  • 防止未来的开发者重用已删除字段的编号,导致数据解析错误
  • 保证不同版本的 proto 文件之间的兼容性

语法示例

message User {
// 保留单个字段编号
reserved 2;

// 保留多个字段编号
reserved 4, 5, 6;

// 保留字段编号范围
reserved 8 to 10;

// 保留字段名(防止字段名被重用)
reserved "old_field", "deprecated_name";

int64 id = 1;
string username = 3;
string email = 7;
int64 created_at = 11;
}

注意事项

  • 不能在同一个 reserved 语句中混合使用字段编号和字段名,需要分开写
  • 字段编号保留后,不能再用于新字段
  • 字段名保留后,不能再用于新字段

实际示例

假设最初的 proto 定义:

message User {
int64 id = 1;
string username = 2;
string password = 3; // 敏感字段,需要删除
string email = 4;
}

删除 password 字段后的正确做法:

message User {
reserved 3; // 保留编号 3,防止未来误用
reserved "password"; // 保留字段名

int64 id = 1;
string username = 2;
string email = 4;
}

optional 关键字(可选参数)

在 Protocol Buffers 中,proto3 和 proto2 对字段的处理方式不同:

proto2

  • 支持 requiredoptionalrepeated 三种修饰符
  • required:必填字段,如果未设置会导致序列化失败
  • optional:可选字段,可以检测字段是否被设置

proto3

  • 默认所有字段都是可选的(除了 repeated
  • 移除了 required 关键字(避免兼容性问题)
  • 从 proto3.15 开始,重新引入了 optional 关键字,用于区分"未设置"和"设置为默认值"

proto3 中的 optional

在 proto3 中,默认字段无法区分"未设置"和"设置为零值"。例如:

syntax = "proto3";

message User {
string username = 1; // 默认可选
int32 age = 2; // 默认可选,但无法区分 0 和未设置
}

在 Go 代码中:

user := &pb.User{}
fmt.Println(user.Age) // 输出 0,但无法确定是未设置还是真的设置为 0

使用 optional 关键字后(需要 proto3.15+):

syntax = "proto3";

message User {
string username = 1;
optional int32 age = 2; // 显式声明为可选
optional string phone = 3;
}

在 Go 代码中,optional 字段会生成为指针类型:

user := &pb.User{
Username: "alice",
Age: proto.Int32(25), // 使用指针
}

// 检查字段是否被设置
if user.Age != nil {
fmt.Printf("Age is set: %d\n", *user.Age)
} else {
fmt.Println("Age is not set")
}

proto2 vs proto3 对比

// proto2 风格
syntax = "proto2";

message CreateUserRequest {
required string username = 1; // 必填,未设置会导致序列化失败
optional string email = 2; // 可选,可以检测是否设置
optional string phone = 3; // 可选
repeated string tags = 4; // 数组类型
}
// proto3 风格(推荐)
syntax = "proto3";

message CreateUserRequest {
string username = 1; // 默认可选,但约定为必填(通过业务逻辑验证)
string email = 2; // 默认可选
optional string phone = 3; // 显式可选,可区分未设置和空字符串
repeated string tags = 4; // 数组类型
}

使用建议

  1. 推荐使用 proto3:proto3 是当前推荐的版本,语法更简洁
  2. 必填字段验证:在 proto3 中,通过业务逻辑代码验证必填字段,而不是使用 required
  3. 使用 optional 的场景
    • 需要区分"未设置"和"零值"时(如年龄为 0 vs 未填写年龄)
    • 需要区分"空字符串"和"未设置"时
    • 部分更新场景(PATCH 操作)

实际示例

syntax = "proto3";

// 用户信息更新请求
message UpdateUserRequest {
int64 user_id = 1; // 必填(业务逻辑验证)
optional string username = 2; // 可选更新
optional string email = 3; // 可选更新
optional string phone = 4; // 可选更新
optional int32 age = 5; // 可选更新,可以区分未设置和设置为 0
}

在 Go 代码中处理:

func (c *UserController) UpdateUser(ctx context.Context, req *pb.UpdateUserRequest) (*pb.UpdateUserResponse, error) {
// 验证必填字段
if req.UserId == 0 {
return nil, status.Errorf(codes.InvalidArgument, "user_id is required")
}

// 只更新设置了的字段
updates := make(map[string]interface{})

if req.Username != nil {
updates["username"] = *req.Username
}

if req.Email != nil {
updates["email"] = *req.Email
}

if req.Age != nil {
updates["age"] = *req.Age
}

// 执行更新
err := c.UserService.UpdateUser(ctx, req.UserId, updates)
// ...
}

任意类型(google.protobuf.Any)

当你需要在 proto 消息中存储任意类型的数据时,可以使用 google.protobuf.Any 类型。它类似于 Go 中的 interface{}

基本用法

syntax = "proto3";

// 导入 Any 类型
import "google/protobuf/any.proto";

// 定义具体的消息类型
message UserProfile {
string bio = 1;
string avatar_url = 2;
}

message CompanyProfile {
string company_name = 1;
string industry = 2;
}

// 使用 Any 类型存储不同类型的数据
message Account {
int64 id = 1;
string username = 2;
google.protobuf.Any profile = 3; // 可以存储 UserProfile 或 CompanyProfile
}

Go 代码中使用 Any

import (
"google.golang.org/protobuf/types/known/anypb"
pb "your-module/pb"
)

// 创建并打包 Any 消息
func CreateAccount() (*pb.Account, error) {
// 创建用户档案
userProfile := &pb.UserProfile{
Bio: "Software Engineer",
AvatarUrl: "https://example.com/avatar.jpg",
}

// 将 userProfile 打包为 Any 类型
profileAny, err := anypb.New(userProfile)
if err != nil {
return nil, err
}

// 创建账户
account := &pb.Account{
Id: 1,
Username: "alice",
Profile: profileAny,
}

return account, nil
}

// 解包 Any 消息
func ProcessAccount(account *pb.Account) error {
// 检查 Any 类型中存储的是什么类型
if account.Profile.MessageIs(&pb.UserProfile{}) {
// 解包为 UserProfile
var userProfile pb.UserProfile
if err := account.Profile.UnmarshalTo(&userProfile); err != nil {
return err
}
fmt.Printf("User bio: %s\n", userProfile.Bio)
} else if account.Profile.MessageIs(&pb.CompanyProfile{}) {
// 解包为 CompanyProfile
var companyProfile pb.CompanyProfile
if err := account.Profile.UnmarshalTo(&companyProfile); err != nil {
return err
}
fmt.Printf("Company: %s\n", companyProfile.CompanyName)
}

return nil
}

使用场景

  1. 动态类型数据:需要存储多种类型的数据,但类型在编译时不确定
  2. 插件系统:允许扩展系统接受未知类型的数据
  3. 通用 API:设计通用的 API 接口,支持多种请求/响应类型
  4. 事件系统:事件载荷可以是任意类型

注意事项

  • Any 类型会增加序列化后的数据大小(需要存储类型信息)
  • 类型检查在运行时进行,不如直接使用具体类型安全
  • 优先考虑使用 oneof(见下文),如果类型是已知的有限集合

enum 关键字(固定值集合)

当某个字段的取值只能落在一个有限集合内时,应该优先使用 enum,而不是简单地用 string 再在业务代码里做字符串比较。典型场景包括:状态、用户类型、订单状态、设备类型等。

基本用法

syntax = "proto3";

message User {
int64 id = 1;
string username = 2;
UserStatus status = 3;
UserType user_type = 4;
}

enum UserStatus {
USER_STATUS_UNSPECIFIED = 0;
USER_STATUS_ACTIVE = 1;
USER_STATUS_DISABLED = 2;
}

enum UserType {
USER_TYPE_UNSPECIFIED = 0;
USER_TYPE_LOCAL = 1;
USER_TYPE_SSO = 2;
}

为什么枚举值通常从 0 开始

proto3 中,枚举字段即使调用方没有传值,也会落到零值。因此推荐保留一个 *_UNSPECIFIED = 0 作为“未指定”占位值,再在服务端显式拒绝这个值。

if req.Status == pb.UserStatus_USER_STATUS_UNSPECIFIED {
return nil, status.Error(codes.InvalidArgument, "status is required")
}

使用建议

  1. 固定值集合优先用 enum:例如状态、类型、来源、动作模式。
  2. 保留 UNSPECIFIED=0:让“未传值”与合法业务值分开。
  3. 不要把 enum 当完整校验proto 只能约束取值集合,不能表达“某个来源只能搭配某种模式”这类组合规则,这仍应在服务端校验。

oneof 关键字(类型联合)

当你有一组互斥的字段(同时只能设置其中一个)时,使用 oneofAny 更高效和类型安全。

基本用法

syntax = "proto3";

message SearchRequest {
string query = 1;

// 搜索条件:只能选择其中一种
oneof filter {
string category = 2;
int32 price_range = 3;
string brand = 4;
}
}

// 更复杂的示例
message Account {
int64 id = 1;
string username = 2;

// 账户类型:个人或企业(二选一)
oneof profile_type {
UserProfile user_profile = 3;
CompanyProfile company_profile = 4;
}
}

message UserProfile {
string bio = 1;
string avatar_url = 2;
}

message CompanyProfile {
string company_name = 1;
string industry = 2;
}

Go 代码中使用 oneof

// 创建账户 - 个人账户
account := &pb.Account{
Id: 1,
Username: "alice",
ProfileType: &pb.Account_UserProfile{ // 注意类型名称格式
UserProfile: &pb.UserProfile{
Bio: "Software Engineer",
AvatarUrl: "https://example.com/avatar.jpg",
},
},
}

// 创建账户 - 企业账户
account := &pb.Account{
Id: 2,
Username: "company_xyz",
ProfileType: &pb.Account_CompanyProfile{
CompanyProfile: &pb.CompanyProfile{
CompanyName: "XYZ Corp",
Industry: "Technology",
},
},
}

// 检查 oneof 字段的类型
switch profile := account.ProfileType.(type) {
case *pb.Account_UserProfile:
fmt.Printf("User bio: %s\n", profile.UserProfile.Bio)
case *pb.Account_CompanyProfile:
fmt.Printf("Company: %s\n", profile.CompanyProfile.CompanyName)
case nil:
fmt.Println("No profile set")
}

oneof vs Any 对比

特性oneofAny
类型安全性编译时类型检查运行时类型检查
性能更高效(无额外类型信息)较低(需要存储类型 URL)
类型范围必须预先定义所有可能的类型可以是任意 protobuf 类型
序列化大小更小更大
使用场景类型是已知的有限集合类型在编译时不确定或需要动态扩展
Go 代码生成类型安全的接口需要手动类型检查和转换

推荐:如果类型集合是已知且有限的,优先使用 oneof;只有在真正需要动态类型时才使用 Any

enum + oneof 组合建模(gRPC 接口常见模式)

在实际业务中,经常会同时出现两类约束:

  • 某些字段的值只能是固定集合,例如状态、类型、模式、来源
  • 另一些字段互斥,只能二选一,例如“手机号登录”或“邮箱登录”

这时通常会组合使用 enum + oneof

syntax = "proto3";

message LoginRequest {
string request_id = 1;
LoginSource source = 2;
ClientType client_type = 3;

oneof credential {
string phone = 4;
string email = 5;
}

string password = 6;
LoginMode login_mode = 7;
}

enum LoginSource {
LOGIN_SOURCE_UNSPECIFIED = 0;
LOGIN_SOURCE_WEB = 1;
LOGIN_SOURCE_MOBILE = 2;
}

enum ClientType {
CLIENT_TYPE_UNSPECIFIED = 0;
CLIENT_TYPE_ADMIN = 1;
CLIENT_TYPE_USER = 2;
}

enum LoginMode {
LOGIN_MODE_UNSPECIFIED = 0;
LOGIN_MODE_PASSWORD = 1;
LOGIN_MODE_OTP = 2;
}

对应 Go 代码中,oneof 会生成接口字段,需要用类型断言区分具体传入的是哪一种目标:

switch credential := req.Credential.(type) {
case *pb.LoginRequest_Phone:
phone := credential.Phone
_ = phone
case *pb.LoginRequest_Email:
email := credential.Email
_ = email
default:
return nil, status.Error(codes.InvalidArgument, "one of phone or email is required")
}

proto 如何表达“必选/可选/固定值/互斥字段”

这是 gRPC/Proto 使用很容易混淆的一点。可以按下面理解:

需求推荐语法说明
固定值集合enum例如状态、用户类型、设备类型
多个字段互斥oneof例如 phoneemail 二选一
区分“未传”和“传零值”optional 或 message 指针字段适合 PATCH、部分更新、零值敏感场景
必填字段proto + 服务端校验proto3 不推荐使用 required,通常由业务代码校验
复杂组合约束服务端校验例如“web 端只允许密码登录”或“admin 端必须带组织信息”

分层校验建议

在实践里,可以把约束拆成三层:

  1. Proto 层:用 enumoneof 表达结构语义。
  2. Handler 层:校验必填、固定值和字段组合关系。
  3. Service 层:继续保留业务兜底校验,不完全信任上游。

例如:

if req.Source == pb.LoginSource_LOGIN_SOURCE_WEB && req.LoginMode != pb.LoginMode_LOGIN_MODE_PASSWORD {
return nil, status.Error(codes.InvalidArgument, "web source must use password login")
}
if req.ClientType == pb.ClientType_CLIENT_TYPE_ADMIN && req.Credential == nil {
return nil, status.Error(codes.InvalidArgument, "admin client requires credential")
}

这种写法的好处是:

  • proto 负责描述接口形状
  • Go 代码负责落实业务约束
  • 调用方和维护者都更容易看清“哪些是结构限制,哪些是业务限制”

多个 proto 文件引用(import)

在实际项目中,通常会将 proto 定义拆分到多个文件中,通过 import 语句引用其他文件的定义。

基本用法

项目结构:

grpc/proto/
├── common/
│ ├── types.proto # 公共类型定义
│ └── timestamp.proto # 时间戳定义
└── user/
└── user.proto # 用户服务定义

common/types.proto

syntax = "proto3";

// 定义包名,用于避免命名冲突
package common.v1;

// 指定 Go 包路径
option go_package = "github.com/example/user-service/grpc/pb/common;common";

// 公共类型定义
message Address {
string country = 1;
string province = 2;
string city = 3;
string street = 4;
string postal_code = 5;
}

message PhoneNumber {
string country_code = 1;
string number = 2;
}

user/user.proto

syntax = "proto3";

package user.v1;

option go_package = "github.com/example/user-service/grpc/pb/user;user";

// 导入其他 proto 文件
import "common/types.proto";
import "google/protobuf/timestamp.proto";

message User {
int64 id = 1;
string username = 2;
string email = 3;

// 使用导入的类型(需要带包名前缀)
common.v1.Address address = 4;
common.v1.PhoneNumber phone = 5;
google.protobuf.Timestamp created_at = 6;
}

message CreateUserRequest {
string username = 1;
string email = 2;
common.v1.Address address = 3;
}

message CreateUserResponse {
User user = 1;
}

service UserService {
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
}