TOML Schema 校验实战:从日期时间类型丢失到 PEP 621/Cargo.toml 工程化

为什么 TOML 没有原生 Schema 标准

TOML 1.0(2021 年正式发布)的设计哲学是”用强类型避免 Schema”。TOML 原生支持:

  • 整数(含 64 位)、浮点数(含 inf/nan)
  • 字符串(基本字面量 / 多行 / 字面量多行)
  • 布尔、日期时间(4 种类型)
  • 数组、表(table)、数组表(array of tables)

理论上,TOML 的强类型已经回答了”这个字段是什么类型”,所以早期社区认为不需要 Schema 标准。但工程实践中,Schema 不仅要回答”是什么类型”,更要回答”必须有什么字段、值域是什么、字段之间有什么约束”

# 类型对,但语义错
[project]
name = "my-package"
version = "0.0.0.1"          # 不是合法 semver
requires-python = ">=3.7"    # PEP 621 已废弃,应 ≥3.8
dependencies = ["requests"]  # 缺少版本约束

这类语义错误,TOML 解析器不会报错,必须靠 Schema 校验。TOML 的 Schema 校验需求是真实存在的,只是社区没有像 JSON Schema 那样形成标准

配套工具:TOML Schema 校验工具 —— 复用 JSON Schema draft-07 引擎校验 TOML,自动检测类型陷阱

一、TOML 的类型系统:与 JSON 的关键差异

TOML 与 JSON 都是配置格式,但类型系统有三大差异,这些差异直接影响 Schema 校验策略。

1.1 日期时间:TOML 原生支持,JSON 没有

TOML 1.0 支持 4 种日期时间类型:

dt1 = 2024-01-15T10:30:00+08:00   # offset date-time(带时区)
dt2 = 2024-01-15T10:30:00          # local date-time(无时区)
dt3 = 2024-01-15                   # local date(仅日期)
dt4 = 10:30:00                     # local time(仅时间)

JSON 没有日期类型,只能用字符串表示。TOML 转 JSON 时,4 种类型全部变成字符串,但格式可能改变:

{
  "dt1": "2024-01-15T02:30:00.000Z",   // 时区偏移丢失,统一转 UTC
  "dt2": "2024-01-15T10:30:00.000",     // 补全毫秒
  "dt3": "2024-01-15",                  // 保留原样
  "dt4": "10:30:00"                     // 保留原样
}

Schema 校验影响

  • dt1 原本是 offset date-time,转 JSON 后无法区分是 offset 还是 local
  • dt2T 分隔符在 ISO 8601 中允许,但部分校验器要求空格分隔
  • Schema 写 "format": "date-time" 对 4 种类型都通过,但无法区分语义

1.2 64 位整数:超过 JS Number 安全范围

TOML 支持 64 位整数(最大 9223372036854775807),但 JS Number 只能安全表示到 2^53-1(9007199254740991):

big_int = 9223372036854775807    # TOML 正确解析
# 转 JS Number 后:9223372036854776000(精度丢失)
# 转 JSON 字符串后:依赖解析器,可能丢失也可能保留

Schema 校验影响

  • maximum: 9223372036854775807 在 JS 中失效(已精度丢失)
  • type: "integer" 对超大整数可能误判(解析为 number)
  • 大整数 ID(如 Discord snowflake、Twitter ID)需要字符串传输

1.3 表与数组表:JSON 的对象与数组

TOML 的表 [table] 等价于 JSON 对象,数组表 [[arr]] 等价于 JSON 数组的对象元素:

[server]
host = "example.com"
port = 8080

[[users]]
name = "alice"
role = "admin"

[[users]]
name = "bob"
role = "viewer"

等价 JSON:

{
  "server": { "host": "example.com", "port": 8080 },
  "users": [
    { "name": "alice", "role": "admin" },
    { "name": "bob", "role": "viewer" }
  ]
}

Schema 校验影响:表与数组表的 Schema 写法与 JSON 完全一致,无差异。

二、用 JSON Schema 校验 TOML:可行性与限制

由于 TOML 解析后会得到与 JSON 等价的 JS 对象,复用 JSON Schema 校验引擎是天然可行的方案。本工具采用此方案。

2.1 校验流程

TOML 文本 → smol-toml 解析 → JS 对象 → JSON Schema 校验 → 错误列表 + 类型陷阱提示

2.2 与 YAML Schema 校验的对比

维度YAML SchemaTOML Schema
解析器js-yamlsmol-toml
类型陷阱yes/no/on/off 布尔化、日期自动解析日期时间类型丢失时区、64 位整数精度
多文档支持(仅校验第一个)不支持(TOML 无多文档概念)
锚点别名支持不支持(TOML 无引用复用)
Schema 引擎JSON Schema draft-07JSON Schema draft-07(复用)

2.3 JSON Schema 关键字在 TOML 上的兼容性

关键字兼容性备注
type✅ 完全兼容TOML 类型与 JSON 类型一一对应
required✅ 完全兼容
properties✅ 完全兼容
items✅ 完全兼容数组表用 items 校验
enum✅ 完全兼容
minimum / maximum⚠️ 限制超过 2^53 的整数失效
pattern✅ 完全兼容
format: "date-time"⚠️ 宽松4 种日期时间类型都通过
format: "date"⚠️ 仅 local date其他类型转 JSON 后含 T 不通过
format: "time"⚠️ 仅 local time
$ref✅ 完全兼容
if/then/else✅ 完全兼容
oneOf / anyOf✅ 完全兼容联合类型场景

三、PEP 621 pyproject.toml Schema 校验实战

PEP 621 定义了 [project] 表的标准结构,是 Python 项目元数据的事实标准。

3.1 PEP 621 核心 Schema

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "properties": {
    "project": {
      "type": "object",
      "required": ["name", "version"],
      "properties": {
        "name": {
          "type": "string",
          "pattern": "^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$",
          "maxLength": 214
        },
        "version": {
          "type": "string",
          "pattern": "^([1-9][0-9]*!)?(0|[1-9][0-9]*)(\\.(0|[1-9][0-9]*))*((a|b|rc)(0|[1-9][0-9]*))?(\\.post(0|[1-9][0-9]*))?(\\.dev(0|[1-9][0-9]*))?$"
        },
        "description": { "type": "string" },
        "readme": {
          "oneOf": [
            { "type": "string" },
            {
              "type": "object",
              "required": ["file"],
              "properties": {
                "file": { "type": "string" },
                "content-type": {
                  "type": "string",
                  "enum": ["text/markdown", "text/x-rst", "text/plain"]
                }
              }
            }
          ]
        },
        "requires-python": {
          "type": "string",
          "pattern": "^~=|==|!=|<=|>=|<|>|===?"
        },
        "license": {
          "oneOf": [
            { "type": "string" },
            {
              "type": "object",
              "properties": {
                "text": { "type": "string" },
                "file": { "type": "string" }
              }
            }
          ]
        },
        "authors": {
          "type": "array",
          "items": {
            "type": "object",
            "properties": {
              "name": { "type": "string" },
              "email": { "type": "string", "format": "email" }
            }
          }
        },
        "dependencies": {
          "type": "array",
          "items": { "type": "string" }
        },
        "optional-dependencies": {
          "type": "object",
          "additionalProperties": {
            "type": "array",
            "items": { "type": "string" }
          }
        }
      }
    },
    "build-system": {
      "type": "object",
      "required": ["requires", "build-backend"],
      "properties": {
        "requires": {
          "type": "array",
          "items": { "type": "string" }
        },
        "build-backend": { "type": "string" }
      }
    }
  }
}

3.2 校验示例:合法 pyproject.toml

[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "my-package"
version = "1.0.0"
description = "示例 Python 包"
readme = "README.md"
requires-python = ">=3.9"
license = { text = "MIT" }
authors = [
    { name = "alice", email = "alice@example.com" }
]
dependencies = [
    "requests>=2.28",
    "pydantic>=2.0"
]

[project.optional-dependencies]
dev = ["pytest>=7.0", "ruff>=0.1"]
docs = ["sphinx>=7.0"]

[project.scripts]
my-cli = "my_package.cli:main"

3.3 校验示例:常见错误

[project]
name = "My Package"          # ❌ 含空格,不符合 name pattern
version = "1.0"              # ⚠️ 不是完整 semver(PEP 440 允许但建议补全)
requires-python = ">=3"      # ⚠️ 应具体到 minor 版本
dependencies = [
    "requests",              # ⚠️ 缺少版本约束
    "numpy < 2.0"            # ❌ TOML 数组字符串不能含未转义空格在 < 前
]

本工具会逐项列出错误路径与失败规则。

四、Cargo.toml Schema 校验实战

Cargo.toml 是 Rust 项目的配置文件,由 Cargo Schema 维护。

4.1 核心 Schema

{
  "type": "object",
  "properties": {
    "package": {
      "type": "object",
      "required": ["name", "version"],
      "properties": {
        "name": {
          "type": "string",
          "pattern": "^[a-zA-Z][a-zA-Z0-9_-]*$"
        },
        "version": {
          "type": "string",
          "pattern": "^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$"
        },
        "edition": {
          "type": "string",
          "enum": ["2015", "2018", "2021", "2024"]
        },
        "rust-version": { "type": "string" },
        "license": { "type": "string" },
        "repository": { "type": "string", "format": "uri" }
      }
    },
    "dependencies": {
      "type": "object",
      "additionalProperties": {
        "oneOf": [
            { "type": "string" },
            { "type": "object" }
          ]
        ]
      }
    },
    "dev-dependencies": {
      "type": "object",
      "additionalProperties": { "type": ["string", "object"] }
    },
    "features": {
      "type": "object",
      "additionalProperties": {
        "type": "array",
        "items": { "type": "string" }
      }
    }
  }
}

