Skip to content

template

官方文檔:template package - text/template - Go Packages

在平時我們經常會使用 fmt.Sprintf 函數來進行字符串格式化,但它只適用於處理小字符串的情況,而且需要使用格式化動詞來指定類型,無法做到參數命名,不支持復雜情況下的處理,而這就是模板引擎所需要解決的問題,比如在直接掛到後端的靜態 HTML 頁面就需要用到模板引擎。社區裡面有很多優秀的第三方模板引擎庫,比如 pongo2 ,sprigjet,不過本文要講述的主角是 go 內置的模板引擎庫 text/template,在實際開發中一般用的是 html/template,後者基於前者並做了很多關於 HTML 的安全處理,一般情況使用前者即可,若是涉及到 HTML 的模板處理建議使用後者會更安全。

快速開始

下面來看一個關於模板引擎的簡單使用示例,如下所示

go
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 方法解析模板字符串,

go
func (t *Template) Parse(text string) (*Template, error)

解析成功後再通過 *Template.Execute 方法將 data 數據應用於模板中,最後輸出到傳入的 Writer 中也就是 os.Stdout

go
func (t *Template) Execute(wr io.Writer, data any) error

在以後模板引擎的使用中,基本上都是這三步:

  1. 獲取模板
  2. 解析模板,
  3. 將數據應用到模板中

可見模板引擎的使用其實相當簡單,稍微復雜一點的是模板引擎的模板語法,這才是本文主要講解的內容。

模板語法

參數

go 通過兩對花括號 {{ }},來在模板中表示這是一個模板參數,通過 . 來表示根對象,根對象就是傳入的 data。就像是訪問一個類型的成員變量一樣,通過 . 符號銜接變量名就可以在模板中訪問對應的值,例如

{{ .data }}

前提與之同名的成員變量存在,否則就會報錯。對於傳入的 data,一般是結構體或者 map,也可以是基本類型,比如數字字符串,這時 . 所代表的根對象就是其自身。在花括號內,不一定非得去訪問根對象來獲取值,也可以是基本類型的字面量,例如

{{ 1 }}
{{ 3.14 }}
{{ "jack" }}

不管什麼類型,最終都會通過 fmt.Sprintf("%s", val) 來獲取其字符串表現形式,看下面的例子。

go
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,可以通過字段名來訪問其值,如下所示

go
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,雖然並沒有提供特定語法來訪問某一個索引的值,但可以通過函數調用的方式來實現,如下所示

go
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 }}

在使用模板參數時,可以在參數前後加上 - 符號來消除參數前後的空白,看個例子

go
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" }}

在後續使用時,通過 $ 銜接變量名來訪問該變量的值,比如

go
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,用於記錄函數的映射

go
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 }}
htmlHTML 轉義{{ html .name }}
jsjs 轉義{{ js .name }}
printfmt.Sprint{{ print . }}
printffmt.Sprintf{{ printf "%s" .}}
printlnfmt.Sprintln{{ println . }}
urlqueryurl query 轉義{{ urlquery .query }}

除了這些之外,還有一個比較特殊的內置函數 call,它是用於直接調用通過在 Execute 時期傳入的 data 中的函數,例如下面的模板

{{ call .string 1024 }}

傳入的數據如下

go
map[string]any{
    "string": func(val any) string { return fmt.Sprintf("%v: 2048", val) },
}

那麼在模板中就會生成

1024: 2048

這是自定義函數的途徑之一,不過通常建議使用 *Template.Funcs 方法來添加自定義函數,因為後者可以作用全局,不需要綁定到根對象中。

go
func (t *Template) Funcs(funcMap FuncMap) *Template

自定義函數的返回值一般有兩個,第一個是需要用到的返回值,第二個是 error。例如有如下自定義函數

go
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=?

在後續的 withifrange 中也會頻繁用到。

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 }}

傳入如下的數據

go
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 }}

傳入的數據

go
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 }}

除此之外,還支持 breakcontinue 這類操作,比如

{{ 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 }}

傳入數據

go
[]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 -}}

傳入如下數據

go
map[string]any{
    "t1": map[string]any{"data": "template body 1"},
    "t2": map[string]any{"data": "template body 2"},
}

代碼

go
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 可以根據自己的需求來指定關聯模板的根對象,或者也可以直接傳入當前模板的根對象。看下面的一段代碼例子

go
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 方法進行關聯

go
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 }}

將兩個模板關聯以後,傳入如下的數據

go
map[string]any{
    "name":    "jack",
    "age":     18,
    "address": "usa",
    "company": "google",
    "school":  "mit",
}

最終輸出的結果為

Basic Person Info
name: jack
age: 18
address: usa

school: mit

模板文件

在模板語法的案例中,都是使用的字符串字面量來作為模板,在實際的使用情況中大多數都是將模板放在文件中。

go
func ParseFS(fsys fs.FS, patterns ...string) (*Template, error)

比如 template.ParseFs 就是從指定的文件系統中加載匹配 pattern 的模板。下面的例子以 embed.FS 作為文件系統,准備三個文件

txt
# 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 }}

代碼如下

go
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 作為插槽文件,其它兩個復用其內容並嵌入自定義的新內容。也可以使用下面兩個函數

go
func ParseGlob(pattern string) (*Template, error)

func ParseFiles(filenames ...string) (*Template, error)

ParseGlob 基於通配符匹配,ParseFiles 基於文件名,它們都是使用的本地文件系統。如果是用於展示在前端的 html 文件,建議使用 html/template 包,它提供的 API 與 text/template 完全一致,但是針對 htmlcssjs 做了安全處理。

Golang學習網由www.golangdev.cn整理維護