Go Template 系统性学习指南

Go 标准库的模板引擎是服务端渲染和数据驱动的文本生成工具,核心设计理念是将表现层与数据模型分离。Go 用同一套语法提供了两个包:text/template 用于通用文本生成,html/template 用于 HTML 输出并自带上下文感知的自动转义安全机制。网络服务中生成 HTML 必须使用 html/template。

一、基础用法:三步流程

模板使用的核心流程固定为三步:定义/解析模板 → 准备数据 → 执行渲染。

1.1 从字符串解析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
    "os"
    "text/template"
)

type Inventory struct {
    Material string
    Count    uint
}

func main() {
    // 步骤1:解析模板
    tmpl := template.Must(template.New("test").
        Parse("{{.Count}} items are made of {{.Material}}"))

    // 步骤2:准备数据
    sweaters := Inventory{"wool", 17}

    // 步骤3:执行渲染
    tmpl.Execute(os.Stdout, sweaters)
    // 输出:17 items are made of wool
}

template.Must 是对 Parse 错误的 panic 包装,适合在包初始化阶段使用。模板中只能访问结构体的导出字段(首字母大写),小写字段在模板侧不可达,会导致执行期报错或呈现零值。

1.2 从文件解析

1
2
3
4
5
6
7
8
// 解析单个文件
t, err := template.ParseFiles("templates/layout.html")

// 解析多个文件(常用于主模板+子模板)
t, err := template.ParseFiles("templates/layout.html", "templates/header.html")

// 使用 Glob 模式批量解析
t, err := template.ParseGlob("templates/*.html")

1.3 数据访问方式

1
2
3
4
5
6
7
8
// 结构体字段访问
t.Execute(w, struct{ Name string }{"Alice"})  // {{.Name}}

// map 键访问(无大小写限制)
t.Execute(w, map[string]string{"name": "Bob"}) // {{.name}} 或 {{.Name}} 均可

// 点(.)表示当前作用域的值
// 在根模板中,{{.}} 就是 Execute 传入的整个数据

二、模板语法核心

2.1 注释

1
2
3
{{/* 这是注释,不会输出 */}}

{{- /* 注释,同时修剪前后空白 */ -}}

2.2 条件判断

Go 模板中,以下值被视为“假”:false、0、nil、空字符串、空切片/映射/数组。

1
2
3
4
5
{{if .Attended}}
    很高兴您能参加婚礼。
{{else}}
    很遗憾您未能参加。
{{end}}

if 支持 else if,也常与内置比较函数配合:

1
{{if eq .Status "active"}} 活跃 {{else if gt .Score 90}} 优秀 {{end}}

常用比较函数:eq(等于)、ne(不等于)、lt(小于)、gt(大于)、le(小于等于)、ge(大于等于)。

2.3 循环 range

range 支持 slice、array、map、channel。内部 {{.}} 为当前元素;可通过变量捕获索引/键和值。

1
2
3
4
5
6
7
8
9
// 遍历切片:下标 + 值
{{range $idx, $email := .Emails}}
    第 {{$idx}} 项: {{$email}}
{{end}}

// 遍历 map:key + value
{{range $key, $value := .MapContent}}
    {{$key}}: {{$value}}
{{end}}

range 在空集合时不执行,. 不会改变。若只需值,使用 {{range .Items}} 即可。

2.4 with 改变作用域

with 将点临时切换到某个值,适合简化深层字段访问:

1
2
3
{{with .Gift}}
    感谢您送的 {{.}}。
{{end}}

若 .Gift 为“假值”,with 块不执行。

2.5 变量

1
2
{{$v := .SomeValue}}      {{/* 声明并赋值 */}}
{{$v = "new value"}}      {{/* 重新赋值(Go 1.11+) */}}

变量在 range 和 with 改变点作用域后仍然可读,这是保留根数据引用的常用手段。

2.6 管道

管道语义类似 UNIX:{{ a | f | g }} 等价于 g(f(a))。前一个函数的结果作为下一个函数的最后一个参数。

1
2
{{ .Personal | printf "[%s]" }}
{{ .Name | upper | printf "用户: %s" }}

管道让展示层的轻量格式化(如 printf)保持在模板内,避免把“仅为排版服务”的逻辑塞进业务层。

2.7 空白修剪

模板中的换行和缩进会原样输出,这在生成代码或结构化文本时往往需要精确控制。

  • {{- :修剪左侧紧邻文本的尾部空白
  • -}} :修剪右侧紧邻文本的前导空白
1
2
3
4
5
6
7
8
9
// 不修剪时,range 循环会引入多余换行
{{range .Items}}
    {{.}}
{{end}}

// 双向修剪,保持紧凑输出
{{- range .Items -}}
    {{.}}
{{- end -}}

注意:{{- 3}} 修剪左侧空白后解析为数字 3;而 {{-3}} 会被解析为负数 -3 的操作。修剪符后的空格必须存在。

三、自定义函数

template.FuncMap 用于将 Go 函数注册到模板命名空间中。必须先调用 Funcs() 注册函数,再调用 Parse(),否则 AST 中出现的未知标识符会导致 Parse 失败。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
funcMap := template.FuncMap{
    "upper":      strings.ToUpper,
    "formatDate": func(t time.Time) string {
        return t.Format("2006-01-02")
    },
    "safeHTML": func(s string) template.HTML {
        return template.HTML(s)  // 慎重使用!
    },
}

tmpl := template.Must(
    template.New("example").
        Funcs(funcMap).
        Parse(`{{ .User | upper }} - {{ .Time | formatDate }}`),
)

自定义函数应只做展示层的纯格式化或轻量映射(日期格式、单位转换、字符串处理)。复杂分支、聚合和权限决策应放在 Go 代码中,不要塞入模板。

四、嵌套模板与布局

4.1 define / template / block

{{define "name"}} 定义命名模板,{{template "name" .}} 调用它并传入数据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
const layout = `
{{define "base"}}
<!DOCTYPE html>
<html>
<head><title>{{.Title}}</title></head>
<body>{{template "content" .}}</body>
</html>
{{end}}`

const page = `
{{define "content"}}
<h1>{{.Heading}}</h1>
<p>{{.Body}}</p>
{{end}}`

加载顺序很重要:Go 模板引擎按加载顺序查找模板,如果多个文件定义了同名模板,只有第一个被使用。因此不同页面的内容模板应使用不同的名称。

4.2 block 用于可覆盖的默认实现

{{block "name" .}}...{{end}} 等价于“定义默认模板 + 调用它”。子模板可以通过 {{define "name"}} 覆盖默认实现。

1
2
3
4
5
// 主模板
const master = `Names:{{block "list" .}}{{range .}}{{println "-" .}}{{end}}{{end}}`

// 子模板覆盖
const overlay = `{{define "list"}}{{join . ", "}}{{end}}`

调用 masterTmpl.Execute 输出默认列表;克隆后解析 overlay 再执行,则输出用逗号连接的结果。

4.3 Gin 框架中的嵌套模板

在 Gin 中,通常用 LoadHTMLGlob 加载所有模板,配合 {{define}} 组织布局:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
r := gin.Default()
r.LoadHTMLGlob("templates/**/*")

// templates/layout/base.html
// {{define "base"}}
// <html><head><title>{{.title}}</title></head>
// <body>{{template .content .}}</body></html>
// {{end}}

// templates/pages/home.html
// {{define "home-content"}}<p>首页内容</p>{{end}}

// 处理器中
c.HTML(http.StatusOK, "base", gin.H{
    "title":   "Home",
    "content": "home-content",  // 动态指定要渲染的子模板名
})

如果子模板名称是动态传入的变量,标准的 {{template .content .}} 语法不支持变量作为模板名,需要通过自定义函数(如示例中的 DynamicTemplate)来间接实现。

五、HTML 安全:html/template 的自动转义

html/template 与 text/template 共享 API,但增加了上下文感知的自动转义。这是 Go 模板最重要的安全特性。

5.1 自动转义演示

1
2
3
4
5
6
7
8
9
// text/template:直接输出,产生 XSS
t, _ := template.New("foo").Parse(`Hello, {{.}}!`)
t.Execute(out, "<script>alert('xss')</script>")
// 输出:Hello, <script>alert('xss')</script>!

// html/template:自动转义,安全
t, _ := htmltemplate.New("foo").Parse(`Hello, {{.}}!`)
t.Execute(out, "<script>alert('xss')</script>")
// 输出:Hello, &lt;script&gt;alert('xss')&lt;/script&gt;!

转义是上下文感知的:同一个 {{.}} 出现在 HTML 文本、属性、JavaScript、CSS、URL 中时,会应用不同的转义规则。例如:

1
2
3
<a href="/search?q={{.}}">{{.}}</a>
<!-- 模板引擎在解析时自动改写为: -->
<a href="/search?q={{. | urlescaper | attrescaper}}">{{. | htmlescaper}}</a>

5.2 安全模型

html/template 的安全模型假设模板作者是可信的,而 Execute 传入的数据不可信。不应在模板中直接拼接用户输入。如果确实需要输出可信的 HTML 片段,使用 template.HTML 类型显式标记,但这绕过了自动转义,必须确保内容来源完全可控,绝不可用于用户输入。

六、性能优化

6.1 核心原则:启动时解析,请求时只执行