4.2 校验示例

[package]
name = "my-crate"
version = "0.1.0"
edition = "2021"
rust-version = "1.70"
license = "MIT OR Apache-2.0"

[dependencies]
serde = { version = "1.0", features = ["derive"] }
tokio = "1"
reqwest = { version = "0.11", optional = true }

[features]
default = ["reqwest"]
full = ["reqwest", "tokio/full"]

4.3 64 位整数陷阱实战

Cargo.toml 的 version 是字符串,不会触发整数陷阱。但若你自定义 Schema 中有数值字段(如 priority),需要注意:

[priority]
id = 123456789012345678    # TOML 正确解析为 64 位整数
# 转 JS Number:123456789012345680(末位精度丢失)
# Schema 校验 maximum: 123456789012345678 永远失败

对策

  1. 大整数 ID 用字符串id = "123456789012345678"
  2. Schema 中限制范围"maximum": 9007199254740991(2^53-1)
  3. 本工具检测:自动扫描超过 Number.MAX_SAFE_INTEGER 的整数,给出加引号或字符串化建议

五、rust-toolchain.toml 校验实战

rust-toolchain.toml 用于声明项目使用的 Rust 工具链版本,是 CI 环境一致性的关键。

[toolchain]
channel = "1.70.0"
components = ["rustfmt", "clippy", "rust-docs"]
targets = ["wasm32-unknown-unknown", "x86_64-unknown-linux-gnu"]
profile = "minimal"

Schema 示例:

{
  "type": "object",
  "properties": {
    "toolchain": {
      "type": "object",
      "required": ["channel"],
      "properties": {
        "channel": {
          "type": "string",
          "pattern": "^(stable|beta|nightly|\\d+\\.\\d+(\\.\\d+)?)$"
        },
        "components": {
          "type": "array",
          "items": { "type": "string" }
        },
        "targets": {
          "type": "array",
          "items": { "type": "string" }
        },
        "profile": {
          "type": "string",
          "enum": ["minimal", "default", "complete"]
        }
      }
    }
  }
}

六、日期时间类型陷阱详解

6.1 时区丢失问题

# 原 TOML:明确东八区
created_at = 2024-01-15T10:30:00+08:00

# 转 JSON:变成 UTC ISO 8601 字符串
"created_at": "2024-01-15T02:30:00.000Z"

# Schema 校验:通过(format: date-time)
# 但语义已变:原意"北京时间 10:30"变成"UTC 02:30"

如果业务逻辑按 created_at 排序或计算时间差,时区偏移会让结果偏差 8 小时。

6.2 local date-time 与 offset date-time 混淆

dt_local = 2024-01-15T10:30:00      # local date-time(无时区)
dt_offset = 2024-01-15T10:30:00Z    # offset date-time(UTC)

转 JSON 后:

{
  "dt_local": "2024-01-15T10:30:00.000",   // 补全毫秒
  "dt_offset": "2024-01-15T10:30:00.000Z"  // 保留 Z
}

format: "date-time" 对两者都通过,但两者语义完全不同(一个本地时间,一个 UTC)。

6.3 local date 与 local time 校验

release_date = 2024-01-15       # local date
business_hours = 09:00-18:00    # 字符串(TOML 不支持时间范围)
open_time = 09:00:00            # local time

Schema:

{
  "release_date": { "type": "string", "format": "date" },
  "open_time": { "type": "string", "format": "time" }
}

陷阱:转 JSON 后 release_date 仍是 "2024-01-15",但 open_time 可能变成 "09:00:00""09:00:00.000"(取决于解析器),format: "time" 校验可能因毫秒失败。

6.4 工程对策

  1. 明确 Schema 中的 format:date / time / date-time 分别用对应 format
  2. 避免依赖时区:所有时间用 offset date-time 或 UTC 字符串
  3. 本工具检测:扫描所有日期时间类型字段,提示转 JSON 后的格式变化

七、64 位整数精度陷阱详解

7.1 Number.MAX_SAFE_INTEGER 边界

Number.MAX_SAFE_INTEGER      // 9007199254740991 (2^53 - 1)
Number.MAX_SAFE_INTEGER + 1  // 9007199254740992 (正确)
Number.MAX_SAFE_INTEGER + 2  // 9007199254740992 (错误,与 +1 相等)
Number.MAX_SAFE_INTEGER + 3  // 9007199254740994 (正确)
Number.MAX_SAFE_INTEGER + 4  // 9007199254740996 (错误)

