template
官方文檔:template package - text/template - Go Packages
在平時我們經常會使用 fmt.Sprintf 函數來進行字符串格式化,但它只適用於處理小字符串的情況,而且需要使用格式化動詞來指定類型,無法做到參數命名,不支持復雜情況下的處理,而這就是模板引擎所需要解決的問題,比如在直接掛到後端的靜態 HTML 頁面就需要用到模板引擎。社區裡面有很多優秀的第三方模板引擎庫,比如 pongo2 ,sprig,jet,不過本文要講述的主角是 go 內置的模板引擎庫 text/template,在實際開發中一般用的是 html/template,後者基於前者並做了很多關於 HTML 的安全處理,一般情況使用前者即可,若是涉及到 HTML 的模板處理建議使用後者會更安全。
快速開始
下面來看一個關於模板引擎的簡單使用示例,如下所示
package main
import (
"fmt"
"os"
"text/template"
)
func main() {
tmpl := `This is the first template string, {{ .message }}`
te, err := template.New("texTmpl").Parse(tmpl)
if err != nil {
fmt.Println(err)
return
}
data := map[string]any{
"message": "hello world!",
}
execErr := te.Execute(os.Stdout, data)
if execErr != nil {
fmt.Println(err)
}
}上述代碼的輸出為
This is the first template string, hello world!在案例代碼中,tmpl 是一個模板字符串,字符串中的 {{ .message }} 是模板引擎的模板參數。首先通過 *Template.Parse 方法解析模板字符串,
func (t *Template) Parse(text string) (*Template, error)解析成功後再通過 *Template.Execute 方法將 data 數據應用於模板中,最後輸出到傳入的 Writer 中也就是 os.Stdout。
func (t *Template) Execute(wr io.Writer, data any) error在以後模板引擎的使用中,基本上都是這三步:
- 獲取模板
- 解析模板,
- 將數據應用到模板中
可見模板引擎的使用其實相當簡單,稍微復雜一點的是模板引擎的模板語法,這才是本文主要講解的內容。
模板語法
參數
go 通過兩對花括號 {{ }},來在模板中表示這是一個模板參數,通過 . 來表示根對象,根對象就是傳入的 data。就像是訪問一個類型的成員變量一樣,通過 . 符號銜接變量名就可以在模板中訪問對應的值,例如
{{ .data }}前提與之同名的成員變量存在,否則就會報錯。對於傳入的 data,一般是結構體或者 map,也可以是基本類型,比如數字字符串,這時 . 所代表的根對象就是其自身。在花括號內,不一定非得去訪問根對象來獲取值,也可以是基本類型的字面量,例如
{{ 1 }}
{{ 3.14 }}
{{ "jack" }}不管什麼類型,最終都會通過 fmt.Sprintf("%s", val) 來獲取其字符串表現形式,看下面的例子。
func main() {
out := os.Stdout
tmpl := "data-> {{ . }}\n"
datas := []any{
"hello world!",
6379,
3.1415926,
[]any{1, "2*2", 3.6},
map[string]any{"data": "hello world!"},
struct {
Data string
}{Data: "hello world!"},
}
for _, data := range datas {
err := ExecTmpl(out, tmpl, data)
if err != nil {
panic(err)
}
}
}
func ExecTmpl(writer io.Writer, tmpl string, data any) error {
parsedTmpl, err := template.New("template").Parse(tmpl)
if err != nil {
return err
}
return parsedTmpl.Execute(writer, data)
}輸出如下
data-> hello world!
data-> 6379
data-> 3.1415926
data-> [1 2*2 3.6]
data-> map[data:hello world!]
data-> {hello world!}可以看到其輸出形式跟直接使用 fmt.Sprintf 一致。對於結構體和 map,可以通過字段名來訪問其值,如下所示
func main() {
out := os.Stdout
tmpl := "data-> {{ .Data }}\n"
datas := []any{
map[string]any{"Data": "hello world!"},
struct {
Data string
}{Data: "hello world!"},
}
for _, data := range datas {
err := ExecTmpl(out, tmpl, data)
if err != nil {
panic(err)
}
}
}輸出如下
data-> hello world!
data-> hello world!對於切片和 map,雖然並沒有提供特定語法來訪問某一個索引的值,但可以通過函數調用的方式來實現,如下所示
func main() {
out := os.Stdout
tmpl := "data-> {{ index . 1}}\n"
datas := []any{
[]any{"first", "second"},
map[int]any{1: "first"},
}
for _, data := range datas {
err := ExecTmpl(out, tmpl, data)
if err != nil {
panic(err)
}
}
}輸出
data-> second
data-> first如果是多維切片,可以通過如下方式來訪問對應下標的值,等同於 s[i][j][k]
{{ index . i j k }}對於嵌套的結構體或 map,可以使用 .k1.k2.k3 這種方式訪問,例如
{{ .person.father.name }}在使用模板參數時,可以在參數前後加上 - 符號來消除參數前後的空白,看個例子
func main() {
out := os.Stdout
tmpl := `{{ .x }} {{ - .op - }} {{ .y }}`
datas := []any{
map[string]any{"x": "10", "op": ">", "y": "2"},
}
for _, data := range datas {
err := ExecTmpl(out, tmpl, data)
if err != nil {
panic(err)
}
}
}正常來說輸出結果應該是 10 > 2,但由於在 op 參數前後添加了 - 符號,所以它前後的空白符都會被消除,所以實際輸出為
10>2需要注意的是,在花括號中,- 符號與參數必須相隔一個空格,也就說必須是 {{- . -}} 這種格式,在例子中之所以會在兩邊額外加個空格寫成 {{ - . - }} 這種格式純粹是個人覺得看的順眼,實際上並沒有這個語法限制。
注釋
模板語法支持注釋,注釋並不會在最終的模板中生成,其語法如下
{{/* this is a comment */}}注釋符號 /* 和 */ 必須與花括號相鄰,它們之間不能有其它字符,否則將無法正常解析。只有一種情況例外,那就是消除空白符的時候
{{- /* this is a comment */ -}}變量
在模板中也可以聲明變量,通過 $ 符號來表示這是一個變量,並通過 := 來進行賦值,就跟 go 代碼一樣,例子如下。
{{ $name := .Name }}
{{ $val := index . 1 }}
{{ $val := index .dict key }}
// 整型賦值
{{ $numer := 1 }}
// 浮點數賦值
{{ $float := 1.234}}
// 字符串賦值
{{ $name := "jack" }}在後續使用時,通過 $ 銜接變量名來訪問該變量的值,比如
func main() {
out := os.Stdout
tmpl := `{{ $name := .name }} {{- $name }}`
datas := []any{
map[string]any{"name": "jack"},
}
for _, data := range datas {
err := ExecTmpl(out, tmpl, data)
if err != nil {
panic(err)
}
}
}輸出
jack變量必須先聲明才能使用,否則將會提示 undefined variable,並且也要在作用域內才能使用。
函數
模板自身的語法其實並不多,大多數功能都是通過函數來實現的,函數調用的格式為函數名後銜接參數列表,以空格為分隔符,如下所示
{{ funcname arg1 arg2 arg3 ... }}例如之前用到的 index 函數
{{ index .s 1 }}用於比較是否相等的函數 eq 函數
{{ eq 1 2 }}每一個 *Template 都有一個 FuncsMap,用於記錄函數的映射
type FuncMap map[string]any在創建模板時從 text/template.builtins 獲取默認的函數映射表,下面是內置的所有函數
| 函數名 | 作用 | 示例 |
|---|---|---|
and | 與運算 | {{ and true false }} |
or | 或運算 | {{ or true false }} |
not | 取反運算 | {{ not true }} |
eq | 是否相等 | {{ eq 1 2 }} |
ne | 是否不相等 | {{ ne 1 2 }} |
lt | 小於 | {{ lt 1 2 }} |
le | 小於等於 | {{ le 1 2 }} |
gt | 大於 | {{ gt 1 2 }} |
ge | 大於等於 | {{ ge 1 2 }} |
len | 返回長度 | {{ len .slice }} |
index | 獲取目標指定索引的元素 | {{ index . 0 }} |
slice | 切片,等價於 s[1:2:3] | {{ slice . 1 2 3 }} |
html | HTML 轉義 | {{ html .name }} |
js | js 轉義 | {{ js .name }} |
print | fmt.Sprint | {{ print . }} |
printf | fmt.Sprintf | {{ printf "%s" .}} |
println | fmt.Sprintln | {{ println . }} |
urlquery | url query 轉義 | {{ urlquery .query }} |
除了這些之外,還有一個比較特殊的內置函數 call,它是用於直接調用通過在 Execute 時期傳入的 data 中的函數,例如下面的模板
{{ call .string 1024 }}傳入的數據如下
map[string]any{
"string": func(val any) string { return fmt.Sprintf("%v: 2048", val) },
}那麼在模板中就會生成
1024: 2048這是自定義函數的途徑之一,不過通常建議使用 *Template.Funcs 方法來添加自定義函數,因為後者可以作用全局,不需要綁定到根對象中。
func (t *Template) Funcs(funcMap FuncMap) *Template自定義函數的返回值一般有兩個,第一個是需要用到的返回值,第二個是 error。例如有如下自定義函數
template.FuncMap{
"add": func(val any) (string, error) { return fmt.Sprintf("%v+1", val), nil },
}然後在模板中直接使用
{{ add 1024 }}其結果為
1024 + 1管道
這個管道與 chan 是兩個東西,官方文檔裡面稱其為 pipeline,任何能夠產生數據的操作都稱其為 pipeline。下面的模板操作都屬於管道操作
{{ 1 }}
{{ eq 1 2 }}
{{ $name }}
{{ .name }}熟悉 linux 的應該都知道管道運算符 |,模板中也支持這樣的寫法。管道操作在模板中經常出現,例如
{{ $name := 1 }}{{ $name | print | printf "%s+1=?" }}其結果為
1+1=?在後續的 with,if,range 中也會頻繁用到。
with
通過 with 語句可以控制變量和根對象的作用域,格式如下
{{ with pipeline }}
text
{{ end }}with 會檢查管道操作返回的值,如果值為空的話,中間的 text 模板就不會生成。如果想要處理空的情況,可以使用 with else,格式如下
{{ with pipeline }}
text1
{{ else }}
text2
{{ end }}如果管道操作返回的值為空,那麼就會執行 else 這塊的邏輯。在 with 語句中聲明的變量,其作用域僅限於 with 語句內,看下面一個例子
{{ $name := "mike" }}
{{ with $name := "jack" }}
{{- $name -}}
{{ end }}
{{- $name -}}它的輸出如下,顯然這是由於作用域不同,它們是兩個不同的變量。
jackmike通過 with 語句還可以在作用域內改寫根對象,如下
{{ with .name }}
name: {{- .second }}-{{ .first -}}
{{ end }}
age: {{ .age }}
address: {{ .address }}傳入如下的數據
map[string]any{
"name": map[string]any{
"first": "jack",
"second": "bob",
},
"age": 1,
"address": "usa",
}它的輸出
name:bob-jack
age: 1
address: usa可以看到在 with 語句內部,根對象 . 已經變成了 .name。
條件
條件語句的格式如下所示
{{ if pipeline }}
text1
{{ else if pipeline }}
text2
{{ else }}
text3
{{ end }}就跟寫普通的代碼一樣,非常好理解。下面看幾個簡單的例子,
{{ if eq .lang "en" }}
{{- .content.en -}}
{{ else if eq .lang "zh" }}
{{- .content.zh -}}
{{ else }}
{{- .content.fallback -}}
{{ end }}傳入的數據
map[string]any{
"lang": "zh",
"content": map[string]any{
"en": "hello, world!",
"zh": "你好,世界!",
"fallback": "hello, world!",
},
}例子中的模板根據傳入的語言 lang 來決定要以何種方式展示內容,輸出結果
你好,世界!迭代
迭代語句的格式如下,range 所支持的 pipeline 必須是數組,切片,map,以及 channel。
{{ range pipeline }}
loop body
{{ end }}結合 else 使用,當長度為 0 時,就會執行 else 塊的內容。
{{ range pipeline }}
loop body
{{ else }}
fallback
{{ end }}除此之外,還支持 break,continue 這類操作,比如
{{ range pipeline }}
{{ if pipeline }}
{{ break }}
{{ end }}
{{ if pipeline }}
{{ continue }}
{{ end }}
loop body
{{ end }}下面看一個迭代的例子。
{{ range $index, $val := . }}
{{- if eq $index 0 }}
{{- continue -}}
{{ end -}}
{{- $index}}: {{ $val }}
{{ end }}傳入數據
[]any{1, "2", 3.14},輸出
1: 2
2: 3.14迭代 map 也是同理。
嵌套
一個模板中可以定義有多個模板,比如
{{ define "t1" }} t1 {{ end }}
{{ define "t2" }} t2 {{ end }}這些定義的模板在並不會生成在最終的模板中,除非在加載時指定了名稱或者通過 template 語句手動指定。
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data any) error比如下面的例子
{{ define "t1" }}
{{- with .t1 }}
{{- .data -}}
{{ end -}}
{{ end }}
{{ define "t2" }}
{{- with .t2 }}
{{- .data -}}
{{ end}}
{{ end -}}傳入如下數據
map[string]any{
"t1": map[string]any{"data": "template body 1"},
"t2": map[string]any{"data": "template body 2"},
}代碼
func main() {
out := os.Stdout
tmpl :=
`{{ define "t1" }}
{{- with .t1 }}
{{- .data -}}
{{ end -}}
{{ end }}
{{ define "t2" }}
{{- with .t2 }}
{{- .data -}}
{{ end}}
{{ end -}}`
datas := []any{
map[string]any{
"t1": map[string]any{"data": "template body 1"},
"t2": map[string]any{"data": "template body 2"},
},
}
name := "t1"
for _, data := range datas {
err := ExecTmpl(out, tmpl, name, data)
if err != nil {
panic(err)
}
}
}
func ExecTmpl(writer io.Writer, tmpl string, name string, data any) error {
t := template.New("template")
parsedTmpl, err := t.Parse(tmpl)
if err != nil {
return err
}
return parsedTmpl.ExecuteTemplate(writer, name, data)
}輸出
template body 1或者也可以手動指定模板
{{ define "t1" }}
{{- with .t1 }}
{{- .data -}}
{{ end -}}
{{ end }}
{{ define "t2" }}
{{- with .t2 }}
{{- .data -}}
{{ end}}
{{ end -}}
{{ template "t2" .}}那麼在解析時是否指定模板名稱,t2 都會加載。
關聯
子模板只是在一個模板內部聲明多個命名的模板,關聯是將外部的多個命名的 *Template 關聯起來。然後通過 template 語句來引用指定的模板。
{{ tempalte "templateName" pipeline}}pipeline 可以根據自己的需求來指定關聯模板的根對象,或者也可以直接傳入當前模板的根對象。看下面的一段代碼例子
func main() {
tmpl1 := `name: {{ .name }}`
tmpl2 := `age: {{ .age }}`
tmpl3 := `Person Info
{{template "t1" .}}
{{template "t2" .}}`
t1, err := template.New("t1").Parse(tmpl1)
if err != nil {
panic(err)
}
t2, err := template.New("t2").Parse(tmpl2)
if err != nil {
panic(err)
}
t3, err := template.New("t3").Parse(tmpl3)
if err != nil {
panic(err)
}
if err := associate(t3, t1, t2); err != nil {
panic(err)
}
err = t3.Execute(os.Stdout, map[string]any{
"name": "jack",
"age": 18,
})
if err != nil {
panic(err)
}
}
func associate(t *template.Template, ts ...*template.Template) error {
for _, tt := range ts {
_, err := t.AddParseTree(tt.Name(), tt.Tree)
if err != nil {
return err
}
}
return nil
}在上述的地面中,t3 關聯了 t1,和 t2,使用 *Template.AddParseTree 方法進行關聯
func (t *Template) AddParseTree(name string, tree *parse.Tree) (*Template, error)最終的模板生成結果為
Person Info
name: jack
age: 18插槽
通過 block 語句,可以實現類似 vue 插槽的效果,其目的是為了復用某一個模板而用的。看一個使用案例就知道怎麼用了,在 t1 模板中定義插槽
Basic Person Info
name: {{ .name }}
age: {{ .age }}
address: {{ .address }}
{{ block "slot" . }} default content body {{ end }}block 語句可以插槽中的默認內容,在後續其它模板使用插槽時,會覆蓋默認的內容。在 t2 模板中引用 t1 模板,並使用 define 定義嵌入的內容
{{ template "person.txt" . }}
{{ define "slot" }}
school: {{ .school }}
{{ end }}將兩個模板關聯以後,傳入如下的數據
map[string]any{
"name": "jack",
"age": 18,
"address": "usa",
"company": "google",
"school": "mit",
}最終輸出的結果為
Basic Person Info
name: jack
age: 18
address: usa
school: mit模板文件
在模板語法的案例中,都是使用的字符串字面量來作為模板,在實際的使用情況中大多數都是將模板放在文件中。
func ParseFS(fsys fs.FS, patterns ...string) (*Template, error)比如 template.ParseFs 就是從指定的文件系統中加載匹配 pattern 的模板。下面的例子以 embed.FS 作為文件系統,准備三個文件
# person.txt
Basic Person Info
name: {{ .name }}
age: {{ .age }}
address: {{ .address }}
{{ block "slot" . }} {{ end }}
# student.txt
{{ template "person.txt" . }}
{{ define "slot" }}
school: {{ .school }}
{{ end }}
# employee.txt
{{ template "person.txt" . }}
{{ define "slot" }}
company: {{ .company }}
{{ end }}代碼如下
import (
"embed"
"os"
"text/template"
)
//go:embed *.txt
var fs embed.FS
func main() {
data := map[string]any{
"name": "jack",
"age": 18,
"address": "usa",
"company": "google",
"school": "mit",
}
t1, err := template.ParseFS(fs, "person.txt", "student.txt")
if err != nil {
panic(err)
}
t1.Execute(os.Stdout, data)
t2, err := template.ParseFS(fs, "person.txt", "employee.txt")
if err != nil {
panic(err)
}
t2.Execute(os.Stdout, data)
}輸出為
Basic Person Info
name: jack
age: 18
address: usa
school: mit
Basic Person Info
name: jack
age: 18
address: usa
company: google這是一個很簡單的模板文件使用案例,person.txt 作為插槽文件,其它兩個復用其內容並嵌入自定義的新內容。也可以使用下面兩個函數
func ParseGlob(pattern string) (*Template, error)
func ParseFiles(filenames ...string) (*Template, error)ParseGlob 基於通配符匹配,ParseFiles 基於文件名,它們都是使用的本地文件系統。如果是用於展示在前端的 html 文件,建議使用 html/template 包,它提供的 API 與 text/template 完全一致,但是針對 html,css,js 做了安全處理。
