4、Protobuf Schema实战:语法详解与Message定义

好,咱们进入实战环节。前面聊了那么多理论,今天直接上手写 Protobuf。说实话,我最早接触 Protobuf 是在一个微服务改造项目里,当时团队还在用 JSON 硬传,线上问题频发。后来切到 Protobuf,数据量直接降了 60%,我心里就踏实了。

这一章,咱们把 Protobuf 的核心语法掰开揉碎。你跟着我写一遍,基本就能上手了。

4.1 从一个最简单的 Message 开始

先看个例子。假设我们要定义一个用户信息的数据结构:

syntax = "proto3";

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
}

嗯,就这么几行。第一行 syntax = "proto3" 是必须的,告诉编译器我们用 proto3 语法。proto2 现在基本没人用了,除非你维护老项目。

每个字段后面那个数字(1、2、3)叫 字段编号。它不是序号,而是二进制编码时的标识。我见过有人把它当序号用,结果后面插入新字段时全乱了——这是个坑。

注意:字段编号 1-15 用 1 个字节编码,16-2047 用 2 个字节。高频字段尽量用 1-15,能省不少空间。

4.2 字段规则:optional 和 repeated

proto3 里字段规则就两种:optional 和 repeated。还有一个 required 在 proto3 里已经移除了,别再用。

4.2.1 optional 字段

说白了就是「可有可无」。proto3 里所有字段默认都是 optional,你不写也成:

message User {
  int32 id = 1;
  string name = 2;
  optional string phone = 3;  // 显式声明 optional
  string email = 4;           // 隐式 optional
}

两者效果一样。但我个人习惯显式写 optional,代码可读性更好。你想想看,别人读你的 .proto 文件,一眼就知道哪些字段可能为空。

4.2.2 repeated 字段

这个就相当于数组。比如一个用户有多个地址:

message User {
  int32 id = 1;
  string name = 2;
  repeated string addresses = 3;
}

我在项目中遇到过一个问题:有人用 repeated 存了大量数据,结果序列化后体积暴增。后来发现 repeated 字段默认是 packed 编码,但如果你用 proto2 的写法,可能没开 packed,性能差很多。proto3 里 repeated 默认就是 packed,省心。

小技巧:如果你需要表示一个「空数组」和「null」的区别,可以用 optional repeated 包裹一个 message 类型。但 proto3 原生不支持,得用 wrapper 类型。

4.3 Message 嵌套与类型组合

实际业务里,数据结构很少是扁平的。咱们可以嵌套定义:

message User {
  int32 id = 1;
  string name = 2;
  
  message Address {
    string street = 1;
    string city = 2;
    string zip = 3;
  }
  
  repeated Address addresses = 3;
}

嵌套的 message 在外面引用时用 User.Address。你也可以把 Address 提出来单独定义,看你的组织习惯。

还有一种常见场景:枚举。比如用户状态:

message User {
  int32 id = 1;
  string name = 2;
  
  enum Status {
    STATUS_UNSPECIFIED = 0;
    STATUS_ACTIVE = 1;
    STATUS_INACTIVE = 2;
    STATUS_BANNED = 3;
  }
  
  Status status = 3;
}

注意枚举的第一个值必须是 0,而且建议命名成 XXX_UNSPECIFIED。为什么?因为 proto3 里默认值就是 0,如果你不显式处理,客户端可能以为状态是「未指定」——我曾经因为这个踩过坑,线上数据全被当成默认值处理了。

4.4 包管理:别让你的命名撞车

项目大了,不同团队定义的 message 可能重名。这时候就需要 package:

syntax = "proto3";

package com.example.user;

message User {
  int32 id = 1;
  string name = 2;
}

在其他 .proto 文件里引用时:

import "user.proto";

message Order {
  int32 id = 1;
  com.example.user.User user = 2;
}

包名一般用公司域名倒序,跟 Java 包名类似。但要注意,Protobuf 的 package 和生成代码的包名是两回事。如果你用 Java,还得加 option java_package = "com.example.user"。

我的建议:包名、java_package、go_package 三者统一,别搞出三套命名。否则维护起来你会疯的。

4.5 知识体系总览

下面这张图帮你理清本章的核心脉络:

Protobuf Schema 实战知识体系 .proto 文件 语法基础 字段规则 Message 定义 包管理 syntax 声明 字段编号 optional repeated 嵌套 Message 枚举 Enum package 声明 import 引用 核心原则 字段编号 1-15 优先用于高频字段 枚举第一个值必须为 0,建议命名 XXX_UNSPECIFIED

4.6 实战:定义一个完整的订单模型

咱们把今天学的串起来,写一个完整的 .proto 文件:

syntax = "proto3";

package com.example.order;

import "user.proto";
import "common.proto";

option java_package = "com.example.order";
option go_package = "example.com/orderpb";

message Order {
  int64 order_id = 1;
  int64 user_id = 2;
  repeated OrderItem items = 3;
  double total_amount = 4;
  OrderStatus status = 5;
  common.Timestamp created_at = 6;
  
  enum OrderStatus {
    ORDER_STATUS_UNSPECIFIED = 0;
    ORDER_STATUS_PENDING = 1;
    ORDER_STATUS_PAID = 2;
    ORDER_STATUS_SHIPPED = 3;
    ORDER_STATUS_COMPLETED = 4;
    ORDER_STATUS_CANCELLED = 5;
  }
}

message OrderItem {
  int64 product_id = 1;
  string product_name = 2;
  int32 quantity = 3;
  double price = 4;
}

你看,这里用到了:

  • package 避免命名冲突
  • import 引用其他 .proto 文件
  • option 指定生成代码的包名
  • enum 定义状态枚举
  • repeated 表示订单包含多个商品
  • 嵌套 message 组织数据结构
避坑指南:我曾经在 import 时用了相对路径,结果不同模块的 proto 文件互相引用,路径乱成一团。后来统一用项目根目录做基准路径,再也没出过问题。

4.7 字段编号的潜规则

最后聊一个容易被忽略的点:字段编号一旦确定,尽量不要改。为什么?因为 Protobuf 的二进制编码依赖编号,不是字段名。你改了编号,老数据就解析不出来了。

我见过一个案例:有人重构时把字段编号重新排了一遍,结果线上老版本客户端全部解析失败。那场面,真是惨烈。

所以我的建议是:

  • 预留一些编号给未来可能加的字段(比如 1-15 用完了,16-20 先空着)
  • 废弃的字段用 reserved 关键字标记,防止别人误用
message User {
  reserved 2, 15, 9 to 11;
  reserved "phone", "age";
  
  int32 id = 1;
  string name = 3;
}

这样如果有人想用编号 2 或字段名 phone,编译器会直接报错。嗯,这个习惯我建议你从一开始就养成。