第5章 内容展示

本章主要内容

Web模板 就是一些预先设计好的HTML页面,名为模板引擎的软件程序会通过重复地使用这些页面来创建一个或多个HTML页面。Web模板引擎是Web应用框架的重要组成部分,绝大多数成熟的框架都会拥有相应的模板引擎:有一小部分框架的模板引擎是直接嵌入框架里面的,而其他绝大多数框架都允许用户像吃自助餐一样,根据自己的喜好选择相应的模板引擎。

Go语言也不例外——尽管Go还是一门相对较新的编程语言,但已经出现了一些使用Go语言构建的模板引擎;除此之外,Go的标准库也通过text/templatehtml/template 这两个库为模板提供了强有力的支持,并且毫不意外地很多Go框架都使用了这两个库作为默认的模板引擎。

本章将对上面提到的两个库进行介绍,并说明如何使用它们生成HTML响应。

如图5-1所示,模板引擎通过将数据和模板组合在一起生成最终的HTML,而处理器则负责调用模板引擎并将引擎生成的HTML返回给客户端。

如前所述,Web模板引擎演变自SSI(服务器端包含)技术,并最终衍生出了诸如PHP、ColdFusion和JSP这样的Web编程语言。这种演变导致的一个结果是模板引擎并没有相应的标准,并且对各个因为不同原因创造出来的模板引擎来说,它们拥有的特性也是五花八门、各不相同的。不过大致来讲,我们可以把模板引擎划分为两种理想的类型,这两种类型的模板正好处于两个极端。

05-01

图5-1 模板引擎通过组合数据和模板来生成最终展示的HTML

因为不需要进行逻辑处理,所以无逻辑模板引擎的渲染速度往往会更快一些。一些模板引擎虽然自称是无逻辑模板引擎,但它们实际上并非只执行字符串替换操作。比如,Mustache虽然自称是无逻辑模板引擎,但它实际上也提供了一些能够执行条件判断操作和循环操作的标签(tag)。

另外,最极端的嵌入逻辑模板引擎通常表现得跟普通的编程语言一样,比如PHP就是一个很好的例子:PHP一开始是作为独立的Web模板引擎出现的,但今时今日的很多PHP页面已经很难看到哪怕一行HTML代码,我们甚至已经不太可能继续把PHP看作是一个模板引擎了,实际上PHP本身就拥有很多模板引擎,比如,Smarty和Blade都是为PHP构建的。

对于嵌入逻辑模板引擎的最大争论,就是认为它把表现和逻辑搅合在了一起,并将逻辑分散在多个不同的地方,导致代码变得难以维护。而对于无逻辑模板引擎的争论则是认为这种理想化的模板引擎并不实用,并且会导致处理器需要包含更多逻辑,特别是表现方面的逻辑,并因此给处理器带来不必要的复杂度。

在实际中,绝大多数有用的模板引擎都会介于以上这两种理想的模板引擎之间,其中有些模板引擎更接近于无逻辑模板引擎,而其他一些模板引擎则更接近于嵌入逻辑模板引擎。Go标准库提供的模板引擎功能大部分都定义在了text/template 库当中,而小部分与HTML相关的功能则定义在了html/template 库里面。这两个库相辅相成:用户可以把这个模板引擎当做无逻辑模板引擎使用,但与此同时,Go也提供了足够多的嵌入式模板引擎特性,使这个模板引擎用起来既有趣又强大。

跟其他大多数模板引擎一样,Go语言的模板引擎也是介于无逻辑模板引擎和嵌入逻辑模板引擎之间的一种模板引擎。在Web应用里面,模板引擎通常由处理器负责触发。作为例子,图5-2展示了处理器调用Go模板引擎的流程:处理器首先调用模板引擎,接着以模板文件列表的方式向模板引擎传入一个或多个模板,然后再传入模板需要用到的动态数据;模板引擎在接收到这些参数之后会生成出相应的HTML,并将这些文件写入到ResponseWriter 里面,然后由ResponseWriter 将HTTP响应返回给客户端。

05-02

图5-2 Go模板引擎在Web应用中的作用示意图

Go的模板都是文本文档(其中Web应用的模板通常都是HTML),它们都嵌入了一些称为动作 (action)的指令。从模板引擎的角度来说,模板就是嵌入了动作的文本(这些文本通常包含在模板文件里面),而模板引擎则通过分析并执行这些文本来生成出另外一些文本。Go语言拥有通用模板引擎库text/template ,它可以处理任意格式的文本,除此之外,Go语言还拥有专门为HTML格式而设的模板引擎库html/template 。模板中的动作默认使用两个大括号{{}} 包围,如果用户有需要,也可以通过模板引擎提供的方法自行指定其他定界符(delimiter)。本章稍后将对动作做更详细的介绍,在此之前,让我们先来看一下代码清单5-1展示的这个非常简单的模板。

代码清单5-1 一个简单的模板

<!DOCTYPE html>
<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  {{ . }}
 </body>
</html>

代码清单5-1展示的模板来源于一个名为tmpl.html 的模板文件。用户可以拥有任意多个模板文件,并且这些模板文件可以使用任意后缀名,但它们的类型必须是可读的文本格式。因为上面这段模板的输出将是一个HTML文件,所以我们使用了.html 作为模板文件的后缀名。

注意,模板中被两个大括号包围的点(. )是一个动作,它指示模板引擎在执行模板时,使用一个值去替换这个动作本身。

使用Go的Web模板引擎需要以下两个步骤:

(1)对文本格式的模板源进行语法分析,创建一个经过语法分析的模板结构,其中模板源既可以是一个字符串,也可以是模板文件中包含的内容;

(2)执行经过语法分析的模板,将ResponseWriter 和模板所需的动态数据传递给模板引擎,被调用的模板引擎会把经过语法分析的模板和传入的数据结合起来,生成出最终的HTML,并将这些HTML传递给ResponseWriter

