bigywater.tech
Gin · 39

JSON、PureJSON、AsciiJSON、SecureJSON 的输出差别

c.JSON、IndentedJSON、PureJSON、AsciiJSON、SecureJSON、JSONP 到底有什么区别?同一份含 HTML 字符、中文、emoji 的数据,它们各自输出什么字节?这篇逐个对比,顺带发现了 AsciiJSON 对 emoji 的一个问题,以及序列化失败时 200 加空正文的行为。

\u1f600
AsciiJSON 对 😀 的输出——不是合法的 JSON 转义,标准解析器读回 "ὠ0"
68 vs 92 字节
同一份数据 PureJSON 与 JSON 的输出长度(不转义 HTML 字符)
200 + 空正文
c.JSON 序列化失败(channel / NaN)时的响应

六种渲染的差别

实测 · 本机执行输出
    /json              Content-Type="application/json; charset=utf-8"  长度=92  结尾换行=false
      "{\"cn\":\"中文\",\"emoji\":\"😀\",\"html\":\"\\u003cb\\u003ea\\u0026b\\u003c/b\\u003e\",\"sep\":\"x\\u2028y\"}"
    /indent            Content-Type="application/json; charset=utf-8"  长度=117 结尾换行=false
      "{\n    \"cn\": \"中文\",\n    \"emoji\": \"😀\",\n    \"html\": \"\\u003cb\\u003ea\\u0026b\\u003c/b\\u003e\",\n    \"sep\": \"x\\u2028y\"\n}"
    /pure              Content-Type="application/json; charset=utf-8"  长度=68  结尾换行=true
      "{\"cn\":\"中文\",\"emoji\":\"😀\",\"html\":\"<b>a&b</b>\",\"sep\":\"x\\u2028y\"}\n"
    /ascii             Content-Type="application/json"                 长度=101 结尾换行=false
      "{\"cn\":\"\\u4e2d\\u6587\",\"emoji\":\"\\u1f600\",\"html\":\"\\u003cb\\u003ea\\u0026b\\u003c/b\\u003e\",\"sep\":\"x\\u2028y\"}"
    /secure            Content-Type="application/json; charset=utf-8"  长度=92  结尾换行=false
      "{\"cn\":\"中文\",\"emoji\":\"😀\",\"html\":\"\\u003cb\\u003ea\\u0026b\\u003c/b\\u003e\",\"sep\":\"x\\u2028y\"}"
    /jsonp?callback=cb Content-Type="application/javascript; charset=utf-8" 长度=97  结尾换行=false
      "cb({\"cn\":\"中文\",\"emoji\":\"😀\",\"html\":\"\\u003cb\\u003ea\\u0026b\\u003c/b\\u003e\",\"sep\":\"x\\u2028y\"});"

c.JSON 用 json.Marshal,所以 < > & 会被转成 \u003c 这样的转义(为了在 HTML 里内嵌更安全),这也是 API 输出里常见的“乱码”。PureJSON 关闭了这个转义,输出更短也更易读,但 Encoder 会在末尾加一个换行。IndentedJSON 只是带缩进,官方也只推荐开发时使用。

AsciiJSON 的 emoji

实测 · 本机执行输出
    /json  HTML 字符转义成 \u003c:true  | 非 ASCII 原样保留:true  | 解析 err=<nil> | cn 往返一致:true emoji 往返一致:true
    /pure  HTML 字符转义成 \u003c:false | 非 ASCII 原样保留:true  | 解析 err=<nil> | cn 往返一致:true emoji 往返一致:true
    /ascii HTML 字符转义成 \u003c:true  | 非 ASCII 原样保留:false | 解析 err=<nil> | cn 往返一致:true emoji 往返一致:false
      AsciiJSON 输出里 emoji 那一段:"emoji":"\u1f600",;标准解析器还原出的 emoji 是 "ὠ0"(原值 "😀")
  render/json.go 的 AsciiJSON 转义循环(Gin v1.12.0):
     155 | func (r AsciiJSON) Render(w http.ResponseWriter) error {
     156 |     r.WriteContentType(w)
     157 |     ret, err := json.API.Marshal(r.Data)
     158 |     if err != nil {
     159 |         return err
     160 |     }
     161 | 
     162 |     var buffer bytes.Buffer
     163 |     escapeBuf := make([]byte, 0, 6) // Preallocate 6 bytes for Unicode escape sequences
     164 | 
     165 |     for _, r := range bytesconv.BytesToString(ret) {
     166 |         if r > unicode.MaxASCII {
     167 |             escapeBuf = fmt.Appendf(escapeBuf[:0], "\\u%04x", r) // Reuse escapeBuf
     168 |             buffer.Write(escapeBuf)
     169 |         } else {
     170 |             buffer.WriteByte(byte(r))
     171 |         }
     172 |     }
     173 | 
     174 |     _, err = w.Write(buffer.Bytes())
     175 |     return err
     176 | }

