模板引擎:text/template 和 html/template

全面讲解 Go 标准库的模板引擎 text/template 和 html/template,涵盖模板基础语法(字段访问、方法调用、管道操作)、控制结构(if/else、range、with)、自定义函数与 FuncMap、模板组合与嵌套、文件模板加载与模板集管理、HTML 自动转义与安全类型、邮件模板系统实战,以及企业级模板框架设计、最佳实践与常见问题解答。

模板引擎:text/template 和 html/template

在构建 Web 应用、生成配置文件、发送电子邮件、生成代码等场景中,我们经常需要把数据填充到预定义的模板中。模板引擎让静态模板和动态数据分离,是前后端开发的核心基础设施。

Go 的标准库提供了两个模板引擎:

  • text/template:用于生成纯文本(邮件、配置文件、代码生成器、命令行输出等)
  • html/template:用于生成 HTML,自动处理转义防止 XSS 攻击,是 Web 应用的安全保障

今天我们就来全面学习 Go 的模板引擎。你将掌握从基础语法到企业级模板系统设计的完整技能树。

模板基础用法

Go 模板的最小化使用如下:

package main

import (
	"os"
	"text/template"
)

func main() {
	// 定义模板字符串(使用反引号方便编写多行)
	tmpl := `Hello, {{.Name}}!
You are {{.Age}} years old.
Your email is {{.Email}}.`

	// 解析模板(创建命名模板"greeting")
	t, err := template.New("greeting").Parse(tmpl)
	if err != nil {
		panic(err)
	}

	// 准备数据(可以是任意类型,常用 struct 或 map)
	data := struct {
		Name  string
		Age   int
		Email string
	}{
		Name:  "张三",
		Age:   25,
		Email: "zhangsan@example.com",
	}

	// 执行模板,输出到标准输出
	err = t.Execute(os.Stdout, data)
	if err != nil {
		panic(err)
	}
}

输出:

Hello, 张三!
You are 25 years old.
Your email is zhangsan@example.com.

核心概念:

  • {{.}}管道(pipeline),表示当前作用域的数据
  • {{.Name}}:访问数据的 Name 字段
  • template.New() 创建模板并赋予名称
  • Parse() 解析模板字符串
  • Execute() 将数据填充到模板并输出

模板语法详解

访问字段

模板可以访问 struct 的导出字段,也可以访问 map 的键值:

package main

import (
	"os"
	"text/template"
)

type Address struct {
	City    string
	ZipCode string
}

type User struct {
	Name    string
	Age     int
	Address Address
}

func main() {
	user := User{
		Name: "张三",
		Age:  25,
		Address: Address{
			City:    "北京",
			ZipCode: "100000",
		},
	}

	tmpl := `姓名: {{.Name}}
城市: {{.Address.City}}
邮编: {{.Address.ZipCode}}`

	t, _ := template.New("user").Parse(tmpl)
	t.Execute(os.Stdout, user)
}

也可以用 map:

	data := map[string]interface{}{
		"Name": "张三",
		"Address": map[string]string{
			"City": "北京",
		},
	}

⚠️ 注意:模板只能访问导出字段(首字母大写)。如果字段名是 name(小写),模板中无法访问。

调用方法

如果数据类型有方法,模板中可以直接调用:

package main

import (
	"os"
	"text/template"
)

type User struct {
	FirstName string
	LastName  string
	BirthYear int
}

// FullName 返回全名(模板可以调用)
func (u User) FullName() string {
	return u.FirstName + " " + u.LastName
}

// Age 计算年龄(模板可以调用)
func (u User) Age() int {
	return 2024 - u.BirthYear
}

// HasTitle 带参数的方法(模板也可以调用)
func (u User) HasTitle(title string) bool {
	return title != ""
}

func main() {
	user := User{FirstName: "三", LastName: "张", BirthYear: 1999}

	tmpl := `全名: {{.FullName}}
年龄: {{.Age}}
有头衔: {{.HasTitle "工程师"}}`

	t, _ := template.New("user").Parse(tmpl)
	t.Execute(os.Stdout, user)
}

方法调用的限制:

  • 必须返回单个值,或一个值加一个 error(error 会被渲染为字符串 “no value”)
  • 不能有可变参数
  • 参数必须是字符串、数字等基本类型

管道(Pipeline)

模板支持管道操作,类似 Unix 命令的管道——前一个命令的输出作为后一个命令的输入:

package main

import (
	"os"
	"strings"
	"text/template"
)

