Init
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
---
|
||||
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]]
|
||||
Reference in New Issue
Block a user