本文叙述了 dds-gen 的架构和实现时遵循的原则。
架构
dds-gen 使用分层架构,api 不存在跨层调用:
每个阶段输出的结果如下:
tree-sitter-idl:
tree-sitter-idl 生成的树是无类型的。节点的类型需要使用 kind() 或者 kind_id() 查看。
ast 将无类型的 node 转换为有类型的 rust 源码。
ast 树并不适合生成代码。因此引入 hir 对 ast 树进一步抽象。生成平坦类型。
不同的 code generator 使用同一份 hir 代码,最后生成目标代码。
生成理念
在代码实现中,为了结构清晰和易于维护。现提出下面的原则:
gen 阶段不关注生成后的格式,因此撰写模板是应以易于阅读的形式撰写。
生成的代码应当是正确的。
生成的代码不要求是可读的。
tree-sitter
tree-sitter 是使用 LR(1) 对语法进行解析,因此能够保证性能上不会出现太大的问题。此外,tree-sitter 的语法文件使用 js 描写,因此可以使用函数等工具加快语法撰写速度。tree-sitter 同时包含了词法解析和语法解析两个阶段。现对两个阶段简要进行介绍:
词法解析:词法解析用来将字符流转换为 token 流。
语法解析:语法解析用来将 token 流转为语句。
通俗来讲。以一份英文文档而言,英文文档由 26 个英文字母和若干标点符号构成。词法解析用来将这些字母构成单词,语法用来将这些单词构成句子。自然的,我们知道单词通过空白字符分割,句子通过标点符号分割(中文没有任何字符来分割词语,因此没有简单的办法进行分词)。
自然的,词法解析和语法解析都涉及到了优先级问题,优先级用来解决词法冲突和语法冲突。
词法优先级决定了一系列字母如何构成单词,语法优先级决定了一系列单词如何构成句子。
以下面的规则为例:
identify = /\w+/
uint8 := "uint8"
现在要求解析字符流 uint8aaaaa。有两种情况会发生:
左结合:左结合倾向于消耗更少的字符。因此这里会被解析为 uint8和 identify两个 token。
右结合:右结合倾向于消耗更多的字符。因此这里会被解析为 identify一个 token。
tree-sitter 词法分析阶段使用右结合。语法解析阶段出现此类冲突需要手动使用 prec.left() 或 prec.right() 解决。
除了左右冲突外,还有另外一种冲突:
uint8 := "uint8"
tiny_type := "uint8"
integer := uint8 | tiny_type
请注意,这里发生了冲突。当解析到字符流 uint8时这里有两个可能的 token:uint8和 tiny_type。此类冲突发生后需要为一个 token 指定更高的优先级:
uint8 := "uint8"
tiny_type := token(prec(1, "uint8"))
integer := uint8 | tiny_type
这里注意是 token(prec())。顺序不能搞乱。
由于对 tree-sitter 的任何变更都会导致 ast 树发生变化。因此请谨慎升级 tree-sitter-idl |
HIR
hir 名字借鉴自 Rust hir 名称。但是和 Rust hir 没任何关系。HIR 层对 ast 进行处理,避免 gen 直接接触 ast,从而避免了:
相同逻辑在多个 gen 中都实现一遍。
更新 tree-sitter-idl 导致所有 gen 都发生 breaking changes。
HIR 进行了以下工作:
module 解析。将所有类型都替换为 full path。
复杂类型展开。将嵌套的类型进行展开。
rpc 类型生成。根据 rpc 规范。需要根据 interface 生成相关的结构体。这一步在 hir 层实现,因此 gen 层不需要操心这些东西。
类型校验。tree-sitter-idl 中类型校验相对宽松。在 HIR 层对类型进一步进行校验。
HIR 的工作如下:
输出
在 gen 处理完 hir 后,就需要将内容写入到文件中。要实现这一步,所有的 generator 都需要实现下面的 trait:
pub trait CodeGenerator {
/// `ast`: IDL ast.
/// `file_path`: IDL file path.
/// `ctx`: Custom Context.
fn generate_single_file(self) -> GenerateResult<Vec<OutFile>>;
}
pub struct OutFile {
/// The content of generated file.
pub content: String,
/// The path of the content will be write in.
pub path: String,
}
从此 trait 可以看出两点:
generator 自己负责构造自己。
dds-gen 不关心文件类型。只关系文件路径和文件内容。generator 需要自己合并文件内容。
这部分抄的谁的? 这部分抄的 protoc 的思想。但是比 protoc 更简单一些,因为我们不需要支持外部拓展。 |
测试
一份合格的代码离不开合格的测试,dds-gen 也需要测试。测试分为三部分:
corpus 测试
编译测试
单元测试
corpus 测试
corpus 测试用来确保生成的代码的 ast 没有发生变化。一个完整的 corpus 测试分为三部分:
================
corpus 测试名
================
IDL 源码
----
期待的 ast
一个文件可以包含多个 corpus 测试。
这部分抄的谁的? 这部分抄的 tree-sitter 的 corpus 测试。名字都没有换!!! |
这里注意单词 ast 这意味着期待结果和实际结果之间并不是简单的字符串比较。而是 ast 比较! |
test_corpus 使用 tree-sitter-c 来分别解析期待结果和实际结果。然后对两个 ast 进行比较。
使用
在 dds-gen 的根目录下的 Makefile 中包含了两个 target 用来跑 cropus 测试:
update_corpus:
TEST_UPDATE=1 make test_corpus
test_corpus:
cargo test --test test_corpus -- --show-output
test_corpus 提供了更人性化的输出结果(相比 cargo test)。如果你确定你的 ast 正确,那么可以使用 update_corpus 来自动更新所有的 ast。
编译测试
编译测试直接调用 dds-gen 二进制文件,然后检查生成的代码是否能够通过编译。
例如对于 microdds c 而言:
add_library(microdds_rpc STATIC
rpcs/SimpleMath.idl
rpcs/robot.idl
rpcs/apa.idl
# 在这里添加需要测试的 IDL
)
dds_generate(
TARGET microdds_rpc
LANGUAGE MicroddsC
ENABLE_RPC
)
target_include_directories(microdds_rpc PRIVATE "./includes/microdds/")
单元测试
单元测试用来检查生成的代码序列化和反序列化基本逻辑是否正确。这部分应该和集成测试放在一起。因为需要用到 dds 的库代码。