func main() {
	funcMap := template.FuncMap{
		"upper":  strings.ToUpper,
		"lower":  strings.ToLower,
		"repeat": strings.Repeat,
	}

	tmpl := `
原始: {{.Name}}
大写: {{.Name | upper}}
小写: {{.Name | lower}}
重复: {{.Symbol | repeat 5}}
组合: {{.Name | upper | printf "Hello, %s!"}}`

	t := template.New("test").Funcs(funcMap)
	t, _ = t.Parse(tmpl)

	data := map[string]string{
		"Name":   "zhangsan",
		"Symbol": "*",
	}

	t.Execute(os.Stdout, data)
}

管道语法 {{.Name | upper}} 等价于 upper(.Name),多个管道串联时 {{.Name | upper | repeat 3}} 等价于 repeat(upper(.Name), 3)

控制结构

if / else

	tmpl := `
{{if .LoggedIn}}
  欢迎回来,{{.Username}}  {{if .IsAdmin}}
    您拥有管理员权限。
  {{else}}
    您是普通用户。
  {{end}}
{{else}}
  请先登录。
{{end}}

{{if gt .Age 18}}
  您已成年
{{else if gt .Age 12}}
  您是青少年
{{else}}
  您是儿童
{{end}}

{{if .Bio}}
  个人简介: {{.Bio}}
{{else}}
  暂无个人简介
{{end}}`

比较函数:

  • eq:等于(eq .Value 1,也支持多值比较 eq .A .B .C)
  • ne:不等于
  • lt:小于
  • le:小于等于
  • gt:大于
  • ge:大于等于

注意:if 判断 ""0nilfalse、空切片/空 map 都为 false。

range 循环

package main

import (
	"os"
	"text/template"
)

func main() {
	data := struct {
		Fruits []string
		Users  []struct {
			Name string
			Age  int
		}
		Tags map[string]string
	}{
		Fruits: []string{"苹果", "香蕉", "橙子"},
		Users: []struct {
			Name string
			Age  int
		}{
			{"张三", 25},
			{"李四", 30},
			{"王五", 35},
		},
		Tags: map[string]string{
			"language": "Go",
			"os":       "Linux",
		},
	}

	tmpl := `
水果列表:
{{range .Fruits}}
  - {{.}}
{{else}}
  没有水果
{{end}}

用户列表:
{{range $index, $user := .Users}}
  {{$index}}. {{$user.Name}} ({{$user.Age}}岁)
{{end}}

标签:
{{range $key, $value := .Tags}}
  {{$key}} = {{$value}}
{{end}}`

	t, _ := template.New("list").Parse(tmpl)
	t.Execute(os.Stdout, data)
}

range 中:

  • {{.}} 代表当前元素
  • $index$element 可以获取索引和元素
  • 对于 map,$key$value 可以获取键和值
  • {{else}} 在集合为空时执行

with 块

with 用于改变当前作用域,类似于局部变量:

	tmpl := `
{{with .Address}}
  城市: {{.City}}
  邮编: {{.ZipCode}}
{{else}}
  没有地址信息
{{end}}`

{{with .Address}} 内部,{{.}} 就变成了 Address 对象,可以直接访问 .City.ZipCode

自定义函数

通过 FuncMap 可以注册自定义函数,极大地扩展模板的能力:

package main

import (
	"fmt"
	"os"
	"strings"
	"text/template"
	"time"
)

func main() {
	funcMap := template.FuncMap{
		"upper":   strings.ToUpper,
		"lower":   strings.ToLower,
		"title":   strings.Title,
		"repeat":  strings.Repeat,
		"replace": strings.ReplaceAll,
		"add": func(a, b int) int {
			return a + b
		},
		"sub": func(a, b int) int {
			return a - b
		},
		"formatDate": func(t time.Time, layout string) string {
			return t.Format(layout)
		},
		"formatCurrency": func(amount float64) string {
			return fmt.Sprintf("¥%.2f", amount)
		},
		"dict": func(values ...interface{}) (map[string]interface{}, error) {
			if len(values)%2 != 0 {
				return nil, fmt.Errorf("dict requires even number of arguments")
			}
			m := make(map[string]interface{})
			for i := 0; i < len(values); i += 2 {
				key, ok := values[i].(string)
				if !ok {
					return nil, fmt.Errorf("dict keys must be strings")
				}
				m[key] = values[i+1]
			}
			return m, nil
		},
	}

	tmpl := `
大写: {{.Name | upper}}
小写: {{.Name | lower}}
标题: {{.Name | title}}
重复: {{.Symbol | repeat 5}}
加法: {{add .Price .Tax}}
日期: {{.Date | formatDate "2006-01-02 15:04:05"}}
货币: {{.Total | formatCurrency}}
字典: {{$d := dict "a" 1 "b" 2}}{{$d.a}}, {{$d.b}}
`

	t := template.New("test").Funcs(funcMap)
	t, err := t.Parse(tmpl)
	if err != nil {
		panic(err)
	}

	data := struct {
		Name   string
		Symbol string
		Price  int
		Tax    int
		Total  float64
		Date   time.Time
	}{
		Name:   "zhang san",
		Symbol: "*",
		Price:  100,
		Tax:    15,
		Total:  199.99,
		Date:   time.Now(),
	}

	err = t.Execute(os.Stdout, data)
	if err != nil {
		panic(err)
	}
}

