Go: 模板
Go的模板
基本概念
Go通过text/template标准库提供数据驱动的模板,用于生成文本输出,配置文件、邮件正文等都可以使用它模板由普通文本和
action组成,普通文本会原样复制到输出,action使用{{和}}包围:1
Hello, {{.Name}}!
Execute()执行模板时会把传入的数据绑定到.,.称为dot,模板执行过程中可以随着with、range等语句切换text/template默认不会对输出进行转义,模板作者必须是可信的;生成HTML时应该使用接口相同但带上下文自动转义的html/template模板解析成功后可以并发执行,但多个执行过程如果共用同一个
io.Writer,输出可能互相交错
执行模板
最基本的流程是创建模板、解析模板文本、传入数据执行:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25package main
import (
"os"
"text/template"
)
type Inventory struct {
Material string
Count uint
}
func main() {
data := Inventory{Material: "wool", Count: 17}
tmpl, err := template.New("inventory").Parse(
"{{.Count}} items are made of {{.Material}}",
)
if err != nil {
panic(err)
}
if err := tmpl.Execute(os.Stdout, data); err != nil {
panic(err)
}
}template.New(name)创建模板对象,name用于标识模板本身,不是模板文件名Parse(text)只负责解析模板文本,语法错误会在这里返回Execute(writer, data)执行模板并写入writer,执行阶段的字段访问、函数调用、方法调用错误也会通过返回值报告template.Must(tmpl, err)在err不为nil时直接panic,适合模板文本固定且程序启动时就应该失败的场景,不适合解析用户输入
数据访问与dot
初始的
.就是传递给Execute()的data,可以访问结构体字段、map键和无参数方法:1
2
3
4{{.Title}}
{{.User.Name}}
{{.Config.timeout}}
{{.DisplayName}}结构体字段必须是公有字段;
map的键名可以是小写字母开头的标识符方法必须是无参数方法,并且返回一个值,或者返回
(value, error);第二种形式返回非空错误时,模板执行会立即结束访问链可以混合字段、键和方法,例如
.User.Profile.Name初始的根数据也可以通过
$访问。进入range或with后.可能已经改变,但$仍然指向本次执行的根数据:1
2
3{{range .Items}}
{{$.Title}}: {{.Name}}
{{end}}
action与管道
普通表达式
{{.}}输出当前dot的默认文本表示,{{.Name}}输出字段值可以使用布尔值、字符串、数字、
nil、变量、字段、键、方法和函数作为参数:1
2
3{{"hello"}}
{{42}}
{{.Name}}注释写在
action内部,不会出现在输出中:1
{{/* 这是一段模板注释 */}}
空白裁剪
action外部的普通文本默认全部保留,包括换行和缩进;可以在分隔符中加入-来裁剪相邻的空白{{-会裁剪action前面普通文本末尾的空格、制表符、回车和换行:1
2
3A
{{- "B"}}
C上面的模板会输出
AB\nC,左侧的换行和缩进都会被裁剪-}}会裁剪action后面普通文本开头的空白:1
2A{{"B" -}}
C上面的模板会输出
ABC,右侧的换行、制表符和缩进都会被裁剪两侧同时使用可以去掉模板控制语句制造的空行:
1
2
3A
{{- "B" -}}
C输出为
ABC-旁边的空白是语法的一部分:{{-3}}表示输出负数-3,不是左裁剪;{{- 3}}才表示左裁剪后输出3空白裁剪只作用于
action两侧原本存在的普通文本,不会修改函数或字段本身产生的字符串内容
管道
管道使用
|连接多个表达式,前一个表达式的结果会作为后一个函数的最后一个参数:1
{{.Name | printf "Hello, %s!"}}
上面的写法等价于调用
printf("Hello, %s!", .Name)管道适合将格式化逻辑串起来,例如:
1
{{.Title | printf "%q"}}
text/template内置了常用函数:and、or、not:逻辑运算eq、ne、lt、le、gt、ge:比较值len:获取数组、切片、map、字符串等值的长度index:按索引或键访问数组、切片、字符串、mapslice:对数组、切片或字符串进行切片print、printf、println:格式化输出call:调用一个函数值
空值
在
if、with和range中,以下值会被当作空值:false、0、nil指针或接口,以及长度为0的数组、切片、map和字符串空值的判断不需要显式调用函数:
1
2
3
4
5{{if .User}}
欢迎回来,{{.User.Name}}
{{else}}
请先登录
{{end}}
条件与循环
if
if只在管道结果非空时渲染内容,支持else以及直接连接的else if:1
2
3
4
5
6
7{{if eq .Status "success"}}
成功
{{else if eq .Status "pending"}}
处理中
{{else}}
失败
{{end}}if不会改变.,条件块内仍然可以访问外层数据
range
range用于遍历数组、切片、map、通道等值:1
2
3
4
5{{range .Items}}
- {{.Name}}
{{else}}
暂无数据
{{end}}循环体中的
.会切换为当前元素,因此需要访问根数据时使用$可以声明索引和元素变量。对数组、切片和字符串来说,第一个变量是索引;对
map来说是键:1
2
3{{range $index, $item := .Items}}
{{$index}}: {{$item.Name}}
{{end}}map的遍历顺序不应依赖;当键是有确定顺序的基本类型时,模板会按键的排序顺序访问
with
with适合在数据非空时切换.,可以减少重复的字段链:1
2
3
4
5
6{{with .User.Profile}}
姓名:{{.Name}}
邮箱:{{.Email}}
{{else}}
没有个人资料
{{end}}with块内的.是.User.Profile,但$仍然是根数据
模板变量
模板变量以
$开头,可以保存管道结果:1
2{{$title := .Title}}
标题:{{$title}}变量可以在后续通过
=重新赋值,但变量的作用域只到当前控制结构的end,或当前模板的结尾range可以同时声明变量,因此通常用来获取索引和元素;range的else分支中,这些变量不会被设置为新的循环值
自定义函数
通过
template.FuncMap可以把Go函数暴露给模板:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17package main
import (
"strings"
"text/template"
)
var funcs = template.FuncMap{
"upper": strings.ToUpper,
"join": strings.Join,
}
var tmpl = template.Must(
template.New("page").
Funcs(funcs).
Parse(`{{upper .Title}}: {{join .Tags ", "}}`),
)函数必须在
Parse()之前通过Funcs()注册,因为解析阶段就需要确认函数名存在函数可以返回一个值,也可以返回
(value, error);如果第二个返回值是非空错误,模板执行会停止并返回这个错误暴露给模板的函数应该保持简单、确定且容易测试,避免在模板函数中修改状态或访问外部资源
模板适合展示和格式化,数据查询、权限判断和复杂计算应该在执行前完成,避免把业务逻辑堆积到模板函数中
Sprig
Masterminds/sprig是一个专门为Go模板提供函数的库,弥补了标准库只提供少量基础函数的不足Sprig不改变text/template的解析和执行模型,本质上仍然是通过Funcs()向模板注册一组函数- 引入依赖:
go get github.com/Masterminds/sprig/v3
注册函数
使用
Sprig时,先把函数表注册到模板,再进行Parse():1
2
3
4
5
6
7
8
9
10
11import (
"text/template"
sprig "github.com/Masterminds/sprig/v3"
)
var tmpl = template.Must(
template.New("page").
Funcs(sprig.TxtFuncMap()).
Parse(`{{.Name | upper}}: {{.Tags | join ", "}}`),
)TxtFuncMap()适用于text/template,HtmlFuncMap()适用于html/templateFuncs()必须在Parse()之前调用,因为解析阶段就需要确认模板中的函数名存在Sprig函数可以和标准库内置函数、自己的FuncMap混合使用;同名函数后注册的定义会覆盖先注册的定义,因此应该避免命名冲突
函数分类
字符串:
upper、lower、trim、trimPrefix、trimSuffix、replace、contains、quote、cat列表:
list、first、last、rest、append、prepend、concat、uniq、without、has字典:
dict、get、set、hasKey、pluck、merge、mergeOverwrite、dig数学与逻辑:
add、sub、mul、div、max、min、default、coalesce、ternary编码与摘要:
toJson、fromJson、b64enc、b64dec、sha256sum时间和格式化:
now、date、dateModify、ago上面只是常用函数的分类,具体函数的参数类型仍然要以当前引入的
Sprig版本为准;模板函数通常要求参数类型可赋值,不能像动态语言一样任意转换
列表与字典
list可以创建模板列表,dict可以创建字符串键的字典,再结合join、get和hasKey生成配置或消息:1
2
3
4
5
6
7{{$ports := list 80 443}}
ports: {{join "," $ports}}
{{$labels := dict "app" .Name "tier" "web"}}
{{if hasKey $labels "app"}}
app: {{get $labels "app"}}
{{end}}管道可以把多个函数组合起来,例如:
1
2image: {{.Tag | default "latest" | quote}}
tags: {{.Tags | uniq | join ", "}}default、coalesce等函数沿用模板的“空值”判断,空字符串、0、false、空列表和nil都可能触发默认分支,不能把“未配置”和“显式配置为零值”混为一谈set、merge等字典函数会修改或合并字典;如果同一个字典在多个模板片段之间复用,需要留意它们共享同一份数据带来的副作用
可复现性与安全边界
Sprig中有些函数依赖当前时间、随机数或环境变量,这些函数会降低输出的可复现性;配置和构建模板应该谨慎使用Sprig本身不负责HTML上下文转义;生成HTML时仍然应该使用html/template和合适的函数表,不能因为引入Sprig就把不可信数据标记为安全内容- 不要把完整的函数表无条件暴露给不可信的模板作者;模板源代码本身需要可信,涉及环境、文件或随机性的函数也应按场景限制
命名模板
可以通过
define声明可复用的命名模板,通过template执行它:1
2
3
4
5
6
7
8{{define "item" -}}
- {{.}}
{{- end}}
{{define "list" -}}
{{range .Items}}{{template "item" .}}
{{end -}}
{{- end}}{{template "item" .}}会把当前.传给item;如果省略数据参数,命名模板收到的是nil解析后通过
ExecuteTemplate(writer, name, data)可以执行指定名称的模板:1
2
3
4var output bytes.Buffer
if err := tmpl.ExecuteTemplate(&output, "list", data); err != nil {
return err
}多个文件可以通过
ParseFiles()、ParseGlob()或ParseFS()解析,适合把页面布局、片段和具体页面拆分到不同文件
从embed.FS加载模板
ParseFS()可以直接从io/fs读取模板,配合embed可以把模板文件编译进二进制:1
2
3
4
5
6
7
8
9
10
11import (
"embed"
"text/template"
)
//go:embed templates/*.tmpl
var templateFS embed.FS
var tmpl = template.Must(
template.ParseFS(templateFS, "templates/*.tmpl"),
)模板文件中的命名模板可以通过
ExecuteTemplate()选择执行;直接解析的文件模板通常使用文件的基础名称作为模板名固定模板可以在包初始化时解析并复用,不要在每次请求到来时重复读取和解析文件
处理缺失键与执行错误
当
.是map时,访问不存在的键默认不会在解析阶段报错,执行结果可能是<no value>可以通过
Option("missingkey=error")让缺失键直接使执行失败:1
2
3
4
5
6tmpl, err := template.New("config").
Option("missingkey=error").
Parse(`name={{.Name}}`)
if err != nil {
return err
}Execute()可能已经写入一部分结果后才遇到错误,因此需要对重要输出先写入bytes.Buffer,确认执行成功后再写入最终的io.Writer:1
2
3
4
5
6
7var output bytes.Buffer
if err := tmpl.Execute(&output, data); err != nil {
return err
}
_, err := writer.Write(output.Bytes())
return err模板解析错误和执行错误都应该显式处理;只检查
Parse()而忽略Execute()的返回值,会把运行时数据错误伪装成成功响应
html/template
html/template和text/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
28package main
import (
"bytes"
"html/template"
"net/http"
)
var page = template.Must(template.New("page").Parse(`
{{define "page"}}
<!doctype html>
<html>
<head><title>{{.Title}}</title></head>
<body><h1>{{.Title}}</h1></body>
</html>
{{end}}
`))
func handler(w http.ResponseWriter, r *http.Request, data any) {
var output bytes.Buffer
if err := page.ExecuteTemplate(&output, "page", data); err != nil {
http.Error(w, "render failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(output.Bytes())
}同一个值放在普通文本、
HTML属性、URL、CSS或JavaScript上下文中时,html/template会采用不同的转义策略text/template不会自动转义,不能用来直接生成包含不可信数据的HTMLtemplate.HTML等安全类型可以让开发者声明一段内容已经安全,但这会绕过对应的自动转义,只能用于已经验证过的可信内容模板源代码本身仍然必须可信:自动转义保护的是执行数据,不是恶意模板作者
常见用法与项目
云原生工具通常复用
Go template的语法模型,再注入自己的数据上下文和函数集合;因此同样的{{if}}、{{range}}在不同项目中可用的对象并不完全相同Helm:helm/helm在Chart的templates/目录中渲染Kubernetes清单,向模板提供.Values、.Release、.Chart等对象,并额外提供include、required、tpl等函数;大量基础辅助函数来自Spriginclude、required、tpl是Helm自己的扩展,nindent、toYaml等则来自Helm或其依赖;这些函数都不能直接复制到只有标准库的Go程序中kubectl:Kubernetes的kubectl支持-o go-template和-o go-template-file,可以直接从资源对象中提取字段:1
kubectl get pods -o go-template='{{range .items}}{{.metadata.name}}{{"\n"}}{{end}}'
监控告警:
Prometheus Alertmanager和Grafana都使用Go template语法生成告警标题、正文和通知消息,但.Alerts、.Labels以及查询函数等上下文由各自项目定义配置生成:
hashicorp/consul-template使用模板从Consul、Vault等外部数据源生成配置文件或触发命令,适合需要随外部配置变化自动渲染的场景
这些项目的共同点是复用模板的语法模型,差异则主要来自数据上下文和函数集合;阅读云原生模板时,首先要确认它是标准库模板、Sprig扩展,还是Helm、Grafana等项目的进一步扩展方言