Files
cs-note/hhs/GIN/9-response-rendering.md
T

409 lines
15 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
tags: [后端, Go, Gin, 响应渲染, JSON]
create time: 2026-04-28 00:00
---
# 响应渲染
## 概述
Gin 提供了丰富的响应渲染方法——不仅限于基础的 JSON 返回,还包括防劫持的 SecureJSON、保留原始 Unicode 的 PureJSON、强制 ASCII 编码的 AsciiJSON,以及 XML / YAML / ProtoBuf / CSV / HTML 等多格式输出。理解各渲染方法的差异和适用场景,能写出更健壮、兼容的 API。
```mermaid
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 字节流。纯英文数据两者完全一致。
>
> ```text
> # 中文场景
> 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` 转义序列。
```go
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)
}
```
实际输出(注意中文被转义):
```json
{"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](https://github.com/goccy/go-json) 库,直接将 UTF-8 字节写入响应体。
```go
func handler(c *gin.Context) {
c.PureJSON(http.StatusOK, gin.H{
"message": "你好世界",
})
}
```
实际输出(中文字符原样输出):
```json
{"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()` 是更好的默认选择
>
> **决策流程图:**
> ```mermaid
> 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` 前缀来阻断这种攻击:
```go
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"}`)永远不会被读取
>
> ```text
> # 正常 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。
```go
func asciiHandler(c *gin.Context) {
c.AsciiJSON(200, gin.H{
"text": "Hello!",
"flag": true,
})
}
```
当数据中包含 Emoji 等特殊字符时,AsciiJSON 的行为与 PureJSON 产生明显分歧:
```go
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):
```go
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 输出示例:**
```xml
<map><title>Go Guide</title><version>1.0</version></map>
```
**YAML 输出示例:**
```yaml
map:
title: Go Guide
version: "1.0"
```
> **思考题:** `gin.H` 本质是 `map[string]interface{}`,所以 XML 根节点总是 `<map>` 且没有属性控制能力。如果需要自定义 XML 标签名和结构,应该怎么改?
答案是用**带标签的结构体**替代 map:
```go
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"}})
```
```xml
<response>
<article version="1.0">
<title>Go Guide</title>
</article>
</response>
```
### 6. JSONP — JSON with Padding
通过 `<script>` 标签的回调机制实现跨域 GET 请求。由于服务端需要将用户提供的回调名直接拼接到 JavaScript 代码中,**安全风险极高**,现代项目中已基本淘汰:
```go
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"})
```
浏览器端用法:
```html
<script>
function getData(data) { console.log(data); }
</script>
<script src="https://api.example.com/data?callback=getData"></script>
```
> [!danger]- JSONP 安全风险与 CORS 替代方案
> JSONP 的本质是在服务端拼接 JavaScript 代码——如果回调名未做白名单校验,攻击者可以注入恶意脚本:
>
> ```text
> # 恶意请求
> ?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()` 即可轻松实现:
```go
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()` 方法,将协商逻辑和响应写入合并到一个调用中:
```go
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` 响应头。**推荐将所有多格式接口统一用此模式编写。**
## 关联笔记
- [[BACKEND/GIN/0-overview]]
- [[BACKEND/GIN/2-context-request]]
- [[BACKEND/GIN/1-middleware]]
- [[BACKEND/GIN/3-error-handling]]