JS Number 用 IEEE 754 双精度浮点数表示,超过 2^53 后整数运算开始丢失精度。

7.2 TOML 64 位整数的危险场景

# Discord snowflake ID
[bot]
id = 1234567890123456789     # TOML 正确解析
# 转 JS Number:1234567890123456800(末三位精度丢失)
# 用作 Map key 时与原 ID 不匹配

# 数据库自增 ID
[record]
id = 9223372036854775807     # 64 位最大值
# 转 JS Number:9223372036854776000(精度丢失)
# Schema maximum: 9223372036854775807 永远校验失败

7.3 工程对策

  1. 大整数 ID 用字符串id = "1234567890123456789"
  2. Schema 限制范围"maximum": 9007199254740991
  3. 用 BigInt:解析器层面支持(如 @iarna/tomlBigInt 选项)
  4. 本工具检测:扫描超过 Number.MAX_SAFE_INTEGER 的整数,提示精度风险

八、TOML Schema 编写最佳实践

8.1 日期时间字段明确 format

{
  "created_at": {
    "type": "string",
    "format": "date-time",
    "description": "ISO 8601 时间戳,建议用 offset date-time 避免时区歧义"
  },
  "release_date": {
    "type": "string",
    "format": "date",
    "description": "仅日期,YYYY-MM-DD 格式"
  }
}

8.2 大整数字段限制范围或要求字符串

{
  "id": {
    "oneOf": [
      { "type": "integer", "minimum": 1, "maximum": 9007199254740991 },
      { "type": "string", "pattern": "^\\d+$" }
    ],
    "description": "若 ID 可能超过 2^53-1,必须用字符串"
  }
}

8.3 用 additionalProperties: false 防止字段拼写错误

{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "name": { "type": "string" },
    "version": { "type": "string" }
  }
}

TOML 1.0 严格模式下,未知表头会报错,但点号键 a.b.c = 1 可能创建意外嵌套对象,additionalProperties: false 能拦截。

8.4 数组表用 items 校验

{
  "users": {
    "type": "array",
    "items": {
      "type": "object",
      "required": ["name", "role"],
      "properties": {
        "name": { "type": "string" },
        "role": { "type": "string", "enum": ["admin", "editor", "viewer"] }
      }
    }
  }
}

8.5 联合类型用 oneOf 而非 anyOf

{
  "version": {
    "oneOf": [
      { "type": "string" },
      { "type": "object", "properties": { "major": { "type": "integer" }, "minor": { "type": "integer" } } }
    ]
  }
}

oneOfanyOf 更严格,确保数据只匹配一种类型,避免歧义校验。

九、与 TOML Schema 工具的协同工作流

9.1 pyproject.toml 上线前校验

  1. TOML Schema 校验工具 粘贴 PEP 621 Schema 与 pyproject.toml
  2. 检查 name / version / requires-python 是否符合 PEP 440 / PEP 621
  3. 检查 dependencies 数组是否完整
  4. pip install -e . 安装测试

9.2 Cargo.toml 上线前校验

  1. 用本工具粘贴 Cargo Schema 与 Cargo.toml
  2. 检查 package.name / version / edition
  3. 检查 dependencies 版本约束
  4. cargo check / cargo build 编译测试

9.3 rust-toolchain.toml 校验

  1. 用本工具粘贴 toolchain Schema 与 rust-toolchain.toml
  2. 检查 channel / components / targets
  3. rustup show 验证工具链可用

十、最佳实践清单

  1. TOML 字段名遵循规则:仅字母 / 数字 / 下划线 / 连字符,首字符必须是字母
  2. 大整数 ID 用字符串:避免 JS Number 精度丢失
  3. 日期时间字段明确 format:date / time / date-time 分别用对应 format
  4. 避免依赖时区:所有时间用 offset date-time 或 UTC 字符串
  5. Schema 用 additionalProperties: false:防止点号键创建意外嵌套
  6. 联合类型用 oneOf 而非 anyOf:避免歧义校验
  7. PEP 621 项目必配 requires-python:明确支持的 Python 版本范围
  8. Cargo 项目必配 edition:避免依赖默认 edition
  9. CI 集成 Schema 校验:在 PR 阶段拦截配置错误
  10. 版本号用字符串并加引号:避免 1.10 被解析为浮点数 1.1

总结

TOML 没有像 JSON Schema 那样的原生 Schema 标准,但用 JSON Schema 校验 TOML 是工程上完全可行的方案。本文梳理了 TOML 类型系统与 JSON 的关键差异(日期时间类型、64 位整数)、PEP 621 与 Cargo.toml 实战、Schema 编写最佳实践,配合 TOML Schema 校验工具,可在本地快速校验配置并自动检测类型陷阱。

相关阅读: