你一定聽說過一種叫做 MVC 的設計模式,Model 處理資料,View 展現結果,Controller 控制使用者的請求,至於 View 層的處理,在很多動態語言裡面都是透過在靜態 HTML 中插入動態語言產生的資料,例如 JSP 中透過插入<%=....=%>
,PHP 中透過插入<?php.....?>
來實現的。
透過下面這個圖可以說明範本的機制
圖 7.1 範本機制圖
Web 應用反饋給客戶端的資訊中的大部分內容是靜態的,不變的,而另外少部分是根據使用者的請求來動態產生的,例如要顯示使用者的訪問記錄列表。使用者之間只有記錄資料是不同的,而列表的樣式則是固定的,此時採用範本可以複用很多靜態程式碼。
在 Go 語言中,我們使用 template
套件來進行範本處理,使用類似Parse
、ParseFile
、Execute
等方法從檔案或者字串載入範本,然後執行類似上面圖片展示的範本的 merge 操作。請看下面的例子:
func handler(w http.ResponseWriter, r *http.Request) {
t := template.New("some template") //建立一個範本
t, _ = t.ParseFiles("tmpl/welcome.html") //解析範本檔案
user := GetUser() //取得當前使用者資訊
t.Execute(w, user) //執行範本的 merger 操作
}
透過上面的例子我們可以看到 Go 語言的範本操作非常的簡單方便,和其他語言的範本處理類似,都是先取得資料,然後渲染資料。
為了示範和測試程式碼的方便,我們在接下來的例子中採用如下格式的程式碼
- 使用 Parse 代替 ParseFiles,因為 Parse 可以直接測試一個字串,而不需要額外的檔案
- 不使用 handler 來寫示範程式碼,而是每個測試一個 main,方便測試
- 使用
os.Stdout
代替http.ResponseWriter
,因為os.Stdout
實現了io.Writer
介面
上面我們示範了如何解析並渲染範本,接下來讓我們來更加詳細的了解如何把資料渲染出來。一個範本都是應用在一個 Go 的物件之上,Go 物件的欄位如何插入到範本中呢?
Go 語言的範本透過 {{}}
來包含需要在渲染時被替換的欄位,{{.}}
表示當前的物件,這和 Java 或者 C++中的 this 類似,如果要訪問當前物件的欄位透過{{.FieldName}}
,但是需要注意一點:這個欄位必須是匯出的(欄位首字母必須是大寫的),否則在渲染的時候就會報錯,請看下面的這個例子:
package main
import (
"html/template"
"os"
)
type Person struct {
UserName string
}
func main() {
t := template.New("fieldname example")
t, _ = t.Parse("hello {{.UserName}}!")
p := Person{UserName: "Astaxie"}
t.Execute(os.Stdout, p)
}
上面的程式碼我們可以正確的輸出hello Astaxie
,但是如果我們稍微修改一下程式碼,在範本中含有了未匯出的欄位,那麼就會報錯
type Person struct {
UserName string
email string //未匯出的欄位,首字母是小寫的
}
t, _ = t.Parse("hello {{.UserName}}! {{.email}}")
上面的程式碼就會報錯,因為我們呼叫了一個未匯出的欄位,但是如果我們呼叫了一個不存在的欄位是不會報錯的,而是輸出為空。
如果範本中輸出{{.}}
,這個一般應用於字串物件,預設會呼叫 fmt 套件輸出字串的內容。
上面我們例子展示了如何針對一個物件的欄位輸出,那麼如果欄位裡面還有物件,如何來迴圈的輸出這些內容呢?我們可以使用{{with …}}…{{end}}
和{{range …}}{{end}}
來進行資料的輸出。
- {{range}} 這個和 Go 語法裡面的 range 類似,迴圈操作資料
- {{with}}操作是指當前物件的值,類似上下文的概念
詳細的使用請看下面的例子:
package main
import (
"html/template"
"os"
)
type Friend struct {
Fname string
}
type Person struct {
UserName string
Emails []string
Friends []*Friend
}
func main() {
f1 := Friend{Fname: "minux.ma"}
f2 := Friend{Fname: "xushiwei"}
t := template.New("fieldname example")
t, _ = t.Parse(`hello {{.UserName}}!
{{range .Emails}}
an email {{.}}
{{end}}
{{with .Friends}}
{{range .}}
my friend name is {{.Fname}}
{{end}}
{{end}}
`)
p := Person{UserName: "Astaxie",
Emails: []string{"astaxie@beego.me", "astaxie@gmail.com"},
Friends: []*Friend{&f1, &f2}}
t.Execute(os.Stdout, p)
}
在 Go 範本裡面如果需要進行條件判斷,那麼我們可以使用和 Go 語言的if-else
語法類似的方式來處理,如果 pipeline 為空,那麼 if 就認為是 false,下面的例子展示了如何使用if-else
語法:
package main
import (
"os"
"text/template"
)
func main() {
tEmpty := template.New("template test")
tEmpty = template.Must(tEmpty.Parse("空 pipeline if demo: {{if ``}} 不會輸出. {{end}}\n"))
tEmpty.Execute(os.Stdout, nil)
tWithValue := template.New("template test")
tWithValue = template.Must(tWithValue.Parse("不為空的 pipeline if demo: {{if `anything`}} 我有內容,我會輸出. {{end}}\n"))
tWithValue.Execute(os.Stdout, nil)
tIfElse := template.New("template test")
tIfElse = template.Must(tIfElse.Parse("if-else demo: {{if `anything`}} if 部分 {{else}} else 部分.{{end}}\n"))
tIfElse.Execute(os.Stdout, nil)
}
透過上面的示範程式碼我們知道if-else
語法相當的簡單,在使用過程中很容易整合到我們的範本程式碼中。
注意:if 裡面無法使用條件判斷,例如.Mail=="astaxie@gmail.com",這樣的判斷是不正確的,if 裡面只能是 bool 值
Unix 使用者已經很熟悉什麼是 pipe
了,ls | grep "beego"
類似這樣的語法你是不是經常使用,過濾當前目錄下面的檔案,顯示含有"beego"的資料,表達的意思就是前面的輸出可以當做後面的輸入,最後顯示我們想要的資料,而 Go 語言範本最強大的一點就是支援 pipe 資料,在 Go 語言裡面任何 {{}}
裡面的都是 pipelines 資料,例如我們上面輸出的 email 裡面如果還有一些可能引起 XSS 注入的,那麼我們如何來進行轉化呢?
{{. | html}}
在 email 輸出的地方我們可以採用如上方式可以把輸出全部轉化 html 的實體,上面的這種方式和我們平常寫 Unix 的方式是不是一模一樣,操作起來相當的簡便,呼叫其他的函式也是類似的方式。
有時候,我們在範本使用過程中需要定義一些區域性變數,我們可以在一些操作中宣告區域性變數,例如 with``range``if
過程中宣告區域性變數,這個變數的作用域是 {{end}}
之前,Go 語言透過宣告的區域性變數格式如下所示:
$variable := pipeline
詳細的例子看下面的:
{{with $x := "output" | printf "%q"}}{{$x}}{{end}}
{{with $x := "output"}}{{printf "%q" $x}}{{end}}
{{with $x := "output"}}{{$x | printf "%q"}}{{end}}
範本在輸出物件的欄位值時,採用了 fmt
套件把物件轉化成了字串。但是有時候我們的需求可能不是這樣的,例如有時候我們為了防止垃圾郵件傳送者透過採集網頁的方式來發送給我們的郵箱資訊,我們希望把 @
替換成 at
例如:astaxie at beego.me
,如果要實現這樣的功能,我們就需要自訂函式來做這個功能。
每一個範本函式都有一個唯一值的名字,然後與一個 Go 函式關聯,透過如下的方式來關聯
type FuncMap map[string]interface{}
例如,如果我們想要的 email 函式的範本函式名是emailDeal
,它關聯的 Go 函式名稱是EmailDealWith
,那麼我們可以透過下面的方式來註冊這個函式
t = t.Funcs(template.FuncMap{"emailDeal": EmailDealWith})
EmailDealWith
這個函式的參數和回傳值定義如下:
func EmailDealWith(args …interface{}) string
我們來看下面的實現例子:
package main
import (
"fmt"
"html/template"
"os"
"strings"
)
type Friend struct {
Fname string
}
type Person struct {
UserName string
Emails []string
Friends []*Friend
}
func EmailDealWith(args ...interface{}) string {
ok := false
var s string
if len(args) == 1 {
s, ok = args[0].(string)
}
if !ok {
s = fmt.Sprint(args...)
}
// find the @ symbol
substrs := strings.Split(s, "@")
if len(substrs) != 2 {
return s
}
// replace the @ by " at "
return (substrs[0] + " at " + substrs[1])
}
func main() {
f1 := Friend{Fname: "minux.ma"}
f2 := Friend{Fname: "xushiwei"}
t := template.New("fieldname example")
t = t.Funcs(template.FuncMap{"emailDeal": EmailDealWith})
t, _ = t.Parse(`hello {{.UserName}}!
{{range .Emails}}
an emails {{.|emailDeal}}
{{end}}
{{with .Friends}}
{{range .}}
my friend name is {{.Fname}}
{{end}}
{{end}}
`)
p := Person{UserName: "Astaxie",
Emails: []string{"astaxie@beego.me", "astaxie@gmail.com"},
Friends: []*Friend{&f1, &f2}}
t.Execute(os.Stdout, p)
}
上面示範了如何自訂函式,其實,在範本套件內部已經有內建的實現函式,下面程式碼擷取自範本套件裡面
var builtins = FuncMap{
"and": and,
"call": call,
"html": HTMLEscaper,
"index": index,
"js": JSEscaper,
"len": length,
"not": not,
"or": or,
"print": fmt.Sprint,
"printf": fmt.Sprintf,
"println": fmt.Sprintln,
"urlquery": URLQueryEscaper,
}
範本套件裡面有一個函式Must
,它的作用是檢測範本是否正確,例如大括號是否匹配,註釋是否正確的關閉,變數是否正確的書寫。接下來我們示範一個例子,用 Must 來判斷範本是否正確:
package main
import (
"fmt"
"text/template"
)
func main() {
tOk := template.New("first")
template.Must(tOk.Parse(" some static text /* and a comment */"))
fmt.Println("The first one parsed OK.")
template.Must(template.New("second").Parse("some static text {{ .Name }}"))
fmt.Println("The second one parsed OK.")
fmt.Println("The next one ought to fail.")
tErr := template.New("check parse error with Must")
template.Must(tErr.Parse(" some static text {{ .Name }"))
}
將輸出如下內容
The first one parsed OK.
The second one parsed OK.
The next one ought to fail.
panic: template: check parse error with Must:1: unexpected "}" in command
我們平常開發 Web 應用的時候,經常會遇到一些範本有些部分是固定不變的,然後可以抽取出來作為一個獨立的部分,例如一個部落格的頭部和尾部是不變的,而唯一改變的是中間的內容部分。所以我們可以定義成header
、content
、footer
三個部分。Go 語言中透過如下的語法來宣告
{{define "子範本名稱"}}內容{{end}}
透過如下方式來呼叫:
{{template "子範本名稱"}}
接下來我們示範如何使用巢狀範本,我們定義三個檔案,header.tmpl
、content.tmpl
、footer.tmpl
檔案,裡面的內容如下
//header.tmpl
{{define "header"}}
<html>
<head>
<title>示範資訊</title>
</head>
<body>
{{end}}
//content.tmpl
{{define "content"}}
{{template "header"}}
<h1>示範巢狀</h1>
<ul>
<li>巢狀使用 define 定義子範本</li>
<li>呼叫使用 template</li>
</ul>
{{template "footer"}}
{{end}}
//footer.tmpl
{{define "footer"}}
</body>
</html>
{{end}}
示範程式碼如下:
package main
import (
"fmt"
"os"
"text/template"
)
func main() {
s1, _ := template.ParseFiles("header.tmpl", "content.tmpl", "footer.tmpl")
s1.ExecuteTemplate(os.Stdout, "header", nil)
fmt.Println()
s1.ExecuteTemplate(os.Stdout, "content", nil)
fmt.Println()
s1.ExecuteTemplate(os.Stdout, "footer", nil)
fmt.Println()
s1.Execute(os.Stdout, nil)
}
透過上面的例子我們可以看到透過template.ParseFiles
把所有的巢狀範本全部解析到範本裡面,其實每一個定義的 {{define}} 都是一個獨立的範本,他們相互獨立,是並行存在的關係,內部其實儲存的是類似 map 的一種關係(key 是範本的名稱,value 是範本的內容),然後我們透過 ExecuteTemplate
來執行相應的子範本內容,我們可以看到 header、footer 都是相對獨立的,都能輸出內容,content 中因為嵌套了 header 和 footer 的內容,就會同時輸出三個的內容。但是當我們執行s1.Execute
,沒有任何的輸出,因為在預設的情況下沒有預設的子範本,所以不會輸出任何的東西。
同一個集合類別的範本是互相知曉的,如果同一範本被多個集合使用,則它需要在多個集合中分別解析
透過上面對範本的詳細介紹,我們了解了如何把動態資料與範本融合:如何輸出迴圈資料、如何自訂函式、如何巢狀範本等等。透過範本技術的應用,我們可以完成 MVC 模式中 V 的處理,接下來的章節我們將介紹如何來處理 M 和 C。