GoWeb編程之模板(一)

NO IMAGE

概述

模板引擎是 Web 編程中必不可少的一個組件。模板能分離邏輯和數據,使得邏輯簡潔清晰,並且模板可複用。引用第二篇文章《程序結構》一文中的圖示,我們可以看到模板引擎在 Web 程序結構中的位置:

GoWeb編程之模板(一)

模板引擎按照功能可以劃分為兩種類型:

  • 無邏輯模板引擎:此類模板引擎只進行字符串的替換,無其它邏輯;
  • 嵌入邏輯模板引擎:此類模板引擎可以在模板中嵌入邏輯,實現流程控制/循環等。

這兩類模板引擎都比較極端。無邏輯模板引擎需要在處理器中額外添加很多邏輯用於生成替換的文本。而嵌入邏輯模板引擎則在模板中混入了大量邏輯,導致維護性較差。實用的模板引擎一般介於這兩者之間。

在Go 語言中,text/templatehtml/template兩個庫實現模板功能。

模板內容可以是 UTF-8 編碼的任何內容。其中用{{}}包圍的部分稱為動作{{}}外的其它文本在輸出保持不變。模板需要應用到數據,模板中的動作會根據數據生成響應的內容來替換。

模板解析之後可以多次執行,也可以並行執行,但是注意使用同一個Writer會導致輸出交替出現。

模板的內容較多,我將分為兩篇文章介紹。本文介紹text/template,包括 Go 模板的基本概念,用法和注意點。下篇文章介紹html/template

初體驗

使用模板引擎一般有 3 個步驟:

  • 定義模板(直接使用字符串字面量或文件);
  • 解析模板(使用text/templatehtml/template中的方法解析);
  • 傳入數據生成輸出。
package main
import (
"log"
"os"
"text/template"
)
type User struct {
Name string
Age  int
}
func stringLiteralTemplate() {
s := "My name is {{ .Name }}. I am {{ .Age }} years old.\n"
t, err := template.New("test").Parse(s)
if err != nil {
log.Fatal("Parse string literal template error:", err)
}
u := User{Name: "darjun", Age: 28}
err = t.Execute(os.Stdout, u)
if err != nil {
log.Fatal("Execute string literal template error:", err)
}
}
func fileTemplate() {
t, err := template.ParseFiles("test")
if err != nil {
log.Fatal("Parse file template error:", err)
}
u := User{Name: "dj", Age: 18}
err = t.Execute(os.Stdout, u)
if err != nil {
log.Fatal("Execute file template error:", err)
}
}
func main() {
stringLiteralTemplate()
fileTemplate()
}

在可執行程序目錄中新建模板文件test,並寫入下面的內容:

My name is {{ .Name }}. I am {{ .Age }} years old.

首先調用template.New創建一個模板,參數為模板名。

然後調用Template類型的Parse方法,解析模板字符串,生成模板主體。這個方法返回兩個值。如果模板語法正確,則返回模板對象本身和一個 nil 值。
如果有語法錯誤,則返回一個 error 類型的值作為第二個返回值,這時不應該使用第一個返回值。

最後,調用模板對象的Execute方法,傳入參數。Execute執行模板中的動作,將結果輸出到os.Stdout,即標準輸出。最終我們看到模板中{{ .Name }}uName字段替換,{{ .Age }}uAge字段替換,標準輸出中顯示下面一行字符串:

My name is darjun. I am 28 years old.

上面代碼中,fileTemplate函數還演示瞭如何從文件中加載模板。其中template.ParseFiles方法會創建一個模板,並將用戶指定的模板文件名用作這個新模板的名字:

t, err := template.ParseFiles("test")

相當於:

t := template.New("test")
t, err := t.ParseFiles("test")

動作

Go 模板中的動作就是一些嵌入在模板裡面的命令。動作大體上可以分為以下幾種類型:

  • 點動作;
  • 條件動作;
  • 迭代動作;
  • 設置動作;
  • 包含動作。

點動作

在介紹其它的動作之前,我們先看一個很重要的動作,點動作{{ . }})。它其實代表是傳遞給模板的數據,其他動作或函數基本上都是對這個數據進行處理,以此來達到格式化和內容展示的目的。

