语法引导¶
本文将介绍如何使用 bitproto 的语法对结构化数据进行描述。
分号¶
在 bitproto 中逗号是可选的:
message Pen {
Color color = 1; // OK with a semicolon.
Timestamp produced_at // OK without a semicolon.
}
协议名字¶
任何一个 bitproto 文件都必须定义它的名字:
proto pen
基本类型¶
bitproto 的基本类型的总览:
bool
- 布尔类型,一个布尔占用 1 个比特。
uint{n}
- 无符号的比特级别的整数类型。其中
n
是从1
到64
的一个数字。举例来说,uint3
,uint13
,uint41
,uint64
都是支持的。一个无符号整数uint{n}
在编码后将占用n
个比特。在代码生成时,uint{n}
将会被映射成为最小可以容纳这些数量的比特的对应语言中的整数类型。举例来说, 生成 C 语言代码时,uint3
将映射成uint8_t
,uint13
映射到uint16_t
,依次类推. int{n}
- 有符号的比特级别的整数类型, 其中
n
是一个从1
到64
的数字。举例来说,int24
,int32
,int64
都支持。一个有符号整数int{n}
在编码后将占用n
个比特。在代码生成时,int{n}
将会被映射成为最小可以容纳这些数量的比特的对应语言中的整数类型,例如生成 C 语言时,int3
将映射成int8_t
,int13
将映射成int16_t
,依此类推。对于一个有符号类型的整数int{n}
而言,第n
位比特会被认为是其符号位,比如说int24
的符号位是第24
个比特。在 bitproto v0.4.5 之前的版本,只有int8,int16,int32,int64
才被支持, 但是自从 v0.4.6 以后,任意比特数目大小的有符号整数都支持了。 byte
- 字节类型。一个字节将会在编码后占用
8
个比特。字节类型会在 C 语言中映射到unsigned char
, 在 Go 语言中映射到byte
, 在 Python 中映射到int
。
备注
更进一步的讨论
非常有趣的问题,是否 uint1
和 bool
是一回事? 不要困惑,对于 bitproto 来说,它仍然将 uint1
映射到 uint8_t
而不是 bool
,就像 uint8
不是一个 byte
一样的道理。前者 uint1
是在讲一个数字,后者 bool
则是一个布尔,语义是关于是或者否的。
Enum - 枚举¶
声明一个枚举类型:
enum Color : uint3 {
COLOR_UNKNOWN = 0
COLOR_RED = 1
COLOR_BLUE = 2
COLOR_GREEN = 3
}
一个枚举类型会绑定到一个无符号的整数类型 uint{n}
, 并在编码后占用 n
个比特。
非常推荐的是,为每一个枚举类型定义一个值为 0
的枚举值,来表示未知的数据。
可以把枚举作为消息字段的一个类型:
message Pen {
Color color = 1
}
HEX 16 进制格式的枚举值也是支持的:
enum Color : uint3 {
COLOR_UNKNOWN = 0x00
COLOR_RED = 0x01
}
Message - 消息¶
声明一个消息类型:
message Pen {
bool is_new = 1
uint3 lucy_number = 2
Color color = 3
}
一个消息由多个消息字段构成,语法类似 protobuf 。
一个消息字段由字段类型、字段名字和字段标号组成。任何 bitproto 支持的类型都是可以作为字段的类型的。字段的标号应该在一个消息中是唯一的。
bitproto 在编码一个消息时,会按照字段标号的大小顺序由小到大编码各个字段。因此,一旦字段标号在通信中已经使用起来,后面就不应该更改标号的值了。每次我们新增字段时,应当为新字段设置一个更大一些的字段标号。
message Pen {
Color color = 3
// Added a field
uint3 new_field = 4
}
一个消息在编码后占用的比特数量是所有字段占用的比特数量的总和。举例来说,上面例子中的 Pen
消息将会在编码后占用 6
个比特。
一个消息当然也可以被用作一个字段的类型:
message Eye {
bool is_open = 1
}
message Face {
Eye left = 1
Eye right = 2
}
备注
在 bitproto 中,消息的大小被限制不得大于
65535
比特 (即8191
字节)。消息字段的标号被限制不得大于
255
。
数组¶
例子:
byte[10] // Array of bytes, occupies 8*10bits.
Color[2] // Array of enums, occupies 8*3bits.
uint3[3] // Array of uint3, occupies 8*3bits.
bool[3] // Array of bool, occupies 3bits.
Pen[3] // Array of messages, occupies 3*7bits.
一个数组由数组元素的类型和数组的容量构成。
在 bitproto 中,我们必须用一个常量来清楚地指定数组的容量。变长数组在 bitproto 中是不支持的。
数组在编码后占用的比特数量是它所有的元素占用的比特数量的总和。比如,byte[10]
将占用 8 * 10
个比特。
一个在消息中使用数组的例子:
message Pen {
byte[8] remark = 1
}
备注
在 bitproto 中,数组的容量被限制不可大于 65535
。
自定义类型¶
类似于 C 语言中的 typedef
关键字,我们可以在 bitproto 中自定义类型:
type Bytes = byte[16]
type Timestamp = int64
type Colors = Color[7]
一个在消息中使用自定义类型的例子:
type Timestamp = int64
message Pen {
Timestamp created_at = 1
}
自定义类型在编码后占用的比特数目等于它所绑定的类型所占用的比特数量。
注意,bitproto 中有一个限制,我们无法为依据有名字的类型起别名。也就是说,消息和枚举是无法被绑定为一个自定义类型的。举例说,下面的 bitproto 语法是不正确的:
message Empty {}
type Void = Empty // invalid
常量¶
声明一个常量:
const SOF = 0x01
const LENGTH = 20
const ENABLE = true // true, false, yes, no
const NAME = "string"
整数、布尔和字符串都可以是一个常量。
常量语法的设计是为了维护和协议相关的一些常量,比如我们常用的 sof 字节(协议头字节)。虽然常量并不是一个类型,不参与序列化过程,但是它仍然是协议的一部分。
此外,整数常量可以用在数组的容量声明中:
const LENGTH = 20
message Pen {
byte[LENGTH] name = 1
}
嵌套类型¶
我们可以在消息中定义消息:
message Outer {
message Inner {
bool ok = 1
}
Inner inner = 1
}
也可以在消息中定义枚举:
message Outer {
enum Color : uint3 {
COLOR_UNKNOWN = 0
COLOR_RED = 1
}
Color color = 1
}
或者嵌套更多层:
message Outer {
message Middle {
message Inner {
bool ok = 1
}
}
Middle.Inner inner = 2
}
嵌套的类型可以跨消息作用域进行引用使用:
message Outer {
enum Color : uint3 {
COLOR_UNKNOWN = 0
COLOR_RED = 1
}
}
message Pen {
Outer.Color color = 1;
}
一个 bitproto 的消息会开一个作用域,bitproto 会优先扫描本地作用域,其次是外部作用域。在下面的例子中,字段 color
的类型是本地消息 B
中定义的枚举 Color
:
message B {
enum Color : uint3 {}
}
message A {
message B {
enum Color : uint3 {}
}
B.Color color = 1 // Local `B.Color` wins
}
在 bitproto 中,只有消息和枚举可以嵌套定义。
在代码生成过程中,一个嵌套的类型会映射到目标语言的全局作用域中,映射后的名字是拼接的。举例说,下面的例子中,bitproto 会为 C 语言生成一个全局的结构体 struct ZooMonkey
:
message Zoo {
message Monkey {}
}
struct ZooMonkey {};
struct Zoo {};
数组的数组¶
在 bitproto 中直接通过字面量的方式声明一个数组的数组(也就是二维数组)是不可行的,因为这种方式缺少可读性:
byte[2][3] // Invalid
但是,我们仍然可以通过 自定义类型 的语法来实现一个二维数组:
type Row = byte[2]
type Table = Row[3]
通过这种方式,我们可以声明三维数组或者更高维的数组。
type Row = bool[2]
type Table = Row[3]
type Cube = Table[4]
这种设计下,可读性会大大提升。
导入语句¶
我们可以通过 import 语句导入另一个 bitproto 文件:
import "path/to/shared.bitproto"
导入的路径是一个绝对路径,也可以是相对于当前 bitproto 文件的一个相对路径:
import "/home/user/shared.bitproto" // absolute
import "shared.bitproto" // relative
导入语句会把要导入的 bitproto 绑定到本地的全局作用域中,我们可以通过点的方式对其中的类型进行引用:
import "shared.bitproto"
message Pen {
shared.Color color = 1
}
有时候,我们希望绑定到一个其他的名字,来避免命名冲突:
import lib "path/to/shared.bitproto"
上面的语句导入了 shared.bitproto
,导入进来的名字是 lib
,这样就可以使用 lib.
的方式进行引用了:
import lib "shared.bitproto"
message Pen {
lib.Color color = 1
}
扩展性¶
因为 bitproto 中所有类型都是定长的,因此 bitproto编译器在代码生成阶段就可以清楚地知道一个消息会在编码后占用多少比特。但是,这给协议的兼容性设计造成了一点麻烦。
如果我们把新增字段追加到消息的尾巴上,看上去似乎满足了协议的兼容性设计。因为已经存在的老的字段的结构是不变的,解码的一端不会扫描到新增的字段的数据,这样就实现了 "协议向后兼容" 。
message Packet {
bool old_field = 1
// Add new field at end with a larger field number
uint3 new_field = 2
}
但是这种机制只有当这个消息后面没有其他数据的时候管用。也就是说,只有这个消息是最顶级的消息的时候,没有其他消息引用这个消息作为一个字段类型的时候,这个机制才可以管用。
就是说,这种机制对于 "中间的消息" 的解码会失败,因为会影响到消息后面的老字段的解码:
message Middle {
bool old_field = 1
}
messages Packet {
Middle middle = 1
uint7 following_field = 2
}
我们不得不打破 bitproto 的传统的编码结构。现在的机制是这样的,在编码后的消息字节流头部添加两个描述消息大小的字节,解码一端会首先查看这两个字节,并跳过冗余的比特,来继续解码后续的老字段数据。
在 bitproto 中有两种消息,一种叫做可扩展消息,一种叫做传统的消息。bitproto 会对一个可扩展消息新增 2
个字节到编码后的字节流中,对于传统的消息,不会新增任何额外字节。
通过单引号 '
的语法来标记一个消息是可扩展的:
message ExtensibleMessage' {
bool old_field = 1
}
message TraditionalMessage {
bool ok = 1
}
在上面的代码中, ExtensibleMessage
将会占用 1+16
个比特,TraditionalMessage
仍然占用 1
个比特。
通过一个单引号标记消息为可扩展消息的方式,我们增大了消息的长度,以换取未来添加字段的可能。你应该在编码大小和扩展性做权衡,只标记那些未来可能会扩展的消息。
回到 Middle
消息的例子来,如果这个消息已经事先被标记为可扩展的 (通信的双方都标记),那么向这个消息中新增一个新的字段,是不影响老的通信方的解码的:
// Before
message Middle' {
bool old_field = 1
}
messages Packet {
Middle middle = 1
uint7 following_field = 2
}
// After
message Middle' {
bool old_field = 1
// Add new field at end with a larger field number
// This field will be skipped, by the end holding
// an older version protocol.
uint3 new_field = 2
}
messages Packet {
Middle middle = 1
uint7 following_field = 2
}
但是,如果通信的一方标记一个消息为可扩展的,另一方标记这个消息是传统的,那么通信将出错。
可扩展消息也可以嵌套使用,例如下面的例子,消息 Outer
占用 2+2
个字节:
message Outer' {
message Inner' {}
// Ha, empty extensible messages still cost bytes ~
}
此外,数组也可以支持标记为可扩展数组:
message Packet {
byte[4]' words = 1;
}
当编码的一端扩大了数组的容量,解码的一端会跳过冗余的元素。和可扩展消息一样,可扩展数组会对原数组的占用的字节扩大 2
个字节。
备注
对于枚举类型,扩展性是不支持的,因为枚举值在目标语言中一般是原子性的。如果编码一端增大了枚举的容量,持有较老版本协议的解码端会按照一个较小的类型解出一个错误的数据。
协议选项¶
bitproto 语言支持少量的选项。我们可以在协议文件的全局作用域或者消息中使用它们:
option name = value
协议选项的值可以是一个整数、字符串或者布尔,视选项的含义而定。
距离来说,消息有一个选项叫做 max_bytes
来约束消息的大小,当我们设计的消息的大小超出这个选项配置的值时,编译器则会报错,拒绝编译:
message Pen {
option max_bytes = 3
byte[4] field = 1 // Violated max_bytes constraint
}
所有的选项列表:
c.struct_packing_alignment
- 协议级别选项,默认是
0
。生成的 C 代码中结构体对齐的字节数。设置为0
表示不设置。 c.name_prefix
- 协议级别选项,默认是
""
。生成的 C 代码中的类型命名前缀。 go.package_path
- 协议级别选项,默认是
""
。当前 bitproto 作为一个被导入 bitproto 的文件时,在 Go 语言中的导入路径。 py.module_name
- 协议级别选项,默认是
""
。当前 bitproto 作为一个被导入 bitproto 的文件时,在 Python 语言中的导入名称。 max_bytes
- 消息级别选项,默认是
0
。设置当前消息编码后最大占用的字节数目。设置为0
表示没有限制。
风格引导¶
bitproto 的编译器包含一个 简单的风格检查器 ,它会在运行编译器时做语言风格上的检查。
缩进¶
语法解析器会忽略所有空白字符,推荐使用 4 个空格对齐。
命名风格¶
bitproto 推荐的命名风格如下面的 bitproto 代码所示:
// Suggest a document for each proto.
proto lower_snake_case
type PascalCaseTypeAlias = byte[7]
enum PascalCaseEnum : uint7 {
// Always define a value 0 for enum.
PASCAL_CASE_ENUM_UNKNOWN = 0
UPPER_CASE_ENUM_FIELD = 1
}
message PascalCaseMessage {
uint3 lower_snake_case_field = 2
}
编辑器集成¶
Vim¶
在 bitproto 的 github 仓库 中包含一个 vim 编辑器 的语法高亮插件。
PyCharm¶
bitproto 的 PyCharm 语法高亮插件: https://github.com/hit9/bitproto/tree/master/editors/pycharm.
VSCode¶
VSCode 插件可以直接从市场安装: https://marketplace.visualstudio.com/items?itemName=hit9.bitproto.