ByteNoteByteNote
Go 读 JSON5:注释和尾逗号能留下
字

字节笔记本

2026年10月7日 · 约 9 分钟读完

Go 读 JSON5:注释和尾逗号能留下

API中转
¥120

标准 JSON 不让写注释,也不让在最后一项后面加逗号。配置文件是人改的,这两条最碍事。Go 标准库 encoding/json 按标准来,读到 // 或尾逗号就返回错误。要在 Go 里读 JSON5,对话里用的库是 github.com/flynn/json5。

JSON5 比标准 JSON 多出来的写法

标准库停在注释上

json.Unmarshal 适合接口正文。接口约定是标准 JSON,注释本来就不该出现。配置文件不同。有人会在键旁边写这一项是干什么的,保存时编辑器还会自动补上尾逗号。这两种输入,标准库都拒,错误停在第一个非法字符,后面的键不会再被读取。

对话里列了 JSON5 比 JSON 多出来的写法,样本里都有:单行注释和块注释,最后一项后面的逗号,合法标识符可以不加引号,字符串可以用单引号。行尾反斜杠把字符串续到下一行。十六进制的样本是 0xdecaf。数字可以写成前导小数点 .8675309、末尾小数点 8675309.,也可以带正号 +1。另外还允许 Infinity、-Infinity 和 NaN。

这些在标准 JSON 里都是非法的。不要指望 encoding/json 忽略注释再继续。它的任务是拒绝不合法的载荷。配置和接口要用两套解析器。

Unmarshal 读进 map

安装命令是 go get github.com/flynn/json5。读的时候和标准库同一形状:准备 []byte,目标用 map[string]interface{},调用 json5.Unmarshal。

go
data := []byte(`{
    // 这是一个注释
    unquoted: 'and you can quote me on that',
    hexadecimal: 0xdecaf,
    trailingComma: 'in objects and arrays',
}`)

var result map[string]interface{}
if err := json5.Unmarshal(data, &result); err != nil {
    return err
}

结构固定时也可以解到结构体,标签仍然写成 json:"name"。库负责把 JSON5 收成 Go 的值。注释不会变成字段。未加引号的键会变成 map 的字符串键。十六进制会变成数字。尾逗号被吃掉,不会多出一个空元素。单引号字符串和双引号字符串进到 Go 里都是 string。

出错就看 err。不要把解析失败当成空 map 继续往下跑。空 map 取出来的端口、地址、密钥都是零值,服务会以默认端口或空地址启动,等流量进来才发现配置根本没装上。日志里至少打出文件名和 err,启动阶段直接退出,比带着零值跑更安全。

样本里的续行字符串用行尾反斜杠。人读的时候像两行,解析之后是一个字符串。不要在业务代码里再按换行去切,那两行已经拼好了。

Marshal 写不回注释

反方向有 json5.Marshal。对话里的例子是把 name 和 age 编成文本。它能得到一份输出,但不会主动加上注释、未加引号的键或单引号。这个库的方向是把 JSON5 读进 Go,不是把 Go 打印成带注释的 JSON5。

所以配置的工作流是:人用 JSON5 写文件,程序只 Unmarshal。程序自己写回文件时,注释会丢。如果进程要改一项再落盘,要么另存一份标准 JSON,要么在写回前保留原文、只替换要改的值。不要假设 Marshal 往返之后文件还长得一样。评审配置变更时,也会看到一整份注释突然消失,那是写回路径用错了库,不是人把注释删了。

标准库那边,结构体用 json.Unmarshal,字段用标签。结构不固定就用 map[string]interface{}。数字进这种 map 时是 float64,这是 encoding/json 的行为。要用整数就解到结构体的 int 字段,不要从 interface{} 里直接做类型断言成 int。

配置可以用,接口别用

JSON5 适合仓库里的手写配置。对外接口、消息队列、浏览器里的 JSON.parse 都不认这套语法。你发出去的 JSON5,对方的标准解析器会在注释处失败,调用方只会看到一个语法错误,不会知道你是故意写的扩展。

