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

409 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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]]