关键函数 dict 非常有用——模板原生不支持创建 map 或 slice,dict 函数允许在模板中动态创建 map。

模板组合与嵌套

Go 模板支持 definetemplate 指令进行组合:

package main

import (
	"os"
	"text/template"
)

var templates = `
{{define "header"}}
========== 报告 ==========
生成平台: Go Template Engine
{{end}}

{{define "footer"}}
==========================
生成时间: {{.}}
版权所有 © 2024
{{end}}

{{define "user_info"}}
用户: {{.Name}}
年龄: {{.Age}}
邮箱: {{.Email}}
状态: {{if .Active}}已激活{{else}}未激活{{end}}
{{end}}

{{define "report"}}
{{template "header"}}

{{template "user_info" .User}}

订单数量: {{.OrderCount}}
总金额: {{.TotalAmount}}

{{template "footer" .GeneratedAt}}
{{end}}`

func main() {
	t := template.Must(template.New("main").Parse(templates))

	data := struct {
		User        struct {
			Name   string
			Age    int
			Email  string
			Active bool
		}
		OrderCount   int
		TotalAmount  string
		GeneratedAt  string
	}{
		User: struct {
			Name   string
			Age    int
			Email  string
			Active bool
		}{
			Name:   "张三",
			Age:    25,
			Email:  "zhangsan@example.com",
			Active: true,
		},
		OrderCount:  42,
		TotalAmount: "¥12,345.67",
		GeneratedAt: "2024-01-15 10:30:00",
	}

	err := t.ExecuteTemplate(os.Stdout, "report", data)
	if err != nil {
		panic(err)
	}
}

说明:

  • {{define "name"}}...{{end}}:定义具名模板
  • {{template "name" .}}:执行具名模板,. 是传给模板的数据
  • ExecuteTemplate():可以指定执行哪个具名模板

从文件加载模板

真实项目中,模板通常放在单独的文件中:

package main

import (
	"bytes"
	"fmt"
	"os"
	"path/filepath"
	"text/template"
)

// EmailTemplateManager 管理邮件模板
type EmailTemplateManager struct {
	templates map[string]*template.Template
	funcMap   template.FuncMap
}

func NewEmailTemplateManager(templateDir string) (*EmailTemplateManager, error) {
	funcMap := template.FuncMap{
		"dateFormat": func(t interface{}) string {
			// 简化的日期格式化
			return fmt.Sprintf("%v", t)
		},
	}

	et := &EmailTemplateManager{
		templates: make(map[string]*template.Template),
		funcMap:   funcMap,
	}

	// 加载所有模板文件
	files, err := filepath.Glob(filepath.Join(templateDir, "*.tmpl"))
	if err != nil {
		return nil, err
	}

	for _, file := range files {
		name := filepath.Base(file)
		tmpl, err := template.New(name).Funcs(funcMap).ParseFiles(file)
		if err != nil {
			return nil, fmt.Errorf("parse %s: %w", name, err)
		}
		et.templates[name] = tmpl
	}

	return et, nil
}

func (et *EmailTemplateManager) Render(name string, data interface{}) (string, error) {
	tmpl, ok := et.templates[name]
	if !ok {
		return "", fmt.Errorf("template %s not found", name)
	}

	var buf bytes.Buffer
	if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
		return "", err
	}

	return buf.String(), nil
}

func main() {
	// 模板目录: templates/
	//   welcome.tmpl
	//   reset_password.tmpl
	//   notification.tmpl

	et, err := NewEmailTemplateManager("templates")
	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	data := map[string]interface{}{
		"Name":         "张三",
		"AppName":      "MyApp",
		"ActivationURL": "https://example.com/activate?token=abc123",
		"Date":         "2024-01-15",
	}

	html, err := et.Render("welcome.tmpl", data)
	if err != nil {
		fmt.Println("Error:", err)
		return
	}

	fmt.Println(html)
}

HTML 模板与 XSS 防护