Parse 包含 I/O 和编译成本,禁止在每个 HTTP 请求中调用 Parse。应在 init() 或程序启动阶段解析一次,请求路径只做 Execute。

1
2
3
4
5
6
7
8
// ✅ 正确:包级别初始化
var reportTmpl = template.Must(
    template.ParseFiles("templates/report.html"),
)

func handler(w http.ResponseWriter, r *http.Request) {
    reportTmpl.Execute(w, data)  // 只执行,不解析
}

6.2 并发安全

模板一旦解析完成,Execute 可以安全地并发调用。但如果多个 goroutine 共享同一个 io.Writer,输出可能交错。每个请求应使用独立的 Writer(如 http.ResponseWriter)。

6.3 需要不同数据隔离时使用 Clone

如果需要在同一套模板基础上为不同请求做数据隔离的变体,使用 Clone() 创建副本。

6.4 避免不必要的转义开销

html/template 的自动转义会带来一定的性能开销。如果输出的是纯文本(如生成代码、配置文件),使用 text/template 可以避免转义成本。只有在生成 HTML 时才应使用 html/template。

七、测试策略

模板测试的核心是构造数据 → 执行 → 断言输出。推荐的模式是表驱动测试。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func TestLetterTemplate(t *testing.T) {
    const letter = `Dear {{.Name}},{{if .Attended}} 感谢您的到来。{{else}} 很遗憾您未能出席。{{end}}`

    tests := []struct {
        name     string
        data     Recipient
        contains string
    }{
        {
            name:     "attended",
            data:     Recipient{Name: "Mildred", Attended: true},
            contains: "感谢您的到来",
        },
        {
            name:     "not attended",
            data:     Recipient{Name: "John", Attended: false},
            contains: "很遗憾您未能出席",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            tmpl := template.Must(template.New("letter").Parse(letter))
            var buf bytes.Buffer
            if err := tmpl.Execute(&buf, tt.data); err != nil {
                t.Fatal(err)
            }
            if !strings.Contains(buf.String(), tt.contains) {
                t.Errorf("output %q does not contain %q", buf.String(), tt.contains)
            }
        })
    }
}

对于 HTML 模板,还应测试特殊字符是否被正确转义,以及子模板的嵌套执行是否正确。

八、实战:完整 HTTP 服务示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package main

import (
    "html/template"
    "log"
    "net/http"
    "time"
)

type User struct {
    Name      string
    Age       int
    JoinedAt  time.Time
    IsPremium bool
}

var tmpl = template.Must(
    template.New("user").
        Funcs(template.FuncMap{
            "formatDate": func(t time.Time) string {
                return t.Format("2006-01-02")
            },
        }).
        Parse(`
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>用户信息</title></head>
<body>
<h1>{{.Name}}</h1>
<p>年龄:{{.Age}}</p>
<p>注册日期:{{.JoinedAt | formatDate}}</p>
{{if .IsPremium}}
    <span class="badge">尊贵会员</span>
{{else}}
    <span>普通用户</span>
{{end}}
</body>
</html>`),
)

func userHandler(w http.ResponseWriter, r *http.Request) {
    user := User{
        Name:      "张三",
        Age:       28,
        JoinedAt:  time.Now(),
        IsPremium: true,
    }
    if err := tmpl.Execute(w, user); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
}

func main() {
    http.HandleFunc("/", userHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

模板在包级别解析一次,请求路径只执行 Execute;自定义 formatDate 函数在 Parse 之前通过 Funcs 注册;html/template 自动对 {{.Name}} 等内容进行 HTML 转义。

九、最佳实践总结

解析时机:模板在启动时解析一次,绝不在请求路径中重复解析。使用 template.Must 在初始化阶段暴露解析错误。

函数注册顺序:Funcs() 必须在 Parse() 之前调用,否则解析阶段就会失败。

选择正确的包:生成 HTML 用 html/template;生成代码、配置、纯文本用 text/template。绝不能用 text/template 渲染用户可见的 HTML 页面。

安全边界:将 template.HTML、template.JS、template.CSS 视为“危险类型”。它们绕过自动转义,只应用于来源完全可控的内容,严禁包裹用户输入。

逻辑归属:模板只负责展示。复杂条件分支、数据聚合、权限判断放在 Go 代码中完成,模板接收的是“已经准备好的展示数据”。

模板命名:嵌套模板中不同页面的内容块使用唯一名称,避免加载顺序导致同名模板被覆盖的隐蔽 bug。

空白控制:生成结构化文本(代码、JSON、YAML)时,在 range、if、end 动作上使用 - 修剪符精确控制输出格式,避免多余换行破坏结构。

并发写入:多 goroutine 并发执行同一模板时,为每个执行者提供独立的 io.Writer,避免输出交错。