在上一节课中,我们学习了如何使用 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!” 字符串开始。
示例文件 template.go
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("Hello 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))
}
如果运行上面的代码,就会发现和上节课的输出毫无区别,并且在作为处理器的匿名函数中增加了更多的逻辑。
➜ curl http://localhost:4000
Hello world!
相比之前多出来的这部分逻辑便是创建、解析和渲染模板的必要步骤:
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” 的值,并返回给客户端。
示例文件 query.go
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” 的值进行渲染了。
示例文件 template_2.go
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.Execute(w, val)
if err != nil {
fmt.Fprintf(w, "Execute: %v", err)
return
}
})
log.Println("Starting HTTP server...")
log.Fatal(http.ListenAndServe("localhost:4000", nil))
}
在上面的代码中,你可以注意到模板的内容被替换为了 The value is: {{.}}
,即使用了分隔符将点操作符包裹起来。在 Go 语言的标准库模板引擎中,点操作符默认指向的是根对象,即我们在调用 template.Execute
方法时传入的第二个参数。本例中,我们传入的根对象是一个单纯的 string
类型的变量 val
,那么点操作符的渲染对象就是变量 val
。
尝试运行以上代码可以在终端获得以下结果:
➜ curl http://localhost:4000/?val=666
The value is: 666
至此,我们就成功地对 text/template
包提供的文本模板引擎实现了第一次动态输出啦!
你是否也正在思考,除了简单类型的变量,根对象还可以是什么类型呢?细心的你可能已经发现,template.Execute
方法的第二个参数类型为 interface{}
,也就是说可以传入任何类型。这代表 text/template
包提供的文本模板引擎会根据所提供的根对象进行底层类型分析,然后自动判断应该以什么样的形式去理解模板中的语法。以点操作符为例,如果根对象为变量,那么点操作符代表的就是一个变量;而如果根对象为一个复合类型,那么点操作符所代表的也就是这个复合类型。
让我们来创建一个名为 Inventory
的复合类型,然后通过 URL 查询参数的值创建一个实例,最后通过模板渲染出各个字段的值:
示例文件 template_3.go
package main
import (
...
"strconv"
)
type Inventory struct {
SKU string
Name string
UnitPrice float64
Quantity int64
}
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}}
`)
...
// 根据 URL 查询参数的值创建 Inventory 实例
inventory := &Inventory{
SKU: r.URL.Query().Get("sku"),
Name: r.URL.Query().Get("name"),
}
// 注意:为了简化代码逻辑,这里并没有进行错误处理
inventory.UnitPrice, _ = strconv.ParseFloat(r.URL.Query().Get("unitPrice"), 64)
inventory.Quantity, _ = strconv.ParseInt(r.URL.Query().Get("quantity"), 10, 64)
// 调用模板对象的渲染方法
...
})
log.Println("Starting HTTP server...")
log.Fatal(http.ListenAndServe("localhost:4000", nil))
}
为了缩减篇幅并更好地专注于有变动的部分,部分未改动的代码块使用了 “…” 进行替代
以上代码主要有两部分变动,第一部分是修改了模板内容(注意代码中使用反引号而非双引号):
Inventory
SKU: {{.SKU}}
Name: {{.Name}}
UnitPrice: {{.UnitPrice}}
Quantity: {{.Quantity}}
你可以注意到,使用分隔符包裹起来的内容和 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)
}
然后在模板中添加相关的内容,使得计算结果能够通过模板渲染展示给客户端。
示例文件 template_4.go
...
// 创建模板对象并解析模板内容
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
我想聪明的你已经意识到将某个具体类型作为模板根对象的局限性,因为不论想要展示什么内容,都需要通过修改添加类型的字段或方法才能实现,在操作上非常的不灵活。但是,如果你还记得根对象的参数类型为 interface{}
的话,应该就不难理解通过利用一个 map[string]interface{}
类型的根对象,可以实现灵活地向模板中添加需要被渲染的子对象。
这种方案可行的根本原因是因为在 Go 语言中,当 interface{}
类型作为参数时,调用者可以传入任意类型的值,效果类似 Java 中的 Object 类型。
接下来,让我们通过使用 map[string]interface{}
类型作为根对象,实现之前展示 Inventory
类型中字段值的效果。
示例文件 template_5.go
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 框架的实现都是基于这个小技巧,如果之前还不明所以的话,现在应该知道这其中的原理了吧?
虽然目前我们所使用的模板文本还都非常简单,但当模板内容变多、逻辑更加复杂的时候就会想要使用注释来进行辅助理解,便于后期的维护和开发。在这节课的最后,我们来学习如何在 text/template
包提供的文本模板引擎中进行注释的语法。
注释的语法和 Go 语言程序代码中的块注释语法相同,即使用 /*
和 */
将注释内容包括起来,例如:{{/* 这是注释内容 */}}
。
简单修改一下我们已有的模板文本,就可以发现如同源代码中的注释一样,模板中的注释会在模板的解析阶段被剔除:
示例文件 template_6.go
...
// 创建模板对象并解析模板内容
tmpl, err := template.New("test").Parse(`{{/* 打印参数的值 */}}
Inventory
SKU: {{.SKU}}
Name: {{.Name}}
UnitPrice: {{.UnitPrice}}
Quantity: {{.Quantity}}
`)
...
为了缩减篇幅并更好地专注于有变动的部分,部分未改动的代码块使用了 “…” 进行替代
尝试运行以上代码可以在终端获得与之前相同的结果:
➜ curl http://localhost:4000/?sku=1122334&name=phone&unitPrice=649.99&quantity=833
Inventory
SKU: 1122334
Name: phone
UnitPrice: 649.99
Quantity: 833
注意 “Inventory” 字符串之前多了一个空行,就是模板文本中注释所在的那一行。
这节课,我们学习了标准库中 text/template
包提供的文本模板引擎的基础用法,了解了模板渲染和根对象的概念,并学会了有关根对象类型的一个小技巧。
下节课,我们将基于这节课所学的基础用法上,进一步学习如何在 Go 语言提供的模板引擎中进行条件判断和更加复杂的逻辑操作。
接下来:第 03 课:进阶模板用法