Files
cs-note/hhs/GIN/9-response-rendering.md
T
2026-05-24 11:42:38 +08:00

15 KiB
Raw Blame History

tags, create time
tags create time
后端
Go
Gin
响应渲染
JSON
2026-04-28 00:00

响应渲染

概述

Gin 提供了丰富的响应渲染方法——不仅限于基础的 JSON 返回,还包括防劫持的 SecureJSON、保留原始 Unicode 的 PureJSON、强制 ASCII 编码的 AsciiJSON,以及 XML / YAML / ProtoBuf / CSV / HTML 等多格式输出。理解各渲染方法的差异和适用场景,能写出更健壮、兼容的 API。

flowchart TD
    A[选择响应渲染方式] --> B{客户端/使用方}
    B -- "浏览器 SPA" --> C[c.JSON]
    B -- "移动端或\n微服务间调用" --> D[c.PureJSON]
    B -- "第三方不可信域名引用" --> E[c.SecureJSON]
    B -- "严格 ASCII-only" --> F[c.AsciiJSON]
    B -- "多格式接口" --> G["Accept\n内容协商"]
    B -- "老旧跨域 GET\n(已淘汰)" --> H[c.JSONP]
    style H fill:#ffcccc
    style C fill:#e0ffe0
    style D fill:#e0ffe0
    C --> I[设置状态码并写入 Body]
    D --> I
    E --> I
    F --> I
    G --> I
    H --> I

思考题: 为什么 Gin 要同时提供 c.JSON() 和 c.PureJSON()?它们在哪些情况下输出相同,哪些情况不同?

[!tip]- 一句话区别 c.JSON() 会对部分非 ASCII 字符做 \uXXXX 转义;c.PureJSON() 原样输出 UTF-8 字节流。纯英文数据两者完全一致。

# 中文场景
c.JSON()    → {"message":"\\u4f60\\u597d"}
c.PureJSON()→ {"message":"你好"}

# 纯英文场景(完全一致)
c.JSON()       → {"message":"hello"}
c.PureJSON()   → {"message":"hello"}

选型速查表

场景 推荐方法 原因
面向浏览器的 SPA c.JSON() 额外防御层(HTML/Unicode 转义)
移动端 API / 微服务 c.PureJSON() 可读性好、带宽小、性能高
第三方不可信域名引用 c.SecureJSON() 防止 <script src> 劫持
日志系统 / 老旧中间件 c.AsciiJSON() 保证输出纯 ASCII
公开 API 需多格式 内容协商(见第 5 节) 通过 Accept 头按需分发
导出 Excel / CSV 自定义(见第 8 节) Gin 未内置 CSV

正文

1. 标准 JSON 渲染 — c.JSON()

最常用的渲染方式,底层调用 Go 标准库 encoding/json.Marshal,自动将汉字等非 ASCII 字符转为 \uXXXX 转义序列。

func handler(c *gin.Context) {
    data := gin.H{
        "message": "你好",
        "items":   []string{"a", "b", "c"},
    }
    // 等价于 c.Render(200, render.JSON{Data: data})
    c.JSON(http.StatusOK, data)
}

实际输出(注意中文被转义):

{"items":["a","b","c"],"message":"你好"}

思考题: RFC 4627 要求 JSON 文本仅包含 ASCII 字符,但 ES5(2009 年)起 JavaScript 就原生支持 Unicode 了——为什么现代浏览器不再需要这种转义?

[!warning]- 历史背景 转义是为了兼容极老的浏览器(IE6~IE8),这些浏览器无法正确处理非 ASCII 的 JSON。如今 ES5+ 已是底线要求,大多数项目可以直接用 c.PureJSON() 省去无意义的转义开销。

2. PureJSON — 保留原始 Unicode

当不需要 Unicode 转义时,使用 c.PureJSON()。它基于 Gin 自研的 goccy/go-json 库,直接将 UTF-8 字节写入响应体。

