在上一节中,我们学习了如何使用Go语言编写HTTP服务器的N种写法。但是,总是响应固定的字符串对用户和开发者而言都会显得非常无趣。因此,这节我们来学习如何使用Go语言标准库的text/template
包来向客户端(即浏览器或终端)响应动态的内容。
初识文本模板引擎
标准库中的text/template
包是Go语言内置的文本模板引擎,虽然在灵活性上不如其它语言中第三方框架自带的模板引擎(如Django、Ruby on Rails等等),但功能依旧十分强大。根据标准库给出的定义,它的主要特性如下:
- 将模板应用于给定的数据结构来执行模板,模板的编程与Go语言源代码文件相同,需为UTF-8编码
- 模板中的注释(Annotation)会根据数据结构中的元素来执行并派生具体的显示结构,这些元素一般指结构体中的字段或map中的键名
- 模板的执行逻辑会依据点(Dot,
"."
)操作符来设定当前的执行位置,并按序完成所有逻辑的执行 - 模板中的行为(Action)包括数据评估(Data Evaluation)和控制逻辑,且需要使用双层大括号(
{{
和}}
)包裹。除行为以外的任何内容都会原样输出不做修改 - 模板解析完成后,从设计上可并发的进行渲染,但要注意被渲染对象的并发安全性。例如,一个模板可以同时为多个客户端的响应进行渲染,因为输出对象(Writer)是相互独立的,但是被渲染的对象可能有各自的状态和时效性
接下来,让我们结合上节课所学的知识,从一个简单的例子开始学习使用Go语言中的文本模板引擎。简单起见,我们依旧从输出"Hello World!"字符串开始。
package mian
import (
"fmt"
"log"
"net/http"
"text/template"
)
func main() {
http.HandleFunc("/",func(w http.ResponseWriter, r *http.Request) {
// 创建模板对象并解析模板内容
tmpl, err := template.New("test").Parse("Helo World!")
if err != nil {
fmt.Fprintf(w,"Parse:%v",err)
return
}
// 调用模板对象的渲染方法
err = tmpl.Execute(w,nil)
if err != nil {
fmt.Fprintf(w,"Execute:%v",err)
return
}
})
log.Println("Starting HTTP server...")
log.Fatal(http.ListenAndServe("localhost:4000", nil))
}
如果运行上面的代码,就会发现和上节课的输出毫无区别,并且在作为处理器的匿名函数中增加了更多的逻辑。
相比之前多出来的这部分逻辑便是创建、解析和渲染模板的必要步骤:
template.New
的作用就是根据用户给定的名称创建一个模板对象,本例中我们使用了"test"字符串作为这个模板对象的名称。另外,由于template.New
函数会直接返回一个*template.Template
对象,因此可以直接链式操作调用该对象的Parse
方法。template.Parse
方法接受一个string
类型的参数,即文本模板的内容,然后对内容进行解析并返回解析过程中发生的任何错误。本例中,我们使用了没有任何模板语法的"Hello World!"字符串,同时获得了两个返回值。第一个返回值依旧是一个*template.Template
对象,此时该对象已经包含了模板解析后的数据结构。第二个返回值便是在解析过程中可能出现的错误,这要求我们对该错误进行检查判断- 如果模板解析过程没有产生任何错误则表示模板可以被用于渲染了,
template.Execute
就是用于渲染模板的方法,该方法接受两个参数:输出对象和指定数据对象(或根对象)。简单起见,本例中我们只使用到了第一个参数,即输出对象。凡是实现了io.Writer
接口的实例均可以作为输出对象,这在Go语言中是非常常见的一种编码模式
在模板中渲染变量
学会了模板渲染的基本操作之后,我们就可以开始向模板中输出一些动态的内容了。首先,我们来快速了解一下怎么获取HTTP协议中GET请求的URL查询参数(即问号"?“之后的内容)。例如,我们想要获取”/?val=123"中的"val"的值,并返回给客户端
package main
import (
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(r.URL.Query().Get("val")))
})
log.Println("Starting HTTP server...")
log.Fatal(http.ListenAndServe("localhost:4000", nil))
}
我们这里用到的方法就是*http.Request
对象的URL.Query().Get
方法。通过终端执行可以获得如下结果,你还可以尝试赋予"val"其它的值,服务端也会输出对应的内容。
curl http://localhost:4000/?val="123"
123
现在,我们可以结合模板语法,将这个"val"的值进行渲染了。
package main
import (
"fmt"
"log"
"net/http"
"text/template"
)
func main() {
http.HandleFunc("/",func(w http.ResponseWriter, r *http.Request) {
// 创建模板对象并解析模板内容
tmpl,err := template.New("test").Parse("The value is:{{.}}")
if err != nil {
fmt.Fprintf(w,"Parse:%v",err)
return
}
// 获取URL参数的值
val := r.URL.Query().Get("val")
// 调用模板对象的渲染方法
err = tmpl.Execuate(w,val)
if err != nil {
fmt.Fprintf(w,"Execuate:%v",err)
return
}
})
log.Println("Starting HTTP server...")
log.Fatal(http.ListenAndServe("localhost:4000", nil))
}
在上面的代码中,你可以注意到模板的内容被替换为了The value is:{{.}}
,即使用了分隔符将点操作符包裹起来。在Go语言的标准库模板引擎中,点操作符默认指向的是根对象,即我们在调用template.Execuate
方法时传入的第二个参数。本例中,我们传入的根对象是一个单纯的string
类型的变量val
,那么点操作符的渲染对象就是变量val
尝试运行以上代码可以在终端获得以下结果:
curl http://localhost:4000/?val=666
The value is: 666
在模板中渲染复杂对象
你是否也正在思考,除了简单类型的变量,根对象还可以是什么类型呢?细心的你可能已经发现,template.Execuate
方法的第二个参数类型为interface{}
,也就是说可以传入任何类型。这代表text/template
包提供的文本模板引擎会根据所提供的根对象进行底层类型分析,然后自动判断应该以什么样的形式去理解模板中的语法。
让我们来创建一个名为Inventory
的复合类型,然后通过URL查询参数的值创建一个实例,最后通过模板渲染出各个字段的值:
package main
import (
"fmt"
"html/template"
"log"
"net/http"
"strconv"
)
type Inventory struct {
SKU string
Name string
UnitPrice float64
Quantity int64
}
func main() {
http.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
// 创建模板对象并解析模板内容
tmpl, err := template.New("test").Parse(`Inventory
SKU:{{.SKU}}
Name:{{.Name}}
UnitPrice:{{.UnitPrice}}
Quantity:{{.Quantity}}
`)
if err != nil {
fmt.Fprintf(writer,"Parse:%v",err)
return
}
// 根据URL查询参数的值创建Inventory实例
inventory := &Inventory{
SKU: request.URL.Query().Get("sku"),
Name: request.URL.Query().Get("name"),
}
// 注意:为了简化代码逻辑,这里并没有进行错误处理
inventory.UnitPrice, _= strconv.ParseFloat(request.URL.Query().Get("unitPrice"), 64)
inventory.Quantity,_ = strconv.ParseInt(request.URL.Query().Get("quantity"),10,64)
// 调用模板对象的渲染方法
err = tmpl.Execute(writer, inventory)
if err != nil {
fmt.Fprintf(writer,"Execute:%v",err)
return
}
})
log.Println("Starting HTTP server...")
log.Fatal(http.ListenAndServe("localhost:4000", nil))
}
可以注意到,使用分隔符包裹起来的内容和Inventory
类型中的字段名称是一一对应的,且大小写保持一致(Go语言是一门大小写敏感的语言)。模板中使用了点操作符指代根对象inventory
,即通过URL查询参数的值所创建的一个变量。这里用到了strconv
包中的ParseFloat
和ParseInt
函数,主要作用为解析字符串为浮点型和整型数字。
尝试运行以上代码可以在终端获得以下结果:
curl http://localhost:4000/?sku=1122334&name=phone&unitPrice=649.99&quantity=833
Inventory
SKU: 1122334
Name: phone
UnitPrice: 649.99
Quantity: 833
在模板中调用结构的方法
我们已经讲解了如何在模板中显示具体对象的字段值,那么,是不是也可以使用同样的方式来调用对象具有的方法呢?答案当然是肯定的。
我们需要先为Inventory
类型添加一个方法,称为Subtotal
,即根据该商品的单价和数量来显示当前库存所具有的价值。
// Subtotal 根据单价和数量计算出总价值
func (i *Inventory) Subtotal() float64 {
return i.UnitPrice * float64(i.Quantity)
}
然后在模板中添加相关的内容,使得计算结果能够通过模板渲染展示给客户端
// 创建模板对象并解析模板内容
tmpl, err := template.New("test").Parse(`Inventory
SKU:{{.SKU}}
Name:{{.Name}}
UnitPrice:{{.UnitPrice}}
Quantity:{{.Quantity}}
Subtotal:{{.Subtotal}}`)
可以注意到,在text/template
包提供的文本模板引擎中,显示方法调用结果的值和字段的值的语法是完全相同的,即不需要在方法名称后使用括号表示调用。该模板引擎会在渲染时自动识别所调用对象的具体类型,然后做出相应的操作。
尝试运行以上代码可以在终端获得以下结果:
curl http://localhost:4000/?sku=1122334&name=phone&unitPrice=649.99&quantity=833
Inventory
SKU: 1122334
Name: phone
UnitPrice: 649.99
Quantity: 833
Subtotal: 541441.67
使用map类型作为模板根对象
我想你应该已经意识到将某个具体类型作为模板对象的局限性,因为不论想要展示什么内容,都需要通过修改添加类型的字段或方法才能实现,在操作上非常的不灵活。但是,如果你还记得根对象的参数类型为interface{}
的话,应该就不难理解通过利用一个map[string]interface{}
类型的根对象,可以实现灵活的向模板中添加需要被渲染的子对象。
这种方案可行的根本原因是因为在Go语言中,当interface{}
类型作为参数时,调用者可以传入任意类型的值,效果类似Java中的Object类型。
接下来,让我们通过使用map[string]interface{}
类型作为根对象,实现之前展示Inventory
类型字段值的效果:
package main
import (
"fmt"
"log"
"net/http"
"strconv"
"text/template"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
// 创建模板对象并解析模板内容
tmpl, err := template.New("test").Parse(`Inventory
SKU: {{.SKU}}
Name: {{.Name}}
UnitPrice: {{.UnitPrice}}
Quantity: {{.Quantity}}
`)
if err != nil {
fmt.Fprintf(w, "Parse: %v", err)
return
}
// 获取 URL 查询参数的值
// 注意:为了简化代码逻辑,这里并没有进行错误处理
sku := r.URL.Query().Get("sku")
name := r.URL.Query().Get("name")
unitPrice, _ := strconv.ParseFloat(r.URL.Query().Get("unitPrice"), 64)
quantity, _ := strconv.ParseInt(r.URL.Query().Get("quantity"), 10, 64)
// 调用模板对象的渲染方法,并创建一个 map[string]interface{} 类型的临时变量作为根对象
err = tmpl.Execute(w, map[string]interface{}{
"SKU": sku,
"Name": name,
"UnitPrice": unitPrice,
"Quantity": quantity,
})
if err != nil {
fmt.Fprintf(w, "Execute: %v", err)
return
}
})
log.Println("Starting HTTP server...")
log.Fatal(http.ListenAndServe("localhost:4000", nil))
}
在以上代码中,我们将URL查询的参数赋值给多个变量,然后将所有变量以键值对的形式生成一个map[string]interface{}
类型的临时对象作为模板的根对象。相比之前需要先定义一个Inventory
类型而言,这种方式可以更加灵活便利的将对象放置到模板中用于渲染。
curl http://localhost:4000/?sku=1122334&name=phone&unitPrice=649.99&quantity=833
Inventory
SKU: 1122334
Name: phone
UnitPrice: 649.99
Quantity: 833
许多Web框架的实现都是基于这个小技巧,如果之前还不明所以的话,现在应该知道其中的原理了吧?
在模板中使用注释
虽然目前我们所使用的模板文本还都非常简单,但当模板内容变多、逻辑更加复杂的时候就会想要使用注释来进行辅助理解,便于后期的维护和开发。
注释语法和Go语言程序代码中的块注释语法相同,即使用/*
和*/
将注释内容包裹起来,例如:{{/*这是注释的内容*/}}