代码清单5-2展示了一个简单而且具体的模板引擎使用例子。

代码清单5-2 在处理器函数中触发模板引擎

package main

import (
  "net/http"
  "html/template"
)

func process(w http.ResponseWriter, r *http.Request) {
  t, _ := template.ParseFiles("tmpl.html")
  t.Execute(w, "Hello World!")
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/process", process)
  server.ListenAndServe()
}

代码清单5-2展示的服务器代码跟之前展示过的服务器代码非常相似,主要的区别在于这次的服务器使用了一个名为process 的处理器函数,而模板引擎就是由这个函数负责触发的。process 函数首先使用ParseFiles 函数对模板文件tmpl.html 进行语法分析,ParseFiles 函数在执行完毕之后将返回一个Template 类型的已分析模板和一个错误作为结果,不过为了保持代码的简洁,我们这里暂时把这个错误忽略了:

t, _ := template.ParseFiles("tmpl.html")

在此之后,process 函数会调用Execute 方法,将数据应用(apply)到模板里面——在这个例子中,数据就是字符串"Hello World!"

t.Execute(w, "Hello World!")

ResponseWriter 和数据会一起被传入Execute 方法中,这样一来,模板引擎在生成HTML之后就可以把该HTML传给ResponseWriter 了。另外需要注意的是,因为这个服务器在指定模板位置时并没有给出模板文件的绝对路径,所以我们在运行这个服务器的时候,需要把模板文件和服务器的二进制文件放到同一个目录里面。

以上展示的就是模板引擎的最基本用法,正如你所料,除了. 之外,Go的模板引擎还提供了其他动作供用户使用,本章将在稍后的内容中对这些动作做进一步的介绍。

ParseFiles 是一个独立的(standalone)函数,它可以对模板文件进行语法分析,并创建出一个经过语法分析的模板结构以供Execute 方法执行。实际上,ParseFiles 函数只是为了方便地调用Template 结构的ParseFiles 方法而设置的一个函数——当用户调用ParseFiles 函数的时候,Go会创建一个新的模板,并将用户给定的模板文件的名字用作这个新模板的名字:

t, _ := template.ParseFiles("tmpl.html")

这相当于创建一个新模板,然后调用它的ParseFiles 方法:

t := template.New("tmpl.html")
t, _ := t.ParseFiles("tmpl.html")

无论是ParseFiles 函数还是Template 结构的ParseFiles 方法,它们都可以接受一个或多个文件名作为参数,换句话说,这两个函数/方法都是可变参数函数/方法,它们可以接受的参数数量是可变的。但与此同时,无论这两个函数/方法接受多少个文件名作为输入,它们都只返回一个模板。

当用户向ParseFiles 函数或ParseFiles 方法传入多个文件时,ParseFiles 只会返回用户传入的第一个文件的已分析模板,并且这个模板也会根据用户传入的第一个文件的名字进行命名;至于其他传入文件的已分析模板则会被放置到一个映射里面,这个映射可以在之后执行模板时使用。换句话说,我们可以这样认为:在向ParseFiles 传入单个文件时,ParseFiles 返回的是一个模板;而在向ParseFiles 传入多个文件时,ParseFiles 返回的则是一个模板集合,理解这一点能够帮助我们更好地学习本章稍后将要介绍的嵌套模板技术。

对模板文件进行语法分析的另一种方法是使用ParseGlob 函数,跟ParseFiles 只会对给定文件进行语法分析的做法不同,ParseGlob 会对匹配给定模式的所有文件进行语法分析。举个例子,如果目录里面只有tmpl.html 一个HTML文件,那么语句

t, _ := template.ParseFiles("tmpl.html")

和语句

t, _ := template.ParseGlob("*.html")

将产生相同的效果。

在绝大多数情况下,程序都是对模板文件进行语法分析,但是在需要时,程序也可以直接对字符串形式的模板进行语法分析。实际上,所有对模板进行语法分析的手段最终都需要调用Parse 方法来执行实际的语法分析操作。比如说,在模板内容相同的情况下,语句

t, _ := template.ParseFiles("tmpl.html")

和代码

tmpl := `<!DOCTYPE html>
<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  {{ . }}
 </body>
</html>
`
t := template.New("tmpl.html")
t, _ = t.Parse(tmpl)
t.Execute(w, "Hello World!")

将产生相同的效果。

到目前为止,本章一直都没有处理分析模板时可能会产生的错误。虽然Go语言的一般做法是手动地处理错误,但Go也提供了另外一种机制,专门用于处理分析模板时出现的错误:

t := template.Must(template.ParseFiles("tmpl.html"))

Must 函数可以包裹起一个函数,被包裹的函数会返回一个指向模板的指针和一个错误,如果这个错误不是nil ,那么Must 函数将产生一个panic。(在Go里面,panic会导致正常的执行流程被终止:如果panic是在函数内部产生的,那么函数会将这个panic返回给它的调用者。panic会一直向调用栈的上方传递,直至main 函数为止,并且程序也会因此而崩溃。)

执行模板最常用的方法就是调用模板的Execute 方法,并向它传递ResponseWriter 以及模板所需的数据。在只有一个模板的情况下,上面提到的这种方法总是可行的,但如果模板不止一个,那么当对模板集合调用Execute 方法的时候,Execute 方法只会执行模板集合中的第一个模板。如果想要执行的不是模板集合中的第一个模板而是其他模板,就需要使用Execute Template方法。比如,对以下语句来说:

t, _ := template.ParseFiles("t1.html", "t2.html")

变量t 就是一个包含了两个模板的模板集合,其中第一个模板名为t1.html ,而第二个模板则名为t2.html (正如前面所说,除非显式地对模板名进行修改,否则模板的名字和后缀名将由传入的模板文件决定)。如果对这个模板集合调用Execute 方法:

t.Execute(w, "Hello World!")

就只有模板t1.html 会被执行。如果想要执行的是模板t2.html 而不是t1.html ,则需要执行以下语句:

t.ExecuteTemplate(w, "t2.html", "Hello World!")

在学会了怎样调用模板引擎并使用它去分析和执行模板之后,接下来我们要学习的是如何使用Go语言提供的各种模板动作。

正如之前所说,Go模板的动作 就是一些嵌入在模板里面的命令,这些命令在模板中使用两个大括号{{}} 进行包围。Go拥有一套非常丰富的动作集合,它们不仅功能强大,而且还非常灵活多变。本节将讨沦以下几种主要的动作:

除了以上4种动作之外,本章稍后还会介绍另外一种重要的动作——定义动作。如果读者对其他类型的动作也感兴趣,那么可以参考text/template 库的文档。

虽然初看上去可能会让人感到惊讶,但其实点(. )也是一个动作,并且是最为重要的一个,它代表的是传递给模板的数据,其他动作和函数基本上都会对这个动作进行处理,以此来达到格式化和内容展示的目的。

条件动作会根据参数的值来决定对多条语句中的哪一条语句进行求值。最简单的条件动作的格式如下:

{{ if arg }}
 some content
{{ end }}

这个动作的另一种格式如下:

{{ if arg }}
 some content
{{ else }}
 other content
{{ end }}

以上两种格式中的arg 都是传递给条件动作的参数。本章稍后会对动作的参数做更详细的介绍,目前来说,我们可以把参数看作是一个值,这个值可以是一个字符串常量、一个变量、一个返回单个值的函数或者方法,诸如此类。现在,让我们来看一下如何在模板中使用这个条件动作。如代码清单5-3所示,我们会在服务器上面创建一个处理器,这个处理器会随机地生成介于0至10之间的随机整数,然后通过判断这个随机整数是否大于5来创建出一个布尔值,并在最后将这个布尔值传递给模板。

代码清单5-3 在处理器里面生成一个随机数

package main

import (
  "net/http"
  "html/template"
  "math/rand"
  "time"
)

func process(w http.ResponseWriter, r *http.Request) {
  t, _ := template.ParseFiles("tmpl.html")
  rand.Seed(time.Now().Unix())
  t.Execute(w, rand.Intn(10) > 5)
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/process", process)
  server.ListenAndServe()
}

在此之后,我们需要在模板文件tmpl.html 里面对传入的参数进行测试,并根据测试的结果,在页面上显示“Number is greater than 5! ”和“Number is 5 or less! ”这两条消息中的一条,具体的做法如代码清单5-4所示。(正如之前所说,动作. 代表的是处理器传递给模板的数据,在这个例子中,. 代表的是被传入的布尔值。)

代码清单5-4 使用了条件动作的模板文件tmpl.html

<!DOCTYPE html>
<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  {{ if . }}
   Number is greater than 5!
  {{ else }}
   Number is 5 or less!
  {{ end }}
 </body>
</html>

迭代动作可以对数组、切片、映射或者通道进行迭代,而在迭代循环的内部,点(. )则会被设置为当前被迭代的元素,就像这样:

{{ range array }}
 Dot is set to the element {{ . }}
{{ end }}

代码清单5-5展示了一个使用迭代动作的例子。

代码清单5-5 迭代动作示例

<!DOCTYPE html>
<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  <ul>
  {{ range . }}
   <li>{{ . }}</li>
  {{ end}}
  </ul>
 </body>
</html>

下面是负责调用这个模板的处理器:

func process(w http.ResponseWriter, r *http.Request) {
  t, _ := template.ParseFiles("tmpl.html")
  daysOfWeek := []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
  t.Execute(w, daysOfWeek)
}

这段代码创建了一个切片,并在切片里面包含了周一到周日的英文缩写,然后将它传递给模板。接着,这个切片会被传递至语句{{ range . }}中. 里面,然后由range 动作对这个切片中的各个元素进行迭代。

迭代循环中的{{ . }} 代表的是当前被迭代的切片元素,图5-3展示了浏览器展示的迭代结果。

05-03

图5-3 使用迭代动作实现迭代

代码清单5-6展示了迭代动作的一个变种,这个变种允许用户在被迭代的数据结构为空时,显示一个备选的(fallback)结果。

代码清单5-6 带有备选结果的迭代动作

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  <ul>
  {{ range . }}
   <li>{{ . }}</li>
  {{ else }}
   <li> Nothing to show </li>
  {{ end}}
  </ul>
 </body>
</html>

模板里面介于{{ else }}{{ end }} 之间的内容将在点(. )为nil 时显示。在这个例子中,被显示的将是文本“Nothing to show”。

设置动作允许用户在指定的范围之内为点(. )设置值。比如,在以下代码中:

{{ with arg }}
 Dot is set to arg
{{ end }}

介于{{ with arg }}{{ end }} 之间的 将被设置为参数arg 的值。再次修改的tmpl.html 文件如代码清单5-7所示,这是一个更为具体的例子。

代码清单5-7 对点进行设置

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  <div>The dot is {{ . }}</div>
  <div>
  {{ with "world"}}
   Now the dot is set to {{ . }}
  {{ end }}
  </div>
  <div>The dot is {{ . }} again</div>
 </body>
</html>

至于调用这个模板的处理器则会将字符串"hello" 传递给模板:

func process(w http.ResponseWriter, r *http.Request) {
  t, _ := template.ParseFiles("tmpl.html")
  t.Execute(w,"hello")
}

这样一来,位于{{ with "world" }} 之前的点就会因为处理器传入的值而被设置成hello ,而位于{{ with "world" }}{{ end }} 之间的点则会被设置成world ;但是,在语句{{ end }} 执行完毕之后,点的值又会重新被设置成hello ,如图5-4所示。

05-04

图5-4 使用设置动作对点(. )进行设置

跟迭代动作一样,设置动作也拥有一个能够提供备选方案的变种:

{{ with arg }}
 Dot is set to arg
{{ else }}
 Fallback if arg is empty
{{ end }}

代码清单5-8展示了这一变种的使用方法。

代码清单5-8 在设置点的时候提供备选方案

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
</head>
<body>
 <div>The dot is {{ . }}</div>
 <div>
 {{ with "" }}
  Now the dot is set to {{ . }}
 {{ else }}
  The dot is still {{ . }}
 {{ end }}
  </div>
  <div>The dot is {{ . }} again</div>
 </body>
</html>

因为传给with 动作的参数为空字符串"" ,所以模板将显示{{ else }} 语句之后的内容;此外,因为with 动作并没有修改点(. )的值,所以模板打印出来的仍然是处理器传入的值"hello" 。执行这个新模板不需要对处理器或者服务器进行任何修改,也不需要重启服务器,只要刷新一下浏览器,就会看到图5-5所示的结果。

05-05

图5-5 在设置点(. )时提供备选方案

包含动作(include action)允许用户在一个模板里面包含另一个模板,从而构建出嵌套的模板。包含动作的格式为{{ template "name" }} ,其中name 参数为被包含模板的名字。

代码清单5-9展示了一个使用包含动作的例子,在这个例子中,模板t1.html 包含了模板t2.html

代码清单5-9 模板t1.html

<!DOCTYPE html>
<html lang="en">
 <head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=9">
  <title>Go Web Programming</title>
 </head>
 <body>
  <div> This is t1.html before</div>
  <div>This is the value of the dot in t1.html - [{{ . }}]</div>
  <hr/>
  {{ template "t2.html" }}
  <hr/>
  <div> This is t1.html after</div>
 </body>
</html>

正如代码所示,模板文件的名字将被用作模板的名字。记住,如果用户在创建模板的时候没有为模板指定名字,那么Go语言在命名模板时将沿用模板文件的名字及扩展名。

代码清单5-10展示了被包含的模板t2.html ,这个模板是一段HTML代码片段。

代码清单5-10 模版t2.html

<div style="background-color: yellow;">
 This is t2.html<br/>
 This is the value of the dot in t2.html - [{{ . }}]
</div>

代码清单5-11展示了使用以上两个模板的处理器。

代码清单5-11 调用嵌套模板的处理器

func process(w http.ResponseWriter, r *http.Request) {
  t, _ := template.ParseFiles("t1.html", "t2.html")
  t.Execute(w, "Hello World!")
}

跟之前展示的代码不同,在执行嵌套模板时,我们必须对涉及的所有模板文件都进行语法分析。牢记这一点是非常重要的,忘记对必要的模板文件进行语法分析将导致程序出现不正确的结果。

因为上面的代码并没有为模板设置名字,所以模板集合中的模板将沿用模板文件的名字。正如之前所说,ParseFiles 函数的第一个参数是具有特殊作用的:在进行语法分析时,用户给定的第一个模板文件将成为主模板(main template),当用户对模板集合调用Execute 方法时,主模板将被执行。

图5-6展示了服务器在执行上述模板之后向浏览器返回的结果。

如图5-6所示,模板t1.html 中的点(. )被传入的"Hello World!" 准确无误地替换掉了,并且模板t2.html 的内容也出现在了语句{{ template "t2.html" }} 所在的位置。因为模板t1.html 并没有把字符串"Hello World!" 也传递给被嵌套的模板t2.html ,所以t2.html 中的点的打印结果为空字符串。为了向被嵌套的模板传递数据,用户可以使用包含动作的变种{{ template "name" arg }} ,其中arg 就是用户想要传递给被嵌套模板的数据,代码清单5-12展示了这个变种的具体使用方法。

05-06

图5-6 嵌套模板的输出结果

代码清单5-12 通过参数将模板t1.html 中的数据传递给被嵌套的模板t2.html

<html>
 <head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=9">
  <title>Go Web Programming</title>
 </head>
 <body>
  <div> This is t1.html before</div>
  <div>This is the value of the dot in t1.html - [{{ . }}]</div>
  <hr/>
  {{ template "t2.html" . }}
  <hr/>
  <div> This is t1.html after</div>
 </body>
</html>

这个模板唯一的改动就是在t1.html 里面将点传递给了t2.html 。现在,如果我们再次执行这个模板,它将产生图5-7所示的结果。

05-07

图5-7 将数据传递给被嵌套的模板

本章稍后将再次回顾嵌套模板,并介绍一种没有在本节中展示的动作——定义动作。虽然使用动作可以给程序员带来方便,但是本节介绍的都是初级的模板用法,它们并不能最大限度地发挥模板的威力。为了解决这个问题,本章接下来将介绍参数、变量和管道等高级模板用法。

一个参数 (argument)就是模板中的一个值。它可以是布尔值、整数、字符串等字面量,也可以是结构、结构中的一个字段或者数组中的一个键。除此之外,参数还可以是一个变量、一个方法(这个方法必须只返回一个值,或者只返回一个值和一个错误)或者一个函数。最后,参数也可以是一个点(. ),用于表示处理器向模板引擎传递的数据。

比如说,在以下这个例子中,arg 是一个参数:

{{ if arg }}
 some content
{{ end }}

除了参数之外,用户还可以在动作中设置变量。变量以美元符号($ )开头,就像这样:

$variable := value

初看上去,变量似乎并没有什么特别大的用处,但实际上它们对动作来说是非常重要的。作为例子,以下代码展示了怎样使用变量去实现迭代动作的一个变种:

{{ range $key, $value := . }}
 The key is {{ $key }} and the value is {{ $value }}
{{ end }}

在这个例子中,点(. )是一个映射,而动作range 在迭代这个映射的时候,会将变量$key$value 分别初始化为当前被迭代映射元素的键和值。

模板中的管道 (pipeline)是多个有序地串联起来的参数、函数和方法,它的工作方式和语法跟Unix的管道也非常相似:

{{ p1 | p2 | p3 }}

这里的p1p2p3 可以是参数或者函数。管道允许用户将一个参数的输出传递给下一个参数,而各个参数之间则使用| 分隔。代码清单5-13展示了一个管道的使用示例。

代码清单5-13 模板中的管道

<!DOCTYPE html>
<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  {{ 12.3456 | printf "%.2f" }}
 </body>
</html>

为了更好地显示内容,用户经常需要在模板中对数据进行格式化。比如,在代码清单5-13所示的例子中,我们想要在显示浮点数的时候只保留两位小数精度。为了做到这一点,我们可以使用fmt.Sprintf 函数或者模板内置的printf 函数对浮点数进行格式化(这个printf 函数实际上就是fmt.Sprintf 函数的别名)。

除此之外,我们还通过管道将数字12.3456 传递给了printf 函数,并在printf 函数的第一个参数中指定了格式指示符(specifier),最终,这个管道将返回12.35 作为结果。

虽然管道已经非常强大,但它还不是模板提供的最为强大的功能,接下来的一节要介绍的函数才是。

正如之前所说,Go函数也可以用作模板的参数:Go模板引擎内置了一些非常基础的函数,其中包括为fmt.Sprint 的不同变种创建的几个别名函数(fmt 包的文档详细地列出了这些别名函数),并且用户不仅可以使用模板引擎内置的函数,还可以自行定义自己想要的函数。

需要注意的是,Go的模板引擎函数都是受限制的:尽管这些函数可以接受任意多个参数作为输入,但它们只能返回一个值,或者返回一个值和一个错误。

为了创建一个自定义模板函数,用户需要:

(1)创建一个名为FuncMap 的映射,并将映射的键设置为函数的名字,而映射的值则设置为实际定义的函数;

(2)将FuncMap 与模板进行绑定。

让我们来看一个创建自定义函数的具体例子。在编写Web应用的时候,用户常常需要将时间对象或者日期对象转换为ISO8601格式的时间字符串或者日期字符串,又或者将ISO8601格式的字符串转换为相应的对象。但遗憾的是,我们正在使用的库并没有内置类似的转换函数,所以我们就需要像代码清单5-14展示的那样,自行创建这些函数。

代码清单5-14 创建模板自定义函数

package main

import (
  "net/http"
  "html/template"
  "time"
)

func formatDate(t time.Time) string {
  layout := "2006-01-02"
  return t.Format(layout)
}

func process(w http.ResponseWriter, r *http.Request) {
  funcMap := template.FuncMap { "fdate": formatDate }
  t := template.New("tmpl.html").Funcs(funcMap)
  t, _ = t.ParseFiles("tmpl.html")
  t.Execute(w, time.Now())
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/process", process)
  server.ListenAndServe()
}

这段程序首先定义了一个名为formatDate 的函数,它接受一个Time 结构作为输入,然后以“年-月-日”的形式返回一个ISO8601格式的字符串。

在之后的处理器中,程序创建了一个变量名为funcMapFuncMap 结构,并使用这个结构将名字fdate 映射至formatDate 函数。接着,程序使用template.New 函数创建了一个名为tmpl.html 的模板。因为template.New 函数会返回被创建的模板,所以程序直接以串联的方式调用模板的Funcs 方法,并将前面创建的funcMap 传递给模板。这样一来,funcMap 与模板的绑定就完成了,于是程序接下来就跟往常一样,对模板文件tmpl.html 进行语法分析。最后,程序调用模板的Execute 方法,并将ResponseWriter 以及当前时间传递给它。

再次提醒,在调用ParseFiles 函数时,如果用户没有为模板文件中的模板定义名字,那么函数将使用模板文件的名字作为模板的名字。与此同时,在调用New 函数创建新模板的时候,用户必须传入一个模板名字,如果用户给定的模板名字跟前面分析模板时通过文件名提取的模板名字不相同,那么程序将返回一个错误。

在看过了处理器的相关代码之后,现在让我们来看看如何在tmpl.html 模板中使用前面定义的函数,具体的方法如代码清单5-15所示。

代码清单5-15 通过管道使用自定义函数

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  <div>The date/time is {{ . | fdate }}</div>
 </body>
</html>

用户可以通过几种不同的方式使用自定义函数。比如,代码清单5-15就展示了如何通过模板的管道特性,将当前时间由管道传递至fdate 函数,并藉此产生图5-8所示的输出。

05-08

图5-8 使用自定义函数格式化日期或时间

除此之外,我们也可以像调用普通函数一样,将点(. )作为参数传递给fdate 函数,具体做法如代码清单5-16所示。

代码清单5-16 通过传递参数的方式使用自定义函数

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  <div>The date/time is {{ fdate . }}</div>
 </body>
</html>

以上两种调用方式会产生相同的结果,但使用管道比直接调用函数要强大和灵活得多。如果用户定义了多个函数,那么他就可以通过管道将一个函数的输出传递给另一个函数作为输入,从而以不同的方式组合使用这些函数;尽管普通的函数调用也能够做到这一点,但使用管道可以产生更简单且更可读的代码。

Go语言的模板引擎拥有一个非常有趣的特性——它可以根据内容所处的上下文改变其显示的内容。是的,你没看错。根据内容在文档中所处的位置,模板在显示这些内容的时候将对其进行相应的修改,换句话说,Go的模板将以上下文感知 (context-aware)的方式显示内容。那么这个特性有什么用,我们又会在什么地方用到这个特性呢?

上下文感知的一个显而易见的用途就是对被显示的内容实施正确的转义(escape):这意味着,如果模板显示的是HTML格式的内容,那么模板将对其实施HTML转义;如果模板显示的是JavaScript格式的内容,那么模板将对其实施JavaScript转义;诸如此类。除此之外,Go模板引擎还可以识别出内容中的URL或者CSS样式。代码清单5-17展示了一个上下文感知特性的使用例子。

代码清单5-17 为了展示模板中的上下文感知特性而设置的处理器

package main

import (
  "net/http"
  "html/template"
)

func process(w http.ResponseWriter, r *http.Request) {
  t, _ := template.ParseFiles("tmpl.html")
  content := `I asked: <i>"What's up?"</i>`
  t.Execute(w, content)
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/process", process)
  server.ListenAndServe()
}

这个处理器向模板发送了文本字符串I asked: <i>"What's up?"</i> ,它包含了几个需要事先转义的特殊字符,代码清单5-18展示了与这个处理器相对应的模板文件tmpl.html

代码清单5-18 上下文感知模板

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  <div>{{ . }}</div>
  <div><a href="/{{ . }}">Path</a></div>
  <div><a href="/?q={{ . }}">Query</a></div>
  <div><a onclick="f('{{ . }}')">Onclick</a></div>
 </body>
</html>

正如代码所示,这个模板将传入的参数放到了HTML中的多个不同的位置,并且每个位置都使用了<div> 标签对其进行包裹。如果我们使用4.1.4节介绍的方法,通过cURL获取未经改动的原始HTML文件,那么我们将得到以下结果:

curl –i 127.0.0.1:8080/process
HTTP/1.1 200 OK
Date: Sat, 07 Feb 2015 05:42:41 GMT
Content-Length: 505
Content-Type: text/html; charset=utf-8

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  <div>I asked: &lt;i&gt;&#34;What&#39;s up?&#34;&lt;/i&gt;</div>
  <div>
   <a href="/I%20asked:%20%3ci%3e%22What%27s%20up?%22%3c/i%3e">
    Path
   </a>
  </div>
  <div>
   <a href="/?q=I%20asked%3a%20%3ci%3e%22What%27s%20up%3f%22%3c%2fi%3e">
    Query 
   </a>
  </div>
  <div>
   <a onclick="f('I asked: \x3ci\x3e\x22What\x27s up?\x22\x3c\/i\x3e')">
    Onclick
   </a>
  </div>
 </body>
</html>

这个结果看上去有点儿复杂,表5-1展示了结果HTML与输入原文之间的区别。

表5-1 Go模板中的上下文感知:根据动作所在的位置,同样的内容输入将产生不同的输出结果

上下文

输出内容

原文本

I asked: <i>"What's up?"</i>

{{ . }}

I asked: &lt;i&gt;&#34;What&#39;s up?&#34;&lt;/i&gt;

<a href="/{{ . }}">

I%20asked:%20%3ci%3e%22What%27s%20up?%22%3c/i%3e

<a href="/?q={{ . }}">

I%20asked%3a%20%3ci%3e%22What%27s%20up%3f%22%3c%2fi%3e

<a onclick="{{ . }}">

I asked: \x3ci\x3e\x22What\x27s up?\x22\x3c\/i\x3e

上下文感知特性主要用于实现自动的防御编程,并且它使用起来非常方便。通过根据上下文对内容进行修改,Go模板可以防止某些明显并且低级的编程错误。比如,接下来的内容就会向我们展示如何使用上下文感知特性来防御XSS(cross-site scripting,跨站脚本)攻击。

持久性XSS漏洞(persistent XSS vulnerability)是一种常见的XSS攻击方式,这种攻击是由于服务器将攻击者存储的数据原原本本地显示给其他用户所致的。举个例子,如果有一个存在持久性XSS漏洞的论坛,它允许用户在论坛上面发布帖子或者回复,并且其他用户也可以阅读这些帖子以及回复,那么攻击者就可能会在他发布的内容中引入带有<script> 标签的代码。因为论坛即使在内容带有<script> 标签的情况下,仍然会原原本本地向用户显示这些内容,所以用户将在毫不知情的情况下,使用自己的权限去执行攻击者发布的恶意代码。预防这一攻击的常见方法就是在显示或者存储用户传入的数据之前,对数据进行转义。但正如很多漏洞以及bug一样,持久性XSS漏洞往往会由于人为的因素而出现。

为了说明如何防御持久性XSS漏洞,我们需要用到一些HTML表单数据。这一次,比起直接将数据硬编码到处理器里面,更好的选择是使用第4章学到的HTML表单知识,创建一个代码清单5-19所示的HTML表单。这个表单允许我们向Web应用发送数据,并将其存储在form.html 文件中。

代码清单5-19 用于实施XSS攻击的表单

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  <form action="/process" method="post">
   Comment: <input name="comment" type="text">
   <hr/>
   <button id="submit">Submit</button>
  </form>
 </body>
</html>

接着,为了处理来自HTML表单的数据,我们需要对处理器做相应的修改,如代码清单5-20所示。

代码清单5-20 测试XSS攻击

package main

import (
  "net/http"
  "html/template"
)

func process(w http.ResponseWriter, r *http.Request) {
  t, _ := template.ParseFiles("tmpl.html")
  t.Execute(w, r.FormValue("comment"))
}

func form(w http.ResponseWriter, r *http.Request) {
  t, _ := template.ParseFiles("form.html")
  t.Execute(w, nil)
}

func main() {
  server := http.Server{
    Addr: "127.0.0.1:8080",
  }
  http.HandleFunc("/process", process)
  http.HandleFunc("/form", form)
  server.ListenAndServe()
}

最后,为了让XSS攻击的测试结果可以更好地显示出来,我们需要修改tmpl.html 模板文件,如代码清单5-21所示。

代码清单5-21 修改后的tmpl.html 模板

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  <div>{{ . }}</div>
 </body>
</html>

现在,编译并启动修改后的服务器,然后访问http://127.0.0.1:8080/form。接着像图5-9所示的那样,将以下内容输入到表单的文本框里面,然后按下Submit按钮:

<script>alert('Pwnd!');</script>

对于那些不过滤用户输入并且在Web页面上直接显示用户输入的模板引擎来说,执行图5-9所示的操作将会显示一条提示信息,这也意味着攻击者可以让网站上的其他用户执行任意可能的攻击代码。与此相反,正如我们之前提到的那样,即使程序员忘了对用户的输入进行过滤,Go的模板引擎也会在显示用户输入时将其转换为转义之后的HTML,以此来避免可能会出现的问题,图5-10证实了这一点。

05-09

图5-9 用于实施XSS攻击的表单

05-10

图5-10 多谢Go的模板引擎,原本会导致漏洞的用户输入已经被转义了

查看这个页面的源代码将会看到以下结果:

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>GoWebProgramming</title>
 </head>
 <body>
  <div>&lt;script&gt;alert(&#39;Pwnd!&#39;);&lt;/script&gt;</div>
 </body>
</html>

上下文感知功能不仅能够自动对HTML进行转义,它还能够防止基于JavaScript、CSS甚至URL的XSS攻击。那么这是否意味着我们只要使用Go的模板引擎就可以无忧无虑地进行开发了呢?并非如此,上下文感知虽然很方便,但它并非灵丹妙药,而且有不少方法可以绕开上下文感知。实际上,如果需要,用户是可以完全不使用上下文感知特性的。

如果真的想要允许用户输入HTML代码或者JavaScript代码,并在显示内容时执行这些代码,可以使用Go提供的“不转义HTML”机制:只要把不想被转义的内容传给template.HTML 函数,模板引擎就不会对其进行转义。作为例子,让我们对之前展示过的处理器做一些小修改:

func process(w http.ResponseWriter, r *http.Request) {
  t, _ := template.ParseFiles("tmpl.html")
  t.Execute(w, template.HTML(r.FormValue("comment")))
}

注意,在这个修改后的处理器函数中,程序通过类型转换(typecast)将表单中的评论值转换成了template.HTML 类型。

现在,重新编译并运行这个服务器,然后再次尝试实施XSS攻击。攻击产生的结果将根据用户使用的浏览器而定,如果用户使用的是Chrome、Safari、IE8或以上版本的IE浏览器,那么什么都不会发生——用户将看到一个空白的页面;但如果用户使用的是Firefox,那么用户将会看到图5-11所示的画面。

因为IE、Chrome和Safari在默认情况下都能够防御某些特定类型的XSS攻击,所以我们的XSS攻击在这3个浏览器上都没有能够成功实施;与此相反,因为Firefox并不具备内置的XSS防御功能,所以我们在Firefox浏览器上成功实施了XSS攻击。

在需要时,用户也可以通过发送一个最初由微软公司为IE浏览器创建的特殊HTTP响应首部X-XSS-Protection来让浏览器关闭内置的XSS防御功能,就像这样:

func process(w http.ResponseWriter, r *http.Request) {
  w.Header().Set("X-XSS-Protection", "0")
  t, _ := template.ParseFiles("tmpl.html")
  t.Execute(w, template.HTML(r.FormValue("comment")))
}

05-11

图5-11 XSS攻击成功

现在,如果再次尝试实施XSS攻击,那么你将会在IE、Chrome和Safari上看到与Firefox相同的攻击效果。

本章到目前为止已经介绍了Go模板引擎的不少特性,在继续了解更多特性之前,我们需要先学习一下如何在Web应用中使用布局。

所谓的布局 (layout),指的是Web设计中可以重复应用在多个页面上的固定模式。为了构建协调一致的用户界面,Web应用常常需要展示一些相似的页面,因此Web应用也会经常用到布局。比如说,很多Web应用都拥有相应的头部菜单,以及提供服务器状态、版权声明、联系方式等附加信息的尾部栏,而其他一些Web应用可能会在屏幕的左侧提供导航栏又或者多级导航菜单。不难猜出,这些布局实际上都可以使用嵌套模板实现。

前面的小节曾经介绍过如何使用包含动作实现嵌套模板,但使用这种方法来开发复杂的Web应用,不仅需要将大量代码硬编码到处理器里面,还需要创建大量的模板文件,而引发这一问题的原因跟我们使用模板的方式有关。

正如之前所说,我们可以通过包含动作,在一个模板里面包含另一个模板:

{{ template "name" . }}

其中动作的参数name 就是被包含模板的名字,并且这个名字还是一个字符串常量。这意味着如果我们继续像之前一样,使用文件名作为模板名,那么因为每个页面都拥有它们各自的布局模板文件,所以程序最终将无法拥有任何可共用的公共布局,而这种做法跟构建布局的想法正好是相背的。比如说,对于代码清单5-22所示的模板文件,我们就不能把它用作公共的布局模板文件。

代码清单5-22 无效的模板布局文件

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  {{ template "content.html" }}
 </body>
</html>

出现这种问题的根源在于我们实际上并没有以正确的方式使用Go模板引擎。尽管我们可以让每个模板文件都只定义一个模板,并将模板文件的名字用作模板的名字,但实际上,我们也可以通过定义动作(define action),在模板文件里面显式地定义模板,就像代码清单5-23所示的那样。

代码清单5-23 显式地定义一个模板

{{ define "layout" }}

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  {{ template "content" }}
 </body>
</html>

{{ end }}

这个文件以一个{{ define "layout" }} 标签作开头,并以一个{{ end }} 标签结尾,而介于这两个标签之间的内容就是layout 模板的定义。与此同时,通过使用另一个定义动作,我们还可以在这个文件里面再多创建一个模板。换句话说,我们可以像代码清单5-24所示的那样,在同一个模板文件里面定义多个不同的模板。

代码清单5-24 在一个模板文件里面定义多个模板

{{ define "layout" }}

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>
  {{ template "content" }}
 </body>
</html>

{{ end }}

{{ define "content" }}

Hello World!

{{ end }}

代码清单5-25展示了处理器使用这些模板的方法。

代码清单5-25 使用显式定义的模板

func process(w http.ResponseWriter, r *http.Request) {
  t, _ := template.ParseFiles("layout.html")
  t.ExecuteTemplate(w, "layout", "")
}

分析模板的方法跟之前介绍过的一样,但是这次在执行模板的时候,程序需要显式地使用ExecuteTemplate 方法,并把待执行的layout 模板的名字用作方法的第二个参数。因为layout 模板嵌套了content 模板,所以程序只需要执行layout 模板就可以在浏览器中得到content 模板产生的Hello World! 输出了。通过使用cURL获取模板输出的实际HTML文件,我们将看到以下结果:

> curl -i http://127.0.0.1:8080/process
HTTP/1.1 200 OK
Date: Sun, 08 Feb 2015 14:09:15 GMT
Content-Length: 187
Content-Type: text/html; charset=utf-8

<html>
 <head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  <title>Go Web Programming</title>
 </head>
 <body>

Hello World!

 </body>
</html>

用户除可以在同一个模板文件里面定义多个不同的模板之外,还可以在不同的模板文件里面定义同名的模板。作为例子,让我们首先移除layout.html 文件中现有的content 模板定义,然后分别在代码清单5-26和代码清单5-27所示的red_hello.html 文件和blue_hello.html 文件中重新定义content 模板。

代码清单5-26 red_hello.html

{{ define "content" }}

<h1 style="color: red;">Hello World!</h1>

{{ end }}

代码清单5-27 blue_hello.html

{{ define "content" }}

<h1 style="color: blue;">Hello World!</h1>

{{ end }}

代码清单5-28展示了修改之后的处理器,它向我们演示了应该如何使用在不同模板文件中定义的两个content 模板。

代码清单5-28 处理器使用在不同模板文件中定义的同名模板

func process(w http.ResponseWriter, r *http.Request) {
  rand.Seed(time.Now().Unix())
  var t *template.Template
  if rand.Intn(10) > 5 {
    t, _ = template.ParseFiles("layout.html", "red_hello.html")
  } else {
    t, _ = template.ParseFiles("layout.html", "blue_hello.html")
  }
  t.ExecuteTemplate(w, "layout", "")
}

这个处理器会根据生成的随机数,决定对red_hello.htmlblue_hello.html 这两个模板文件中的哪一个进行语法分析。当处理器像之前一样执行包含了content 模板的layout 模板时,被随机选中的那个模板文件中定义的content 模板就会被执行。因为red_hello.htmlblue_hello.html 这两个模板文件都定义了content 模板,所以它们中的哪一个被随机选中了进行语法分析,被分析文件中定义的content 模板就会被执行。换句话说,我们可以在维持“layout 模板包含content 模板”这一关系不变的情况下,通过对不同的模板文件进行语法分析来达到改变输出结果的目的。

现在,如果我们重新编译并启动修改后的服务器,然后通过浏览器对其进行访问,那么我们将会随机看到蓝色或者红色的Hello World! 输出,就像图5-12所示的那样。

05-12

图5-12 能够随机切换内容的模板

Go 1.6引入了一个新的块动作(block action),这个动作允许用户定义一个模板并且立即使用。块动作看上去是下面这个样子的:

{{ block arg }}
 Dot is set to arg
{{ end }}

为了更好地了解块动作的使用方法,我们将使用块动作重新实现上一节展示过的例子,并在处理器没有指定特定的模板时,默认展示蓝色的Hello World模板。代码清单5-29展示了修改之后的处理器,正如加粗的代码行所示,处理器的else 块将不再同时分析layout.html 文件和blue_hello.html 文件,而是只分析layout.html 文件。

代码清单5-29 只对layout.html 进行语法分析

func process(w http.ResponseWriter, r *http.Request) {
  rand.Seed(time.Now().Unix())
  var t *template.Template
  if rand.Intn(10) > 5 {
    t, _ = template.ParseFiles("layout.html", "red_hello.html")
  } else {
    t, _ = template.ParseFiles("layout.html")


  }
  t.ExecuteTemplate(w, "layout", "")
}

如果我们现在就重新编译并启动服务器,那么服务器就会因为在else 块中找不到需要进行语法分析的content 模板而出现随机崩溃的情况。为了解决这个问题,我们需要像代码清单5-30所示的那样,在layout.html 模板文件中通过块动作定义content 模板,并将其用作默认的content 模板。

代码清单5-30 通过块动作添加默认的content 模板

{{ define "layout" }}

< html>
 < head>
  < meta http-equiv="Content-Type" content="text/html; charset=utf-8">
  < title>Go Web Programming< /title>
 < /head>
 < body>
  {{ block "content" . }}


   < h1 style="color: blue;">Hello World!< /h1>


  {{ end }}


 < /body>
< /html>

{{ end }}

块动作能够高效地定义一个content 模板,并将它放置到layout 模板里面。当layout 模板被执行时,如果模板引擎没有找到可用的content 模板,那么它就会使用块动作中定义的content 模板。

在最近的这几章,我们学习了如何接收请求,如何处理请求,以及如何生成用于响应请求的内容,而在接下来的一章,我们将要学习如何通过Go语言将数据存储到内存、文件或者数据库里面。