模板引擎: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 判断 ""、0、nil、false、空切片/空 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 模板支持 define 和 template 指令进行组合:
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>", // 会被转义为 <script>...
Items: []string{"苹果", "香蕉", "橙子"},
Link: "https://example.com",
}
err = t.Execute(os.Stdout, data)
if err != nil {
panic(err)
}
}
html/template 会自动将 < 转义为 <,> 转义为 >," 转义为 ",有效防止 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}} 实现组合。或者使用第三方库如 ace、jet。
Q5: 为什么我的方法在模板中不可用?
A: 检查方法是否是导出的、返回值是否符合要求、参数类型是否支持。
Q6: 如何在模板中使用全局变量?
A: 将全局变量注入到每个请求的数据结构体中,或者使用自定义的 Execute 包装函数。
延伸阅读
- go:embed:把文件打包进二进制 — 将模板文件嵌入可执行文件的分发方案
- Go Modules:现代化的依赖管理 — 管理模板引擎相关依赖
- HTTP 编程基础 — Web 框架与模板渲染结合
- 项目架构:如何组织 Go 项目 — 模板文件在项目结构中的最佳位置
- 正则表达式 — 在模板渲染前对内容做正则过滤
- Go 设计模式 — 模板方法模式与渲染架构
- 字符串处理 — 模板输出前的字符串操作技巧
参考资料:
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。