對前面的代碼示例稍作修改:

func main() {
s := "The user is {{ . }}."
t, err := template.New("test").Parse(s)
if err != nil {
log.Fatal("Parse error:", err)
}
u := User{Name: "darjun", Age: 28}
err = t.Execute(os.Stdout, u)
if err != nil {
log.Fatal("Execute error:", err)
}
}

運行程序,標準輸出顯示:

The user is {darjun 28}.

實際上,{{ . }}會被替換為傳給給模板的數據的字符串表示。這個字符串與以數據為參數調用fmt.Sprint函數得到的內容相同。我們可以為User結構編寫一個方法:

func (u User) String() string {
return fmt.Sprintf("(name:%s age:%d)", u.Name, u.Age)
}

這樣替換的字符串就是格式化之後的內容了:

The user is (name:darjun age:28).

注意:為了使用的方便和靈活,在模板中不同的上下文內,.的含義可能會改變,下面在介紹不同的動作時會進行說明。

條件動作

在介紹動作的語法時,我採用 Go 標準庫中的寫法。我覺得這樣寫更嚴謹。
其中pipeline表示管道,後面會有詳細的介紹,現在可以將它理解為一個值。
T1/T2等形式表示語句塊,裡面可以嵌套其它類型的動作。最簡單的語句塊就是不包含任何動作的字符串

條件動作的語法與編程語言中的if語句語法類似,有幾種形式:

形式一:

{{ if pipeline }} T1 {{ end }}

如果管道計算出來的值不為空,執行T1。否則,不生成輸出。下面都表示空值:

  • false、0、空指針或接口;
  • 長度為 0 的數組、切片、map或字符串。

形式二:

{{ if pipeline }} T1 {{ else }} T2 {{ end }}

如果管道計算出來的值不為空,執行T1。否則,執行T2

形式三:

{{ if pipeline1 }} T1 {{ else if pipeline2 }} T2 {{ else }} T3 {{ end }}

如果管道pipeline1計算出來的值不為空,則執行T1。反之如果管道pipeline2的值不為空,執行T2。如果都為空,執行T3

舉個栗子:

type AgeInfo struct {
Age           int
GreaterThan60 bool
GreaterThan40 bool
}
func main() {
t, err := template.ParseFiles("test")
if err != nil {
log.Fatal("Parse error:", err)
}
rand.Seed(time.Now().Unix())
age := rand.Intn(100)
info := AgeInfo {
Age:           age,
GreaterThan60: age > 60,
GreaterThan40: age > 40,
}
err = t.Execute(os.Stdout, info)
if err != nil {
log.Fatal("Execute error:", err)
}
}

在可執行程序的目錄下新建模板文件test,鍵入下面的內容:

Your age is: {{ .Age }}
{{ if .GreaterThan60 }}
Old People!
{{ else if .GreaterThan40 }}
Middle Aged!
{{ else }}
Young!
{{ end }}

運行程序,會隨機一個年齡,然後根據年齡區間選擇性輸出Old People!/Middle Age!/Young!其中一個。下面是我運行兩次運行的輸出:

Your age is: 7
Young!
Your age is: 79
Old People!

這個程序有一個問題,會有多餘的空格!我們之前說過,除了動作之外的任何文本都會原樣保持,包括空格和換行!針對這個問題,有兩種解決方案。第一種方案是刪除多餘的空格和換行,test文件修改為:

Your age is: {{ .Age }}
{{ if .GreaterThan60 }}Old People!{{ else if .GreaterThan40 }}Middle Aged!{{ else }}Young!{{ end }}

顯然,這個方法會導致模板內容很難閱讀,不夠理想。為此,Go 提供了針對空白符的處理。如果一個動作以{{-(注意有一個空格),那麼該動作與它前面相鄰的非空文本或動作間的空白符將會被全部刪除。類似地,如果一個動作以-}}結尾,那麼該動作與它後面相鄰的非空文本或動作間的空白符將會被全部刪除。例如:

{{23 -}} < {{- 45}}

將會生成輸出:

23<45

回到我們的例子中,我們可以將test文件稍作修改:

Your age is: {{ .Age }}
{{ if .GreaterThan60 -}}
"Old People!"
{{- else if .GreaterThan40 -}}
"Middle Aged!"
{{- else -}}
"Young!"
{{- end }}

這樣,輸出的文本就不會包含多餘的空格了。

迭代動作

迭代其實與編程語言中的循環遍歷類似。有兩種形式:

形式一:

{{ range pipeline }} T1 {{ end }}

管道的值類型必須是數組、切片、map、channel。如果值的長度為 0,那麼無輸出。否則,.被設置為當前遍歷到的元素,然後執行T1,即在T1.表示遍歷的當前元素,而非傳給模板的參數。如果值是 map 類型,且鍵是可比較的基本類型,元素將會以鍵的順序訪問

形式二:

{{ range pipeline }} T1 {{ else }} T2 {{ end }}

與前一種形式基本一樣,如果值的長度為 0,那麼執行T2

舉個栗子:

type Item struct {
Name	string
Price	int
}
func main() {
t, err := template.ParseFiles("test")
if err != nil {
log.Fatal("Parse error:", err)
}
items := []Item {
{ "iPhone", 5499 },
{ "iPad", 6331 },
{ "iWatch", 1499 },
{ "MacBook", 8250 },
}
err = t.Execute(os.Stdout, items)
if err != nil {
log.Fatal("Execute error:", err)
}
}

在可執行程序目錄下新建模板文件test,鍵入內容:

Apple Products:
{{ range . }}
{{ .Name }}: ¥{{ .Price }}
{{ else }}
No Products!!!
{{ end }}

運行程序,得到下面的輸出:

Apple Products:
iPhone: ¥5499
iPad: ¥6331
iWatch: ¥1499
MacBook: ¥8250

range語句循環體內,.被設置為當前遍歷的元素,可以直接使用{{ .Name }}{{ .Price }}訪問產品名稱和價格。在程序中,將nil傳給Execute方法會得到下面的輸出:

Apple Products:
No Products!!!

設置動作

設置動作使用with關鍵字重定義.。在with語句內,.會被定義為指定的值。一般用在結構嵌套很深時,能起到簡化代碼的作用。

形式一:

{{ with pipeline }} T1 {{ end }}

如果管道值不為空,則將.設置為pipeline的值,然後執行T1。否則,不生成輸出。

形式二:

{{ with pipeline }} T1 {{ else }} T2 {{ end }}

與前一種形式的不同之處在於當管道值為空時,不改變.執行T2。舉個栗子:

type User struct {
Name string
Age  int
}
type Pet struct {
Name  string
Age   int
Owner User
}
func main() {
t, err := template.ParseFiles("test")
if err != nil {
log.Fatal("Parse error:", err)
}
p := Pet {
Name:  "Orange",
Age:   2,
Owner: User {
Name: "dj",
Age:  28,
},
}
err = t.Execute(os.Stdout, p)
if err != nil {
log.Fatal("Execute error:", err)
}
}

模板文件內容:

Pet Info:
Name: {{ .Name }}
Age: {{ .Age }}
Owner:
{{ with .Owner }}
Name: {{ .Name }}
Age: {{ .Age }}
{{ end }}

運行程序,得到下面的輸出:

Pet Info:
Name: Orange
Age: 2
Owner:
Name: dj
Age: 28

可見,在with語句內,.被替換成了Owner字段的值。

包含動作

包含動作可以在一個模板中嵌入另一個模板,方便模板的複用。

形式一:

{{ template "name" }}

形式二:

{{ template "name" pipeline }}

其中name表示嵌入的模板名稱。第一種形式,將使用nil作為傳入內嵌模板的參數。第二種形式,管道pipeline的值將會作為參數傳給內嵌的模板。舉個栗子:

package main
import (
"log"
"os"
"text/template"
)
func main() {
t, err := template.ParseFiles("test1", "test2")
if err != nil {
log.Fatal("Parse error:", err)
}
err = t.Execute(os.Stdout, "test data")
if err != nil {
log.Fatal("Execute error:", err)
}
}

ParseFiles方法接收可變參數,可將任意多個文件名傳給該方法。

模板test1:

This is in test1.
{{ template "test2" }}
{{ template "test2" . }}

模板test2:

This is in test2.
Get: {{ . }}.

運行程序得到輸出:

This is in test1.
This is in test2.
Get: <no value>.
This is in test2.
Get: test data.

前一個嵌入模板,沒有傳遞參數。後一個傳入.,即傳給test1模板的參數。

其它元素

在介紹了幾種動作之後,我們回過頭來看幾種基本組成部分。

註釋

註釋只有一種語法:

{{ /* 註釋 */ }}

註釋的內容不會呈現在輸出中,它就像代碼註釋一樣,是為了讓模板更易讀。

參數

一個參數就是模板中的一個值。它的取值有多種:

  • 布爾值、字符串、字符、整數、浮點數、虛數和複數等字面量
  • 結構中的一個字段或 map 中的一個鍵。結構的字段名必須是導出的,即大寫字母開頭,map 的鍵名則不必
  • 一個函數或方法。必須只返回一個值,或者只返回一個值和一個錯誤。如果返回了非空的錯誤,則Execute方法執行終止,返回該錯誤給調用者;
  • 等等等等。

上面幾種形式可以結合使用:

{{ .Field1.Key1.Method1.Field2.Key2.Method2 }

其實,我們已經用過很多次參數了。下面看一個方法調用的栗子:

type User struct {
FirstName 	string
LastName	string
}
func (u User) FullName() string {
return u.FirstName + " " + u.LastName
}
func main() {
t, err := template.ParseFiles("test")
if err != nil {
log.Fatal("Parse error:", err)
}
err = t.Execute(os.Stdout, User{FirstName: "lee", LastName: "darjun"})
if err != nil {
log.Fatal("Execute error:", err)
}
}

模板文件test

My full name is {{ .FullName }}.

模板執行會使用FullName方法的返回值替換{{ .FullName }},輸出:

My full name is lee darjun.

關於參數的幾個要點:

  • 參數可以是任何類型;
  • 如果參數為指針,實現會根據需要取其基礎類型;
  • 如果參數計算得到一個函數類型,它不會自動調用。例如{{ .Method1 }},如果Method1方法返回一個函數,那麼返回值函數不會調用。如果要調用它,使用內置的call函數。

管道

管道的語法與 Linux 中的管道類似,即命令的鏈式序列:

{{ p1 | p2 | p3 }}

每個單獨的命令(即p1/p2/p3...)可以是下面三種類型:

  • 參數,見上面;
  • 可能帶有參數的方法調用;
  • 可能帶有參數的函數調用。

在一個鏈式管道中,每個命令的結果會作為下一個命令的最後一個參數。最後一個命令的結果作為整個管道的值。

管道必須只返回一個值,或者只返回一個值和一個錯誤。如果返回了非空的錯誤,那麼Execute方法執行終止,並將該錯誤返回給調用者。

在迭代程序的基礎上稍作修改:

type Item struct {
Name  string
Price float64
Num   int
}
func (item Item) Total() float64 {
return item.Price * float64(item.Num)
}
func main() {
t, err := template.ParseFiles("test")
if err != nil {
log.Fatal("Parse error:", err)
}
item := Item {"iPhone", 5499.99, 2 }
err = t.Execute(os.Stdout, item)
if err != nil {
log.Fatal("Execute error:", err)
}
}

模板文件test

Product: {{ .Name }}
Price: ¥{{ .Price }}
Num: {{ .Num }}
Total: ¥{{ .Total | printf "%.2f" }}

先調用Item.Total方法計算商品總價,然後使用printf格式化,保留兩位小數。最終輸出:

Product: iPhone
Price: ¥5499.99
Num: 2
Total: ¥10999.98

printf是 Go 模板內置的函數,這樣的函數還有很多。

變量

在動作中,可以用管道的值定義一個變量。

$variable := pipeline

$variable為變量名,聲明變量的動作不生成輸出。

類似地,變量也可以重新賦值:

$variable = pipeline

range動作中可以定義兩個變量:

range $index, $element := range pipeline

這樣就可以在循環中通過$index$element訪問索引和元素了。

變量的作用域持續到定義它的控制結構的{{ end }}動作。如果沒有這樣的控制結構,則持續到模板結束。模板調用不繼承變量。

執行開始時,$被設置為傳入的數據參數,即.的值。

函數

Go 模板提供了大量的預定義函數,如果有特殊需求也可以實現自定義函數。模板執行時,遇到函數調用,先從模板自定義函數表中查找,而後查找全局函數表。

預定義函數

預定義函數分為以下幾類:

  • 邏輯運算,and/or/not
  • 調用操作,call
  • 格式化操作,print/printf/println,與用參數直接調用fmt.Sprint/Sprintf/Sprintln得到的內容相同;
  • 比較運算,eq/ne/lt/le/gt/ge

在上面條件動作的示例代碼中,我們在代碼中計算出大小關係再傳入模板,這樣比較繁瑣,可以直接使用比較運算簡化。

有兩點需要注意:

  • 由於是函數調用,所有的參數都會被求值,沒有短路求值
  • 比較運算只作用於基本類型,且沒有 Go 語法那麼嚴格,例如可以比較有符號和無符號整數。

自定義函數

默認情況下,模板中無自定義函數,可以使用模板的Funcs方法添加。下面我們實現一個格式化日期的自定義函數:

package main
import (
"log"
"os"
"text/template"
"time"
)
func formatDate(t time.Time) string {
return t.Format("2016-01-02")
}
func main() {
funcMap := template.FuncMap {
"fdate": formatDate,
}
t := template.New("test").Funcs(funcMap)
t, err := t.ParseFiles("test")
if err != nil {
log.Fatal("Parse errr:", err)
}
err = t.Execute(os.Stdout, time.Now())
if err != nil {
log.Fatal("Exeute error:", err)
}
}

模板文件test

Today is {{ . | fdate }}.

模板的Func方法接受一個template.FuncMap類型變量,鍵為函數名,值為實際定義的函數。
可以一次設置多個自定義函數。自定義函數要求只返回一個值,或者返回一個值和一個錯誤。
設置之後就可以在模板中使用fdate了,輸出:

Today is 7016-01-07.

這裡不能使用template.ParseFiles,因為在解析模板文件的時候fdate未定義會導致解析失敗。必須先創建模板,調用Funcs設置自定義函數,然後再解析模板。

模板的幾種創建方式

我們前面學習了兩種模板的創建方式:

  • 先調用template.New創建模板,然後使用Parse/ParseFiles解析模板內容;
  • 直接使用template.ParseFiles創建並解析模板文件。

第一種方式,調用template.New創建模板時需要傳入一個模板名字,後續調用ParseFiles可以傳入一個或多個文件,這些文件中必須有一個基礎名(即去掉路徑部分)與模板名相同。如果沒有文件名與模板名相同,則Execute調用失敗,返回錯誤。例如:

package main
import (
"log"
"os"
"text/template"
)
func main() {
t := template.New("test")
t, err := t.ParseFiles("test1")
if err != nil {
log.Fatal("Parse error:", err)
}
err = t.Execute(os.Stdout, nil)
if err != nil {
log.Fatal("Execute error:", err)
}
}

上面代碼先創建模板test,然後解析文件test1。執行該程序會出現下面的錯誤:

Execute error:template: test: "test" is an incomplete or empty template

Why?

我們先來看看模板的結構:

// src/text/template.go
type common struct {
tmpl   map[string]*Template // Map from name to defined templates.
option option
muFuncs    sync.RWMutex // protects parseFuncs and execFuncs
parseFuncs FuncMap
execFuncs  map[string]reflect.Value
}
type Template struct {
name string
*parse.Tree
*common
leftDelim  string
rightDelim string
}

模板結構Template中有一個字段commoncommon中又有一個字段tmpl保存名字到模板的映射。其實,最外層的Template結構是主模板,我們調用Execute方法時執行的就是主模板。
執行ParseFiles方法時,每個文件都會生成一個模板。只有文件基礎名與模板名相同時,該文件的內容才會解析到主模板中。這也是上面的程序執行失敗的原因——主模板為空。
其它文件解析生成關聯模板,存儲在字段tmpl中。關聯模板可以是在主模板中通過{{ define }}動作定義,或者在非主模板文件中定義。關聯模板也可以執行,但是需要使用ExecuteTemplate方法,顯式傳入模板名:

func main()
t := template.New("test")
t, err := t.ParseFiles("test1")
if err != nil {
log.Fatal("in associatedTemplate Parse error:", err)
}
err = t.ExecuteTemplate(os.Stdout, "test1", nil)
if err != nil {
log.Fatal("in associatedTemplate Execute error:", err)
}
}

