313 lines
11 KiB
Markdown
313 lines
11 KiB
Markdown
|
|
---
|
|||
|
|
tags: [后端, Go, Gin, 模板, HTML]
|
|||
|
|
create time: 2026-04-28 00:05
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
# HTML 模板渲染
|
|||
|
|
|
|||
|
|
## 概述
|
|||
|
|
|
|||
|
|
Gin 内建了对 Go 标准库 `html/template` 的封装,支持简单模板加载、多模板引擎配置、以及通过 `embed.FS` 将模板打包进单一二进制。虽然现代前后端分离架构中较少直接使用服务端渲染,但在管理后台、邮件模板等场景中仍然实用。
|
|||
|
|
|
|||
|
|
思考题:`c.HTML` 和 `http.ServeFile` 直接返回 `.html` 文件有什么区别?
|
|||
|
|
|
|||
|
|
## 正文
|
|||
|
|
|
|||
|
|
### 1. 基本模板渲染
|
|||
|
|
|
|||
|
|
Gin 通过 `gin.H`(即 `map[string]any`)将数据传给 Go 标准库 `html/template`。Gin 内部调用 `template.ParseFiles()` 加载模板,并以**文件名(不带路径)**作为模板名建立映射:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func main() {
|
|||
|
|
r := gin.Default()
|
|||
|
|
|
|||
|
|
// 加载 templates/ 目录下所有 .html 文件
|
|||
|
|
r.LoadHTMLGlob("templates/*")
|
|||
|
|
|
|||
|
|
r.GET("/index", func(c *gin.Context) {
|
|||
|
|
// 渲染 templates/index.html,传入模板数据
|
|||
|
|
// Gin 模板的 key 使用大驼峰(类似结构体字段),Go 的 html/template 按反射访问
|
|||
|
|
c.HTML(http.StatusOK, "index", gin.H{
|
|||
|
|
"Title": "Home Page",
|
|||
|
|
"User": "wonder",
|
|||
|
|
"Items": []string{"item1", "item2"},
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
r.GET("/news", func(c *gin.Context) {
|
|||
|
|
c.HTML(http.StatusOK, "news.html", gin.H{
|
|||
|
|
"Title": "News",
|
|||
|
|
"Items": []string{"item1", "item2"},
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
r.Run(":8080")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
模板文件 `templates/index.html`:
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<!DOCTYPE html>
|
|||
|
|
<html>
|
|||
|
|
<head><title>{{.Title}}</title></head>
|
|||
|
|
<body>
|
|||
|
|
<h1>Welcome, {{.User}}</h1>
|
|||
|
|
<!-- 遍历切片 -->
|
|||
|
|
{{range .Items}}
|
|||
|
|
<li>{{.}}</li>
|
|||
|
|
{{end}}
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!TIP] Gin 模板的 key 命名约定
|
|||
|
|
> Gin 模板通过 Go 反射访问字段,因此 `gin.H` 中的 key 应使用**大驼峰**(如 `"Title"`),与 HTML 模板中的 `{{.Title}}` 对应。如果写成小写 key `{"title": ...}`,模板中使用 `{{.Title}}` 将无法找到该字段。
|
|||
|
|
|
|||
|
|
> [!INFO] 思考题答案
|
|||
|
|
> `c.HTML` vs `http.ServeFile`:**本质区别是"渲染"还是"返回"**。`c.HTML` 先将数据注入模板执行一次模板引擎渲染(可以做条件判断、循环遍历等),最终输出完整 HTML;`http.ServeFile` 原样发送文件内容,无法动态插入数据。如果要返回静态页面(如前端 SPA 入口),用 `ServeFile` / `StaticFile` 更高效。
|
|||
|
|
|
|||
|
|
### 2. `LoadHTMLGlob` vs `LoadHTMLFiles`
|
|||
|
|
|
|||
|
|
| 方法 | 用途 | 示例 |
|
|||
|
|
|------|------|------|
|
|||
|
|
| `LoadHTMLGlob(pattern)` | 按 glob 模式加载一批模板 | `"templates/**/*.html"` |
|
|||
|
|
| `LoadHTMLFiles(paths...)` | 指定具体的文件列表 | `"templates/base.html", "templates/index.html"` |
|
|||
|
|
|
|||
|
|
Gin 加载模板后会在内部建立**文件名 → `*template.Template`** 的映射关系,因此 `c.HTML()` 第二个参数只需匹配文件名即可。
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// Glob — 适合模板较多、结构简单的场景
|
|||
|
|
r.LoadHTMLGlob("templates/**/*") // 包括子目录
|
|||
|
|
|
|||
|
|
// Files — 适合明确知道有哪些模板的场景
|
|||
|
|
r.LoadHTMLFiles(
|
|||
|
|
"templates/base.html",
|
|||
|
|
"templates/index.html",
|
|||
|
|
"templates/error.html",
|
|||
|
|
)
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!WARNING] 常见陷阱
|
|||
|
|
> `LoadHTMLGlob("templates/**/*")` 会用每个文件的**基名**作为模板名。如果 `templates/sub/page.html` 也被加载,模板名就是 `sub/page.html`——渲染时需写 `c.HTML(200, "sub/page.html", data)`。建议始终用绝对路径模式(如 `"./templates/*.html"`)避免意外匹配到无关文件。
|
|||
|
|
|
|||
|
|
### 3. 模板函数(FuncMap)
|
|||
|
|
|
|||
|
|
Go 模板不像 Jinja2 那样内置丰富的过滤器,因此提供了 `SetFuncMap` 接口来注册自定义函数。这些函数可以在模板中以 **管道符 `|`** 链式调用:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
func main() {
|
|||
|
|
r := gin.Default()
|
|||
|
|
|
|||
|
|
r.SetFuncMap(template.FuncMap{
|
|||
|
|
"formatDate": func(t time.Time) string {
|
|||
|
|
return t.Format("2006-01-02")
|
|||
|
|
},
|
|||
|
|
"truncate": func(s string, n int) string {
|
|||
|
|
if len(s) <= n {
|
|||
|
|
return s
|
|||
|
|
}
|
|||
|
|
return s[:n] + "..."
|
|||
|
|
},
|
|||
|
|
"upper": strings.ToUpper,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
r.LoadHTMLGlob("templates/*")
|
|||
|
|
|
|||
|
|
r.GET("/article", func(c *gin.Context) {
|
|||
|
|
c.HTML(200, "article", gin.H{
|
|||
|
|
"Title": "Gin Templating Guide",
|
|||
|
|
"Body": "这是一篇很长的文章...",
|
|||
|
|
"Date": time.Now(),
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
r.Run(":8080")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
模板中使用:
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<h1>{{.Title | upper}}</h1>
|
|||
|
|
<p>{{.Body | truncate 50}}</p>
|
|||
|
|
<small>发布于 {{.Date | formatDate}}</small>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!WARNING] SetFuncMap 必须先于 LoadHTMLGlob 调用
|
|||
|
|
> Gin 将 `SetFuncMap` 的设置缓存在内部,因此必须在 `LoadHTMLGlob()` / `LoadHTMLFiles()` **之前**设置。否则注册的函数不会生效,模板中出现自定义管道符时会报 "undefined function" 错误。
|
|||
|
|
|
|||
|
|
### 4. 模板继承(Base Template)
|
|||
|
|
|
|||
|
|
Go 原生模板不支持继承,但可以通过 `define` + `block` + `template` 模拟页面布局系统:`base.html` 定义骨架和可替换区块(block),子模板用 `define` 覆盖对应区块。
|
|||
|
|
|
|||
|
|
> [!INFO] block vs define 的区别
|
|||
|
|
> - `{{block "name" .}}...{{end}}` — 用于 **base.html** 中定义默认内容,子模板可以选择性覆盖
|
|||
|
|
> - `{{define "name"}}...{{end}}` — 用于 **子模板** 中实现自己的版本来覆盖 base 中的默认内容
|
|||
|
|
> - 两者配合才能实现"继承"效果
|
|||
|
|
|
|||
|
|
```html
|
|||
|
|
<!-- templates/base.html -->
|
|||
|
|
<!DOCTYPE html>
|
|||
|
|
<html>
|
|||
|
|
<head>
|
|||
|
|
<title>{{block "title" .}}Default{{end}}</title>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<nav>...</nav>
|
|||
|
|
{{block "content" .}}{{end}}
|
|||
|
|
<footer>...</footer>
|
|||
|
|
</body>
|
|||
|
|
</html>
|
|||
|
|
|
|||
|
|
<!-- templates/index.html — 引用 base -->
|
|||
|
|
{{define "title"}}Home{{end}}
|
|||
|
|
{{define "content"}}
|
|||
|
|
<h1>Welcome</h1>
|
|||
|
|
<p>{{.Message}}</p>
|
|||
|
|
{{end}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
渲染时传入组合后的模板:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
r.LoadHTMLFiles(
|
|||
|
|
"templates/base.html",
|
|||
|
|
"templates/index.html",
|
|||
|
|
)
|
|||
|
|
// LoadHTMLFiles 将多个文件加载到同一个 *template.Template 中,
|
|||
|
|
// 因此 index.html 中的 define 可以找到 base.html 中 block 的定义并合并输出。
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!TIP] 更复杂的场景 → 多模板引擎
|
|||
|
|
> 如果项目需要按模块隔离不同的模板集(如后台管理一套模板、前台展示一套模板),可以使用第三方库 [gin-contrib/multitemplate](https://github.com/gin-contrib/multitemplate),它允许在同一 Router 下注册多个独立的 `*template.Template` 对象。详见本章「进阶用法」部分。
|
|||
|
|
|
|||
|
|
### 5. 将模板打包进单一二进制(Go 1.16+)
|
|||
|
|
|
|||
|
|
使用 `embed.FS` 把模板文件嵌入 Go 编译产物,适合 Docker 单镜像部署:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
import _ "embed"
|
|||
|
|
import "html/template"
|
|||
|
|
|
|||
|
|
//go:embed templates/*.html
|
|||
|
|
var templateFS embed.FS
|
|||
|
|
|
|||
|
|
func main() {
|
|||
|
|
r := gin.Default()
|
|||
|
|
|
|||
|
|
// Go 1.23+:ParseFS 直接返回 *template.Template,配合 Must 处理错误
|
|||
|
|
t := template.Must(template.New("").ParseFS(templateFS, "templates/*.html"))
|
|||
|
|
r.SetHTMLTemplate(t)
|
|||
|
|
|
|||
|
|
r.Run(":8080")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!INFO] 原理
|
|||
|
|
> `SetHTMLTemplate` 替换 Gin 内部的默认模板对象(`*template.Template`),之后所有 `c.HTML()` 调用都走这个已嵌入的模板集。如果只需加载单个新模板而非全部替换,也可以用 `t.AddParseTree("name", tree)` 追加。
|
|||
|
|
|
|||
|
|
> [!NOTE] 项目目录结构
|
|||
|
|
>
|
|||
|
|
> ```
|
|||
|
|
> cmd/
|
|||
|
|
> ├── server/main.go ← embed 入口
|
|||
|
|
> templates/ ← 模板文件,不会被 gitignore
|
|||
|
|
> ├── base.html
|
|||
|
|
> ├── index.html
|
|||
|
|
> └── error.html
|
|||
|
|
> internal/
|
|||
|
|
> └── handlers/
|
|||
|
|
> ```
|
|||
|
|
>
|
|||
|
|
> `embed` 指令位于 `main.go` 所在目录下执行,`templates/` 是相对于 `main.go` 的路径。部署时只需一个二进制文件,不再需要挂载 Volume 或复制模板文件到容器中。
|
|||
|
|
|
|||
|
|
### 6. 模板安全注意事项
|
|||
|
|
|
|||
|
|
> [!INFO] 安全原则
|
|||
|
|
> 永远不要手动拼接入用户可控的 HTML 内容。Go 的 `html/template` 包根据**上下文自动选择转义策略**,使用默认字符串类型即可获得最安全的输出。只有在明确需要渲染富文本时才考虑绕过转义。
|
|||
|
|
|
|||
|
|
```mermaid
|
|||
|
|
flowchart LR
|
|||
|
|
A["用户输入"] --> B["存入 gin.H 数据"]
|
|||
|
|
B --> C["传入 c.HTML 渲染"]
|
|||
|
|
C --> D{是否 HTML 转义?}
|
|||
|
|
D -->|"是 ✅"| E["自动转义,安全"]
|
|||
|
|
D -->|"否 ❌"| F["XSS 漏洞"]
|
|||
|
|
|
|||
|
|
style E fill:#e8f5e9
|
|||
|
|
style F fill:#ffebee
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Go 的 `html/template` 包**自动对上下文相关内容进行转义**:
|
|||
|
|
- 在 HTML body 中 → 转义 `<>&"'`
|
|||
|
|
- 在 attribute 中 → 转义引号和 `<>&`
|
|||
|
|
- 在 JS 上下文中 → 转义 `'` 和 `<\/`
|
|||
|
|
|
|||
|
|
**唯一例外:** 使用 `template.HTML` / `template.JS` 类型包装的内容不会转义——这意味着你主动告诉模板"这段内容是安全的"。滥用会导致 XSS:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
// ❌ 危险:用户输入未过滤就标为 safe
|
|||
|
|
c.HTML(200, "page", gin.H{
|
|||
|
|
"content": template.HTML(userInput), // 可能被注入 <script>
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// ✅ 安全:让模板自己决定如何转义
|
|||
|
|
c.HTML(200, "page", gin.H{
|
|||
|
|
"content": userInput, // html/template 自动转义
|
|||
|
|
})
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!WARNING] XSS 防御清单
|
|||
|
|
> 1. **绝不用 `template.HTML()` 包装用户输入** — 如果业务需要富文本,先用 `bluemonday` / `goquery` 等库做白名单过滤
|
|||
|
|
> 2. **设置正确的 Content-Type** — `c.HTML()` 默认发送 `text/html; charset=utf-8`,不要手动改为 `text/plain` 后直接输出用户数据
|
|||
|
|
> 3. **CSP 头** — 在中间件中设置 `Content-Security-Policy: default-src 'self'` 作为纵深防御的最后一道防线
|
|||
|
|
|
|||
|
|
### 7. 多模板引擎(gin-contrib/multitemplate)
|
|||
|
|
|
|||
|
|
当项目中存在多个隔离的模板集(如后台管理一套、前台展示一套),Gin 内置方案会将所有文件合并到同一个模板对象中。此时可以使用第三方库 [`gin-contrib/multitemplate`](https://github.com/gin-contrib/multitemplate) 实现真正的多模板引擎:
|
|||
|
|
|
|||
|
|
```go
|
|||
|
|
import "github.com/gin-contrib/multitemplate"
|
|||
|
|
|
|||
|
|
func main() {
|
|||
|
|
r := gin.Default()
|
|||
|
|
|
|||
|
|
// 创建工厂:按需构建独立的 Template 集合
|
|||
|
|
tmpls := multitemplate.NewTemplates()
|
|||
|
|
|
|||
|
|
// Admin 模板集:base.html + admin/index.html + admin/dashboard.html
|
|||
|
|
tmpls.AddTemplate("admin", template.Must(template.ParseGlob(
|
|||
|
|
"templates/admin/*.html",
|
|||
|
|
)))
|
|||
|
|
|
|||
|
|
// Public 模板集:base.html + index.html + about.html
|
|||
|
|
tmpls.AddTemplate("public", template.Must(template.ParseGlob(
|
|||
|
|
"templates/public/*.html",
|
|||
|
|
)))
|
|||
|
|
|
|||
|
|
// 注册为 Gin 的 HTML Render
|
|||
|
|
r.HTMLRender = tmpls
|
|||
|
|
|
|||
|
|
r.GET("/admin/dashboard", func(c *gin.Context) {
|
|||
|
|
c.HTML(200, "admin/index", gin.H{"Title": "Dashboard"})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
r.GET("/", func(c *gin.Context) {
|
|||
|
|
c.HTML(200, "public/index", gin.H{"Title": "Home Page"})
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
r.Run(":8080")
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
> [!TIP] 何时需要多模板引擎?
|
|||
|
|
> - ✅ 项目有独立的后台/前台模板目录,且命名可能冲突(如都有 `index.html`)
|
|||
|
|
> - ✅ 不同模块需要不同的 FuncMap 或全局变量
|
|||
|
|
> - ❌ 单套模板系统即可满足时,直接用 `LoadHTMLFiles` + `define/block` 更简单
|
|||
|
|
|
|||
|
|
## 关联笔记
|
|||
|
|
|
|||
|
|
- [[GIN/9-response-rendering]] — 除了 HTML,Gin 还支持 JSON/XML 等多种渲染
|
|||
|
|
- [[GIN/11-static-files]] — 静态资源(CSS/JS/图片)通过 Static 路由提供
|