Go语言编程笔记15:模版引擎

除去一些作为API使用或者其他特殊用途的Web应用,大多数Web应用都是以网站的形式对外提供服务,所以自然的,返回的HTTP响应内容也都是以HTML页面为主。在[Go语言编程笔记12:web基础](Go语言编程笔记12:web基础 - 魔芋红茶’s blog (icexmoon.xyz))中我提到过,在Web技术发展的过程中,因为对交互的需要,Web诞生了一种SSI技术,即在服务端通过编程语言来“动态”生成HTML页面并返回给客户端。这种技术进一步发展,最后的结果就是我们现在经常在Web开发中会提到的模版引擎

模版引擎

所谓的模版引擎,其功能相当明确和单一:就是负责将服务端根据请求生成的响应数据,“渲染”到指定的HTML模版文件上,最后生成将要返回给客户端的最终HTML页面。

这个过程可以用下图表示:

虽然模版引擎的功能简单且易于理解,但模版引擎真正要掌握的“模版语法”并不简单,且各种不同的模版引擎之间在语法上可能千差万别。

从功能上,模版引擎可以分为两类:

  • 无逻辑模版引擎
  • 嵌入逻辑的模版引擎

前者的“模版语法”相对简单,仅仅实现简单的内容替换。后者的“模版语法”就复杂一些,看起来更像是小型的编程语言,但功能也更强大,可以使用编程语言的“流程控制”,比如条件语句或循环语句。就实用性而言,后者显然应用的更广泛,所以一般Web框架会集成一个嵌入逻辑的模版引擎。

text/templatehtml/template

html/template

template
type Template struct {
    escapeErr error
    text *template.Template
    Tree       *parse.Tree
    *nameSpace // common to all associated templates
}
New
t := template.New("index.html")
index.html

后边会说明如何通过“定义动作”给模版起名。

在“真正”使用模版之前,需要先解析模版。具体有两种方式,一种是指定一个或多个模版文件来解析:

t.ParseFiles("index.html")

另一种是指定一个包含模版文件内容的字符串:

content := `
    <!DOCTYPE html>
    <html>
        <head></head>
        <body>
            <h1>{{ . }}</h1>
            <h1>index page.</h1>
        </body>
    </html>
    `
t.Parse(content)

最后“执行模版”就可以调用模版引擎将给定的数据“渲染”到模版中,生成HTML页面并写入响应报文:

msg := "hello world!"
t.Execute(rw, msg)

现在看一个完整的例子:

package main

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

func index(rw http.ResponseWriter, r *http.Request) {
    t := template.New("index.html")
    t.ParseFiles("index.html")
    msg := "hello world!"
    t.Execute(rw, msg)
}

func main() {
    http.HandleFunc("/index", index)
    http.ListenAndServe(":8080", http.DefaultServeMux)
}
index.html
<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <h1>{{ . }}</h1>
        <h1>index page.</h1>
    </body>
</html>
text/templatehtml/templateimport

除了上边这种“按部就班”的方式,还可以用一种更简单的方式使用模版:

...
func index(rw http.ResponseWriter, r *http.Request) {
    t, err := template.ParseFiles("index.html")
    if err != nil {
        log.Fatal(err)
    }
    msg := "hello world!"
    t.Execute(rw, msg)
}
...
template.ParseFiles
template
...
func index(rw http.ResponseWriter, r *http.Request) {
    t := template.Must(template.ParseFiles("index.html"))
    msg := "hello world!"
    t.Execute(rw, msg)
}
...
template.Must
func Must(t *Template, err error) *Template {
    if err != nil {
        panic(err)
    }
    return t
}
MustParseFilesMustParseFiles
MustMustXXX

动作

{{ . }}

模版引擎正是通过解析模版中定义的动作,再结合给定的数据来“渲染”模版。

{{ . }}
    t.Execute(rw, msg)
msg

如果模版绑定的数据是一个复合结构,比如结构体,还可以访问其属性:

...
func index(rw http.ResponseWriter, r *http.Request) {
    t := template.Must(template.ParseFiles("index.html"))
    p := Person{
        Name: "icexmoon",
        Age:  19,
    }
    t.Execute(rw, p)
}
...
index.html
<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <h1>name:{{ .Name }}</h1>
        <h1>age:{{ .Age }}</h1>
    </body>
</html>
template
  • 条件动作
  • 循环动作
  • 替换动作
  • 包含动作

循环动作

循环动作的语法是:

{ range param }
html sentence
{ end }
param

我们来看一个用页面展示学生成绩的示例:

...
func index(rw http.ResponseWriter, r *http.Request) {
    t := template.Must(template.ParseFiles("index.html"))
    scores := make(map[string][]float64)
    scores["Li lei"] = []float64{55, 60, 80}
    scores["Han Meimei"] = []float64{90, 60, 33}
    scores["Jack Chen"] = []float64{80, 100, 95}
    t.Execute(rw, scores)
}
...

html模版使用表格展示成绩:

<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <table>
            <tr>
                <th>姓名</th>
                <th>语文</th>
                <th>数学</th>
                <th>英语</th>
            </tr>
            {{ range $key,$val := . }}
            <tr>
                <td>{{ $key }}</td>
                {{ range $val }}
                <td>{{ . }}</td>
                {{ end }}
            </tr>
            {{ end }}
        </table>
    </body>
</html>

效果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kYAnI77f-1640598382178)(https://image2.icexmoon.xyz/image/image-20211226194053782.png)]

scores
{{ range $key,$val := . }}$key$valscores

循环动作还有一种“变种”形式:

{{ range param }}
html sentence.
{{ else }}
html sentence.
{{ end }}
paramelse

我们来看改进后的成绩显示模版:

            ...
            {{ range $key,$val := . }}
            <tr>
                <td>{{ $key }}</td>
                {{ range $val }}
                <td>{{ . }}</td>
                {{ end }}
            </tr>
            {{ else }}
            <tr><td colspan="4">没有可显示的成绩</td></tr>
            {{ end }}
            ...

现在如果服务端给该模版绑定一个空数据:

...
func index(rw http.ResponseWriter, r *http.Request) {
    t := template.Must(template.ParseFiles("index.html"))
    t.Execute(rw, nil)
}
...

页面就会显示:

判断动作

判断动作也比较常用,我们可以利用判断动作来实现根据内容不同,显示不同的HTML内容。

判断动作的基本语法是:

{{ if param }}
html sentence.
{{ else }}
html sentence.
{{ end }}

这里修改一下上边的成绩单,假如我们需要用不同的颜色标记成绩:

...
func index(rw http.ResponseWriter, r *http.Request) {
    t := template.New("index.html")
    fm := template.FuncMap{"score_pass": func(score float64) bool { return score > 60 }}
    t.Funcs(fm)
    t.ParseFiles("index.html")
    scores := make(map[string][]float64)
    scores["Li lei"] = []float64{55, 60, 80}
    scores["Han Meimei"] = []float64{90, 60, 33}
    scores["Jack Chen"] = []float64{80, 100, 95}
    t.Execute(rw, scores)
}
...
index.html
<!DOCTYPE html>
<html>
    <head></head>
    <body>
        <table>
            <tr>
                <th>姓名</th>
                <th>语文</th>
                <th>数学</th>
                <th>英语</th>
            </tr>
            {{ range $key,$val := . }}
            <tr>
                <td>{{ $key }}</td>
                {{ range $val }}
                    {{if score_pass . }}
                    <td style="background-color: greenyellow;">{{ . }}</td>
                    {{ else }}
                    <td style="background-color: red;">{{ . }}</td>
                    {{ end }}
                {{ end }}
            </tr>
            {{ end }}
        </table>
    </body>
</html>
{{ if score_pass }}

这里利用模版函数来对成绩进行筛选,对及格的成绩用绿色背景色,不及格的成绩用红色背景色,最后的效果是:

替换动作

使用替换动作可以将当前“块”中的绑定数据“临时替换”。

原书中称为“设置动作”,但我觉得替换动作这个名称更贴切。

替换动作的基本语法是:

{{ with param }}
html sentence.
{{ end }}

我们可以利用替换动作给成绩表添加一个“表头”:

   <table>
            <tr>
                {{ with "成绩汇总表" }}
                <th colspan="4">{{ . }}</th>
                {{ end }}
            </tr>
            <tr>
                <th>姓名</th>
                <th>语文</th>
                <th>数学</th>
                <th>英语</th>
            </tr>
            {{ range $key,$val := . }}
            ...
            {{ end }}
        </table>
{{ . }}scores{{ with "成绩汇总表" }}{{ . }}withwith

替换动作也存在一种变种形式:

{{ with param }}
html sentence.
{{ else }}
html sentence.
{{ end }}
paramelse

这里依然使用成绩表标题作为示例:

        ...
        <tr>
            {{ with "" }}
            <th colspan="4">{{ . }}</th>
            {{ else }}
            <th colspan="4">成绩汇总表</th>
            {{ end }}
        </tr>
        ...

因为示例中的替换动作参数是空字符串,所以最终依然会正常显示表头。

当然这里的这个示例并不是很合适,显得完全没有必要,只是用于说明替换动作的用法。

包含动作

使用包含动作可以让一个模板包含另一个模板。

包含动作的基本语义:

{{ temlate t_name }}
t_name

下面我们使用包含动作给示例页面添加上头部和尾部,这也是绝大多数网站很常见的做法。因为头部和尾部几乎所有网站的页面都会使用相同素材,所以使用模版可以提高HTML代码复用。

<!DOCTYPE html>
<html>

<head>
    {{ template "header.html" }}
</head>

<body>
...
</body>
<footer>
    {{ template "footer.html" }}
</footer>

</html>
header.html
<h1>Welcome to my homepage</h1>
<p>My name is icexmoon</p>
footer.html
<h2>All copyright by icexmoon.xyz</h2>

此外还需要修改服务端代码,加载相应的模版文件:

...
func index(rw http.ResponseWriter, r *http.Request) {
	...
	t.ParseFiles("index.html", "header.html", "footer.html")
	...
}
...

最终呈现的效果:

ParseFiles
t.Execute
...
func index(rw http.ResponseWriter, r *http.Request) {
	t.ParseFiles("index.html", "header.html", "footer.html")
	...
	t.ExecuteTemplate(rw, "index.html", scores)
}
...
ExecuteTemplate

管道

html/template

管道可以和任意的动作结合使用,比如和条件动作:

{{ if param1|param2|param3 }}
html sentence.
{{ end }}
param1|param2|param3param1param2param2param3if

这里以之前添加的判断成绩是否及格的判断动作作为示例:

            ...
			{{if score_pass . }}
            <td style="background-color: greenyellow;">{{ . }}</td>
            {{ else }}
            <td style="background-color: red;">{{ . }}</td>
            {{ end }}
			...
score_pass .score_pass.
            ...
            {{ if . | score_pass }}
            <td style="background-color: greenyellow;">{{ . }}</td>
            {{ else }}
            <td style="background-color: red;">{{ . }}</td>
            {{ end }}
            ...
.|score_pass.score_pass
. | func1 | func2 | func3

函数

模版中可以使用自定义函数,这点在之前示例中已经有过展示,现在详细说明如何做到这一点。

将函数绑定到模版的关键代码是:

	...
	t := template.New("index.html")
	fm := template.FuncMap{"score_pass": func(score float64) bool { return score > 60 }}
	t.Funcs(fm)
	...
template.FuncMap
type FuncMap map[string]interface{}

其键就是模版中调用函数时的函数名,值是函数变量(这里直接使用了匿名函数)。

FuncMaptemplate.Funcs

需要注意的是,绑定函数的工作需要在解析模版之前完成,否则模版中是无法识别到对应名称的函数的。

这也不难理解,解析模版本来就是为了识别模版中的语法来构建对绑定变量和函数的映射关系。

在模版中调用函数就简单了:

{{ if score_pass . }}

当然也可以使用之前介绍的管道方式调用。

模版中除了可以使用自定义函数以外,还可以使用一些定义好的内建函数,比如:

            ...
            {{ if . | score_pass }}
            <td style="background-color: greenyellow;">{{ printf "%.2f" . }}</td>
            {{ else }}
            <td style="background-color: red;">{{ printf "%.2f" . }}</td>
            {{ end }}
            ...
printffmt.Sprintf
fmttemplate

最后要说明的是,模版使用函数是有限制的,即绑定到模版的函数的返回值只能是一个或者包含错误信息的两个。之所以会有这种限制,大概是考虑到超过两个返回值会给管道带来一些麻烦。

上下文感知

模版中的语法可以“感知”所处的上下文环境,并因环境的不同做出相应的改变,这种特点被称作上下文感知

上下文感知的最大用途是对嵌入模版的文本进行“转义”。

来看一个简单的例子:

...
<body>
    <h1>{{ . }}</h1>
    <a href="{{ . }}"></a>
    <button onclick="alert('{{ . }}')">click</button>
</body>
...
{{ . }}h1

我们通过后台代码给该模版绑定一个字符串:

...
func index(rw http.ResponseWriter, r *http.Request) {
	t := template.New("index.html")
	t.ParseFiles("index.html", "header.html", "footer.html")
	t.ExecuteTemplate(rw, "index.html", "<Hello world!>")
}
...

此时用浏览器访问:

curl
❯ curl -i 127.0.0.1:8080/index
HTTP/1.1 200 OK
Date: Mon, 27 Dec 2021 07:17:58 GMT
Content-Length: 201
Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html>

<body>
    <h1>&lt;Hello world!&gt;</h1>
    <a href="%3cHello%20world!%3e"></a>
    <button onclick="alert('\u003cHello world!\u003e')">click</button>
</body>

</html>
h1

最妙的是我们使用模版嵌入文本时并没有告诉模版应当进行何种转义,但模版“自动”以正确的方式完成了转义,并且页面可以正常运行,这就是上下文感知的功劳。

可能你会困惑,这么做的意义何在。答案是可以防范某些形式的恶意代码攻击,比如XSS。

防御XSS攻击

XSS(cross site scripting)全称跨站脚本攻击。实际上是通过一些漏洞(比如网站的数据提交功能),将恶意js代码提交到网站,从而让网站在加载页面时加载相应的代码,以达成某些攻击者的目的。

下面我们看一个简单的XSS攻击示例。

textarea

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GXyjEK3n-1640598382205)(https://image2.icexmoon.xyz/image/image-20211227155215409.png)]

然后会跳转到另一个页面显示刚才填充的内容:

前后端的代码如下:

submit.html
<!DOCTYPE html>
<html>

<body>
    <form action="/submit" method="post">
        <textarea name="comment" rows="10" cols="30"></textarea><br/>
        <input type="submit" value="submit"/>
    </form>
</body>

</html>
show.html
<html>
    <body>
        comment:{{ . }}<br/>
        <button onclick="location.href='/index'">back</button>
    </body>
</html>
main.go
package main

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

func index(rw http.ResponseWriter, r *http.Request) {
	t := template.New("submit.html")
	t.ParseFiles("submit.html")
	t.Execute(rw, nil)
}

func submit(rw http.ResponseWriter, r *http.Request) {
	comment := r.PostFormValue("comment")
	t := template.Must(template.ParseFiles("show.html"))
	t.Execute(rw, comment)
}

func main() {
	http.HandleFunc("/index", index)
	http.HandleFunc("/submit", submit)
	http.ListenAndServe(":8080", http.DefaultServeMux)
}

正常情况下这个应用可以正常运行,并不会有什么问题,但如果有心人输入的内容并非普通文本,而是js代码:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fcQarmdr-1640598382210)(https://image2.icexmoon.xyz/image/image-20211227155623405.png)]

就可能会在加载内容的页面加载相应的代码,从而产生一些安全问题,这就是XSS攻击。

template/submit
<html>
    <body>
        comment:&lt;script&gt;
alert(&#39;hacked!&#39;);
&lt;/script&gt;<br/>
        <button onclick="location.href='/index'">back</button>
    </body>
</html>

不使用转义

在某些情况下可能开发者并不希望模版在替换文本时自动转义,此时可以:

...
func submit(rw http.ResponseWriter, r *http.Request) {
	comment := r.PostFormValue("comment")
	t := template.Must(template.ParseFiles("show.html"))
	t.Execute(rw, template.HTML(comment))
}
...
template.HTMLtemplate
type HTML string
template.HTML(comment)HTML

此时再尝试提交js代码:

<script>
alert('hacked!');
</script>
/show
X-XSS-Protection
...
func submit(rw http.ResponseWriter, r *http.Request) {
	comment := r.PostFormValue("comment")
	t := template.Must(template.ParseFiles("show.html"))
	rw.Header().Set("X-XSS-Protection", "1")
	t.Execute(rw, template.HTML(comment))
}
...
meta
    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src https://*; child-src 'none';">
Content-Security-Policy

嵌套模版

在前边介绍包含动作的时候鉴定单介绍过如何用一个模版加载另一个模版。其实更常见的使用模版的方式是给模版进行命名,具体需要使用定义动作:

{{ define t_name }}
template content.
{{ end }}

有了定义动作,我们可以在一个模版文件中定义多个模版:

{{ define "html" }}
<!DOCTYPE html>
<html>
<header>{{ template "header" }}</header>

<body>
    <h1>This is index page.</h1>
</body>
<footer>{{ template "footer" }}</footer>

</html>
{{ end }}
{{ define "header" }}
<h1>Welcome to my homepage</h1>
<p>My name is icexmoon</p>
{{ end }}

{{ define "footer" }}
<h2>All copyright by icexmoon.xyz</h2>
{{ end }}

加载模版:

...
func index(rw http.ResponseWriter, r *http.Request) {
	t := template.Must(template.ParseFiles("index.html"))
	t.ExecuteTemplate(rw, "html", nil)
}
...

但显然这样做并不利于代码维护,所以正确的做法是将代码拆分:

index.html
{{ define "html" }}
<!DOCTYPE html>
<html>
{{ template "header" }}

<body>
    <h1>This is index page.</h1>
</body>
{{ template "footer" }}

</html>
{{ end }}
header.html
<header>
    {{ define "header" }}
    <h1>Welcome to my homepage</h1>
    <p>My name is icexmoon</p>
    {{ end }}
</header>
footer.html
<footer>
    {{ define "footer" }}
    <h2>All copyright by icexmoon.xyz</h2>
    {{ end }}
</footer>

加载模版:

...
func index(rw http.ResponseWriter, r *http.Request) {
	t := template.Must(template.ParseFiles("index.html", "footer.html", "header.html"))
	t.ExecuteTemplate(rw, "html", nil)
}
...

我们甚至可以给“子模版”传值:

header.html
<header>
    {{ define "header" }}
    <h1>Welcome to my homepage</h1>
    <p>My name is icexmoon, Today is {{ . }}</p>
    {{ end }}
</header>
index.html
{{ define "html" }}
<!DOCTYPE html>
<html>
{{ template "header" . }}

<body>
    <h1>This is index page.</h1>
</body>
{{ template "footer" }}

</html>
{{ end }}
main.go
...
func index(rw http.ResponseWriter, r *http.Request) {
	today := time.Now().Format("2006-01-02")
	t := template.Must(template.ParseFiles("index.html", "footer.html", "header.html"))
	t.ExecuteTemplate(rw, "html", today)
}
...

使用模版将可以重复使用的HTML部分分离的另外一个好处是,可以在服务端根据需要加载不同的模版。

header
package main

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

func addHeaderAndFooterFiles(tFiles []string) []string {
	//工作日使用普通头尾,双休使用节日专用头尾
	switch time.Now().Weekday() {
	case time.Saturday:
	case time.Sunday:
		tFiles = append(tFiles, "header_week.html", "footer_week.html")
	default:
		tFiles = append(tFiles, "header.html", "footer.html")
	}
	return tFiles
}

func index(rw http.ResponseWriter, r *http.Request) {
	today := time.Now().Format("2006-01-02")
	tFiles := []string{"index.html"}
	tFiles = addHeaderAndFooterFiles(tFiles)
	t := template.Must(template.ParseFiles(tFiles...))
	t.ExecuteTemplate(rw, "html", today)
}

func main() {
	http.HandleFunc("/index", index)
	http.ListenAndServe(":8080", nil)
}

现在我们就可以为周末和工作日设计两套不同的头部和尾部模版了,具体的前端代码这里不再展示,可以查看我的Github仓库。

使用块动作定义默认模版

Go1.6加入了一个新的“块动作”:

{{ block arg }}
html sentence.
{{ end }}

使用它可以定义一个默认的模版,比如:

{{ define "html" }}
<!DOCTYPE html>
<html>

<head>
    {{ block "header" . }}
    <h1>This is a default header.</h1>
    {{ end }}
</head>

<body>
    <h1>{{ . }}</h1>
    <h1>index page.</h1>
</body>

</html>
{{ end }}
header.html
package main

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

func index(rw http.ResponseWriter, r *http.Request) {
	t := template.Must(template.ParseFiles("index.html", "header.html"))
	msg := "hello world!"
	t.ExecuteTemplate(rw, "html", msg)
}

func index2(rw http.ResponseWriter, r *http.Request) {
	t := template.Must(template.ParseFiles("index.html"))
	msg := "hello world!"
	t.ExecuteTemplate(rw, "html", msg)
}

func main() {
	http.HandleFunc("/index", index)
	http.HandleFunc("/index2", index2)
	http.ListenAndServe(":8080", http.DefaultServeMux)
}

运行结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8kvuJQLy-1640598382214)(https://image2.icexmoon.xyz/image/image-20211227174201687.png)]

可以看到,如果模版集中找不到对应的模版,块动作就会执行,否则就不执行。

本来以为这篇文章会很简单,依然写了2天…

谢谢阅读。

参考资料: