2、Protobuf基础语法:定义.proto文件、消息类型、字段规则、字段编号

好,咱们正式开始动手写 Protobuf 了。

这一章我带你过一遍 .proto 文件的基础语法。说白了,这就是 Protobuf 的「身份证」——你定义好数据结构,它才能帮你生成代码。我在做物联网项目时,经常看到新手一上来就写字段,结果后面改得想哭。所以,咱们先把规矩讲清楚。

2.1 .proto 文件长什么样?

一个 .proto 文件,本质上就是一个文本文件。你用记事本都能写。但它的结构非常严格。

我习惯把 .proto 文件放在项目根目录下的 proto/ 文件夹里。比如这样:

syntax = "proto3";  // 指定语法版本,目前主流是 proto3

package sensor_data;  // 包名,防止命名冲突

message TemperatureReading {
  float value = 1;
  string unit = 2;
  int64 timestamp = 3;
}

嗯,这里要注意:syntax 必须写在第一行,不写的话默认是 proto2。我个人强烈建议你用 proto3,它去掉了不少坑爹的规则,后面我会讲到。

2.2 消息类型(Message)

消息类型是 Protobuf 的核心。你可以把它理解成 C 语言里的 struct,或者 JSON 里的一个对象。

定义消息用 message 关键字,后面跟消息名字。名字建议用大驼峰命名法,比如 MyMessageDeviceStatus

核心原则:一个 .proto 文件里可以定义多个 message,甚至可以嵌套。但别搞得太复杂,我见过有人一个文件塞了 50 个 message,维护起来简直是噩梦。

举个例子,一个传感器节点可能包含多个数据:

message SensorNode {
  string node_id = 1;
  float temperature = 2;
  float humidity = 3;
  bool is_online = 4;
}

你看,每个字段都有类型、名字和编号。类型可以是 int32、float、string、bool 等基础类型,也可以是另一个 message 类型。

2.3 字段规则:required / optional / repeated

这是新手最容易踩坑的地方。咱们一个一个说。

2.3.1 required(proto2 才有,proto3 已废弃)

在 proto2 里,required 表示这个字段必须存在,不能为空。听起来很美好对吧?

但我曾经在一个项目中吃过它的亏。当时我们定义了一个 required 字段,后来业务变更,这个字段在某些场景下不需要了。结果一升级,所有旧数据都解析失败,线上直接崩了。

所以 proto3 直接砍掉了 required。现在所有字段默认都是可选的。我个人觉得这是最明智的决定之一。

2.3.2 optional(proto3 中的显式可选)

proto3 里虽然没有 required,但你可以显式声明 optional。它的作用是告诉编译器:这个字段可能有值,也可能没有。当你读取时,可以用 has_xxx() 方法判断。

message DeviceConfig {
  optional int32 baud_rate = 1;  // 波特率,可能没设置
  string device_name = 2;        // 默认就是 optional
}

说实话,我平时很少用 optional,因为 proto3 默认所有字段都是 optional 的。只有在需要区分「字段没设置」和「字段设置了默认值」时,我才会显式加上。

2.3.3 repeated(列表/数组)

repeated 表示这个字段可以重复多次,说白了就是数组或列表。这是物联网场景里最常用的规则。

message SensorHistory {
  repeated float temperature_samples = 1;  // 温度采样列表
  repeated int64 sample_times = 2;         // 对应的时间戳
}

我在做环境监测项目时,一个节点每 10 秒上报一次数据,一天下来就是 8640 条。用 repeated 打包发送,比一条一条发效率高太多了。

小技巧:repeated 字段在 proto3 中默认使用 packed 编码,也就是把所有值紧凑地打包在一起。这能节省不少带宽,尤其是在传输大量整数或浮点数时。

2.4 字段编号(Field Number)

每个字段都必须有一个唯一的编号。这个编号不是随便写的,它直接影响序列化后的字节大小。

编号范围是这样的:

编号范围 说明 占用字节
1 - 15 最常用字段,推荐使用 1 字节
16 - 2047 不常用字段 2 字节
19000 - 19999 保留,不可使用 -
大于 2^29 - 1 保留,不可使用 -

为什么会这样?因为 Protobuf 用 Varint 编码编号,1-15 只需要 1 个字节,16 以上就需要 2 个字节了。你想想看,如果一个消息有 100 个字段,每个都用编号 1000,那光编号就占 200 字节,太浪费了。

我的建议:

  • 把最常用的字段放在 1-15 号
  • 把扩展字段、不常用的放在 16 号以后
  • 永远不要修改已有字段的编号
  • 删除字段时,用 reserved 关键字保留编号,防止别人误用
message MyMessage {
  reserved 2, 15, 9 to 11;  // 这些编号已被删除,不可再用
  reserved "old_field", "deprecated_field";  // 这些字段名也被保留

  string new_field = 1;
  int32 another_field = 3;
}

避坑指南:我曾经在升级协议时,直接删除了一个字段,然后新加了一个字段用了同样的编号。结果旧设备解析新数据时,把新字段的值当成了旧字段,数据全乱了。所以,删除字段一定要用 reserved 把编号和名字都锁死。

2.5 一个完整的例子

最后,我给你看一个我在智能家居项目中用过的 .proto 文件片段。它包含了我们刚才讲的所有知识点:

syntax = "proto3";

package smart_home;

message LightStatus {
  bool is_on = 1;               // 常用字段,编号 1
  int32 brightness = 2;         // 亮度 0-100
  string color = 3;             // 颜色值,如 "#FF0000"
  reserved 4;                   // 之前有个 mode 字段,已废弃
}

message DeviceReport {
  string device_id = 1;
  repeated LightStatus lights = 2;  // 多个灯的状态
  float battery_level = 16;         // 不常用,放 16 号
  int32 signal_strength = 17;       // 信号强度,也不常用
}

你看,我把 device_idlights 放在了 1-15 号,因为它们每次上报都会出现。而 battery_levelsignal_strength 可能隔几次才上报一次,所以放在 16 号以后,省带宽。

嗯,这一章的内容就这些。说白了,.proto 文件就是一张蓝图,你把结构画清楚,后面生成代码、序列化、反序列化就都顺了。下一章咱们就动手编译这个 .proto 文件,看看它到底能生成什么代码。