func handler(c *gin.Context) {
    c.PureJSON(http.StatusOK, gin.H{
        "message": "你好世界",
    })
}

实际输出(中文字符原样输出):

{"message":"你好世界"}

c.JSON() vs c.PureJSON() 对比:

特性 c.JSON() c.PureJSON()
中文字符 你好(转义) 你好(直出)
Emoji 😀(Go 1.20+ 前) 😀(始终直出)
底层库 encoding/json(stdlib) goccy/go-json
性能 标准速度 Marshal/Unmarshal 通常更快
适用场景 浏览器 SPA(旧规范兼容) 移动端 / 服务间调用

[!info]- 何时选哪个?

  • 面向浏览器的 SPA → c.JSON():额外的 HTML/Unicode 转义作为防御层,兼容极老旧浏览器
  • 移动端 API / 微服务间调用 → c.PureJSON():可读性好、占用带宽更小、性能更好
  • 绝大多数新项目 → c.PureJSON() 是更好的默认选择

决策流程图:

flowchart LR
    A["客户端是谁?"] --> B{"浏览器?"}
    B -- "是" --> C["用 c.JSON()"]
    B -- "否: App/SDK/内部" --> D["用 c.PureJSON()"]
    style C fill:#e0ffe0
    style D fill:#e0ffe0

3. SecureJSON — 防 JSON 劫持

老式浏览器可以通过 <script src="..."> 标签跨域加载任意域的 JSON 响应。如果该响应包含敏感信息(如用户数据),攻击者可以将其内嵌到自己的页面中窃取。SecureJSON 通过在 JSON 内容前添加 )]}',\n 前缀来阻断这种攻击:

func secureHandler(c *gin.Context) {
    c.SecureJSON(200, gin.H{"name": "wonder"})
}

实际输出:

)];},{"name":"wonder"}

[!question]- 原理拆解:SecureJSON 是如何防劫持的? 浏览器在解析这行内容时,会尝试将其当作 JavaScript 表达式执行:

  1. ')]}' — 不是合法的 JS 标识符、关键字或运算符开头
  2. 引擎抛出 SyntaxError,脚本终止执行
  3. JSON 数据({"name":"wonder"})永远不会被读取
# 正常 JSON 响应(可直接被劫持)
{"name":"wonder"}           ← <script> 加载后直接成为 JS 对象

# SecureJSON 响应(被阻断)
]);},{"name":"wonder"}      ← 语法错误,无法解析

⚠️ 注意: 原始 JSON 数据仍然暴露在 Network 面板中,它只是增加了攻击门槛而非绝对安全。配合 CSP 和 X-Content-Type-Options: nosniff 头效果更佳。

✅ 适用场景:

  • API 需要被不受信任的第三方域名内嵌引用
  • 遗留系统无法升级 CSP(Content Security Policy)头策略
  • 安全合规要求严格的金融 / 政企项目

❌ 不适用场景:

  • 现代 SPA(前后端分离,不用 <script> 标签加载 JSON)
  • 移动端 API(App 不做 JS 脚本解析)
  • 自动化测试 / 爬虫(需要额外去除前缀才能解析)

4. AsciiJSON — 强制 ASCII 编码

与 PureJSON 相反,将所有非 ASCII 字符(包括日文、韩文、表情符号等)都转为 \uXXXX 转义序列,保证输出为纯 ASCII。

func asciiHandler(c *gin.Context) {
    c.AsciiJSON(200, gin.H{
        "text": "Hello!",
        "flag": true,
    })
}

当数据中包含 Emoji 等特殊字符时,AsciiJSON 的行为与 PureJSON 产生明显分歧:

c.AsciiJSON(200, gin.H{"emoji": "😀"})
// AsciiJSON → {"emoji":"\\ud83d\\ude00"}   ← 转义为 ASCII
// PureJSON  → {"emoji":"😀"}                ← 保留 UTF-8
// c.JSON()  → 因 Go 版本而异               ← Go 1.20+ 可能不转义

[!caution]- c.JSON() 对 Emoji 的处理因 Go 版本而异 c.JSON() 对 Emoji(以及部分其他 Unicode 字符)的转义行为取决于 Go 版本:

Go 版本 Emoji 处理 中文处理
≤ 1.19 自动转义 😀 \uXXXX
≥ 1.20 不转义 😀 \uXXXX

c.AsciiJSON() 则无视 Go 版本,始终将所有非 ASCII 转义为纯 ASCII。如果你需要跨版本一致的输出(例如部署在不同 Go 版本的机器上),请使用 AsciiJSON()。

适用场景:

  • 某些老旧中间件或日志系统要求严格 ASCII-only
  • 管道传输层只能处理 ASCII(如某些 MQ/消息队列的明文协议)
  • 安全扫描工具对非 ASCII 字符有告警阈值

5. XML / YAML / ProtoBuf — 多格式渲染

Gin 支持多种序列化格式的响应,可通过 Accept 请求头实现内容协商(Content Negotiation):

func multiFormat(c *gin.Context) {
    data := gin.H{"title": "Go Guide", "version": "1.0"}

    switch c.NegotiationFormat() {
    case "application/xml":
        c.XML(http.StatusOK, data)
    case "application/yaml":
        c.YAML(http.StatusOK, data)
    default:
        c.JSON(http.StatusOK, data)
    }
}

[!note]- 关键概念辨析:NegotiationFormat vs ContentType

方法 读取方向 用途
c.NegotiationFormat() 请求头 Accept 内容协商——客户端告知"我想要什么格式"
c.ContentType() 响应头 Content-Type / 请求头 Content-Type 描述当前写入/读取的数据类型

做多格式接口时,永远用 c.NegotiationFormat() 来决定返回哪种序列化格式。c.ContentType() 用于你手动设置 Content-Type 响应头的场景。

XML 输出示例:

<map><title>Go Guide</title><version>1.0</version></map>

YAML 输出示例:

map:
    title: Go Guide
    version: "1.0"

思考题: gin.H 本质是 map[string]interface{},所以 XML 根节点总是 <map> 且没有属性控制能力。如果需要自定义 XML 标签名和结构,应该怎么改?

答案是用带标签的结构体替代 map:

type Article struct {
    Title   string `xml:"title"`           // 子元素 <title>Hello</title>
    Version string `xml:"version,attr"`    // 属性 version="1.0"
}

// 用外层结构体定义根节点
type Response struct {
    Article Article `xml:"article"`
}

c.XML(http.StatusOK, Response{Article: Article{Title: "Go Guide", Version: "1.0"}})
<response>
  <article version="1.0">
    <title>Go Guide</title>
  </article>
</response>

6. JSONP — JSON with Padding

通过 <script> 标签的回调机制实现跨域 GET 请求。由于服务端需要将用户提供的回调名直接拼接到 JavaScript 代码中,安全风险极高,现代项目中已基本淘汰:

func jsonpHandler(c *gin.Context) {
    callback := c.Query("callback")
    // 基础白名单校验:只允许字母、数字、下划线、美元符号
    if callback == "" || !regexp.MustCompile(`^[a-zA-Z_$][a-zA-Z0-9_$]*$`).MatchString(callback) {
        c.JSONP(http.StatusBadRequest, gin.H{"error": "invalid callback"})
        return
    }
    c.JSONP(http.StatusOK, gin.H{"msg": "hello"})
}

预期输出(当 ?callback=getData 时):

getData({"msg":"hello"})

浏览器端用法:

<script>
function getData(data) { console.log(data); }
</script>
<script src="https://api.example.com/data?callback=getData"></script>

[!danger]- JSONP 安全风险与 CORS 替代方案 JSONP 的本质是在服务端拼接 JavaScript 代码——如果回调名未做白名单校验,攻击者可以注入恶意脚本:

# 恶意请求
?callback=<script>alert(document.cookie)</script>

# 被注入的输出
<script>alert(document.cookie)</script>({"msg":"hello"})

现代替代方案:

方案 优点 缺点
CORS (Access-Control-Allow-Origin: *) 安全、灵活、现代浏览器全支持 需要预检(OPTIONS)请求
JSONP 兼容 IE6+ 仅支持 GET、安全风险高、已淘汰

生产环境强烈建议改用 CORS,配合 Access-Control-Allow-Origin 头使用。

7. 其他常用渲染方法一览

方法 Content-Type 典型用途
c.String(code, fmt, a...) text/plain; charset=utf-8 短文本 / 调试
c.Data(code, ct, bytes) 自定义 原始字节流(图片、PDF 等)
c.File(filepath) 自动推断 静态资源下载
c.FileBinary(filepath) application/octet-stream 二进制文件下载
c.FileAttachment(filepath, name) application/octet-stream; attachment 强制下载弹窗 + 自定义文件名
c.HTML(code, tmpl, obj) text/html HTML 模板渲染
c.ProtoBuf(code, pb) application/x-protobuf Protobuf 序列化

[!note]- File / FileBinary / FileAttachment 选型

方法 Content-Type 浏览器行为
c.File(filepath) 按扩展名自动推断(.jpg → image/jpeg) 可能直接在浏览器预览
c.FileBinary(filepath) application/octet-stream 触发下载,但无自定义文件名
c.FileAttachment(filepath, name) application/octet-stream; attachment 触发下载 + 指定弹出文件名

如果希望用户下载文件而非在浏览器中打开,优先用 c.FileAttachment()。如果需要指定弹窗时显示的文件名(例如 report-2026Q2.pdf),这个方法也最方便。

8. 自定义 CSV 渲染

Gin 没有内置 c.CSV(),但组合 c.Data() 即可轻松实现:

import (
    "bytes"
    "encoding/csv"
    "net/http"

    "github.com/gin-gonic/gin"
)

func csvExport(c *gin.Context) {
    var buf bytes.Buffer
    w := csv.NewWriter(&buf)
    w.Write([]string{"name", "age", "city"})
    w.Write([]string{"Alice", "30", "Beijing"})
    w.Write([]string{"Bob", "25", "Shanghai"})
    w.Flush()

    c.Header("Content-Disposition", "attachment; filename=data.csv")
    c.Data(http.StatusOK, "text/csv; charset=utf-8", buf.Bytes())
}

思考题: 上面几节我们讨论了如何用 switch 根据 Accept 头做内容协商。如果支持的格式超过三种,每个分支都要写一次 switch,比较繁琐。有没有更优雅的封装?

Gin 提供了 c.Negotiate() 方法,将协商逻辑和响应写入合并到一个调用中:

func negotiateHandler(c *gin.Context) {
    data := gin.H{"title": "Go Guide", "version": "1.0"}

    c.Negotiate(http.StatusOK, gin.Negotiate{
        Offered: []string{
            "application/json",
            "application/xml",
            "application/yaml",
        },
        Handler: func() {
            switch c.NegotiationFormat() {
            case "application/json":
                c.JSON(http.StatusOK, data)
            case "application/xml":
                c.XML(http.StatusOK, data)
            case "application/yaml":
                c.YAML(http.StatusOK, data)
            default:
                c.AbortWithError(http.StatusNotAcceptable,
                    errors.New("unsupported media type"))
            }
        },
    })
}

c.Negotiate() 内部会先检查 Accept 头是否在 Offered 列表中——如果是则进入 Handler 写入对应响应;如果不是则从 Offered 中选第一个作为默认格式写入,最后自动设置正确的 Content-Type 响应头。推荐将所有多格式接口统一用此模式编写。

关联笔记