GEML 与 XML / JSON —— 对照,以及一次失败的复盘
English | 中文
拿 GEML 和 XML 对比,不是因为它们像,而是因为 GEML 想解决的问题,XML 解决过一次,而且在两块地盘上输掉了。
「结构化的、可校验的、机器能精确寻址的文档」——这正是 1998 年 XML 的承诺,而且它兑现过:Office 文档、DocBook、Android 布局、Maven、RSS、SOAP,二十年里它无处不在。任何今天提出同类主张的格式,都有义务先回答一个问题:你凭什么不重蹈覆辙。
JSON 在这里不是配角。它是打赢那一仗的一方,赢法值得单独拆;更重要的是,JSON 早已是文档的机器侧表示——MDAST、ProseMirror、Slate、Lexical 的存储都是 JSON AST。所以 GEML 与 JSON 的真问题不是「谁取代谁」,而是存哪一个当事实源。
本页分三部分:逐构造对照(每格实测,方法同 GEML-vs-CommonMark_CN.md)、复盘(XML 输在哪、JSON 怎么赢的、XSLT 为什么死)、以及 GEML 的对应设计与仍然暴露的风险。
1. 逐构造对照:GEML 与 XML
| 主题 | XML | GEML | |
|---|---|---|---|
| 基本单位 | 元素 <tag>…</tag>,可任意嵌套 | 类型块 === type {attrs} … ===,围栏长度决定嵌套(§3) | 🔁 |
| 标签词汇表 | 完全开放,任何名字都合法 | 有注册类型;未知类型是 warning,正文按 raw 保留,不是错误 | 🔁 |
| 属性 | key="value",只能是字符串 | {#id .class key=val},同一个属性对象贯穿所有块类型(§4) | 🔁 |
| 元素 vs 属性 | 没有正确答案——每套 schema 设计的第一场架 | 不存在这个问题:正文是正文,属性是属性 | 🔁 |
| 唯一标识 | xml:id;唯一性要挂 DTD/XSD 才被强制 | {#id} 是核心语法,重复 id 直接 error(实测 exit 1),无需 schema | 🔁 |
| 引用 | IDREF(需 schema)、XLink、XPointer | [[#id]]、[t](#id)、other.geml#id,构建期校验,悬空即 error(§5.2) | 🔁 |
| 命名空间 | xmlns:,前缀绑定 URI | 没有。刻意不做 | ❌ |
| Schema 校验 | 外置:DTD / XSD / RELAX NG,两套体系 | 内建:geml check + 附录 A 的稳定诊断码 | 🔁 |
| 良构 vs 有效 | 两级概念,需两套工具 | 一级:诊断带固定严重级别,error 即非零退出 | 🔁 |
| 转义 | 五个预定义实体;< & 必须转义 | 反斜杠转义 ASCII 标点;不解码任何实体,& 就是字面五个字符 | ⚠️ |
| 大段原文 | <![CDATA[…]]>,且不能嵌套 ]]> | raw 正文;正文含围栏就把外层围栏加长(====),可无限嵌套 | 🔁 |
| 实体展开 | 内部/外部实体,可递归 | 只有 {{key}} 从 meta 取值,单次展开、不递归(实测:a = "{{b}}" 取出来是字面 {{b}}) | 🔁 |
| 外部实体 | SYSTEM "file:///etc/passwd" —— XXE 的来源 | 没有实体机制。 外部内容只经 src=/embed 进来,而它受 --root 圈定、scheme 受限,且解析器从不发起抓取(§9.4) | ❌ |
| 跨文档包含 | XInclude —— 独立规范,多数解析器里是可选的;靠 xml:base 修正保住目标的基 URI | 核心语法:=== embed {src=other.geml#id}、行内 ![[…]]、以及 other.geml#id 引用——构建期解析,检测嵌入成环(transclusion-cycle),范围由 --root 界定。被指名的文档是作为一篇独立文档解析的,因此在引用链的每一层,它都相对自己解析路径与元数据(§3) | 🔁 |
| 类型化数据负载 | 任意元素树;要有类型就得有 schema | === data {format=json|jsonl} —— 原样承载 JSON 的值域,构建期解析并校验,可用 #id 寻址(§3.2) | 🔁 |
| 注释 | <!-- … -->,不能含 -- | %% 行;另有 {hidden} —— 在模型里、受引用校验、但不渲染 | 🔁 |
| 混合内容 | 元素与文本自由交错 | flow 正文里的内联文法(*em*、[[#id]]、$math$) | 🔁 |
| 空白 | xml:space,处理规则复杂 | raw 正文逐字保留;flow 正文按段落规则 | 🔁 |
| 编码 | 声明在 <?xml encoding=?>,多种 | 必须 UTF-8(§0.1) | 🔁 |
| 不渲染时可读 | 差——尖括号与闭合标签淹没正文 | 这是设计约束(§1) | 🔁 |
| 变换 | XSLT(另一门语言) | 无。--to md | html | geml,固定投影 | ❌ |
| 查询 | XPath / XQuery | 无查询语言。geml get <file> '#id' 按主键取 | 🔁 |
实测到的一处不一致:未知块类型会给 warning,未知属性 key 却一声不吭({#n wibble=3} 零诊断),而未知 {{meta}} 引用是 error。同一个「拼错了」在三处有三种待遇——属性那一档最松,拼错 caption 不会有人告诉你。
2. 三方横向对照
| 维度 | XML | JSON | GEML |
|---|---|---|---|
| 规范体量 | 一打互相依赖的规范 | 十几页,且已冻结 | 一份核心规范 + 一个可选扩展 |
| 生来为了 | 文档 和 数据 | 数据交换 | 文档 |
| 映射到语言原生类型 | 差——每个绑定层都漏 | 1:1 | 不适用:正文就是文本 |
| 人手写 | 敌对 | 短的还行,散文灾难 | 设计约束 |
| 混合内容(散文里交错结构) | 原生 | 只能编码成类型化节点数组 | 原生(flow 正文) |
| 原样承载一个 JSON 值 | 只能当转义文本,或做一套有损的元素映射 | 它就是那个值 | === data {format=json} —— 同一个值域,构建期校验,并且带 id(§3.2) |
| 注释 | <!-- --> | 没有(刻意去掉) | %% 与 {hidden} |
| 唯一标识 | xml:id,要 schema 才强制 | 无(只有键路径) | 核心语法,重复即 error |
| 引用完整性 | IDREF,要 schema | 无 | 构建期强制 |
| Schema | 外置,两套体系,文化上必需 | 可选、后加(JSON Schema) | 内建诊断,无外置体系 |
| 行导向 diff | 中 | 差(顺序、缩进、尾逗号) | 好 |
| 规范形式 | 有 C14N,但是另一套规范、为签名而生 | 有 JCS(RFC 8785),实际少用 | 主工具内建:--to geml |
| 与自身模型互转 | 无对应概念 | 天然(JSON 就是模型) | 双向,且两条路径逐字节收敛(实测) |
| 遇到不认识的东西 | 任意标签合法 | 多余的键照收 | 未知类型 warning,正文保留 |
| 版本演进 | XML 1.1 几乎无人用,但解析器都得考虑 | 从不出 v2 | 1.0;spec 与实现分开计版 |
3. 先纠正一件事:XML 没有死
复盘的前提是把事实说准,否则得出的教训是假的。
XML 今天仍然牢牢占着几块地盘:Office / OpenDocument、DocBook 与 DITA 的出版工具链、XBRL(财报)、HL7(医疗)、航空航天与半导体的长周期技术文档。 共同点是混合内容 + 强 schema 校验 + 几十年的归档要求。在这些地方它至今没有对手。
它输掉的是两块特定地盘,而且输给两个不同的对手,原因完全不同:
| 地盘 | 输给 | 大致时间 |
|---|---|---|
| 数据交换 | JSON | 2005–2012 |
| 人手写的文档 | Markdown | 2004 起,2014 CommonMark 后加速 |
把这两件事混成一句「XML 太啰嗦所以死了」,是复盘里最常见的错误——它只会让你学到「别啰嗦」这条没什么用的教训。
4. JSON 是怎么赢的
「映射到原生类型」是最常被引用的答案。它对,但不完整。JSON 赢在四件事上:
一、它和程序里已有的东西一一对应。 对象、数组、字符串、数字、布尔、null——每种动态语言的原生类型就是这些。JSON.parse() 之后拿到的就能直接用。
XML 映射过去是:元素树 + 属性 + 文本节点 + 混合内容 + 命名空间 + 顺序敏感。没有任何语言的原生类型长这样,所以每个绑定层(JAXB、XmlSerializer…)都是有损的、都会漏。
二、规范小到可以一次读完,而且冻结了。 ECMA-404 / RFC 8259 是十几页;json.org 上一张铁路图就是全部语法。更关键的是它不再演进——JSON 没有也不会有 v2。
一个永不变的格式,工具链就永远不会碎。
这是 XML 的反面:XML 1.1 几乎无人使用,但每个解析器都得考虑它的存在。「规范不动」本身就是交付给生态的一项功能,而多数格式作者把它当成停滞。
三、schema 是可选的、后加的。 JSON Schema 是十多年后才出现的,且从来不是准入门槛——没有它 JSON 照用。XML 世界里 DTD/XSD 是文化必需品,「没 schema 的 XML」会被当成不专业。把校验做成可选项,等于把入门成本降到零。
四、「元素还是属性」这个问题消失了。 它在 XML 里没有正确答案,于是成了每套 schema 设计的第一场架,且永远不会有定论。JSON 只有一种写法。
在这之上,XML 还有两笔自己的账:
- 命名空间。 它解决的是真问题——独立编写的词汇表如何组合——但成本是所有人交的,收益是少数人拿的。绝大多数文档从不需要组合词汇表,却人人都要理解前缀绑定。
- 规范栈的增殖。 XSD、XPath、XSLT、XLink、XPointer、XQuery、XInclude、XMLDSig、SOAP、WSDL、WS-*。要当「XML 开发者」,你得知道一打规范。
这些账其实是同一笔:复杂度的成本被摊给了每一个碰到它任何一部分的人。
这才是 XML 输掉数据交换的真正原因。不是尖括号啰嗦——是没有一条「你可以不知道这部分」的路。
5. JSON 的短板,正是 GEML 站的位置
JSON 赢了数据交换,但它从来没有赢下文档,而且不是因为没人试。
- 没有注释。 这是刻意去掉的——因为有人拿注释塞解析指令。理由成立,代价是 JSON5、JSONC、YAML 全都是为了补这一刀而存在。
- 数字欠规定。 没有整数与浮点之分;int64 进 JS 变 float64 就丢精度。
- 没有日期类型。 人人重新发明「ISO-8601 塞进字符串」。
- 散文是灾难。 全部内容是转义字符串,换行写成
\n。不可读、不可 diff、不可 grep。 - 没有混合内容。 一段散文里交错着强调、链接、引用——JSON 只能把它编码成嵌套的类型化节点数组。机器完全可用,人类完全不可读。
最后一条是决定性的,而且这条路已经有人走完了:MDAST、ProseMirror、Slate、Lexical、Tiptap、Notion、Contentful——现代富文本工具的存储几乎全是 JSON AST。
它的失败模式有据可查:不能 diff,不能 grep,离开那个 app 就编辑不了。 一次 code review 里看到的是几百行节点数组的重排;想知道「谁改了这句话」,git 帮不上忙。
这就是「状态黑盒」,但例子不是 Word——是今天最现代的那批工具。
所以 GEML 的位置不是「JSON 的竞争者」。而是:当你需要一份长期存在、人要读、机器要改、还要进 git 的文档时,「存 JSON AST」是那个已经被试过、且失败模式已知的选项。
5.1 反过来说:GEML 是承载 JSON,而不是跟它争
data 块(§3.2)原样接下 JSON 的值域——标量、序列、映射——作为一个构建会解析的类型块;正文格式不对,就是一个错误,并指出出错的那一行。这从另一侧把缺口补上了:
=== data {#limits format=json}
{ "retries": 3, "timeout_ms": 500 }
===- 这个值带着 id,因此可被引用、可按块编辑(
geml get/set)、可用与散文同一套机制做版本管理——这是文档旁边一个裸.json文件做不到的事。 format=jsonl是记录流形态。因为一篇文档就是一串扁平的块,在文件末尾追加一个完整的data块,对任何文档都是合法的续写:jsonl 那种闭眼追加的手感,外加校验。src=events.jsonl让记录留在一个现有工具都能 append、能 tail 的普通文件里。GEML 文档成为它受校验、可寻址、可制图的视图,而不是第二份副本。同时写正文和src=是错误——来源永远只有一个。- 当
data块的值是记录数组时,它可以直接喂给geml-chart(§7.1):服务写下的那串字节,就是图表画的那串字节。 csv/tsv被刻意排除在data的 format 之外。进入这张注册表要求语法是自描述的——只看字节就能定出值。分隔文本达不到这条(分隔符、有无表头、引号规则都是参数,而且只有对着列模型才有意义),所以它留在table那边(§6)。yaml与toml是保留名,没有对应引擎的处理器必须保留原始正文并告警,而不是去猜。
由此得到的定位,比「与 JSON 竞争」更窄、也更站得住:做数据交换就用 JSON——而当这份数据属于某篇文档时,GEML 会承载同样的字节,并且校验它们。
6. XSLT 为什么失败
这一节值得单独写,因为「把一种能力挂在文档旁边」这件事,GEML 已经做了一次——.gemlhistory——而 XSLT 是同一件事最著名的一次失败。
XSLT 不是被「太啰嗦」杀死的。真正的死因有五条,按杀伤力排序:
一、它在作者不工作的那道缝上做了切分。
这是最深的一条,也最容易学错。CSS 做的是名义上同一件事——把表现从内容里分出去——而 CSS 成功了。差别在哪?
- CSS 从不要求内容为它改变(class 是可选的,选择器能靠结构走),而 XSLT 要求你用一套为变换设计的词汇表来写内容;
- CSS 的反馈是即时的(改一行,浏览器立刻重画),而 XSLT 在按键与结果之间插进了一个编译步骤和另一门语言。
缝没切错,切断的是反馈回路。 作者迭代的单位从来不是「内容」或「表现」,而是两者构成的那一对。
二、它一次要求学会三样陌生的东西。 函数式(模板递归、无可变状态)+ 树模式匹配(XPath)+ 全程透过尖括号读。任何一样单拿出来都能学会,三样叠在一起就超出了预算。<xsl:for-each> 是一种很贵的写循环的方式。
三、调试基本为零。 模板匹配冲突按优先级规则解决,而那套规则没几个人真正掌握。输出不对时没有调用栈,只能靠删减法二分。
四、模板引擎从下面把它吃掉了。 ERB、JSP、Smarty,后来的 Jinja、Handlebars、JSX——用你已经会的语言,10% 的学习成本,拿到 90% 的价值。 一个需要专门学一门语言的方案,对手是「你已经会了」,这仗没法打。
五、最好的实现是商业的。 XSLT 2.0(2007)和 3.0(2017)是真的好——流式处理、包、高阶函数。但那时受众已经走了,而且好东西主要活在 Saxon-EE 里。
一个标准,如果它最好的实现要付费,它就已经丢掉了默认位置。
这条对 GEML 的意义不在「别收费」(GEML 是 MIT + CC-BY),而在它的镜像形式:GEML 目前只有一个实现。 单一实现与单一商业实现,在「这个格式能不能脱离特定供应方存在」这个问题上,是同一类风险。这也是 GOVERNANCE.md 把「第二个独立实现」定为验收标准、而不是锦上添花的原因。
7. GEML 已经避开了什么
把上面的死因逐条对回来。这些不是运气,是写在约束里的(核心规范 §1):
| 死因 | GEML 的对应设计 |
|---|---|
| XML:规范栈增殖,成本全民摊 | 一个原语:=== type {attrs} 覆盖代码/表格/公式/图形/提示框/元数据 |
| XML:命名空间 | 不做。类型词汇表扁平、可扩展,未知类型降级为 raw 而非报错 |
| XML:元素 vs 属性的永恒争论 | 正文与属性职责分明,没有可争的 |
| XML:手写敌对 | 不渲染即可读是硬约束(§1)——从 Markdown 学的那一条 |
| XML:外部实体 / XXE | 没有实体机制。{{key}} 只从本文档 meta 取值。内容嵌入(embed、src=)确实会伸到文档之外,处理方式是设栅栏而不是一禁了之:受 --root 圈定(§9.4),允许清单之外的 URL scheme 一律拒绝,http(s) 交给渲染器——解析器从不发起抓取 |
| XML:实体递归 / billion laughs | 单次展开,a = "{{b}}" 取出来是字面 {{b}}(实测)。内容嵌入本身是递归的,因此改用查环来兜底:链上已在展开中的文档再次出现,就是 transclusion-cycle 错误 |
| XML:ID 唯一性要靠 schema | 核心语法就管,重复 id 是 error |
| XML:校验要外挂两套体系 | geml check 内建,诊断码稳定 |
| XSLT:另一门语言 | 不做变换语言,只有三个固定投影 |
| JSON:没有注释 | %% 行 + {hidden}(在模型里、受校验、不渲染) |
| JSON:散文塞进转义字符串 | raw 正文逐字保留 + 围栏升级,无需转义 |
| JSON:没有混合内容 | flow 正文原生支持内联结构 |
从 JSON 学来的三条:规范面小到能一次读完;schema 不做准入门槛(未知类型只 warning,对应 JSON「多余的键照收」);以及最重要的——映射到消费方已有的动作,见下节。
8. GEML 与 JSON 的往返能到什么程度
两个方向都通:
$ geml doc.geml --to json -o doc.json # 文档模型
$ geml doc.json --from json --to geml # 回到 GEML那么往返到什么程度?决定性的实验是拿两条路径对撞:直接规范化(--to geml),与绕一圈 JSON 再回来(--to json 然后 --from json --to geml)。
在一份专门踩边界的文档上(meta 带引号、%% 注释、自动派生 id 与显式 id 各一个、五个 = 的围栏且正文里含一整行 ===、乱序属性、混合引号、行尾空格、跨块引用):
$ diff direct.geml viajson.geml
$ # 无输出——逐字节相同两条路径落到同一份规范形式,逐字节相同;再往返一次幂等;check 零诊断;%% 注释、行尾空格、跨块引用全部保留。
所以结论是语义完全还原,逐字节不还原——而后半句的「不还原」只针对原始书写风格,不针对内容。
| 源码特征 | 在模型里 | canonical 往返后 | 判定 |
|---|---|---|---|
| raw 正文逐字(含行尾空格) | ✅ | 原样 | 不丢 |
%% 注释 | ✅ kind:"hidden" | 保留 | 不丢 |
meta 键顺序 | ✅ 对象插入序 | 保留 | 不丢 |
.class 顺序 | ✅ 数组 | 保留 | 不丢 |
| 围栏长度 | ❌ | 按正文重算最小可行值 | 可推导,不算丢 |
| 标题 id 是显式还是派生 | ❌ | 一律显式写出 | 可判定:算一遍派生值,相同即可省略 |
标签式闭合 === #id | ❌ | 统一成 === | 丢,纯书写风格 |
| 属性书写顺序 | ❌ | 规范序 #id .class key= | 丢,纯书写风格 |
| 属性值引号 | ❌ | 一律加引号 | 丢,纯书写风格 |
meta 值引号 | ❌ | 一律去引号 | 丢,纯书写风格 |
| 空行数量 | ❌ | 归一 | 丢,纯排版 |
三条结论:
一、模型是语义完备的,这一点现在是测出来的,不是推出来的。 「绕一圈 JSON」和「不绕」落在同一个字节上,意味着模型没有丢任何影响文档含义的东西。--from json 也因此不是一项独立能力——它是同一个规范序列化器换了个入口。
二、被归一掉的全是书写风格,没有一项改变语义。 其中两项甚至不算「丢失」,而是可推导的:
- 围栏长度由正文决定。实测一份正文含整行
===的文档,原文用 5 个=,规范化后正确降为 4 个——最小可行值,正文分毫不差。 - 标题 id 是显式写的还是自动派生的,模型里没有这个区分,但派生值是标题文本的函数,算一遍就知道;规范形式选择一律显式写出。
真正丢掉的只有:标签式闭合 === #id、属性书写顺序、引号风格、空行数量。这些是排版偏好,不是信息。
三、因此「同构」这条定律可以改成一句能站住、而且更强的话。 原稿说「绝对不丢失任何排版信息和语义」——后半句对,前半句不对,排版一定被归一。准确的说法是:
模型是语义完备的;序列化是规范化的,不是逐字节的。
而「规范化」本身是一项能力,不是退让:它意味着语义相同的两份文档必然收敛到同一份字节,所以 diff 里剩下的一定是真实改动,不是排版噪声。
准确地说,规范形式不是 GEML 独有——XML 有 C14N(为数字签名而生),JSON 有 JCS(RFC 8785)。区别在于那两个都是另一套规范、需要另找实现、实际很少被用,而 GEML 的规范形式就是主工具的一个输出目标(--to geml),并且已验证与绕道 JSON 的结果逐字节一致。CommonMark 则连这个概念都没有。
这条比吹「逐字节同构」更硬,因为它可验证——上面那条 diff 就是验证方法。
顺带一处不一致:规范化给块属性加引号(
lang=py→lang="py"),却给meta值去引号(zebra = "last key"→zebra = last key)。同一个序列化器,两套相反的约定。
不过即便往返已经通了,agent 的写路径仍然不该走整份 AST 回灌——那正是「把整篇读进来、整篇吐回去」,GEML 要消灭的就是这个动作。合理的分工是:
- 读:要结构化视图给 JSON(
--to json),要单块按块给(geml get '#id'); - 写:走块操作(
geml set / add / delete / rename / revert)。
这也是 JSON 那条正面教训的落点。JSON 赢在映射到消费方已经有的东西——语言的原生类型。GEML 的对应问题是:它映射到 agent 已经在做的事情上了吗?
- agent 的原生动作是工具调用,不是文件覆写 → 上面那组命令一一对应;
- agent 的运行环境是 MCP → 一行
claude mcp add接进去,十一个 tool; - agent 最怕改坏了不知道 → 写入前先解析,坏的带着诊断被拒。
这是 GEML 最像 JSON、最不像 XML 的一面:它不要求 agent 学会一个新范式,而是把自己做成 agent 已有动作的形状。
9. GEML 还暴露在哪里
诚实地列,因为这些是真的:
一、sidecar 模式正是 WS-* 的胚胎形状。
.gemlhistory 目前是唯一一个 sidecar,但它是一个模式而非一次性设计——而「一族以点号后缀区分的伴生规范」和当年 XML 那张规范表,形状上没有区别。每多一个,都往那个方向走一步。
挡住它的只有一条规则,而这条规则是历史扩展规范自己写下的(§1):
「历史层是可选的。它的存在只由同名
.gemlhistory文件是否存在来宣告。」 不实现这个扩展的处理器不受影响——.geml仍是一份完整、合法、可渲染的文档。
XML 的命名空间没有这条性质:你不能「忽略」一个命名空间,遇到就得处理它。这就是全部的区别。任何一份让「不认识它的工具也必须跟着改」的伴生规范提案,都是在把 GEML 变成 XML——这应当是接受任何新 sidecar 的前置门槛,而不是风格建议。
二、任何「表现层 sidecar」都是 XSLT 那道缝。
把样式挂到块 id 上,是最容易被提出、也最危险的方向:它切的正是 §6 里 XSLT 切错的那道缝。要提这类提案,第一段就得回答「凭什么不重蹈覆辙」,而不是把这个问题留到最后当风险项。
三、构建期校验是一道税,而 XSLT 死于税。
geml check 在按键和「确认没写坏」之间插了一步——形状上就是 XSLT 那个编译步骤。GEML 目前站在安全一侧,因为文件不校验也能读、也能渲染,校验只在写入路径上强制。这个性质必须守住:一旦某天「不跑 check 就看不了文档」,XSLT 的死法就复现了。
四、迁移是「切换」,不是「滑入」。
Markdown 当年能铺开,一个被低估的原因是它允许内嵌 HTML——你可以只用 10% 的 Markdown,剩下照旧写 HTML,零风险试水。JSON 同理:它从不要求你放弃什么,JSON.parse() 就能开始。
GEML 刻意关掉了原始 HTML 逃逸口(§1.5),换来语义与后端无关,代价是没有渐进路径:geml notes.md 转进来是一次性切换,转回去是有损的。
这是 GEML 目前最硬的采纳障碍,比生态、比工具都硬。 清楚地知道它是什么,比假装它不存在有用。