打包二进制其实很有趣
作者用自制的二进制 schema 语言 jBin 把 JSON 载荷缩小了 80%,并完整实现了词法分析、AST 与代码生成。
中文
复制

目录
- 为什么会有人做这种事?
- 什么是二进制打包?
- 构建 schema 语法
- 动手实现
- 数据的编码与解码
- 构建编译器
- 问题所在
- 构建词法分析器
- 什么是 AST?
- 构建 AST
- 构建语法分析器
- 构建语义分析器
- 动态打包器
- 代码生成
- 访问者模式
- 构建 CLI
- 结语
为什么会有人做这种事?
我在 Twitter 上看到有人争论说,把数据存成 JSON 显然不是真正的™开发者会做的事。
那我当然也得成为真正的™开发者。
结果发现,答案是二进制。
于是,我灵光一闪:
“自己造一个二进制格式能有多难?”
不就是一个小小的 wb 嘛。( ˶ˆᗜˆ˵ )
可惜,它并不只是一个小小的 wb。
最后我造出了一整套二进制 schema 语言,能把 JSON 载荷缩小 80%。
什么是二进制打包?
假设我们有一些数据
"hello world"
它在 ASCII 里会被转成
104 101 108 108 111 32 119 111 114 108 100
h e l l o [SPACE] w o r l d
所以 h 在 ASCII 里就是 104。
这些 ASCII 值都能塞进 8 位,所以每个字符占 1 个字节。
h e l
01101000 01100101 01101100
l o [SPACE]
01101100 01101111 00100000
w o r
01110111 01101111 01110010
l d
01101100 01100100
那我们用一点点 c 就能写出来:
FILE *f = fopen("file.bin", "wb");
unsigned char data[] = "hello world";
fwrite(data, 1, sizeof(data) - 1, f);
fclose(f);
够简单。现在试试写 104。
当然,我们可以直接把 104 写成 ASCII 字符:
'1' '0' '4' → 49 48 52
但一个只需要 1 个字节的数字,却占了 3 个字节。
想省下这 2 个字节,就得有办法告诉解码器:“嘿,这是个整数,不是字符串。”
你可以加个头部,不过那样只会多出一个字节(嗯,取决于你有多少种类型……希望你别超过 128 种……真超过了,你的问题可就大了。
太好了!那就用第一个字节表示类型,第二个字节表示数据。
[TYPE][DATA]
假设 0 是 int,1 是 string,那么 104 就是
00000000 01101000
而字符串则是……
00000001 01101000
哦等等……
那样只能得到 h
我们需要一种方式来表示不同长度的数据。那就再加一个字节吧,用它表示字符串的长度。 于是我们的二进制就变成了
[TYPE][LENGTH][DATA]
太好了!现在我们可以这样表示字符串:
[TYPE] [LENGTH] [DATA]
(STRING) (11)
00000001 00001011 00...
h e l
01101000 01100101 01101100
l o [SPACE]
01101100 01101111 00100000
w o r
01110111 01101111 01110010
l d
01101100 01100100
太好了!现在我们可以把字符串和整数打包在一起了!
假设我们想表示 "userid": 123
现在你可以把它们全部打包在一起
[TYPE:STRING][LENGTH:6][WORD:userid][TYPE:INT][LENGTH:-][DATA:123]
太好了!我们可以把 123 表示为一个 1 字节的数字,外加 2 字节的头部。 但注意,我们其实并没有真正用到整数的 LENGTH 字段吧?那为什么还需要它?这不是浪费字节吗?
嗯……如果我们把它去掉,二进制读取器怎么知道头部在哪里结束?
它需要某种方式来说“好了,头部结束了,现在开始读取实际数据。”
嗯。我们能用什么来表示一个字节结束了呢。
也许用一个头部长度字节?
呃。那是多余的。我们去掉长度字段,只是为了再加一个长度字段。
不过,我们可以用头部本身的一个位。
我们可以用一个位来表示:
我不是头部的最后一个字节。后面还有。
你可能会想:为什么不用 LSB?
那样的话,我们就只能表示偶数了。这……不太理想。
所以我们改用 MSB。
现在我们的 tag 看起来大概是这样:
[CONTINUATION BIT][7 BITS OF DATA]
如果 continuation bit 是 1,就还有另一个头部字节。 如果是 0,头部就结束了。
用这个方式,我们可以把 123 表示为
[TYPE=0][DATA=123]
如果它是字符串。
[TYPE=1][LENGTH=11][DATA=104]...
所以它的类型是 1,长度是 11
但是等等……如果长度大于 127 呢?用 7 位你最多只能表示到 127!
我们用同样的办法!但这次是用于整数!
如果第一位是 1,那么整数继续。 128 可以写成:
10000001 00000000
^
MSB / continuation bit
(大端序)
二进制读取器会这样做:
- 读取第一个字节。
- MSB 是 1,所以还有另一个字节。
- 剩下的 7 位是 1。
- 读取第二个字节。
- 它的 MSB 是 0,所以这是最后一个字节。
- 它剩下的 7 位是 0。
- 把这两个 7 位值组合起来得到 128。
这是一种 varint(可变长度整数)。
我们这里使用的编码是小端序:最低有效的 7 位在前。
10000000 00000001
嘿,这很棒,对吧?你可以在同一个二进制里表示不同的类型,而你的二进制解析器会把它们全部正确读取
但注意,我们是按字段存储数据的。
[TYPE][DATA]
[TYPE][DATA]
[TYPE][DATA]
而且大多数数据并不是一堆随机漂浮的值,它通常是有结构的。
拿一个 C struct 来说:
struct {
int i;
char *s;
int a[10];
}
假设这是在 32 位系统上。
[32-bit int] [32-bit pointer] [10 × 32-bit ints]
我们不需要每次都加头部信息,因为从 struct 本身就能知道数据的类型。
嗯,我在想能不能对二进制数据也这么干……
答案是能。
这就是 schema 的作用!
所以对于我们的 struct,schema 可以写成:
i: int
s: char *
a: list(int)
schema 让我们知道类型,而不必在每个值旁边都存一份类型信息。
现在我们的二进制格式不用再操心类型了,只需要关心数据的大小!
这就是 protobuf 的做法。
接下来想想我们可能遇到的各种数据大小。
有 int、float、bool、string。
int 和 bool 可以当作 varint 处理,float 则是固定宽度:f32 或 f64。
string 不一样。我们不能把它的字节直接编码成 varint,因为这些字节本身就是我们需要保留的真实数据。
所以,在开始读取之前,我们得先知道这个 string 占多少字节。
于是我们的编码类型就是:
- varint
- f32
- f64
- delimited
但等等!我们怎么访问各个字段?总不能直接说“给我那个 string”,得指明是哪一个。没问题,给每个字段编个号就行。 可以按顺序索引,也可以让用户自己指定编号来寻址。这就是 field number 的用途。
再注意一件事:我们只有四种可能的类型。
四个值刚好塞进 2 个 bit。
00 - varint
01 - f32
10 - f64
11 - delimited
嘿,挺妙的吧。那我们可以直接把它编码……塞进 field number 里!
等等,怎么塞?
一些二进制小把戏……其实也不算。
把它们拼在一起就行了。
把 field number 左移 2 位,腾出空间放那 2 bit 的类型,然后把类型 OR 进这些空位。
假设你的 field number 是 10,类型是 delimited。
00001010 (10)
left shift that by 2
00101000
OR it with our type!
00101011
看吧!这一个字节的数字同时告诉了我们它是什么类型、field number 是多少!
但如果我们再往前一步呢?
我们已经把类型塞进了字段编号里。
为什么要停在这里?
要是把长度也塞进去呢?
可以做到。
现在我们的 header 能装下
[FIELD NUMBER][LENGTH][TYPE]
全都塞进一个 varint 里!!!! ◝(ᵔᗜᵔ)◜
挺好。就是有一点
这活儿实在太繁琐了。 要打包一个字符串,我得先告诉它「这是分隔的」,再给它长度,然后给它字节。每。一。次。
那是下等人才干的。庶民。我们不这么干。
所以,解决方案显然就是写一整套 schema 语言。 我们把数据声明在 schema 文件里,剩下的格式化、编码这些破事全交给程序去处理。
设计 schema 语法
好吧……我们得定一套不会榨干你灵魂的语法(说的就是你,protobuf)
字段编号……它是什么?索引对吧。你在购物清单上怎么编号?写个数字,再写条目。 那为什么不用同样的方式!
大概长这样:
1. name
schema 还得能表示类型。直接抄其他语言的做法,写成这样:
1. name: type
挺妙吧。 但等等。message(struct)的结尾怎么表示?
可以用 {},但那样不太好看吧。干嘛把事情搞复杂,它就是个列表。列表有结尾。那就给它一个结尾。
message name:
1. name:type
2. name:type
3. name:type
end
另外,缩进不该有意义。用那些缩进敏感的语言太烦了,纯粹是折磨。我们干脆不搞这套。
那……没有分号,你怎么判断一行结束了?换行符?
可以这么做,但万一有人想写在一行里呢?虽然丑,但假设他出于某种原因就是想这么写。别限制他。可分号又丑。
我们可以用数字! 只要看到一个数字加一个点,就当作是新条目!
挺妙吧。𐔌_ˊᵕˋ_𐦯
问题来了……如果编译器读的时候找不到某个字段呢? 我们可以直接崩……所有字段都这么干也行,但我们确实想要可选字段吧。那就走最显然的路子,写成这样:
number. name: type = defaultValue
这是可选的,而且挺直观。
既然都到这一步了,不妨想想人们可能想表示的各种数据……
嗯,显然有各种类型的条目。
哦,我们需要列表。还需要某种在几个选项之间做选择的方式,所以需要 enum。 好,来想想这些……
哦,我们可以让它们像函数一样,这挺直观的。
number. name: structure(type)
比如:
1. friends: list(People)
哦等等,那 people 是什么?哦,它也应该是一条 message。大概这样:
message People:
1. name: string
2. location: string
end
嗯……我们还需要自定义类型……那在语言里,我们要让所有东西都按顺序来吗?呃,那挺蠢的,之后我们完全可以做一遍 pass,把所有 table 拼起来,然后让它引用那个 struct。
嘿,多亏了这一点,我们还能有自引用。因为打包的时候它就是一个指向 struct 的指针!所以我们可以这样写:
message People:
1. name: string
2. location: string
3. friends: list(People)
现在说 enum。因为有两遍 pass,我们可以直接把 enum 放在 message 外面!声明在哪里也无所谓!可以这样声明 enum:
enum Name:
number. name
number. name
number. name
end
另外 protobuf 有个做法,强制 enum 从 0 开始 我们真的需要这样吗?编译器就这么笨,看不出来?让编译器默认处理就行了
哦等等……人们可能需要某种方式来表示一个 enum,但所有类型并不一样。 对,那就是 union。加上就行,没问题
number. name: union(type, type, type...)
哦等等……我们的默认值。那在 union 里怎么处理。 如果有这样一个 union:
number. name: union(i32, i64) = 10
10 到底是 i32 还是 i64 就有歧义了 那字段找不到的时候该怎么办?
可以把默认值放在类型旁边来解决。
number. name: union(i32 = 10, i64)
这样字段找不到时,就是一个值为 10 的 i32
好,人们还会想映射一些东西……所以我们加上
number. name: map(key_type, value_type)
再加上声明 package 和导入东西的语法:
package "package name"
import "package"
嘿,瞧瞧,我们有了相当漂亮的语法。把它们拼起来:
package "com.game.core"
import "math.jbin"
enum Activity:
1. active
2. inactive
end
message Player:
1. name: string
2. health: i32 = 100
3. weapons: list(string)
4. connections: list(Player)
5. activeStatus: Activity
6. inventory: map(string, i32)
7. balance: union(string="empty", i32)
end
哦等等……我们现在有一门完整的语言了……
总之,这就是它在 .proto 里的写法
syntax = "proto2";
package com.game.core;
import "math.proto";
enum Activity {
ACTIVE = 1;
INACTIVE = 2;
}
message Player {
optional string name = 1;
optional int32 health = 2 [default = 100];
repeated string weapons = 3;
repeated Player connections = 4;
optional Activity active_status = 5;
map<string, int32> inventory = 6;
oneof balance {
string empty = 7;
int32 amount = 8;
}
}
看看这玩意儿。呕。
是时候真正动手实现了
要实现它,我自然选了 C++。
所谓“自然”,是因为我想要一个能写进简历的东西,而且没心情去跟 Rust 的借用检查器较劲。我年纪大了。
好了。设计够了。该让它真正跑起来了。
首先,我们得把二进制编码出来。
数据的编码与解码
编码一个 varint,基本上就是这样:
void encodeVariant(std::vector<uint8_t> &buffer, uint64_t value) {
while (value >= 128) {
uint8_t lower = value & 127; // 127 = 01111111
lower |= 128;
buffer.push_back(lower);
value >>= 7;
}
uint8_t lower = value & 127;
buffer.push_back(lower);
}
这就构造出了我们的 varint。如果值 >= 128,就取最低 7 位,把最高位设为 1 表示“后面还有”,然后追加到缓冲区。
接着把值右移 7 位,重复。
最后一个字节,最高位保持为 0。
挺简单的。解码用这个:
uint64_t decodeVariant(const std::vector<uint8_t> &buffer, size_t &offset) {
uint64_t result = 0;
int shift = 0;
while ((buffer[offset] & 128) != 0 && offset < buffer.size()) {
uint8_t byte = buffer[offset++];
byte &= 127;
const uint64_t cast = static_cast<uint64_t>(byte) << shift;
result |= cast;
shift += 7;
}
uint8_t byte = buffer[offset++] & 127;
const uint64_t cast = static_cast<uint64_t>(byte) << shift;
result |= cast;
return result;
}
现在能用 LEB128 编解码 varint 了,那就来编解码一些 tag(就是我们前面讨论过的头部元数据)
void encodeTag(std::vector<uint8_t> &buffer, uint32_t fieldNumber,
wiretype wiretype) {
uint64_t val = (static_cast<uint64_t>(fieldNumber) << 2);
val |= static_cast<uint64_t>(wiretype);
encodeVariant(buffer, val);
}
void decodeTag(const std::vector<uint8_t> &buffer, size_t &offset,
uint32_t &outFieldNumber, wiretype &outType) {
uint64_t val = decodeVariant(buffer, offset);
outType = static_cast<wiretype>(val & 3);
outFieldNumber = val >> 2;
}
再给字符串加几个辅助函数:
void encodeString(std::vector<uint8_t> &buffer, uint32_t fieldNumber,
const std::string &text) {
encodeTag(buffer, fieldNumber, wiretype::Delimited);
encodeVariant(buffer, text.size());
buffer.insert(buffer.end(), text.begin(), text.end());
}
std::string decodeString(const std::vector<uint8_t> &buffer, size_t &offset) {
uint64_t size = decodeVariant(buffer, offset);
std::string result =
std::string(buffer.begin() + offset, buffer.begin() + offset + size);
offset += size;
return result;
}
编码一个字符串现在就是三件事:写它的 tag,写它的长度,再写它的字节。
信不信由你,我们的核心引擎基本就这些了!剩下的只是编译器和 JSON 转换那些东西!٩(^ᗜ^ )و ´-
好!既然二进制编码写完了……那就来做个编译器吧,应该很简单……对吧?
构建编译器
对,它就是编译器。别说了,我不想叫它转译器,它就是编译器。定义是:
编译器是一种计算机程序,它把用一种编程语言(源语言)编写的源代码翻译成另一种编程语言(目标语言),同时保留原始代码的确切含义和行为。
我们的程序就是这么做的。编译器原教旨主义者滚远点。我的程序接收我的 schema,把它翻译成二进制或者代码。
问题
我们的语言挺可爱的。挺利落的。
可惜计算机完全不知道它是什么意思。
它只是一堆字节。
那就给这些符号赋予含义吧!
拿我们的语法来说:
1. name:string
结构是这样的:
number → dot → identifier → colon → type
然后我们用它来构建这些条目的表示。这就是 Lexer 干的事。
构建 Lexer
没错,lexer 就是你想的那样。一个大循环加一堆 if 语句。 它只做一件事:读我们的文件,吐出这些 token。(不是 AI 那种)
enum class TokenType {
Keyword_Message,
Keyword_Enum,
Keyword_Optional,
Keyword_Map,
Keyword_Union,
Keyword_End,
Keyword_Package,
Keyword_Import,
Identifier,
Number,
StringLiteral,
Comment,
Equals,
Colon,
Comma,
Dot,
LParen,
RParen,
EndOfFile
};
那么我们的文件
message User:
1. name: string
2. id: i32
tokenize 之后的输出就是:
[Keyword_Message]
[Identifier: "User"]
[Colon]
[Number: 1]
[Dot]
[Identifier: "name"]
[Colon]
[Identifier: "string"]
[Number: 2]
[Dot]
[Identifier: "id"]
[Colon]
[Identifier: "i32"]
[EndOfFile]
挺不错。现在 parser 不用去辨认一堆原始字符了,直接处理这些 token 就行。 parser 看完这些,真正吐出的是 AST(抽象语法树)。
什么是 AST?
它就是我们用 graphLang 表示程序数据的方式,那里是一个字面意义上的树节点,装着所有数据,而所有数据都只有一种形状。 这次我们可以把 schema 定义为根节点。目前只有两种消息:messages 和 enums,所以头部可以这样定义:
struct Schema {
std::vector<EnumDef> enums;
std::vector<MessageDef> messages;
};
于是 Schema 就是根节点,树长这样:
(Schema)
/ \
(enums) (messages)
构建 AST
那么……enums 和 messages 是什么?从上面的定义能看出,它是一个 struct 的 vector。 messages 可以定义为:
struct MessageDef {
std::string name;
std::vector<Field> fields;
int line = 0;
std::string comment = "";
};
Field 则是:
struct Field {
uint32_t number;
std::string name;
DataType type;
bool isOptional = false;
std::string defaultValue = "";
int line = 0;
std::string comment = "";
};
现在 AST 长这样:
Schema
└── messages
└── MessageDef
├── name
├── line
├── comment
└── fields
└── Field
├── number
├── name
├── type
├── isOptional
├── defaultValue
├── line
└── comment
剩下就是 enum 的定义:
struct EnumDef {
std::string name;
std::vector<EnumEntry> entries;
int line = 0;
std::string comment = "";
};
以及 enum 条目:
struct EnumEntry {
uint32_t number;
std::string name;
int line = 0;
std::string comment = "";
};
好!所有零件都齐了。AST 现在长这样:
Schema
├── Enums
│ └── EnumDef
│ ├── name
│ └── entries
│ └── EnumEntry
│ ├── number
│ └── name
│
└── Messages
└── MessageDef
├── name
└── fields
└── Field
├── number
├── name
├── type
├── isOptional
└── defaultValue
构建 parser
我们用它来搭 parser。没错,parser 也就是一个循环加 if 语句(严格说叫递归下降之类的,但递归不过是迭代的另一种形式)。
整个循环其实很简单:
Schema Parser::parse() {
Schema s;
while (!isAtEnd()) {
if (peek().type == TokenType::Comment) {
consume();
continue;
}
if (peek().type == TokenType::Keyword_Package) {
consume();
if (peek().type == TokenType::StringLiteral) {
s.packageName = consume().value;
} else {
error("Expected string literal after package");
}
} else if (peek().type == TokenType::Keyword_Import) {
consume();
if (peek().type == TokenType::StringLiteral) {
s.imports.push_back(consume().value);
} else {
error("Expected string literal after import");
}
} else if (peek().type == TokenType::Keyword_Message)
s.messages.push_back(parseMessage());
else if (peek().type == TokenType::Keyword_Enum)
s.enums.push_back(parseEnum());
else
error("Unexpected token in global scope");
}
return s;
}
对每种类型,就是一堆条件判断,然后返回对应的 AST 节点。
consume() 返回当前 token 并把指针往后移一位,peek() 则是在不移动指针的情况下给你下一个 token 的数据。
好!来测一下。
message User:
1. name: adfasdfasdfasdfasd
2. id: 012349
看看 parser 怎么说:
“Mighty good mate! seems excellent innit? want a cuppa?”
嗯……所以我们需要一个东西,不光检查用户语法对不对,还得看这玩意儿讲不讲得通。
所以我们需要一个事实核查器,可以说是 Twitter 的社区笔记。
而这正是我们的语义分析器所做的!
构建语义分析器
为了验证这些东西,我们分两遍来做。
- 第一遍,我们构建所有符号并把它们放进一张表里。(基本上就是所有可识别的合法内容)
- 第二遍,我们用建好的表来验证 AST。
void SemanticAnalyzer::analyze(const Schema &schema) {
buildSymbolTable(schema);
validateEnums(schema);
validateMessages(schema);
validateCyclicDependencies(schema);
}
因为分两遍做,声明顺序就不重要了。这挺妙的。
于是在 validateMessages 期间,当它看到
adfasdfasdfasdfasd 时,会检查我们的符号表,
发现那个类型不存在,然后抛出一个错误。它还会检查
你有没有干蠢事,比如把字段
编号 1 用了两次,或者拿列表当 map 的键。
好!看看我们目前有什么!
- 一个 Lexer,把一切切成 token
- 一个 Parser,接收 token 并构建 AST
- 一个语义分析器,验证 AST 并抛出错误
- 一个用 LEB128 的编码器/解码器。
现在剩下的就是两件事。
- 把 JSON 文件动态转换成二进制
- 从 schema 生成代码,让他们能在自己的语言里原生使用
动态打包器
现在说动态打包器。它接收一个 schema,接收一个 JSON 文件,然后构建出我们的二进制。
JSON 方面,我直接用了 nlohmann/json。在这一切之上再自己解析 JSON,会是一个完全没必要的支线任务。
于是我们的 JSON 加载在内存里。我们的 AST 也加载在内存里。现在只需要把它们一起遍历。
当 JSON 解析器看到 键 “id”: 123 时,它不知道该做什么。但它 会去问 AST!AST 说,
“哦,id ?那是字段编号 2,类型是 i32 。”
于是打包器就调用我们的 encodeTag() 和 encodeVariant()
函数,把字节吐进我们的缓冲区。
注意到我们没做什么了吗?我们 没有生成任何 C++ 代码。我们没有编译任何 包装层。我们只是读了 JSON,读了 schema,然后 动态构建了二进制。
去你的 protobuf
来测试一下!
{
"name": "Pranav",
"health": 100
}
如果我们把它存成 JSON,带空格和引号, 大约 35 字节
咱们把它塞进二进制里。
结果就是……
九个字节
爽爆了,看看这个!!!
缩减了 74.29%
要不是有字符串这些东西,还能更小。 9 个字节里有 6 个都花在 “Pranav” 这个词上。 不过这不重要。
存储体积被我们砍掉了一大截。
解码也简单多了。二进制本身已经告诉我们每个字段是什么,解码器不用再去处理 JSON 的语法和类型表示。
但如果你不想付运行时动态查表的代价呢?如果你就想要你语言里的普通 struct 呢?
这就是 AOT 代码生成登场的地方。
代码生成
嗯……代码该怎么生成?我们已经有 AST 了,知道它长什么样、语义上是什么,所以无非就是把它翻译成目标语言……
你可以写个 cCodeGen() 函数,加进我们的类里,然后调用。
想要个 Python 的生成器?简单!加个 pythonCodeGen 就行!
哦,我还需要个 jsCodeGen(),行吧。扯远了。你写 JavaScript 干嘛。不过无所谓。
但据说客户在语言偏好上永远是对的,大概吧。 行,现在这个类对我来说有点太臃肿了。
这正是我们需要 visitor 模式的原因!
Visitor 模式
那什么是 visitor?事后看来,就是给那些语言没有代数数据类型的倒霉蛋准备的设计模式安慰剂。那 cpp 明明有 std::variant,我为什么还用它?不知道,我在某篇文章里读到过,想学一学。
总之。visitor 模式就是调用方和被调用方之间的一次小握手。我们从中获得类型安全。在我们的实现里,调用方把自己传进去,成为 visitor。被调用方,也就是 acceptor,接受这个 visitor,然后对 visitor 做一次函数调用,把自己传进去,触发双重分派。挺巧妙的……但对这么简单的问题来说,心智负担实在太重了。
要做到这一点,我们得改 ADT 里的 struct,让它们也持有一个函数。
void accept(SchemaVisitor &visitor) const;
然后我们创建一个抽象类,里面放一堆虚方法,由各个生成器去实现:
#pragma once
#include "Schema.hpp"
class SchemaVisitor {
public:
virtual ~SchemaVisitor() = default;
virtual void visit(const Schema &s) = 0;
virtual void visit(const MessageDef &m) = 0;
virtual void visit(const EnumDef &e) = 0;
virtual void visit(const EnumEntry &ee) = 0;
virtual void visit(const Field &f) = 0;
};
比如我们的 C 生成器长这样:
#pragma once
#include "SchemaVisitor.hpp"
#include <ostream>
#include <string>
class CGenerator : public SchemaVisitor {
private:
std::ostream &out;
std::string currentEnumName;
const Schema* currentSchema = nullptr;
public:
CGenerator(std::ostream &outputStream) : out(outputStream) {}
void visit(const Schema &schema) override;
void visit(const MessageDef &message) override;
void visit(const EnumDef &enumDef) override;
void visit(const EnumEntry &ee) override;
void visit(const Field &field) override;
};
C 生成器只是重写这些函数,在对应的 visit 里完成相应的工作:
void CGenerator::visit(const EnumDef &enumDef) {
currentEnumName = enumDef.name;
out << "typedef enum {\n";
for (const auto &ee : enumDef.entries) {
ee.accept(*this);
}
out << "} " << enumDef.name << ";\n\n";
}
当我们调用 ee.accept(*this) 时,会执行:
void EnumEntry::accept(SchemaVisitor &visitor) const { visitor.visit(*this); }
它只是调用 CGenerator.visit(ee),于是 CGenerator 中针对 environment entry 的 visit 就被调用了。
void CGenerator::visit(const EnumEntry &ee) {
out << " " << currentEnumName << "_" << ee.name << " = " << ee.number << ",\n";
}
每种类型都这么走一遍。代码就是这么生成出来的。
太好了!来测一下……
哦等等,测不了。我们还没法跑它。得先有个 CLI。
构建 CLI
CLI 我用了 jarro2783/cxxopts,不想手写解析。这个和 json 都是挺有意思的项目,回头再单独做,眼下先用这两个库。
有了这个库,搭 CLI 相当轻松,很多东西开箱即用。
我只需要写好选项。
options.add_options()("command", "Command to run (e.g. build, pack)",
cxxopts::value<std::string>())(
"input", "Input schema file", cxxopts::value<std::string>())(
"o,out",
"Output (target language for build, or output binary file for "
"pack)",
cxxopts::value<std::string>())("j,json",
"Input JSON file (for pack command)",
cxxopts::value<std::string>())(
"m,msg", "Root message name to pack (for pack command)",
cxxopts::value<std::string>())("h,help", "Print usage");
options.parse_positional({"command", "input"});
auto result = options.parse(argc, argv);
能用,效果很好。这些选项是在一堆 if 语句里处理的(现在想想,这个项目大部分代码也就是一个循环加一堆 if)。
if (command == "build") {
if (!result.count("input")) {
std::cerr << "Error: No input file specified." << std::endl;
return 1;
}
if (!result.count("out")) {
std::cerr << "Error: --out flag is required (e.g., --out c)."
<< std::endl;
return 1;
}
std::string inputFile = result["input"].as<std::string>();
std::string targetLang = result["out"].as<std::string>();
std::ifstream file(inputFile);
if (!file.is_open()) {
std::cerr << "Error: Could not open file " << inputFile
<< std::endl;
return 1;
}
std::stringstream buffer;
buffer << file.rdbuf();
std::string schemaText = buffer.str();
std::vector<Token> tokens = tokenize(schemaText);
Parser parser(tokens);
Schema schema = parser.parse();
SemanticAnalyzer analyzer;
analyzer.analyze(schema);
if (targetLang == "c") {
CGenerator cGen(std::cout);
schema.accept(cGen);
} else if (targetLang == "py" || targetLang == "python") {
PythonGenerator pyGen(std::cout);
schema.accept(pyGen);
} else {
std::cerr << "Code generation for '" << targetLang
<< "' is not supported yet!" << std::endl;
}
总之,来测一下。
schema 就写成这样:
package "com.mmo.game"
enum Faction:
1. alliance
2. horde
3. neutral
end
message Vector3:
1. x: i32
2. y: i32
3. z: i32
end
message InventoryItem:
1. itemId: i32
2. quantity: i32
3. isSoulbound: bool
end
message Character:
1. id: i64
2. name: string
3. level: i32
4. faction: Faction
5. position: Vector3
6. inventory: list(InventoryItem)
7. attributes: map(string, i32)
end
然后运行 build,输出选 c……结果:
#pragma once
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
#include <stdlib.h>
typedef struct Vector3 Vector3;
typedef struct InventoryItem InventoryItem;
typedef struct Character Character;
typedef struct {
InventoryItem* data;
size_t length;
size_t capacity;
} jbin_list_InventoryItem;
typedef struct {
char** keys;
int32_t* values;
size_t length;
size_t capacity;
} jbin_map_char_ptr_int32;
typedef enum {
Faction_alliance = 1,
Faction_horde = 2,
Faction_neutral = 3,
} Faction;
struct Vector3 {
int32_t x;
int32_t y;
int32_t z;
};
struct InventoryItem {
int32_t itemId;
int32_t quantity;
bool isSoulbound;
};
struct Character {
int64_t id;
char* name;
int32_t level;
Faction faction;
Vector3 position;
jbin_list_InventoryItem inventory;
jbin_map_char_ptr_int32 attributes;
};
看看这个!!程序不再依赖任何东西了。它需要的一切都由我们的 schema 提供!!!
(注:这不是生成出来的完整 C 文件。里面还有大量针对单个消息的 pack/unpack 函数、setter 和 getter,以及 encode/decode 函数)
我们还可以传入一个 json 文件来编码成二进制。
{
"id": 123456789,
"name": "LeroyJenkins",
"level": 60,
"faction": "alliance",
"position": {
"x": 100,
"y": 200,
"z": 300
},
"inventory": [
{ "itemId": 999, "quantity": 1, "isSoulbound": true },
{ "itemId": 45, "quantity": 100, "isSoulbound": false }
],
"attributes": {
"strength": 120,
"agility": 45
}
}
这个 json 的大小是 401 bytes
把它打包成二进制,文件大小是……
80 bytes!
减少80%
80.05%
结论
所以就是这样。我一开始只是想赌气存点 JSON,结果不小心写出了一个词法分析器、一个语法分析器、一个 AST、一个语义分析器、一个动态二进制打包器,还有一个多语言代码生成器。说实话?打包二进制其实挺有意思的。
总之,去看看仓库吧。