文章

【笔记】TOML 的数据树与声明边界

【笔记】TOML 的数据树与声明边界

TOML 表面上同时支持 a.b.c = 3[a.b]{ c = 3 } 等多种建模方式,很容易给人“同一棵配置树可以随意切换写法、反复打开和追加”的感觉。实际上,TOML 只是表达方式灵活,声明语义很严格:它构造的是一棵 Table 树,键只能赋值一次,Table 只能显式声明一次,且 Table header 会改变后续键的作用域。

这篇笔记的中心问题是:dotted key 和 Table header 为什么能生成相同结构,却不能在同一份 TOML 中任意混写? 其他类型和容器语法只做建立全局认识所需的展开。

1. 整体模型:TOML 是对一棵 Table 树的单次声明

TOML 的目标数据模型可以理解为一个根 Table,其中包含标量、Array、Table 和 Array of Tables。

flowchart TB
    R[root table]
    K1[scalar key]
    K2[array key]
    T[table]
    AT[array of tables]
    TK[nested keys and tables]
    E1[table element]
    E2[table element]

    R --> K1
    R --> K2
    R --> T
    R --> AT
    T --> TK
    AT --> E1
    AT --> E2

TOML 文本不是一组可覆盖的配置指令,也不是一串可以重复执行的“进入 Table 并修改它”操作。解析器会边读取边构造这棵树,同时记录每个路径是否已经赋值或显式定义。

可以用下列状态建立心智模型:

节点状态如何出现后续边界
未出现路径尚未被使用可定义为值或 Table
普通值a = 3不能重复赋值,也不能再变成 a.b 的父 Table
隐式父 Table[a.b] 为路径自动建立 aa 之后仍可通过 [a] 首次显式声明
已定义的普通 Table[a] 或 dotted key 的中间路径可增加尚未定义的成员,但不能再用 Table header 重新声明自己
Inline Tablea = { b = 3 }完全封闭,花括号外不能再增加成员

“已经存在”和“已经显式定义”不是一回事,隐式父 Table 就是两者之间的差异。这是理解后续看似宽松、其实严格的规则的关键。

2. dotted key 是结构声明,不是带点的普通字符串键

1
a.b.c = 3

a.b.c 称为 dotted key(点分键)。它会:

  1. 创建并定义 Table a
  2. 创建并定义 Table a.b
  3. 定义叶子键 a.b.c = 3

解析结果是嵌套数据,而不是扁平 Map:

1
2
3
4
5
6
7
{
  "a": {
    "b": {
      "c": 3
    }
  }
}

因此可以继续用 dotted key 向已有 Table 增加新成员:

1
2
3
a.b.c = 3
a.b.d = 4
a.x = 5

它没有重复定义 aa.b,而是在它们之下定义不同的叶子键。最终结构是:

1
2
3
4
5
6
{
  "a": {
    "b": { "c": 3, "d": 4 },
    "x": 5
  }
}

如果点本身是键名的一部分,必须引用该键段:

1
site."example.com".enabled = true

这里的第二层键名是完整的 example.com,不会被拆成 examplecom

3. 结构等价不等于可以把两种声明拼在一起

下面是两份彼此独立、都合法的 TOML。

写法一:

1
2
a.b.c = 3
a.b.d = 4

写法二:

1
2
3
[a.b]
c = 3
d = 4

两者解析为同一棵数据树,因此可以说它们“结构等价”。但这不意味着可以在同一份文档中使用第一种写法创建 a.b,再使用第二种写法“进入”它:

1
2
3
4
a.b.c = 3

[a.b] # INVALID: a.b 已经由 dotted key 定义
d = 4

关键修正是:

[a.b] 不是选中或重新打开已有 Table,而是显式声明 Table a.b

前面的 dotted key 已经定义了 a.b,后面的 Table header 就构成重复声明。TOML 在这里不像支持反复打开 section 的配置格式,也不像可以通过后续赋值覆盖前值的 Map 合并系统。

同一规则也会拒绝:

1
2
3
4
a.b.c = 3

[a] # INVALID: a 也已由 dotted key 定义
x = 1

但可以在已有 Table 之下定义一个尚不存在的更深子 Table:

1
2
3
4
a.b.c = 3

[a.b.extra] # VALID: 新声明的是 extra
x = 1

因为这里没有重新声明 a.b,而是首次声明 a.b.extra