html/template 是生成 HTML 的首选,因为它会自动转义特殊字符:

package main

import (
	"html/template"
	"os"
)

func main() {
	tmpl := `<!DOCTYPE html>
<html>
<head>
    <title>{{.Title}}</title>
</head>
<body>
    <h1>{{.Title}}</h1>
    <p>{{.Content}}</p>
    
    <div>用户输入: {{.UserInput}}</div>
    
    <ul>
    {{range .Items}}
        <li>{{.}}</li>
    {{end}}
    </ul>
    
    {{if .Link}}
    <a href="{{.Link}}">点击这里</a>
    {{end}}
</body>
</html>`

	t, err := template.New("page").Parse(tmpl)
	if err != nil {
		panic(err)
	}

	data := struct {
		Title     string
		Content   string
		UserInput string
		Items     []string
		Link      string
	}{
		Title:     "测试页面",
		Content:   "这是正常内容",
		UserInput: "<script>alert('xss')</script>", // 会被转义为 &lt;script&gt;...
		Items:     []string{"苹果", "香蕉", "橙子"},
		Link:      "https://example.com",
	}

	err = t.Execute(os.Stdout, data)
	if err != nil {
		panic(err)
	}
}

html/template 会自动将 < 转义为 &lt;> 转义为 &gt;" 转义为 &quot;,有效防止 XSS 攻击。

安全的 HTML 类型

当你确实需要输出原始 HTML(如富文本编辑器中的内容),可以使用以下安全标记类型:

package main

import (
	"html/template"
	"os"
)

func main() {
	tmpl := `<div>{{.NormalText}}</div>
<div>{{.SafeHTML}}</div>
<a href="{{.SafeURL}}">链接</a>
<script>{{.SafeJS}}</script>
<style>{{.SafeCSS}}</style>`

	t, _ := template.New("test").Parse(tmpl)

	data := struct {
		NormalText string
		SafeHTML   template.HTML
		SafeURL    template.URL
		SafeJS     template.JS
		SafeCSS    template.CSS
	}{
		NormalText: "<b>会转义</b>",
		SafeHTML:   template.HTML("<b>这是粗体</b>"),         // 原始 HTML
		SafeURL:    template.URL("https://example.com"),      // 原始 URL
		SafeJS:     template.JS("console.log('hello')"),      // 原始 JavaScript
		SafeCSS:    template.CSS("body { color: red; }"),     // 原始 CSS
	}

	t.Execute(os.Stdout, data)
}

⚠️ 安全警告:使用这些类型意味着你确认内容是安全的。如果内容来自用户输入,必须严格验证和清理,否则还是会面临 XSS 风险!

实战:邮件模板系统

package main

import (
	"bytes"
	"fmt"
	"html/template"
	"os"
	"path/filepath"
)

// EmailTemplateSystem 企业级邮件模板系统
type EmailTemplateSystem struct {
	templates map[string]*template.Template
	funcMap   template.FuncMap
}

// NewEmailTemplateSystem 创建模板系统
func NewEmailTemplateSystem(templateDir string) (*EmailTemplateSystem, error) {
	funcMap := template.FuncMap{
		"safeHTML": func(s string) template.HTML {
			return template.HTML(s)
		},
		"safeURL": func(s string) template.URL {
			return template.URL(s)
		},
		"truncate": func(s string, n int) string {
			if len(s) <= n {
				return s
			}
			return s[:n] + "..."
		},
	}

	system := &EmailTemplateSystem{
		templates: make(map[string]*template.Template),
		funcMap:   funcMap,
	}

	// 加载所有 .html 模板
	files, err := filepath.Glob(filepath.Join(templateDir, "*.html"))
	if err != nil {
		return nil, err
	}

	for _, file := range files {
		name := filepath.Base(file)
		tmpl, err := template.New(name).Funcs(funcMap).ParseFiles(file)
		if err != nil {
			return nil, fmt.Errorf("parse %s: %w", name, err)
		}
		system.templates[name] = tmpl
	}

	// 加载所有 .txt 模板
	txtFiles, err := filepath.Glob(filepath.Join(templateDir, "*.txt"))
	if err != nil {
		return nil, err
	}

	for _, file := range txtFiles {
		name := filepath.Base(file)
		tmpl, err := template.New(name).Funcs(funcMap).ParseFiles(file)
		if err != nil {
			return nil, fmt.Errorf("parse %s: %w", name, err)
		}
		system.templates[name] = tmpl
	}

	return system, nil
}

func (s *EmailTemplateSystem) Render(name string, data interface{}) (string, error) {
	tmpl, ok := s.templates[name]
	if !ok {
		return "", fmt.Errorf("template '%s' not found", name)
	}

	var buf bytes.Buffer
	if err := tmpl.ExecuteTemplate(&buf, name, data); err != nil {
		return "", err
	}

	return buf.String(), nil
}

func main() {
	// 创建示例模板文件
	os.MkdirAll("email_templates", 0755)

	welcomeHTML := `<!DOCTYPE html>
<html>
<head><title>欢迎</title></head>
<body>
    <h1>欢迎,{{.Name}}!</h1>
    <p>感谢您注册 {{.AppName}}。</p>
    <p>请点击以下链接激活您的账户:</p>
    <a href="{{.ActivationURL | safeURL}}">激活账户</a>
    <p>如果您没有注册,请忽略此邮件。</p>
</body>
</html>`
	os.WriteFile("email_templates/welcome.html", []byte(welcomeHTML), 0644)

	welcomeTxt := `欢迎,{{.Name}}
感谢您注册 {{.AppName}}
请点击以下链接激活您的账户:
{{.ActivationURL}}

如果您没有注册,请忽略此邮件。`
	os.WriteFile("email_templates/welcome.txt", []byte(welcomeTxt), 0644)

	// 使用模板系统
	system, err := NewEmailTemplateSystem("email_templates")
	if err != nil {
		panic(err)
	}

	data := map[string]interface{}{
		"Name":          "张三",
		"AppName":       "MyApp",
		"ActivationURL": "https://example.com/activate?token=abc",
	}

	htmlVersion, _ := system.Render("welcome.html", data)
	txtVersion, _ := system.Render("welcome.txt", data)

	fmt.Println("===== HTML 版本 =====")
	fmt.Println(htmlVersion)
	fmt.Println("===== TXT 版本 =====")
	fmt.Println(txtVersion)
}

最佳实践

1. 模板预编译

在应用启动时预编译模板,不要在请求时实时解析:

var templates *template.Template

func init() {
	templates = template.Must(template.ParseGlob("templates/*.html"))
}

func handler(w http.ResponseWriter, r *http.Request) {
	// 直接使用预编译的模板
templates.ExecuteTemplate(w, "index.html", data)
}

2. 统一的错误处理

func renderTemplate(w http.ResponseWriter, name string, data interface{}) {
	err := templates.ExecuteTemplate(w, name, data)
	if err != nil {
		log.Printf("template error: %v", err)
		http.Error(w, "Internal Server Error", 500)
	}
}

3. HTML 与 text 分离

  • HTML 渲染始终使用 html/template,绝不使用 text/template
  • 邮件同时提供 HTML 和纯文本版本
  • 配置文件生成使用 text/template

4. 模板热重载(开发环境)

var templates *template.Template
var templatesMutex sync.RWMutex

func loadTemplates() {
	t, err := template.ParseGlob("templates/*.html")
	if err != nil {
		log.Println("template load error:", err)
		return
	}
	templatesMutex.Lock()
	templates = t
	templatesMutex.Unlock()
}

func handler(w http.ResponseWriter, r *http.Request) {
	templatesMutex.RLock()
	t := templates
	templatesMutex.RUnlock()
	t.ExecuteTemplate(w, "page.html", data)
}

5. 使用嵌入存储模板

结合 go:embed 将模板打包进二进制:

import _ "embed"

//go:embed templates/*.html
var templateFS embed.FS

func init() {
	templates = template.Must(template.ParseFS(templateFS, "templates/*.html"))
}

常见问题(FAQ)

Q1: 模板报错 “no such template” 怎么办?

A: 确保模板名称和 ExecuteTemplate 中使用的名称一致。使用 ParseFiles 时,默认模板名称是文件名,不是文件的第一个 {{define}}

Q2: html/template 为什么把我的 HTML 标签转义了?

A: 这是安全特性。如果不想转义,使用 template.HTML 类型,但务必确保内容安全。

Q3: 模板中能否修改数据?

A: 不能。Go 模板只有读取能力,没有写入能力。所有逻辑应该在数据准备阶段完成。

Q4: 如何实现模板继承(类似 Django 的 extends)?

A: Go 模板没有继承概念,但可以通过 {{template}}{{define}} 实现组合。或者使用第三方库如 acejet

Q5: 为什么我的方法在模板中不可用?

A: 检查方法是否是导出的、返回值是否符合要求、参数类型是否支持。

Q6: 如何在模板中使用全局变量?

A: 将全局变量注入到每个请求的数据结构体中,或者使用自定义的 Execute 包装函数。

延伸阅读


参考资料:

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南