c.JSON、IndentedJSON、PureJSON、AsciiJSON、SecureJSON、JSONP 到底有什么区别?同一份含 HTML 字符、中文、emoji 的数据,它们各自输出什么字节?这篇逐个对比,顺带发现了 AsciiJSON 对 emoji 的一个问题,以及序列化失败时 200 加空正文的行为。
/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 只是带缩进,官方也只推荐开发时使用。
/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。
/obj → "{\"a\":1}"
/arr → "while(1);[1,2]"
/empty → "while(1);[]"
/nested → "{\"list\":[1]}"
/str → "\"[x]\""
SecureJsonPrefix 之后 /arr → ")]}',\n[1,2]"它针对的是早期浏览器里的 JSON 劫持(文档的说法,本文没有验证这个攻击):对序列化结果以 [ 开头、以 ] 结尾的情形加一个 while(1); 前缀。对象、嵌套数组、字符串都不加。客户端必须先去掉前缀才能解析,所以只有你同时控制客户端时才有意义。
/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()(见错误收集一篇)。