如何在Go中使用模板
引言
在展示数据时,您需要以规范的格式输出、文本报告还是HTML页面吗?您可以使用Go模板来实现。任何Go程序都可以使用text/template或html/template包(都包含在Go标准库中)来整洁地展示数据。
两个软件包都允许您编写文本模板,并将数据传入模板中以渲染出符合您需求的格式化文档。在模板中,您可以对数据进行循环,并使用条件逻辑来决定应将哪些项包含在文档中以及它们应该如何显示。本教程将向您展示如何使用这两个模板包。首先,您将使用 text/template 将一些数据渲染成纯文本报告,采用循环、条件逻辑和自定义函数的方式。接着,您将使用 html/template 将相同的数据渲染成一个没有代码注入风险的 HTML 文档。
先决条件
在开始本教程之前,你只需要安装Go。仔细阅读适合你操作系统的正确教程。
- How To Install Go On Ubuntu 20.04
- How To Install Go and Set Up a Local Programming Environment on macOS
- How To Install Go and Set Up a Local Programming Environment on Windows 10
你还需要对Go语言有一定的工作知识,包括创建结构体和使用结构体方法。
我们开始吧。
第一步 – 导入文本/模板
假设你想要生成一份关于你拥有的狗的简单报告。你希望以这种方式展示它:
---
Name: Jujube
Sex: Female (spayed)
Age: 10 months
Breed: German Shepherd/Pitbull
---
Name: Zephyr
Sex: Male (intact)
Age: 13 years, 3 months
Breed: German Shepherd/Border Collie
这是使用text/template包生成的报告。 高亮显示的项目是您的数据,其余部分是来自模板的静态文本。 模板可以是代码中的字符串,也可以是与代码并列的文件。 它们包含借用条件语句(例如if/else)、流控制语句(例如循环)、函数调用来插入模板内的样板静态文本,并且都被{{. . .}}标记包裹。 您将向模板传递一些数据以呈现类似上述的最终文档。
要开始,请导航到您的Go工作区(go env GOPATH)并为此项目创建一个新目录。
- cd `go env GOPATH`
- mkdir pets
- cd pets
使用nano或您喜欢的文本编辑器,打开一个名为pets.go的新文件,并将以下内容粘贴进去:
- nano pets.go
package main
import (
"os"
"text/template"
)
func main() {
}
这个文件声明自己是位于主包中,包含了一个main函数,意味着可以使用go run来运行。它导入了text/template标准库包,允许你编写和渲染一个模板,同时还导入了os包,用于在终端打印输出。
第二步 – 创建模板数据
在写模板之前,让我们先创建一些数据传入模板。在导入语句下方和 main() 函数之前,定义一个叫做宠物(Pet)的结构体,包含宠物的姓名(Name)、性别(Sex)、是否绝育(Intact)、年龄(Age)以及品种(Breed)等字段。在 pets.go 文件中进行编辑,并添加该结构体。
. . .
type Pet struct {
Name string
Sex string
Intact bool
Age string
Breed string
}
. . .
现在,在main()函数的主体部分,创建一个宠物切片来存储有关两只狗的数据。
. . .
func main() {
dogs := []Pet{
{
Name: "Jujube",
Sex: "Female",
Intact: false,
Age: "10 months",
Breed: "German Shepherd/Pitbull",
},
{
Name: "Zephyr",
Sex: "Male",
Intact: true,
Age: "13 years, 3 months",
Breed: "German Shepherd/Border Collie",
},
}
} // end main
这些数据将被传递给你的模板以生成最终报告。当然,你传递给模板的数据可以来自任何地方:你的数据库,第三方API等。对于本教程来说,最简单的方法是将一些示例数据直接粘贴到你的代码中。
现在让我们来看一下如何渲染模板——或者用这些包的术语来说,执行模板。
第三步 — 执行模板
在这一步中,您将看到如何使用text/template从模板生成一个完整的文档,但是直到第四步,您才会真正写一个有用的模板。
创建一个名为pets.tmpl的空文本文件,其中包含一些静态文本。
Nothing here yet.
保存模板并退出编辑器。如果您使用的是nano编辑器,按下CTRL+X,然后按Y和回车键以确认更改。
尽管执行此模板只会打印出“暂无内容”,让我们传入您的数据并执行模板,以证明文本/模板确实起作用。在您的main()函数中的dogs片段之后添加以下内容。
. . .
var tmplFile = “pets.tmpl”
tmpl, err := template.New(tmplFile).ParseFiles(tmplFile)
if err != nil {
panic(err)
}
err = tmpl.Execute(os.Stdout, dogs)
if err != nil {
panic(err)
}
} // end main
在这段代码中,你使用Template.New来创建一个新的模板,然后调用ParseFiles方法来解析你的最小化模板文件。在检查错误后,你调用了新模板的Execute方法,将os.Stdout传入以将完成的报告打印到终端,并传入了你的狗数组。对于第一个参数,你可以传入任何实现了io.Writer接口的东西,这意味着你可以将报告写入文件,例如。我们稍后将看到如何完成这一操作。
完整的程序应该是这样的:
package main
import (
"os"
"text/template"
)
type Pet struct {
Name string
Sex string
Intact bool
Age string
Breed string
}
func main() {
dogs := []Pet{
{
Name: "Jujube",
Sex: "Female",
Intact: false,
Age: "10 months",
Breed: "German Shepherd/Pitbull",
},
{
Name: "Zephyr",
Sex: "Male",
Intact: true,
Age: "13 years, 3 months",
Breed: "German Shepherd/Border Collie",
},
}
var tmplFile = “pets.tmpl”
tmpl, err := template.New(tmplFile).ParseFiles(tmplFile)
if err != nil {
panic(err)
}
err = tmpl.Execute(os.Stdout, dogs)
if err != nil {
panic(err)
}
} // end main
保存程序,然后用”go run”运行它。
- go run pets.go
Nothing here yet.
程序还没有打印您的数据,但至少代码执行得很干净。现在让我们写一个模板。
第四步 — 编写模板
一个模板仅仅是UTF-8纯文本,但它并不止于此。是的,它包含一些静态文本,最终输出时将保持不变,但它还包含动作,这些动作是给模板引擎的指令,告诉它如何遍历传入的数据,并决定输出中要包含什么内容。动作被包裹在一对双大括号中—{{ <动作> }}—它们通过光标来访问数据,光标用一个点(.)表示。
传入模板的数据可以是任何东西,但通常传入切片、数组或映射类型——可迭代的东西。让我们让模板依次遍历你的狗切片。
循环遍历切片
在Go代码中,您可以在for循环的开头语句中使用range来迭代一个切片。在模板中,您可以使用range操作实现相同的目的,但是它的语法有所不同:没有for关键字,但是有一个额外的end来关闭循环。
打开 pets.tmpl 文件,并将其内容替换为以下内容:
{{ range . }}
---
(Pet will appear here...)
{{ end }}
这里的range动作接受一个参数:光标(.),它指的是整个dogs切片。循环以底部的{{ end }}闭合。在循环体内,你打印了一些静态文本,但还没有涉及到dogs。
保存pets.tmpl文件并重新运行pets.go。
- go run pets.go
— (Pet will appear here…) — (Pet will appear here…)
由于片刻中有两只狗,因此静态文本打印两次。现在让我们将其替换为一些更有用的静态文本,以及狗的数据。
展示一个领域
在这个模板中,当将 . 传递给 range 时,点表示整个切片,但在 range 循环的每次迭代中,点表示切片中的当前项。这使您只需使用裸点即可访问每个宠物的导出字段,而无需引用切片索引。
显示一个字段就像将它用花括号括起来,并在前面加上一个点一样简单。打开pets.tmpl,并用以下内容替换其中的内容:
{{ range . }}
---
Name: {{ .Name }}
Sex: {{ .Sex }}
Age: {{ .Age }}
Breed: {{ .Breed }}
{{ end }}
现在,pets.go将输出每只狗的四个字段中的五个,包括一些字段的标签。(我们稍后解释第五个字段。)
保存并重新运行程序。
- go run pets.go
— Name: Jujube Sex: Female Age: 10 months Breed: German Shepherd/Pitbull — Name: Zephyr Sex: Male Age: 13 years, 3 months Breed: German Shepherd/Border Collie
看起来不错。现在让我们看看如何使用一些条件逻辑来显示第五个字段。
使用条件语句
我们没有在模板中使用{{ .Intact }}来包含”Intact”这个字段的原因是这样不太方便读者。想象一下,如果你的兽医账单中总结你的狗是Intact: false的话。虽然把这个字段存储为布尔值而不是字符串可能更高效,而且Intact是一个适用于性别中性的好名字,但我们可以通过if-else操作在最终报告中以不同的方式显示它。
再次打开pets.tmpl文件,然后将此处突出显示的部分添加进去。
{{ range . }}
---
Name: {{ .Name }}
Sex: {{ .Sex }} ({{ if .Intact }}intact{{ else }}fixed{{ end }})
Age: {{ .Age }}
Breed: {{ .Breed }}
{{ end }}
模板现在检查Intact字段是否为真,并且如果是真,则打印(完整),如果不是,则打印(修复)。但是我们可以做得更好。让我们进一步编辑模板,以便打印已绝育或已阉割的固定狗的性别特定术语,而不是固定。在最初的else部分中添加一个嵌套的if语句:
{{ range . }}
---
Name: {{ .Name }}
Sex: {{ .Sex }} ({{ if .Intact }}intact{{ else }}{{ if (eq .Sex "Female") }}spayed{{ else }}neutered{{ end }}{{ end }})
Age: {{ .Age }}
Breed: {{ .Breed }}
{{ end }}
保存模板并运行pets.go
- go run pets.go
— Name: Jujube Sex: Female (spayed) Age: 10 months Breed: German Shepherd/Pitbull — Name: Zephyr Sex: Male (intact) Age: 13 years, 3 months Breed: German Shepherd/Border Collie
我们有两只狗,但是对于显示完整的情况有三种可能。让我们在pets.go中加入一只狗,以涵盖所有三种情况。编辑pets.go并将第三只狗追加到切片中。
. . .
func main() {
dogs := []Pet{
{
Name: "Jujube",
Sex: "Female",
Intact: false,
Age: "10 months",
Breed: "German Shepherd/Pitbull",
},
{
Name: "Zephyr",
Sex: "Male",
Intact: true,
Age: "13 years, 3 months",
Breed: "German Shepherd/Border Collie",
},
{
Name: "Bruce Wayne",
Sex: "Male",
Intact: false,
Age: "3 years, 8 months",
Breed: "Chihuahua",
},
}
. . .
现在保存并运行pets.go文件。
- go run pets.go
— Name: Jujube Sex: Female (spayed) Age: 10 months Breed: German Shepherd/Pitbull — Name: Zephyr Sex: Male (intact) Age: 13 years, 3 months Breed: German Shepherd/Border Collie — Name: Bruce Wayne Sex: Male (neutered) Age: 3 years, 8 months Breed: Chihuahua
很好,看起来跟预期的一样。
现在让我们讨论一下模板函数,就像你刚刚使用的eq函数一样。
使用模板函数
除了eq之外,还有其他用于比较字段值并返回布尔值的函数:gt(>)、ne(!=)、le(<=)等。您可以通过两种方式调用这些函数和任何模板函数之一。
-
- 以函数名字开头,之后是一个或多个参数,每个参数之间用空格分开。这就是你在上面使用eq的方式:eq .Sex “Female”。
- 首先写一个参数,之后是一个竖线(|),然后是函数名字,然后是更多的参数。这与Unix命令行上的命令管道类似,就像在命令行上一样,你可以将许多函数调用链接在一起形成一个流水线,将一个调用的输出作为下一个调用的输入,依此类推。
所以虽然你的模板中的eq比较是以eq .Sex “Female”的形式书写的,也可以写成.Sex | eq “Female”。这两个表达式是等价的。
让我们使用len函数在报告的顶部显示狗的数量。打开pets.tmpl文件,将以下内容添加到顶部:
Number of dogs: {{ . | len -}}
{{ range . }}
. . .
你也可以写成{{ len . -}}。
请注意双大括号闭合处的破折号(-)。这样可以防止动作后打印换行符(\n)。您也可以在开头的双大括号前添加破折号({{-),以避免动作前的换行符。
保存模板并运行pets.go。
- go run pets.go
Number of dogs: 3 — Name: Jujube Sex: Female (spayed) Age: 10 months Breed: German Shepherd & Pitbull — Name: Zephyr Sex: Male (intact) Age: 13 years, 3 months Breed: German Shepherd & Border Collie — Name: Bruce Wayne Sex: Male (neutered) Age: 3 years, 8 months Breed: Chihuahua
由于{{. | len -}} 中的连字符,”狗的数量”标签和第一只狗之间没有空行。
你可能已经注意到了,在 text/template 文档中内置函数的列表相当小。好消息是,只要一个 Go 函数返回单个值,或者如果第二个值是错误类型,你就可以让它在模板中可用。
在模板中使用Go函数
假设你想要写一个模板,接收一个狗的切片并只打印最后一个。在模板中,你可以使用内置函数slice来获取切片的子集,它的用法类似于Go语言的mySlice[x:y]。你可以写{{ slice . 2 }}来获取一个有三个元素的切片的最后一个,尽管slice函数返回的是另一个切片而不是一个元素。也就是说,{{ slice . 2 }}等同于slice[2:],而不是slice[2]。(该函数还可以接受多个索引,例如{{ slice . 0 2 }}表示获取切片slice[0:2],但在这里不会使用。)
但是在模板中,如何引用切片的最后一个索引呢?虽然可以使用len函数,但切片的最后一个元素索引是len-1,而不幸的是,在模板中不能进行数学运算。
这就是自定义函数的用武之地。让我们编写一个减少整数的函数,并将该函数提供给我们的模板使用。
但在此之前,让我们创建一个新的模板。打开一个名为lastPet.tmpl的新文件,并粘贴以下内容:
{{- range (len . | dec | slice . ) }}
---
Name: {{ .Name }}
Sex: {{ .Sex }} ({{ if .Intact }}intact{{ else }}{{ if ("Female" | eq .Sex) }}spayed{{ else }}neutered{{ end }}{{ end }})
Age: {{ .Age }}
Breed: {{ .Breed }}
{{ end -}}
在第一行对dec函数的调用是指你将要定义并传递给模板的自定义函数。在pets.go的main()函数中,在dogs切片的下面和tmpl.Execute()的调用之前进行以下更改(已标出)。
. . .
funcMap := template.FuncMap{
"dec": func(i int) int { return i - 1 },
}
var tmplFile = “lastPet.tmpl”
tmpl, err := template.New(tmplFile).Funcs(funcMap).ParseFiles(tmplFile)
if err != nil {
panic(err)
}
. . .
首先,你正在声明一个FuncMap,它是一个函数映射:键是模板中可用的函数名称,值是函数本身。这里你的一个函数,dec,是一个匿名函数,在此提供是因为它非常简短。它接受一个整数,从中减去一,并返回结果。
然后您正在更改模板文件名称。最后,在调用ParseFile之前,您将在调用之前插入对Template.Funcs的调用,并将刚刚定义的funcMap传递给它。在调用ParseFiles之前必须调用Funcs方法。
在运行代码之前,让我们了解一下模板中的范围操作所发生的情况。
{{- range (len . | dec | slice . ) }}
你正在获取你的狗的切片长度,将其传递给你的自定义减法函数减去一,然后将其作为第二个参数传递给前面讨论过的切片函数。所以对于一个三只狗的切片,range动作相当于{{- range (slice . 2) }}。
保存 pets.go 并运行它。
go run pets.go
— Name: Bruce Wayne Sex: Male (neutered) Age: 3 years, 8 months Breed: Chihuahua
看起来不错。如果你想展示最后两只狗而不只是最后一只,那该怎么办?编辑 lastPet.tmpl 文件,并在管道中再次调用 dec。
{{- range (len . | dec | dec | slice . ) }}
. . .
保存文件并再次运行pets.go。
go run pets.go
— Name: Zephyr Sex: Male (intact) Age: 13 years, 3 months Breed: German Shepherd/Border Collie — Name: Bruce Wayne Sex: Male (neutered) Age: 3 years, 8 months Breed: Chihuahua
你可以想象一下如何通过给dec函数传递一个参数并更改其名称来改进它,这样你就可以调用减2而不是dec | dec。
现在假设你想要以不同于斜杠的方式展示像Zephyr这样的混血狗。你不需要编写自己的函数来做到这一点,因为字符串包中已经有一个函数可以实现这个目的,并且你可以从任何包中借用一个函数来在你的模板中使用。编辑pets.go文件,引入strings包并将其函数之一添加到funcMap中。
package main
import (
"os"
"strings"
"text/template"
)
. . .
func main() {
. . .
funcMap := template.FuncMap{
"dec": func(i int) int { return i - 1 },
"replace": strings.ReplaceAll,
}
. . .
} // end main
您正在导入strings包,并将其ReplaceAll函数添加到您的funcMap中,名称为replace。然后,请编辑lastPet.tmpl以使用此函数。
{{- range (len . | dec | dec | slice . ) }}
---
Name: {{ .Name }}
Sex: {{ .Sex }} ({{ if .Intact }}intact{{ else }}{{ if ("Female" | eq .Sex) }}spayed{{ else }}neutered{{ end }}{{ end }})
Age: {{ .Age }}
Breed: {{ replace .Breed “/” “ & ” }}
{{ end -}}
保存文件并再次运行它。
- go run pets.go
— Name: Zephyr Sex: Male (intact) Age: 13 years, 3 months Breed: German Shepherd & Border Collie — Name: Bruce Wayne Sex: Male (neutered) Age: 3 years, 8 months Breed: Chihuahua
盈风的品种现在不再使用斜杠,而是使用一个和符号。
您本可以在 pets.go 中操纵该字符串,而不是在模板中操纵,但数据的展现是模板的工作,而不是代码的工作。
实际上,一些狗的数据中已经包含了一些展示内容,也许不应该这样。品种字段将多个品种压缩成一个字符串,并用斜杠标点分隔它们。这种单字符串模式可能会导致数据输入员在数据库中引入不同的格式:拉布拉多/贵宾、拉布拉多和贵宾、拉布拉多、贵宾、拉布拉多贵宾混合等等。为了避免这种格式的歧义,更灵活地按品种进行搜索和更容易地展示它,最好将品种存储为一个字符串切片 ([]string) 而不是字符串。然后你可以在模板中使用 strings.Join 函数来打印所有品种,再加上 .Breed 字段的额外注释(纯种或混合品种)。
Note
尝试修改你的代码和模板来实现这些更改。完成后,点击下方的“解决方案”来检查你的工作。
解决方案
pets.go
. . .
type Pet struct {
Name string
Sex string
Intact bool
Age string
Breed []string
}
func main() {
dogs := []Pet{
{
Name: “Jujube”,
. . .
Breed: []string{“German Shepherd”, “Pit Bull”},
},
{
Name: “Zephyr”,
. . .
Breed: []string{“German Shepherd”, “Border Collie”},
},
{
Name: “Bruce Wayne”,
. . .
Breed: []string{“Chihuahua”},
},
}
funcMap := template.FuncMap{
“dec”: func(i int) int { return i – 1 },
“replace”: strings.ReplaceAll,
“join”: strings.Join,
}
. . .
} // end main
lastPet.tmpl
{{- range (len . | dec | dec | slice . ) }}
—
名字: {{ .Name }}
性别: {{ .Sex }} ({{ if .Intact }}未绝育{{ else }}{{ if (“女性” | eq .Sex) }}绝育{{ else }}中性化{{ end }}{{ end }})
年龄: {{ .Age }}
品种: {{ join .Breed “和” }} ({{ if len .Breed | eq 1 }}纯种{{ else }}混种{{ end }})
{{ end -}}
最后,让我们将相同的狗的数据渲染到一个HTML文档中,看看为什么在模板的输出是HTML时,你应该始终使用html/template包。
步骤5 — 编写HTML模板
一个命令行工具可以使用text/template来整洁地打印输出,而其他一些批处理程序可以使用它从某些数据创建结构良好的文件。但Go模板通常用于渲染Web应用程序的HTML页面。例如,流行的开源静态网站生成器Hugo同时使用text/template和html/template作为其模板的基础。
HTML使用角括号来包含元素(
),使用和号来标记实体( ),以及使用引号来包裹标签属性()。如果您的模板插入的任何数据包含这些字符,使用text/template包可能会导致HTML格式不正确,或者更糟糕的是,代码注入。
html/template包的作用不同。这个包会转义那些有问题的字符,将它们替换成安全的HTML等价物(实体)。你数据中的&符号会变成&,左尖括号会变成<,以此类推。
让我们使用之前的相同狗狗数据来创建一个HTML文件,但首先我们将继续使用text/template来显示其危险性。
打开pets.go文件,并将下面的文本添加到枣子的姓名字段中:
. . .
dogs := []Pet{
{
Name: "<script>alert(\"Gotcha!\");</script>Jujube",
Sex: "Female",
Intact: false,
Age: "10 months",
Breed: "German Shepherd/Pit Bull",
},
{
Name: "Zephyr",
Sex: "Male",
Intact: true,
Age: "13 years, 3 months",
Breed: "German Shepherd/Border Collie",
},
{
Name: "Bruce Wayne",
Sex: "Male",
Intact: false,
Age: "3 years, 8 months",
Breed: "Chihuahua",
},
}
. . .
现在在一个名为petsHtml.tmpl的新文件中创建一个HTML模板。
宠物网页模板 (petsHtml.tmpl)
<p><strong>Pets:</strong> {{ . | len }}</p>
{{ range . }}
<hr />
<dl>
<dt>Name</dt>
<dd>{{ .Name }}</dd>
<dt>Sex</dt>
<dd>{{ .Sex }} ({{ if .Intact }}intact{{ else }}{{ if (eq .Sex "Female") }}spayed{{ else }}neutered{{ end }}{{ end }})</dd>
<dt>Age</dt>
<dd>{{ .Age }}</dd>
<dt>Breed</dt>
<dd>{{ replace .Breed “/” “ & ” }}</dd>
</dl>
{{ end }}
保存HTML模板。在运行pets.go之前,我们需要编辑tmpFile变量,但是让我们同时编辑程序,将模板输出到文件而不是终端。打开pets.go并在main()函数中添加突出显示的代码。
. . .
funcMap := template.FuncMap{
"dec": func(i int) int { return i - 1 },
"replace": strings.ReplaceAll,
}
var tmplFile = "petsHtml.tmpl"
tmpl, err := template.New(tmplFile).Funcs(funcMap).ParseFiles(tmplFile)
if err != nil {
panic(err)
}
var f *os.File
f, err = os.Create("pets.html")
if err != nil {
panic(err)
}
err = tmpl.Execute(f, dogs)
if err != nil {
panic(err)
}
err = f.Close()
if err != nil {
panic(err)
}
} // end main
你正在打开一个名为pets.html的新文件,将其传递给tmpl.Execute(而不是os.Stdout),然后在完成后关闭文件。
现在运行”go run pets.go”来生成HTML文件。然后,在您的浏览器中打开这个本地网页。
浏览器已运行注入的脚本。这就是为什么你永远不应该使用text/template软件包来生成HTML,尤其是当你无法完全信任模板数据的来源时。
除了在数据中转义HTML字符外,html/template包的使用方式与text/template完全相同,并且具有相同的基本名称(template)。这意味着要使您的模板注入安全,您只需将text/template导入替换为html/template即可。编辑pets.go文件并立即执行此操作。
package main
import (
"os"
"strings"
"html/template"
)
. . .
保存文件并最后一次运行,覆盖pets.html。然后在浏览器中刷新HTML文件。
html/template包将注入的脚本渲染为网页中的纯文本。在您的文本编辑器中打开pets.html(或在浏览器中查看页面源代码),并查看第一只狗狗菜菜。
. . .
<dl>
<dt>Name</dt>
<dd><script>alert("Gotcha!");</script>Jujube</dd>
<dt>Sex</dt>
<dd>Female (spayed)</dd>
<dt>Age</dt>
<dd>10 months</dd>
<dt>Breed</dt>
<dd>German Shepherd & Pit Bull</dd>
</dl>
. . .
HTML包裹在枣树名字中替换了尖括号和引号字符,并且还替换了品种中的&符号。
结论 (jié
Go Templates是一个方便的工具,可以将任何文本包装在任何数据周围。您可以在命令行工具中使用它们来格式化输出,在您的Web应用程序中使用它们来渲染HTML等等。在本教程中,您使用了Go语言的内置模板包,可以从相同的数据中打印出格式良好的文本和HTML页面。要了解如何使用这些包的更多信息,请查看text/template和html/template的文档。