本文叙述了 dds-gen 的架构和实现时遵循的原则。

架构

dds-gen 使用分层架构,api 不存在跨层调用:

每个阶段输出的结果如下:

Diagram

tree-sitter-idl:

Diagram
  1. tree-sitter-idl 生成的树是无类型的。节点的类型需要使用 kind() 或者 kind_id() 查看。

  2. ast 将无类型的 node 转换为有类型的 rust 源码。

  3. ast 树并不适合生成代码。因此引入 hir 对 ast 树进一步抽象。生成平坦类型。

  4. 不同的 code generator 使用同一份 hir 代码,最后生成目标代码。

生成理念

在代码实现中,为了结构清晰和易于维护。现提出下面的原则:

  1. gen 阶段不关注生成后的格式,因此撰写模板是应以易于阅读的形式撰写。

  2. 生成的代码应当是正确的。

  3. 生成的代码不要求是可读的。

tree-sitter

tree-sitter 是使用 LR(1) 对语法进行解析,因此能够保证性能上不会出现太大的问题。此外,tree-sitter 的语法文件使用 js 描写,因此可以使用函数等工具加快语法撰写速度。tree-sitter 同时包含了词法解析和语法解析两个阶段。现对两个阶段简要进行介绍:

  1. 词法解析:词法解析用来将字符流转换为 token 流。

  2. 语法解析:语法解析用来将 token 流转为语句。

通俗来讲。以一份英文文档而言,英文文档由 26 个英文字母和若干标点符号构成。词法解析用来将这些字母构成单词,语法用来将这些单词构成句子。自然的,我们知道单词通过空白字符分割,句子通过标点符号分割(中文没有任何字符来分割词语,因此没有简单的办法进行分词)。

自然的,词法解析和语法解析都涉及到了优先级问题,优先级用来解决词法冲突和语法冲突。

词法优先级决定了一系列字母如何构成单词,语法优先级决定了一系列单词如何构成句子。

以下面的规则为例:

identify = /\w+/
uint8 := "uint8"

现在要求解析字符流 uint8aaaaa。有两种情况会发生:

  1. 左结合:左结合倾向于消耗更少的字符。因此这里会被解析为 uint8和 identify两个 token。

  2. 右结合:右结合倾向于消耗更多的字符。因此这里会被解析为 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,从而避免了:

  1. 相同逻辑在多个 gen 中都实现一遍。

  2. 更新 tree-sitter-idl 导致所有 gen 都发生 breaking changes。

HIR 进行了以下工作:

  1. module 解析。将所有类型都替换为 full path。

  2. 复杂类型展开。将嵌套的类型进行展开。

  3. rpc 类型生成。根据 rpc 规范。需要根据 interface 生成相关的结构体。这一步在 hir 层实现,因此 gen 层不需要操心这些东西。

  4. 类型校验。tree-sitter-idl 中类型校验相对宽松。在 HIR 层对类型进一步进行校验。

HIR 的工作如下:

Diagram

输出

在 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 可以看出两点:

  1. generator 自己负责构造自己。

  2. 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 的库代码。

Last moify: 2022-12-04 15:11:33
Build time:2025-07-18 09:41:42
Powered By asphinx