4. 隐式父 Table:“路径存在”不等于“Table 已经显式声明”

下面的写法是合法的:

1
2
3
4
5
[a.b]
c = 3

[a]
x = 1

[a.b] 必须先让父路径 a 存在,但它显式声明的只是 a.ba 此时是隐式父 Table,后面的 [a] 才是对它的第一次显式声明。

可以用状态变化理解:

1
2
3
4
5
6
[a.b]
  a   = 隐式父 Table
  a.b = 已显式定义的 Table

[a]
  a   = 第一次显式定义,合法

这与 dotted key 的规则不同:

1
a.b.c = 3

官方规范明确规定,dotted key 会创建并定义最后一段之前的各层 Table。因此这条语句已经定义 aa.b,之后再写 [a][a.b] 都是重复声明。

这个特性是规范允许但不值得在日常配置中刻意利用的语法空间。把父 Table 放在子 Table 后面定义虽然合法,却会让阅读者必须跟踪隐式和显式状态。实际写作应尽量按父子顺序组织。

5. Table header 建立当前作用域,dotted key 从当前 Table 开始

文件开头位于无名的 root table。第一个 Table header 出现后,后续 key/value 属于当前 Table,直到下一个 header 或文件结束。

1
2
3
4
5
root_key = 1

[a.b]
c = 3
d = 4

对应:

1
2
3
root_key = 1
a.b.c = 3
a.b.d = 4

容易出错的是,Table header 下的 dotted key 也相对于当前 Table:

1
2
3
[a.b]
c = 3
a.b.d = 4

最后一行不是 root 下的 a.b.d,而是:

1
a.b.a.b.d = 4

解析结果为:

1
2
3
4
5
6
7
8
9
10
11
12
{
  "a": {
    "b": {
      "c": 3,
      "a": {
        "b": {
          "d": 4
        }
      }
    }
  }
}

因此在 [a.b] 下给当前 Table 增加 d,应直接写:

1
2
3
[a.b]
c = 3
d = 4

需要更深一层时,可以写相对 dotted key:

1
2
[a.b]
extra.enabled = true

这会定义 a.b.extra.enabled

Table header 自身则始终声明它写出的完整 Table 路径。例如在 [a] 之后写 [b],声明的是 root 下的 b,不是 a.b;需要后者必须显式写 [a.b]

6. 单次定义的其他直接结果

6.1 叶子键不能重复赋值

1
2
name = "first"
name = "second" # INVALID

TOML 规范不定义“后写覆盖先写”。如果应用支持多文件配置合并,覆盖规则属于应用自己,不是单份 TOML 文档的语义。

6.2 普通值不能后续变成 Table

1
2
a.b = 1
a.b.c = 3 # INVALID

第一行已经把 a.b 定义成 Integer,第二行却要把它当作 Table 继续挂载 c,节点类型发生冲突。

6.3 普通 Table 可增加成员,但不能重复声明自己

1
2
3
4
5
6
7
a.b.c = 3
a.b.d = 4       # VALID: 新成员

[a.b.extra]     # VALID: 新子 Table
x = 1

# [a.b]         # INVALID: 重新声明 a.b

“Table 不能重复定义”不等于“Table 第一次出现后就被冻结”。普通 Table 可以继续获得新的唯一成员,只是不能再声明同一个 Table header。

6.4 Inline Table 定义后完全封闭

1
2
a = { b = { c = 3 } }
a.b.d = 4 # INVALID

Inline Table 的花括号内容就是它的完整定义,所有键和子 Table 都必须在其中完成。它比普通 Table 更严格:后续不能再追加成员。

7. 把这套模型放回 mise 配置

mise 中常见:

1
2
3
[env]
_.file = ".env"
_.path = "./bin"

这里先由 [env] 建立当前 Table,然后 _.file_.path 作为相对 dotted key,定义:

1
2
env._.file
env._.path

另一份文档可以选择 Table header 写法:

1
2
3
[env._]
file = ".env"
path = "./bin"

两种写法生成的数据结构相同,所以 mise 看到的配置含义相同。但不能在同一份 TOML 中这样拼接:

1
2
3
4
5
[env]
_.file = ".env"

[env._] # INVALID: env._ 已由 _.file 定义
path = "./bin"

原先“_.file = ".env" 等价于 [env._] file = ".env"”的结论没有错,但完整表述应该是:

它们是两种可替代的整体写法,解析结构等价;它们不是能够在同一份文档中先后使用的追加操作。

这个案例正好说明,理解 TOML 不能只看最终 JSON 树,还要跟踪这棵树是如何被声明出来的。

8. 其他 TOML 结构的必要全景

本篇不做完整语法手册,但需要知道 Table 树的叶子和容器还有哪些形式。

8.1 键和值

bare key 只允许 ASCII 字母、数字、下划线和连字符。其他字符需要 quoted key:

1
2
3
simple_key = "value"
"key with spaces" = "value"
"example.com" = true

TOML 的主要值类型包括:

  • String;
  • Integer 和 Float;
  • Boolean;
  • 带 offset 的 date-time,以及不带 offset 的 local date-time/date/time;
  • Array;
  • Inline Table。

TOML 规范没有通用 null 值。“缺少该键”、空字符串、空 Array 和应用定义的特殊值是不同语义,不应自动互换。

8.2 Array 与 Array of Tables

Array 是一个值:

1
ports = [8080, 8081]

Array of Tables 用双方括号创建 Table 列表:

1
2
3
4
5
6
7
[[servers]]
name = "api"
port = 8080

[[servers]]
name = "admin"
port = 8081

对应:

1
2
3
4
5
6
{
  "servers": [
    { "name": "api", "port": 8080 },
    { "name": "admin", "port": 8081 }
  ]
}

[table] 表示一个 Table,[[table]] 则每次创建 Array 中的一个新 Table 元素。两者不是同一数据类型的两种外观。

8.3 规范版本与解析器能力是实际边界

本篇核心的 dotted key、Table 单次定义和作用域规则在 TOML 1.0 与 1.1 中保持一致。但 TOML 1.1 允许 Inline Table 跨多行并带 trailing comma,而 TOML 1.0 要求 Inline Table 单行书写且末尾不能有逗号。实际项目能使用哪些语法,还取决于宿主工具采用的 parser 版本。

因此配置文件不应只因为通过某个在线 TOML 1.1 validator 就认为可用,还要用真正消费该文件的应用或 parser 验证。

9. 实际写作约束

TOML 规范允许的写法比日常需要的更多。为了避免未来重新追踪声明状态,可主动采用更严格的项目风格。

9.1 同一分支选一种主要写法

少量、稀疏的嵌套配置可用 dotted key:

1
2
3
server.host = "localhost"
server.port = 8080
server.tls.enabled = true

同一 Table 下字段较多时改用 header:

1
2
3
4
5
6
[server]
host = "localhost"
port = 8080

[server.tls]
enabled = true

不要在同一分支中先用 dotted key 定义 Table,再尝试用 header 打开它。

9.2 Table 声明按父子与业务邻近关系组织

即使规范允许先写 [a.b] 再写 [a],也不应把它当成常规编排方式。相关字段集中放置,父 Table 和子 Table 按自然顺序排列,可避免不必要的隐式状态推理。

9.3 不把应用合并规则当成 TOML 语义

工具可能会按全局、项目、本机文件的优先级合并多棵 TOML 数据树。这是 mise、Cargo 或其他具体应用的配置策略。单份 TOML 内部仍然不允许重复 key 或重复 Table 声明。

10. 复习索引

10.1 一句话心智模型

TOML 是对一棵 Table 树的单次声明;dotted key 和 Table header 可以构造相同结构,但不能用来重复声明同一 Table。

10.2 遇到边界时的判断顺序

  1. 当前 key/value 位于 root table 还是某个 Table header 下?
  2. dotted key 是从当前 Table 开始的哪条相对路径?
  3. 路径中的节点是未定义、隐式父 Table、已定义 Table,还是普通值?
  4. 当前语句是在添加新成员,还是重复赋值或重复声明已有 Table?
  5. 如果使用 Inline Table,是否试图在花括号外继续扩展它?

10.3 最容易忘的五条边界

  • [a.b] 是声明 Table,不是重新打开已有 Table。
  • a.b.c = 3 会定义中间 Table aa.b
  • [a.b] 下的 x.y = 1 表示 a.b.x.y = 1
  • [a.b] 创建的父 a 可能仍是隐式 Table,之后可首次显式声明 [a]
  • Inline Table 在花括号结束时就完全封闭。

11. 核验锚点

本文由作者按照 CC BY 4.0 进行授权