1. 这个痛,每个写配置的人都遇到过
先说个我自己的经历。之前维护一个数据同步服务,配置文件是标准 JSON,里面有个字段是目标表名。某天同事手动往配置里加了一个新表,手滑在最后一个字段后面多留了一个逗号——就是那种肉眼几乎看不出来的尾随逗号。结果服务启动后反序列化直接抛异常,他盯着配置看了十分钟愣是没发现哪里错了。后面我过去一看,好家伙,标准 JSON 规范里根本不允许尾随逗号,解析器一概报错。那一整天的排查成本,就栽在一个逗号上。
另一个更常见的场景,是配置里想写注释。做大数据同步的应该都有体会,DataX 那种 JSON 格式的作业配置,字段一多,想给每个字段写一行说明,结果发现 JSON 压根不支持注释。你写个 // 上去,解析器直接红脸。最后只能用 "字段注释" 这种 key 硬塞进去,等运行时再忽略掉。丑是丑了点,但确实没别的办法。
这其实就是标准 JSON 用来做配置文件时的先天缺陷:它是一门严格的数据交换语言,不是给人手写维护的配置格式。JSON 设计之初的定位是机器与机器之间、程序与程序之间交换数据,语法严格到近乎苛刻,为的是序列化和反序列化时没有任何歧义。但配置文件不一样,配置文件是给人看的、给人改的,人需要注释来解释每个字段的含义,也需要容忍尾随逗号这种小失误。
这个矛盾怎么解决?业界其实已经有一堆答案了:JSON5、JSONC、HOCON、YAML,或者干脆自己写一个宽松版 JSON 解析器。这篇我就把我这几年的实践经验完整梳理一遍,从方案选型对比到具体代码实现,再到生产环境里的坑,一次讲透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先搞清楚问题本质:JSON 的严格语法到底卡在哪
在聊解决方案之前,我建议大家先花两分钟把 JSON 的语法限制想清楚。因为很多人的需求其实没有一开始想的那么复杂,搞清楚限制在哪里,才能判断自己到底适合引入重型依赖还是写个小工具函数就够了。
2.1 标准 JSON 对配置文件不友好的三个具体表现
标准 JSON 的语法规定其实很短,RFC 8259 里写得很清楚。但就是这几条规定,在配置场景下处处扎手。
第一,不允许注释。JSON 的语法树里根本不存在注释节点。// 和 /* */ 哪怕出现在字符串外面,也都会被当成非法字符。这直接导致了一个特别反直觉的事实:JSON 的发明者 Douglas Crockford 后来在多种场合说过,JSON 设计成这样是为了防止“注释被用来破坏兼容性”——因为各家解析器对注释的处理不一样,就会产生方言。
第二,不允许尾随逗号。数组和对象的最后一个元素后面多了一个逗号,语法检查直接失败。但人写东西的习惯偏偏就是会在最后一项后面顺手加个逗号,尤其是从别的语言复制粘贴过来的时候。
第三,字符串必须双引号。单引号、反引号、不带引号的裸 key 在标准 JSON 里都是非法的。写配置文件的时候,尤其是从 JavaScript/TypeScript 习惯转过来的人,很容易写顺手。
2.2 配置文件的真实需求清单
结合我这些年用过的各种配置系统的经验,配置文件场景的真实需求其实可以列成一张清单:
| 需求 | 标准 JSON | 说明 |
|---|---|---|
| 注释解释字段含义 | 不支持 | 团队协作中最刚需的能力 |
| 容忍尾随逗号 | 不支持 | 减少手工编辑时的心智负担 |
| 多行字符串 | 不支持 | 写 SQL、正则、密钥块时极其不便 |
| 单引号/裸 key | 不支持 | 与日常写代码的习惯有冲突 |
| 可读性 | 尚可 | 嵌套深了确实难读 |
| 与 JSON 生态兼容 | 完全兼容 | 最大的优势 |
所以你看,需求其实分两类:一类是“最好有”的(注释、尾随逗号),另一类是“锦上添花”的(单引号、裸 key、多行字符串)。不同的人对这两类的需求程度不一样,这直接影响后续选型。
3. 主流宽松方案的横向对比,以及我为什么最终选了自研
如果你也有“JSON 配置文件想加注释和尾随逗号”的需求,市面上能用的方案大致有这几类。我一个个说,每个都说说优点和暗坑。
3.1 JSON5:功能最全的 JSON 超集
JSON5 是 JSON 的官方超集,设计目标就是把 JSON 扩展成 ES5 语法的一个子集。它支持注释(行注释和块注释)、尾随逗号、单引号字符串、双引号字符串、裸 key、十六进制数字、正负 Infinity、多行字符串。
如果你用的是 Python,有 pyjson5;如果是 JavaScript,有官方 json5 包;Java 也有 org.json:json 的扩展版本。功能确实全,几乎能解决我上面列的所有痛点。
但 JSON5 有一个问题:它把语法放宽太多了。裸 key、单引号、十六进制数字这些特性,在很多场景下是好事,但在配置管理场景下反而是风险。配置文件的编写者可能是运维、数据分析师,不是程序员,语法太灵活意味着不同人写出来的配置风格千差万别,规范化成本很高。而且,如果你的配置需要被多个语言(比如 Python 服务 + Java 服务 + Node 脚本)同时读取,语言之间的 JSON5 解析实现不完全一致,字段顺序、错误报文都不太统一,排查问题的时候会比较难受。
3.2 JSONC:主要是编辑器生态在用
JSONC,全称叫 JSON with Comments,是 VS Code 这一派推进出来的概念。它允许在 JSON 里写注释(通常是 //),但其他地方保持标准 JSON 的语法要求——尾随逗号仍然不允许。
这个东西的好处是易实现。你写一个小的注释剥离器(把注释去掉再丢给标准 JSON 解析器),100 行以内就能搞定跨语言版本。坏处也明显:没有标准化规范。不同编辑器支持的 JSONC 注释语法不完全一致,有的支持 //,有的只支持 /* */。而且在标准 JSON 解析器面前,JSONC 不是一个合法的 JSON,所以如果你只是想要“稍微宽松一点”,JSONC 是个轻量选择;但如果你还想要尾随逗号,它就无能为力了。
3.3 HOCON:为配置而生的格式
HOCON(Human-Optimized Config Object Notation)是 Typesafe(现在叫 Lightbend)公司为 Scala/Java 的 Typesafe Config 库设计的。它支持注释、尾随逗号、可以省略引号、支持变量替换和文件 include。如果你写的是 JVM 系的应用,HOCON 确实是一个非常优秀的配置格式。
但 HOCON 的问题是:生态绑定太死。在 Python、Node 里用 HOCON,解析器都是第三方移植的,成熟度和维护频率参差不齐。而且 HOCON 的语法远比 JSON 复杂,学习成本高。如果你的团队不是 JVM 系,引入 HOCON 的收益并不大。
3.4 YAML:另一个方向的答案
YAML 的定位就是“比 JSON 更适合人类书写”。它天然支持注释,不需要尾随逗号(因为列表和对象靠缩进表达),字符串也不用引号。我确实见过很多项目把配置文件从 JSON 迁到 YAML,感觉确实舒服。
但 YAML 的问题也有两个。一是缩进敏感,多一个空格少一个空格就解析崩溃,这个问题在复制粘贴场景里特别烦人。二是类型解析太聪明,on、yes、no 会被解析成布尔值,时间字符串会被解析成日期对象,这种隐式类型转换在配置场景里很容易埋下隐蔽的 bug。后来 YAML 1.2 规范里修掉了一部分,但很多语言的实现还是旧行为。
3.5 我的选型结论
我最终的选择是:自研一个轻量的“JSON 宽松版解析器”,只放宽两个点——注释和尾随逗号。
理由很简单:第一,这两个点解决了 80% 的配置烦恼,但不会引入太多其他方言特性,配置文件的风格仍然是标准 JSON 的样子,团队成员不需要学新语法;第二,实现成本低,代码量控制在几百行内,还能按团队需求定制错误提示;第三,跨语言实现非常容易,Python、JS、Java 各写一个也不费劲。
4. 手写一个支持注释和尾随逗号的 JSON 解析:从设计到代码
下面进入正题。我以 Python 为首要实现语言,因为这个需求在 Python 生态里最常见(尤其是数据处理、自动化脚本、爬虫相关的配置),然后给出 JavaScript 版本的关键差异点。你先理解设计思路,代码直接可以抄。
4.1 总体设计思路:先剥离注释,再处理尾随逗号,最后交给标准解析器
很多人的第一反应是“改 JSON 解析器本身”。但我的经验是:不要碰解析器。JSON 的语法解析是一个已经被标准库实现得很好的东西,你非要自己重写一遍完整解析逻辑,等于重新发明轮子,而且容易在边界 case 上翻车。
稳妥的做法分两步走:
- 剥离注释:写一个状态机,逐字符扫描文本,把注释内容替换成空白字符(空格或换行),保留原始字符串内容不变。这一步的目的是让“带注释的 JSON”变成“标准 JSON”。
- 处理尾随逗号:用正则或者状态机,把对象和数组最后一对键值对后面的逗号去掉。这一步也是文本层面的清理,不涉及语法解析。
- 把清理后的文本交给标准库的
json.loads()解析。
为什么是先剥离注释再处理尾随逗号?因为注释里面可能包含 , 字符,如果先处理尾随逗号,会把注释里的逗号误伤。而先剥离注释,把注释内容全部替换掉,后面做尾随逗号处理时就不会受干扰。
4.2 注释剥离状态机的完整实现
先说思路。JSON 里的字符串是用双引号包裹的,字符串内部可能包含 //、/*、*/ 这些字符,它们不应该被当成注释处理。所以需要一个标志位 in_string,记录当前正在扫描的字符是否处于字符串内。
同时还要考虑转义字符。字符串内部的 \" 表示的是两个字符:反斜杠和双引号,这个双引号不应该被当成字符串结束标志。所以我又加了 escape 标志位。
python复制def strip_json_comments(
text: str,
preserve_blocks: bool = False
) -> str:
"""移除 JSON 文本中的注释,返回纯 JSON 文本。
preserve_blocks 为 True 时,块注释保留为一个空格,
否则整块注释替换为等长的空格(保持行号和列号不变)。
"""
result = []
i = 0
n = len(text)
in_string = False
escape = False
while i < n:
ch = text[i]
if in_string:
result.append(ch)
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
i += 1
continue
# 不在字符串内
if ch == '"':
in_string = True
result.append(ch)
i += 1
continue
# 行注释:// 到行尾
if ch == "/" and i + 1 < n and text[i + 1] == "/":
j = i + 2
while j < n and text[j] not in "\r\n":
j += 1
# 用空格填充,长度与注释一致,保持字符位置
result.extend(" " * (j - i))
i = j
continue
# 块注释:/* ... */
if ch == "/" and i + 1 < n and text[i + 1] == "*":
j = i + 2
while j < n and not (text[j] == "*" and j + 1 < n and text[j + 1] == "/"):
j += 1
if j >= n:
raise ValueError("JSON 中存在未闭合的块注释")
# j + 1 指向 '/'
result.extend(" " * ((j + 2) - i))
i = j + 2
continue
result.append(ch)
i += 1
return "".join(result)
这里有个关键设计决策:注释替换成空格而不是直接删除。为什么要这么做?因为 JSON 报错信息通常会告诉你“第几行第几列有问题”,如果直接把注释删除,后面的内容会往前移,导致报错位置与实际文件位置对不上,排查时非常痛苦。替换成等长的空格,行号和列号完全不变,报错定位就非常准确。
还有一个细节:行注释遇到 \r\n 时,\n 前面的 \r 也不用保留?不对,\r 属于行尾的一部分,如果替换成空格,\r\n 就变成 \n,虽然不影响 JSON 解析,但最好还是保留原始换行符。上面的代码里,\r 也会被空格替换,但实际上我更推荐保留 \r。我把这个作为一个改进点,你可以在自己的实现里优化。
4.3 尾随逗号处理:用状态机精确识别,不用正则硬撸
尾随逗号的场景分两种:
json复制{
"name": "test",
"age": 18,
}
和:
json复制[
1,
2,
3,
]
对象最后一个键值对后面、数组最后一个元素后面的逗号,应该去掉。但有个例外要注意:空对象 {} 和空数组 [] 内部没有逗号,不需要处理。而且,单个元素的对象和数组也不能误伤。
之前我第一版做的时候,直接用了正则 /,(\s*[}\]])/g,效果还行,但后来被一个 case 打败了:
json复制{
"message": "hello, world]",
"status": "ok",
}
正则匹配到字符串内部的 , 和后面的 ] 时,会误以为 "world]" 里的 ] 是数组结束符,然后把这个逗号删掉。结果字符串内容被篡改了。
所以正则方案不可靠,还是得用状态机。这次的状态机要识别:当前是否在字符串内、当前是否遇到 } 或 ]、以及逗号前面是否还有有效内容。
python复制import re
def strip_trailing_commas(text: str) -> str:
"""去掉 JSON 中数组和对象末尾的尾随逗号。"""
result = []
i = 0
n = len(text)
in_string = False
escape = False
while i < n:
ch = text[i]
if in_string:
result.append(ch)
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == '"':
in_string = False
i += 1
continue
if ch == '"':
in_string = True
result.append(ch)
i += 1
continue
# 尝试匹配:逗号 + 若干空白 + 右括号
if ch == ",":
# 向后看
j = i + 1
while j < n and text[j] in " \t\r\n":
j += 1
if j < n and text[j] in "}]":
# 是尾随逗号,跳过它,只保留后续空白
# 空白需要保留,因为要保持位置
i += 1
continue
result.append(ch)
i += 1
continue
result.append(ch)
i += 1
return "".join(result)
这里的设计思路是:当遇到一个逗号时,往后看,跳过所有空白字符,如果下一个非空白字符是 } 或 ],说明这个逗号是尾随逗号,直接丢弃。注意这里我 i += 1 跳过逗号后,后续的空白字符会在下一轮循环里被普通逻辑追加到 result,所以空白是保留的。
4.4 更稳的做法:一个状态机同时处理注释和尾随逗号
如果你不想跑两遍扫描,可以把两个状态机合并成一个。但我的建议是:分开写,逻辑更清晰,测试更好写。先剥离注释,再处理尾随逗号,两个函数各自职责单一,调试的时候也方便定位。
不过在实际使用中,两个函数合起来有一个坑:第一个函数剥离注释后,会把注释区域变成空格,如果注释里恰好有尾随逗号的样子,比如:
json复制{
// 这里有个逗号,,
"name": "test",
}
注释被替换成空格后,逗号也没了,所以第二步不会误伤。但如果顺序反过来,先处理尾随逗号再剥离注释,注释里的逗号就可能被误删。所以先剥离注释、再处理尾随逗号的顺序不能颠倒。
4.5 最终的解析入口函数
python复制import json
def parse_jsonc(text: str) -> dict:
"""解析带注释和尾随逗号的 JSON 配置。"""
cleaned = strip_json_comments(text)
cleaned = strip_trailing_commas(cleaned)
return json.loads(cleaned)
就这么简单。用的时候:
python复制config_text = """
{
// 服务端口
"port": 8080,
/* host 地址 */
"host": "127.0.0.1",
"features": [
"fast",
"safe",
],
}
"""
config = parse_jsonc(config_text)
print(config)
# {'port': 8080, 'host': '127.0.0.1', 'features': ['fast', 'safe']}
这个函数我在实际项目中已经用了两年多,处理过几百个配置文件,目前没出过问题。
5. JavaScript/TypeScript 等语言里的等价实现
Python 写完了,但实际项目里经常碰到 JavaScript 环境。比如 Node.js 的配置文件、前端构建脚本的配置。JavaScript 的 JSON 解析就是 JSON.parse(),标准严格,同样不支持注释和尾随逗号。
5.1 JavaScript 版注释剥离
JS 版本的思路完全一样,只是语法上略有差异:
javascript复制function stripJsonComments(text) {
let result = '';
let i = 0;
const n = text.length;
let inString = false;
let escape = false;
while (i < n) {
const ch = text[i];
if (inString) {
result += ch;
if (escape) {
escape = false;
} else if (ch === '\\') {
escape = true;
} else if (ch === '"') {
inString = false;
}
i++;
continue;
}
if (ch === '"') {
inString = true;
result += ch;
i++;
continue;
}
if (ch === '/' && text[i + 1] === '/') {
let j = i + 2;
while (j < n && text[j] !== '\n' && text[j] !== '\r') {
j++;
}
result += ' '.repeat(j - i);
i = j;
continue;
}
if (ch === '/' && text[i + 1] === '*') {
let j = i + 2;
while (
j < n &&
!(text[j] === '*' && text[j + 1] === '/')
) {
j++;
}
if (j >= n) {
throw new Error('未闭合的块注释');
}
result += ' '.repeat(j + 2 - i);
i = j + 2;
continue;
}
result += ch;
i++;
}
return result;
}
5.2 JS 版尾随逗号剥离
javascript复制function stripTrailingCommas(text) {
let result = '';
let i = 0;
const n = text.length;
let inString = false;
let escape = false;
while (i < n) {
const ch = text[i];
if (inString) {
result += ch;
if (escape) {
escape = false;
} else if (ch === '\\') {
escape = true;
} else if (ch === '"') {
inString = false;
}
i++;
continue;
}
if (ch === '"') {
inString = true;
result += ch;
i++;
continue;
}
if (ch === ',') {
let j = i + 1;
while (j < n && /[\s]/.test(text[j])) {
j++;
}
if (j < n && (text[j] === '}' || text[j] === ']')) {
i++;
continue;
}
result += ch;
i++;
continue;
}
result += ch;
i++;
}
return result;
}
用的时候:
javascript复制function parseJsonc(text) {
const cleaned = stripTrailingCommas(stripJsonComments(text));
return JSON.parse(cleaned);
}
5.3 注意:不同运行时对正则表达式的性能差异
在 JS 版本里,我用了一个正则 [\s] 来测试空白字符,这个在 V8 引擎里没问题。但如果你处理的是超大配置文件(几十 MB 级别),每遇到一个逗号就执行一次正则匹配,性能会有一定损耗。这时候建议改成 text[j] === ' ' || text[j] === '\t' || text[j] === '\n' || text[j] === '\r' 这样的纯字符比较,虽然丑一点但快得多。
同样的思路可以扩展到其他语言。原则就一条:状态机扫一遍,字符串内的内容绝对不动,字符串外的注释和尾随逗号处理掉。
6. 其他语言生态里的现成库:什么时候用库,什么时候自己写
前面说的是自研方案,但有些时候你的项目里已经引入了某些库,或者团队规范要求少造轮子,那用现成库完全合理。我根据自己的使用经验,把几个主流语言里值得用的库列一下。
6.1 Python:json5 库和 commentjson 库
Python 里最出名的两个库是 json5 和 commentjson。
json5 是 JSON5 规范的标准实现,功能全,但它把语法放宽得比较多(支持单引号、裸 key 等)。如果你需要一个格式上和 JSON5 规范完全对齐的工具,选它。
commentjson 这个库的名字很直白,就是“带注释的 JSON”。它的实现思路和我上面写的自研方案很接近:先用正则剥离注释,再用标准 json 模块解析。但它不处理尾随逗号,这是个短板。
如果你用的是 Python 3.9 及以上版本,我倒是发现一个取巧的办法:标准库 ast 模块的 literal_eval 函数可以解析字面值表达式,而 Python 的 dict/list 语法天然允许尾随逗号。所以:
python复制import ast
def parse_jsonc_with_ast(text: str) -> dict:
# 先剥离注释
cleaned = strip_json_comments(text)
# 用 ast.literal_eval 解析,它支持尾随逗号
return ast.literal_eval(cleaned)
但这个方法有风险:ast.literal_eval 能解析的不只是 JSON 兼容的写法,它还会把 True 解析成布尔值(虽然 JSON 里应该是 true)、None 解析成 None(JSON 里没有)。所以如果你希望严格保持 JSON 语义,还是用标准 json.loads 加手动处理尾随逗号更稳妥。
6.2 JavaScript/Node.js:json5 包和 strip-json-comments 包
Node 生态里,json5 就不用多说了,功能最全的 JSON5 解析器。strip-json-comments 是一个只做注释剥离的轻量包,代码量极简,而且处理字符串内注释的边界情况做得比较好,很多 CLI 工具(比如 eslint 的配置文件加载)内部就在用它。
如果你用 strip-json-comments,尾随逗号可以配合一个正则处理,或者像我前面写的那样自己写个 stripTrailingCommas。
6.3 Java:何时值得引入轮子,何时只写工具类
Java 生态里我没有找到特别理想的 JSONC 解析库,常见的做法是用 Jackson 或 Gson 的自定义配置。Jackson 有一个 JsonFactory 的特征开关,但默认不支持注释。实际上 Jackson 官方推荐的做法是:配置文件用 YAML 或 Properties,而不是 JSONC。
如果只是在 Java 项目里偶尔用一下 JSONC,我建议别引库,直接写一个小的工具类。Java 字符串处理虽然啰嗦,但这种简单场景几十行代码也够用了。你完全可以参考上面的 Python 状态机思路,翻译成 Java 即可。
6.4 选型决策表
我把结论整理成一张表,方便你根据自己项目的情况对照选择:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| Python 数据处理/脚本 | 自研 strip_json_comments + strip_trailing_commas |
轻量、无依赖、可控 |
| Python 重度使用 JSON5 语法 | json5 库 |
规范齐全 |
| Node.js 项目 | strip-json-comments + 自写尾随逗号处理 |
轻量 |
| Node.js 全功能需求 | json5 包 |
规范齐全 |
| Java 项目偶尔用 | 自研工具类 | 避免引入重量级依赖 |
| JVM 系应用,团队接受新格式 | HOCON/Typesafe Config | 配置能力最强 |
| 纯 JSON 文本,不需要注释 | 保持标准 JSON | 不要自找麻烦 |
7. 生产环境落地时我踩过的坑和排查过的问题
最后一部分,我把自己在真实项目里遇到的几个问题和排查过程写出来。这些问题不是你写个解析器就完事了,而是真正在集成、部署、维护阶段才会暴露出来的。
7.1 坑一:BOM 头导致的第一个字符解析失败
有一次同事反馈配置文件解析报错,提示第一行第一列有非法字符。我打开文件看了半天,肉眼看起来没有任何异常。后面用十六进制查看器一开,发现文件开头有两个字节 EF BB BF——这是 UTF-8 的 BOM(字节序标记)。
Windows 下用记事本编辑过的文件很可能会带上 BOM 头。JSON 解析器不认识 BOM 头,直接报错。解决方法是:在读取文件后,如果文本以 BOM 开头,就剥掉它:
python复制def read_config(path):
with open(path, 'rb') as f:
raw = f.read()
# 去掉 UTF-8 BOM
if raw.startswith(b'\xef\xbb\xbf'):
raw = raw[3:]
text = raw.decode('utf-8')
return parse_jsonc(text)
这个坑和 JSON 注释没有直接关系,但配置文件场景里特别常见,顺手就处理了。
7.2 坑二:Windows 路径分隔符被当成了注释
这个坑很经典。配置文件里有 Windows 路径,比如:
json复制{
"log_dir": "D:\\logs\\",
"temp_dir": "C:/temp"
}
问题出在字符串内部的 \\ 转义上。如果有人在字符串里写单反斜杠,比如 "path": "D:\newfolder",那 \n 会被 JSON 解析成换行符而不是字母 n。但这不是注释的问题。
真正会被注释剥离器误伤的情况是这样的:
json复制{
"url": "http://example.com/api"
}
注意 http:// 里的双斜杠。如果注释剥离器没有正确处理“字符串内”这个状态,就会把 // 后面的内容当作行注释给抹掉,最后字符串内容全没了。这就是我前面为什么反复强调状态机必须处理 in_string 状态。这是注释剥离器最容易翻车的 case,没有之一。
如果你用的是正则表达式来做注释剥离,比如 re.sub(r'//.*', '', text),遇到 URL 就必炸。所以务必要用状态机实现,真正理解每一行代码在干什么。
7.3 坑三:报错信息定位失真
刚开始我第一版实现是直接把注释删除掉,不填充空格。结果配置文件解析报错的时候,报错信息指向的行列位置和实际文件对不上,排查起来极其痛苦。
举个具体例子,假设文件第 10 行有一个语法错误,但前面有一些注释被删了,导致实际内容整体上移。解析器报“第 8 行第 5 列”,你到文件第 8 行看,内容是注释,根本找不到问题。
后来我改成用等长空格填充,行号列号都保持一致,报错定位一下子准确了。这个改动看起来微不足道,但在编辑器里排查错误时体验差距巨大。
7.4 坑四:嵌套注释的误判
某些配置里,注释里又有注释的写法,比如:
json复制{
/* 这是外层注释 /* 内层注释 */
"key": "value"
}
标准 JSONC 并不支持嵌套注释。我的实现里,块注释遇到第一个 */ 就结束了,所以实际上解析出来的结果是注释从 /* 这是外层注释 /* 内层注释 */ 结束,后面的内容会变成普通文本。这不算 bug,但你要知道这个行为边界,避免团队里有人写嵌套注释。
7.5 坑五:字符串里包含尾随逗号样式的内容
这是我朋友遇到的一个 case。配置里有一段文本:
json复制{
"sql": "SELECT * FROM table WHERE id IN (1, 2, 3,)",
"desc": "注意这里的逗号,后面是右括号",
}
字符串内容 (1, 2, 3,) 里的逗号在右括号前面,如果不小心,可能会被尾随逗号处理器误删。但我的状态机实现里,字符串内部的内容是原样保留的,所以不会出现这个问题。这个 case 提醒我们:写尾随逗号处理逻辑时,字符串状态标志必须和注释剥离时一样严格。
7.6 性能:大配置文件的扫描开销
我测试过一个 10 MB 的配置文件,用 Python 状态机跑一遍注释剥离加尾随逗号处理,大概耗时 300 毫秒左右。对于配置文件这个场景,这个开销完全可接受。但如果你的配置读取发生在每次服务启动时,300 毫秒累积起来也不小。
一个优化思路是:把清理后的纯 JSON 内容缓存起来。比如以文件路径和 mtime(修改时间)为 key 缓存解析结果,文件没变就直接用缓存。更简单的做法是:只在开发环境下做宽松解析,生产环境直接用标准 JSON 解析器,从源头上保证配置文件已经是合法 JSON(可以通过 CI 检查强制)。
最后的实践经验:几个小建议
回到源头。这三四年里,我团队里的配置文件从标准 JSON 迁移成了“JSON + 注释 + 尾随逗号”的宽松方案,配合上面的解析器用。实际用下来的体感是:同事改配置时心理负担小了很多,不用再费劲记“最后一项后面不能加逗号”,新人也更容易理解每个字段的含义,因为注释可以写在字段旁边了。
但我也有一条底线建议:宽松解析只应该发生在本地开发阶段,或者作为兜底。正式的部署环境,配置文件应该经过一次“格式化”——用工具把所有注释剥掉、尾随逗号去掉,生成一份标准 JSON 作为最终产物,再发给服务读取。这样可以避免生产环境和本地开发环境的解析行为不一致。
如果你想把这件事做得更细,还可以把这个问题引申到配置管理工具层面:写一个小的 pre-commit 钩子,每次提交配置文件前自动做语法检查,有注释和尾随逗号就放行,其他的语法错误直接拦住。这个钩子的实现也简单,调一下我前面写的 parse_jsonc() 函数,能解析就通过,不能解析就报错。这样一来,既保留了开发体验的宽松,又保证了进到仓库的配置一定不会带低级错误。
最后分享一个调试的小技巧:当你觉得注释剥离器处理有问题,但又说不清哪里不对的时候,在剥离注释之后、解析 JSON 之前,把中间结果打印出来看一眼。这个“中间产物可见”的设计,是我做这个工具时最满意的一点。因为每个步骤职责单一,出问题时你永远能定位到是哪一步出的错。这在排查复杂配置文件时,能省下大量时间。