第二種方式將創建和解析兩步合併在一起了。template.ParseFiles方法將傳入的第一個文件名作為模板名稱,其餘的文件(如果有的話)解析後存放在tmpl中。

t, err := template.ParseFiles("file1", "file2", "file3")

其實就等價於:

t := template.New("file1")
t, err := t.ParseFiles("file1", "file2", "file3")

少了不一致的可能性,所以調用Execute方法時不會出現上面的錯誤。

還有一種創建方式,使用ParseGlob函數。ParseGlob會對匹配給定模式的所有文件進行語法分析。

func main() {
t, err := template.ParseGlob("tmpl*.glob")
if err != nil {
log.Fatal("in globTemplate parse error:", err)
}
err = t.Execute(os.Stdout, nil)
if err != nil {
log.Fatal(err)
}
for i := 1; i <= 3; i++ {
err = t.ExecuteTemplate(os.Stdout, fmt.Sprintf("tmpl%d.glob", i), nil)
if err != nil {
log.Fatal(err)
}
}
}

ParseGlob返回的模板以匹配的第一個文件基礎名作為名稱。ParseGlob解析時會對同一個目錄下的文件進行排序,所以第一個文件總是固定的。

我們創建三個模板文件,tmpl1.glob

In glob template file1.

tmpl2.glob

In glob template file2.

tmpl3.glob

In glob template file3.

最終輸出為:

In glob template file1.
In glob template file1.
In glob template file2.
In glob template file3.

注意,如果多個不同路徑下的文件名相同,那麼後解析的會覆蓋之前的。

嵌套模板

在一個模板文件中還可以通過{{ define }}動作定義其它的模板,這些模板就是嵌套模板。模板定義必須在模板內容的最頂層,像 Go 程序中的全局變量一樣。

嵌套模板一般用於佈局(layout)。很多文本的結構其實非常固定,例如郵件有標題和正文,網頁有首部、正文和尾部等。
我們可以為這些固定結構的每部分定義一個模板。

定義模板文件layout.tmpl

{{ define "layout" }}
This is body.
{{ template "content" . }}
{{ end }}
{{ define "content" }}
This is {{ . }} content.
{{ end }}

上面定義了兩個模板layoutcontentlayout中使用了content。執行這種方式定義的模板必須使用ExecuteTemplate方法:

func main() {
t, err := template.ParseFiles("layout.tmpl")
if err != nil {
log.Fatal("Parse error:", err)
}
err = t.ExecuteTemplate(os.Stdout, "layout", "amazing")
if err != nil {
log.Fatal("Execute error:", err)
}
}

嵌套模板在網頁佈局中應用非常廣泛,下一篇文章介紹html/template時還會講到。

塊動作

塊動作其實就是定義一個默認模板,語法如下:

{{ block "name" arg }}
T1
{{ end }}

其實它就等價於定義一個模板,然後立即使用它:

{{ define "name" }}
T1
{{ end }}
{{ template "name" arg }}

如果後面定義了模板content,那麼使用後面的定義,否則使用默認模板。

例如上面的示例中,我們將模板修改如下:

{{ define "layout" }}
This is body.
{{ block "content" . }}
This is default content.
{{ end }}
{{ end }}

去掉後面的content模板定義,執行layout時,content部分會顯示默認值。

總結

本文介紹了 Go 提供的模板text/template。模板比較簡單易用,對於一些細節需要多加留意。代碼在Github上。

參考鏈接

  1. Go Web 編程
  2. text/template文檔

相關文章

【一分鐘系列】一分鐘瞭解git常用操作

架構師,怎樣才能搞定上下游客戶?

當Parallel遇上了DISpring並行數據聚合最佳實踐

異常記錄——使用Mybatis報BindingException