AsciiJSON 把所有非 ASCII 字符转成 \uXXXX,对中文是对的(\u4e2d\u6587);但源码是按 rune 用 \u%04x 输出的,超过 0xFFFF 的字符(emoji)需要拆成代理对,这里却输出了 5 位的 \u1f600,标准解析器读成 \u1f60 加字符 0。如果你的数据里可能有 emoji,不要用 AsciiJSON,或者自己处理。Content-Type 也没有带 charset。

SecureJSON

实测 · 本机执行输出
    /obj     → "{\"a\":1}"
    /arr     → "while(1);[1,2]"
    /empty   → "while(1);[]"
    /nested  → "{\"list\":[1]}"
    /str     → "\"[x]\""
    SecureJsonPrefix 之后 /arr → ")]}',\n[1,2]"

它针对的是早期浏览器里的 JSON 劫持(文档的说法,本文没有验证这个攻击):对序列化结果以 [ 开头、以 ] 结尾的情形加一个 while(1); 前缀。对象、嵌套数组、字符串都不加。客户端必须先去掉前缀才能解析,所以只有你同时控制客户端时才有意义。

JSONP

实测 · 本机执行输出
    /p                         → Content-Type="application/json; charset=utf-8" "{\"a\":1}"
    /p?callback=cb             → Content-Type="application/javascript; charset=utf-8" "cb({\"a\":1});"
    /p?callback=app.handle     → Content-Type="application/javascript; charset=utf-8" "app.handle({\"a\":1});"
    /p?callback=a(1)%3Bb       → Content-Type="application/javascript; charset=utf-8" "a(1);b({\"a\":1});"
    /p?callback=%3Cscript%3E   → Content-Type="application/javascript; charset=utf-8" "\\u003Cscript\\u003E({\"a\":1});"
    /p?callback=x%27y%22z      → Content-Type="application/javascript; charset=utf-8" "x\\'y\\\"z({\"a\":1});"

JSONP 把 JSON 包成 callback(...) 返回给 <script> 标签。Gin 对 callback 只做 JSEscapeString(转义引号、尖括号等),圆括号、点号、分号都原样输出,所以 a(1);b 这样的值也会被写进响应。需要 JSONP 时请自己用正则把 callback 限制成合法的标识符,并优先使用 CORS。

序列化失败

实测 · 本机执行输出
    /ok   → 状态 200 正文 "{\"a\":1}" Content-Type="application/json; charset=utf-8" c.Errors=0
    /ch   → 状态 200 正文 "" Content-Type="application/json; charset=utf-8" c.Errors=1(第一条:json: unsupported type: chan int)
    /nan  → 状态 200 正文 "" Content-Type="application/json; charset=utf-8" c.Errors=1(第一条:json: unsupported value: NaN)

Gin 的 Render 失败时只是 c.Error(err) 并 Abort,没有改状态码:此时 Content-Type 已经设好、状态是 200、正文是空的。想把它变成 500,要在统一的错误处理中间件里检查 c.Errors 和 c.Writer.Written()(见错误收集一篇)。

分步演示

JSON 渲染
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。请求用 httptest 直接调用 ServeHTTP;JSON 编解码用 Gin 默认的 encoding/json;往返检查用标准库的 json.Unmarshal;AsciiJSON 的源码行号来自模块缓存;每种情形各一次
    • 没有验证:用 sonic、jsoniter 等替代 JSON 库(构建标签)时六种渲染的输出差别;AsciiJSON 的问题是否在更新的 Gin 版本中已修复(只测了 v1.12.0);JSONP 在浏览器里的实际行为;大对象下 IndentedJSON 与 JSON 的耗时对比
    京ICP备2026067502号-1京公网安备11010502063487号