FlatBuffers 是Google推出的一个跨平台、跨语言的序列化和反序列化库,主要用于游戏以及对性能要求较高的系统中,例如RPC框架、保存端测推理的模型文件等(如TFLite)。端测不同于服务器,内存和算力等资源相对于服务器十分有限,想要缩短整个推理的时间和内存消耗,模型加载的阶段也需要考虑。FlatBuffers可以只使用一块内存进行解析,恰好满足这些要求。其使用步骤如下:
- 下载源码编译得到一个编译该库指定的IDL(Interface Definition Language)所定义的Schema的编译器
flatc
; - 按照IDL的语法编写Schema;
- 使用第一步编译出的
flatc
编译第二步写出的Schema,得到对应语言的序列化和反序列化接口; - 使用第三步得到的接口进行序列化和反序列化。
具体使用方法参考官方文档即可。一般情况下,我们只需要知道FlatBuffers这个库是怎么使用的就够了,并不需要知道我们编写的Schema是如何被编译生成对应语言的接口的。
但是有意思的是,FlatBuffers包含了两个我感兴趣的东西:一个是它序列化数据的时候的思想,之前在FlatBuffer内部解析原理简介一文中有做过总结;另一个就是它的编译器。
俗话说麻雀虽小五脏俱全,作为一个编译器,虽然相比于GCC、LLVM等它非常简单,但是它的代码中对于词法分析、语法分析以及代码生成等都有体现。
1. 工作流程
flatc
的入口位于flatbuffers/src/flatc_main.cpp
中,其具体工作流程如图1所示。整个工作流程可以分为三部分:
- 解析命令行、初始化;
- 对源文件进行解析,涉及词法分析和语法分析,这两个阶段是合并在一起的;
- 目标语言的代码生成。
首先,
flatc
开辟了一个结构体Generator
的数组空间,该结构体如下所示。
struct Generator {
typedef bool (*GenerateFn)(const flatbuffers::Parser &parser,
const std::string &path,
const std::string &file_name);
typedef std::string (*MakeRuleFn)(const flatbuffers::Parser &parser,
const std::string &path,
const std::string &file_name);
GenerateFn generate;
const char *generator_opt_short;
const char *generator_opt_long;
const char *lang_name;
bool schema_only;
GenerateFn generateGRPC;
flatbuffers::IDLOptions::Language lang;
const char *generator_help;
MakeRuleFn make_rule;
};
后续通过匹配用户命令行的参数选生成哪些语言的API,例如下面的结构体实例是用于生成C++ API的,当用户的命令中存在-c
或者--cpp
,最终就会有C++的API生成。
{ flatbuffers::GenerateCPP, "-c", "--cpp", "C++", true,
flatbuffers::GenerateCppGRPC, flatbuffers::IDLOptions::kCpp,
"Generate C++ headers for tables/structs", flatbuffers::CPPMakeRule
}
紧接着,flatc
解析命令行参数,解析完成后便开始编译。FlatCompiler
对源文件进行加载,之后委托Parser
进行解析,DoParse()
就是整个解析的核心。
源文件解析完成后,通过查看Generator
数组,再相应的委托BaseGenerator
对应的子类进行代码生成,例如要生成C++代码就委托CppGenerator
。
2. 词法分析
词法分析是每个编译器进行编译的第一个阶段,词法分析的目的就是扫描从源码文件中读入的字符串,并将它们分成一个一个的Token,以便后面做语法分析。
虽然词法分析和语法分析是编译过程中的两个阶段,但通常情况下,它们之间并不是完全独立的。语法分析并不会等待词法分析将整个源文件都分成一个个Token才开始工作,语法分析会以命令的方式要求词法分析器提供一个一个的Token。
在FlatBuffers flatc中,词法分析和语法分析的代码都是在类Parser
中完成的,其中Next()
方法负责词法分析,每一次调用,它就会从当前光标开始扫描,然后返回下一个Token。Parser
中有一块用于存放从文件中读入的字符串的缓存source_
,它是一块连续的内存区域,可以看做是一个存放字符的数组;还有一个光标cursor_
用于表示当前扫描位置。
Parser
的语法分析器(其实也就是一个函数Parse()
)通过调用Next()
获得一个一个的Token进行语法分析。在Next()
方法中光标cursor_
在source_
上从左向右滑动,并返回一个一个Token。Parse()
负责分析。例如在下面的例子,例子所示为一个名为Monster
的结构体的定义。
table Monster {
pos:Vec3;
mana:short = 150;
hp:short = 100;
name:string;
friendly:bool = false (deprecated, priority: 1);
inventory:[ubyte];
color:Color = Blue;
test:Any;
}
一开始,光标位于最开始得字符t
,然后开始滑动,直到划过table
这个词,遇见第一个空格,根据规则,此时table
被识别成一个Token,因此Next()
函数便将这个Token返回给调用者Parse()
。Parse()
在得到该Token后,识别到它是一个关键字,它后面应该需要跟上的是一个标识符,因此它再次调用Next()
去获取下一个Token,并判断这个Token是不是所期望的标识符。如果得到的并不是一个标识符,那么说明语法有误,终止编译并报错。如果此时得到的Token是标识符,那么根据要求,需要紧接着的是又花括号包含的成员定义,因此Parse()
在此调用Next()
去获取下一个Token。语法分析器和词法分析器就是这样反复交互,直到整个文件扫描分析结束或者出错终止。
这个Next()
的逻辑如图2所示(状态图更合适,但是奈何手头没有适合画状态图的工具)。
3. 语法分析
通常情况下,一般编译器的语法分析器会构造一颗解析树,并将这颗解析树传递给后续的编译阶段进行进一步处理。但是由于flatc编译的是接口描述语言,语言本身并不复杂也不包含计算,并且最终生成的是其他语言的代码,并不是直接运行的机器码,因此它只需要解析的同时提取到每个定义的结构的名字、初始值等信息即可。
还是以上面的代码为例,当解析Monster
的时候,Parser
会将Monster
的信息保存在一个名叫struct_
的数组中。后续读取此数组便可以获取到用户定义的信息进行代码生成。
整个解析过程如图3所示。
图3 Parser::DoParse()
4. 总结
看这部分的代码最大的收获就是对于如何解析一个文件豁然开朗,很多需要文本处理的软件中都有着编译器前端的部分影子。甚至是正则表达,其实仔细想想,不就是一个词法分析器么?
本文首发于个人微信公众号TensorBoy,微信扫描上方二维码或者微信搜索TensorBoy并关注,及时获取最新文章。C++ | Python | Linux | 原理 | 源码,有一起玩耍的么?5. References
[1] http://google.github.io/flatbuffers/index.html
[2] https://github.com/google/flatbuffers
网友评论