键可以不加引号,只在它是合法标识符的时候成立。含有连字符、空格或从数字开头的键,仍然要加引号。这一点和 JavaScript 的对象字面量相同,不要写成“所有键都能裸写”。

边界就这一条:进进程用 json5.Unmarshal,出进程用 encoding/json。注释留在源文件里,不要留在线上载荷里。依赖只加在读配置的那个包,接口层不要 import 这个库,避免有人顺手把响应也编成 JSON5。

Unmarshal 吃掉注释,Marshal 不写回

样本里的每一项进 Go 之后

把对话里的那份对象拆开看,比只记“支持 JSON5”有用。unquoted 这个键没有引号,值用单引号,Unmarshal 之后键是字符串 unquoted,值是普通 string。singleQuotes 的值里可以再出现双引号,因为外层已经是单引号,不需要再转义。lineBreaks 用行尾反斜杠续行,解析结果是一个字符串,反斜杠本身不会留在值里。hexadecimal: 0xdecaf 变成数字,不是保留 0x 前缀的字符串。.8675309 和 8675309. 都是数,+1 也是数。trailingComma 后面那个逗号只是语法,map 里不会多一个空键。

backwardsCompatible 在样本里用了双引号键,说明同一份文件里可以混用。程序不要假设所有键都没引号,读取时一律按字符串键处理。

若目标是结构体而不是 map,多出来的键会被忽略,缺少的字段保持零值。配置里把必填项放进结构体,启动后检查零值。用 map 则每个键都要自己断言类型。十六进制进 map 时具体的数字类型以这个库的实现为准,用到之前用 %T 打一次,不要按 encoding/json 的 float64 去断言。对话里的示例是 fmt.Printf("%+v\n", result),那一步就是用来看解析结果,而不是把 interface{} 直接当 int 用。

和标准库放在两个入口

对话后半也写了标准 JSON 的读法:Person 有 Name 和 Age,标签 json:"name"、json:"age",json.Unmarshal 进结构体。另一段把不固定的对象放进 map[string]interface{}。这两段用的是 encoding/json,输入必须是合法 JSON。把带注释的配置误送到这个入口,错误发生在注释字符,结构体仍是零值。

可以在程序里分两个函数。读配置文件的函数只接受 JSON5,读请求体的函数只接受标准 JSON。文件后缀或调用点把它们分开,不要做一个“先试标准库、失败再试 JSON5”的静默回退。回退会把一份损坏的接口载荷当成配置语法吃掉,校验就漏了。

json5.Marshal 的样本是 name 和 age 的 map。失败同样看 err。成功得到的字节是给日志或调试的,不是给浏览器的 JSON.parse。要给浏览器,再走一次 encoding/json。

往返会丢注释

读配置用 github.com/flynn/json5。尾逗号、注释、单引号、十六进制按样本那样写,Unmarshal 能进 map 或结构体。Marshal 只负责吐出一份文本,注释不会回来。接口响应继续用标准库。解析失败要在启动时退出,不要带着空配置继续服务。

配置文件放在仓库里,由人编辑,所以才需要注释和尾逗号。接口的请求体由程序生成,标准 JSON 足够,也是对方唯一能稳定解析的格式。两种输入不要共用一个解码函数。单元测试至少准备两份字节:一份带 // 和尾逗号,交给 json5.Unmarshal 应该成功;同一份交给 json.Unmarshal 应该失败。再准备一份标准 JSON,两个入口都应该成功。这样回退逻辑如果被加回来,测试会先红。

go get github.com/flynn/json5 之后,依赖写在读配置的模块里。对外的 HTTP 包不引用它。评审时看到接口层出现这个 import,就是响应有可能被编成 JSON5 的信号。Marshal 的输出只留在进程内或调试日志。需要落盘给下一次启动读,就接受注释会消失,或者不要走 Marshal,改在原文里替换值。

相关文章

分享: