go template

1,225 阅读11分钟

背景

在go项目的开发过程中,我遇到了一些需要格式化生成的数据文件,动态的数据配置以特定格式生成所需文件。在没有接触模板引擎的情况下,都是通过循环以及逻辑判断来处理字符串拼接。在复杂的场景可能会嵌套多层循环,而且if判断满屏皆是。为了提高代码的可读性以及维护成本,决定使用golang模板引擎template。

目前模板引擎我使用的两个地方,第一个是sql生成,主要是建表语句的表字段以及类型等。第二个是代码生成,基于数据库表字段,自动生成增删改查等多个api接口、model、路由,以及前端数据展示、编辑两个页面。

概述

golang模板引擎分为两个包,text/template和html/template。

直观的看,html是针对于html代码的生成,text针对于文本内容的生成,

不同点主要是:html/template包实现了数据驱动的模板,用于生成可防止代码注入的安全的HTML内容,

下面有例子演示。

注意:

  • 模板文件后缀一般有两种,.tmpl 和 .tpl,也可以使用其他后缀,但必须是utf-8编码
  • 模板文件默认使用{{}}包裹和标识的变量,变量一般开头大写,{{}} 也可以通过定义去替换
  • 传给模板的数据可以通过点号(.)来访问,如果数据是复杂类型的数据,可以通过{{.FieldName}}来访问它的字段
  • 除{{和}}包裹的内容外,其他内容均不做修改原样输出。

解析模板文件方法

func (t *Template) Parse(src string) (*Template, error)
func ParseFiles(filenames ...string) (*Template, error)
func ParseGlob(pattern string) (*Template, error)

// 也可以使用func New(name string) *Template函数创建一个名为name的模板,
// name一定要和文件名保持一致
// 然后对其调用上面的方法去解析模板字符串或模板文件。

模板渲染方法

func (t *Template) Execute(wr io.Writer, data interface{}) error
func (t *Template) ExecuteTemplate(wr io.Writer, name string, data interface{}) error

for一个ample

入门

根据模板语法定义一个hello.tmpl的模板文件

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <title>Hello</title>
  </head>
  <body>
    <p>Hello {{.}}</p>
  </body>
</html>

解析模板以及渲染

// main.go

import (
	"html/template"
	"os"
)
func Hello(w http.ResponseWriter, r *http.Request) {
    // 解析指定文件生成模板对象
    tmpl, err := template.ParseFiles("./hello.tmpl")
    if err != nil {
        fmt.Println("create template failed, err:", err)
        return
    }
    // 利用给定数据渲染模板,并将结果写入w
    tmpl.Execute(w, "world")
}
func main() {
    http.HandleFunc("/", Hello)
    err := http.ListenAndServe(":8888", nil)
    if err != nil {
        fmt.Println("HTTP server failed,err:", err)
        return
    }
}

使用template.ParseFiles("*"),会创建一个模板(关联到变量tmpl上),其解析一个或多个文本文件,解析之后就可以使用Execute(w,"xxx")去执行解析后的模板对象。

执行过程是合并、替换的过程。例如上面的{{.}}中的.会替换成当前对象"world",并和其它纯字符串内容进行合并,最后写入w中.

进阶

定义一个Person结构

type Person struct {
	Name string
	Age  int
}

使用template.New()函数创建了一个空Template实例,这个template实例调用Parse()方法,Parse()方法用来解析模板中使用{{}}包围需要执行的动作,并将解析后的结果赋值给tmpl

调用Execute()方法,将数据对象Person的实例p应用到已经解析的tmpl模板,将整个应用合并后的结果输出到os.Stdout

import (
	"html/template"
	"os"
)

func main() {
	p := Person{"jm", 23}
	tmpl, err := template.New("test").Parse("Name: {{.Name}}, Age: {{.Age}}")
	if err != nil {
		panic(err)
	}
	err = tmpl.Execute(os.Stdout, p)
	if err != nil {
		panic(err)
	}
	fmt.Println(tmpl)
}

流程:构建模板对象New()-->解析数据Parse()-->数据合并Execute()

Parse()解析的对象中包含了{{}},其中使用了点(.),{{.Name}}代表Execute()第二个参数p对象的Name字段,同理{{.Age}}。

{{.}}表示的是所处作用域的当前对象,不只代表Execute()中的第二个参数对象。

例如,本示例中{{.}}代表顶级作用域的对象p,也就是Execute()中的第二个参数对象,如果Parse()中还有嵌套的作用域range,则{{.}}代表range迭代到的每个元素对象。下面会有例子演示。

模板语法

{{.}}

在template的模板文件中,会经常用到"."。比如{{.}}、{{len .}}、{{.Name}}、{{$x.Name}}等等。

模板语法都包含在{{ }}中间,其中{{.}}中的点表示当前作用域的当前对象。

当我们传入一个结构体对象时,我们可以根据.来访问结构体的对应字段。

// main.go

type Person struct {
	Name   string
	Age    int
}

func Test(w http.ResponseWriter, r *http.Request) {
	// 解析指定文件生成模板对象
	tmpl, err := template.ParseFiles("./hello.tmpl")
	if err != nil {
		fmt.Println("create template failed, err:", err)
		return
	}
	// 利用给定数据渲染模板,并将结果写入w
	p := Person{
		Name:   "小明",
		Age:    18,
	}
	tmpl.Execute(w, p)
}

模板文件hello.tmpl内容如下:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8">
    <title>Hello</title>
  </head>
  <body>
    <p>Hello {{.Name}}</p>
    <p>年龄:{{.Age}}</p>
  </body>
</html>

{{.}},这个点是顶级作用域范围内的,它代表Execute(w, p)的第二个参数p。也就是说它代表这个字符串对象。{{.Name}}相当于user.name

同理,当我们传入的变量是map时,也可以在模板文件中通过.根据key来取值。

并非只有一个顶级作用域,range、with、if等都有自己的本地作用域。下面有例子演示。

range

Go的模板语法中使用range关键字进行遍历,有两种方式,其中pipeline的值必须是数组、切片、map或channel

{{range pipeline}} T1 {{end}}
如果pipeline的值其长度为0,不会有任何输出

{{range pipeline}} T1 {{else}} T0 {{end}}
如果pipeline的值其长度为0,则会执行T0。

{{range $value := .}}
{{range $key,$value := .}}
//如果range中只赋值给一个变量,则这个变量是当前正在迭代元素的值。
//如果赋值给两个变量,则第一个变量是索引值(map/slice是数值,map是key),第二个变量是当前正在迭代元素的值。
package main

import (
    "os"
    "text/template"
)

type Friend struct {
    Fname string
}
type Person struct {
    Name string
    Emails   []string
    Friends  []*Friend
}

func main() {
    f1 := Friend{Fname: "test1"}
    f2 := Friend{Fname: "test2"}
    t := template.New("test")
    t = template.Must(t.Parse(
        `hello {{.Name}}!
{{ range .Emails }}
an email {{ . }}
{{- end }}
{{ with .Friends }}
{{- range . }}
my friend name is {{.Fname}}
{{- end }}
{{ end }}`))
    p := Person{UserName: "=jm",
                Emails:  []string{"jm@qq.com", "jm@gmail.com"},
                Friends: []*Friend{&f1, &f2}}
    t.Execute(os.Stdout, p)
}

// 输出结果
hello jm!

an email jm@qq.com
an email jm@gmail.com

my friend name is test1
my friend name is test2
  • {{.Name}}、{{.Emails}}、{{.Friends}}中的点都代表Execute()的第二个参数Person对象p,分别被替换成p.UserName、p.Emails、p.Friends。
  • 因为Emails和Friend字段都是可迭代的,在{{range .Emails}}...{{end}}这一段结构内部an email {{.}},这个"."代表的是range迭代时的每个元素对象,也就是p.Emails这个slice中的每个元素。
  • with结构内部{{range .}}的"."代表的是p.Friends,{{.Fname}}的点代表Friend结构的实例,{{.Fname}}代表实例对象的Fname字段

with

例子参考上面 range中 with 用法

{{with pipeline}} T1 {{end}}
如果pipeline为0不产生输出,否则将.设为pipeline的值并执行T1。不修改外面的.

{{with pipeline}} T1 {{else}} T0 {{end}}
如果pipeline为0,不改变.并执行T0,否则.设为pipeline的值并执行T1。

移除空格

在使用模板语法的时候会不可避免的引入一下空格或者换行符,这样最终渲染出来的内容可能就和我们想的不一样,这个时候可以使用{{-语法去除模板内容左侧的所有空白符号, 使用-}}去除模板内容右侧的所有空白符号

可以在{{符号的后面加上短横线并保留一个或多个空格"- "来去除它前面的空白(包括换行符、制表符、空格等),即{{- xxxx 在}}的前面加上一个或多个空格以及一个短横线"-"来去除它后面的空白,即 xxxx -}}。

注意:-要紧挨{{ }},同时与模板值之间需要使用空格分隔。

{{- .Name -}}

{{23}} < {{45}}        ->  23 < 45
{{23}} < {{- 45}}      ->  23 <45
{{23 -}} < {{45}}      ->  23< 45
{{23 -}} < {{- 45}}    ->  23<45
t.Parse(
    `hello {{.Name}}!
{{ range .Emails }}
an email {{ . }}
{{- end }}
{{ with .Friends }}
{{- range . }}
my friend name is {{.Fname}}
{{- end }}
{{ end }}`)

上面没有进行缩进。因为缩进的制表符或空格在替换的时候会保留。

第一行和第二行之间输出时会换行输出,不仅如此,range {{.Emails}}自身也占一行,在替换的时候它会被保留为空行。除非range前面没加{{-。由于range的{{- end加上了去除前缀空白,所以每次迭代的时候,每个元素之间都换行输出但却不多一空行,如果这里的end去掉{{-,则每个迭代的元素之间输出的时候都会有空行。同理后面的with和range。

注释

注释,执行时会忽略。并且必须紧贴分界符。

注释后的内容不会被引擎进行替换。但需要注意,注释行在替换的时候也会占用行,所以应该去除前缀和后缀空白,否则会多一空行。

{{/* a comment */}}

{{- /* a comment without prefix/suffix space */}}
{{/* a comment without prefix/suffix space */ -}}
{{- /* a comment without prefix/suffix space */ -}}

pipeline

pipeline是指产生数据的操作。比如{{.}}、{{.Name}}等。Go的模板语法中支持使用管道符号|链接多个命令,用法和linux的管道类似:|前面的命令会将运算结果(或返回值)传递给后一个命令,作为后一个命令的入参。

{{.}} | printf "%s\n" "abcd"

{{.}}的结果将传递给printf,且传递的参数位置是"abcd"之后。
命令可以有超过1个的返回值,这时第二个返回值必须为err类型。

并不是只有使用了 | 才是pipeline。Go的模板语法中,pipeline的概念是传递数据,只要能产生数据的,都是pipeline。

{{println (len "output")}}
{{`"output"`}}
{{printf "%q" "output"}}
{{"output" | printf "%q"}}
{{printf "%q" (print "out" "put")}}
{{"put" | printf "%s%s" "out" | printf "%q"}}
{{"output" | printf "%s" | printf "%q"}}

变量

模板中声明变量,用来保存传入模板的数据或其他语句生成的结果。

// $obj是变量名字,在后续的代码中就可以直接使用该变量。
$obj := {{.}}

// 未定义过的变量
$var := pipeline

// 已定义过的变量
$var = pipeline

{{- $how_long :=(len "output")}}
{{- println $how_long}}   // 输出6

tx := template.Must(template.New("test").Parse(
`{{range $x := . -}}
{{$y := 333}}
{{- if (gt $x 33)}}{{println $x $y ($z := 444)}}{{- end}}
{{- end}}
`))
s := []int{11, 22, 33, 44, 55}
_ = tx.Execute(os.Stdout, s)

//输出
44 333 444
55 333 444

注意:

  • 变量有作用域,只要出现end,则当前层次的作用域结束。内层可以访问外层变量,但外层不能访问内层变量。
  • 有一个特殊变量$,它代表模板的最顶级作用域对象,在Execute()执行的时候进行赋值,且一直不变。
  • 变量不可在模板之间继承。本文不研究。

条件判断

{{if pipeline}} T1 {{end}}
{{if pipeline}} T1 {{else}} T0 {{end}}
{{if pipeline}} T1 {{else if pipeline}} T0 {{end}}
//pipeline为false的情况是各种数据对象的0值:数值0,指针或接口是nil,数组、slice、map或string则是len0

预定义函数

预定义的全局函数如下:

and
    函数返回它的第一个empty参数或者最后一个参数;
    就是说"and x y"等价于"if x then y else x";所有参数都会执行;
or
    返回第一个非empty参数或者最后一个参数;
    亦即"or x y"等价于"if x then x else y";所有参数都会执行;
not
    返回它的单个参数的布尔值的否定
len
    返回它的参数的整数类型长度
index
    执行结果为第一个参数以剩下的参数为索引/键指向的值;
    如"index x 1 2 3"返回x[1][2][3]的值;每个被索引的主体必须是数组、切片或者字典。
print
    即fmt.Sprint
printf
    即fmt.Sprintf
println
    即fmt.Sprintln
html
    返回与其参数的文本表示形式等效的转义HTML。
    这个函数在html/template中不可用。
urlquery
    以适合嵌入到网址查询中的形式返回其参数的文本表示的转义值。
    这个函数在html/template中不可用。
js
    返回与其参数的文本表示形式等效的转义JavaScript。
call
    执行结果是调用第一个参数的返回值,该参数必须是函数类型,其余参数作为调用该函数的参数;
    如"call .X.Y 1 2"等价于go语言里的dot.X.Y(1, 2);
    其中Y是函数类型的字段或者字典的值,或者其他类似情况;
    call的第一个参数的执行结果必须是函数类型的值(和预定义函数如print明显不同);
    该函数类型值必须有12个返回值,如果有2个则后一个必须是error接口类型;
    如果有2个返回值的方法返回的errornil,模板执行会中断并返回给调用模板执行者该错误;

比较函数

布尔函数会将任何类型的零值视为假,其余视为真。

下面是定义为函数的二元比较运算的集合:

eq      如果arg1 == arg2则返回真
ne      如果arg1 != arg2则返回真
lt      如果arg1 < arg2则返回真
le      如果arg1 <= arg2则返回真
gt      如果arg1 > arg2则返回真
ge      如果arg1 >= arg2则返回真

// 为了简化多参数相等检测,eq(只有eq)可以接受2个或更多个参数,它会将第一个参数和其余参数依次比较,返回下式的结果:
{{eq arg1 arg2 arg3}}   等价于  arg1==arg2 || arg1==arg3 || arg1==arg4 	

比较函数只适用于基本类型(或重定义的基本类型,如”type xx float32”)。但是,整数和浮点数不能互相比较。

自定义函数

Go的模板支持自定义函数。

例如:当我们想在模板文件里做一些加减乘除的运算的时候,发现没有提供此类的函数。可以自定义一些函数使用。

func TemplateSub(a, b int) int {
	return a - b
}

func Test() {
    data := "[{"FieldJson": "id"}, {"FieldJson": "name"}, {"FieldJson": "age"}]"
    // 采用链式操作在Parse之前调用Funcs添加自定义函数
    tmpl, err = template.New("test.tpl").Funcs(template.FuncMap{"sub": TemplateSub}).ParseFiles("./test.tpl")
    if err != nil {
        fmt.Println("create template failed, err:", err)
        return
    }
    // 使用user渲染模板,并将结果写入w
    tmpl.Execute(w, data)
}

我们可以在模板文件中使用我们自定义的sub函数了

目的为了生成 const fields = [ 'id', 'name', 'age' ]

const fields = [{{- $last := (sub (len .Fields) 1) -}} {{- range $k, $v := .Fields -}} {{ if lt $k $last }} '{{ .FieldJson }}', {{- else }} '{{ .FieldJson }}' {{- end }} {{- end }} ]

改默认的标识符

Go标准库的模板引擎使用的花括号{{和}}作为标识,在vue中也使用{{和}}作为标识符,就会出现冲突,所以需要修改标识符。

如何修改Go语言模板引擎默认的标识符:

template.New("test").Delims("{[", "]}").ParseFiles("./test.tmpl")

html/tempalte特殊之处

html/template针对的是需要返回HTML内容的场景,在模板渲染过程中会对一些有风险的内容进行转义,以此来防范跨站脚本攻击。

例如,我定义下面的模板文件:

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <title>Hello</title>
  </head>
  <body>
    {{.}}
  </body>
</html>

如果传入一段JS代码并使用html/template去渲染该文件,会在页面上显示出转义后的JS内容。

但是在某些场景下,我们如果相信用户输入的内容,不想转义的话,可以自行编写一个safe函数,手动返回一个template.HTML类型的内容。示例如下:

func xss(w http.ResponseWriter, r *http.Request){
    tmpl,err := template.New("xss.tmpl").Funcs(template.FuncMap{
        "safe": func(s string)template.HTML {
            return template.HTML(s)
        },
    }).ParseFiles("./xss.tmpl")
    if err != nil {
        fmt.Println("create template failed, err:", err)
        return
    }
    jsStr := `<script>alert('嘿嘿嘿')</script>`
    err = tmpl.Execute(w, jsStr)
    if err != nil {
        fmt.Println(err)
    }
}

这样我们只需要在模板文件不需要转义的内容后面使用我们定义好的safe函数就可以了。

{{ . | safe }}

模板结构

t := template.New("abc")
tt,err := t.Parse("xxxxxxxxxxx")

这里的t和tt其实都指向同一个模板对象。

这里的t称为模板的关联名称。就是创建了一个模板,关联到变量t上。但注意,t不是模板的名称,因为Template中有一个未导出的name字段,它才是模板的名称。可以通过Name()方法返回name字段的值。

之所以要区分模板的关联名称(t)和模板的名称(name),是因为一个关联名称t(即模板对象)上可以"包含"多个name,也就是多个模板,通过t和各自的name,可以调用到指定的模板。

Template结构:

type Template struct {
    name string
    *parse.Tree
    *common
    leftDelim  string
    rightDelim string
}

name是这个Template的名称,Tree是解析树,common下面解释。leftDelim和rightDelim是左右两边的分隔符,默认为{{和}}。

这里主要关注name和common两个字段,name字段模板名称。common是一个结构:

type common struct {
    tmpl   map[string]*Template // Map from name to defined templates.
    option option
    muFuncs    sync.RWMutex // protects parseFuncs and execFuncs
    parseFuncs FuncMap
    execFuncs  map[string]reflect.Value
}

这个结构的第一个字段tmpl是一个Template的map结构,key为template的name,value为Template。也就是说,一个common结构中可以包含多个Template,而Template结构中又指向了一个common结构。所以,common是一个模板组,在这个模板组中的(tmpl字段)所有Template都共享一个common(模板组),模板组中包含parseFuncs和execFuncs。

使用template.New()函数可以创建一个空的、无解析数据的模板,同时还会创建一个common,也就是模板组

func New(name string) *Template {
    t := &Template{
        name: name,
    }
    t.init()
    return t
}

其中t为模板的关联名称,name为模板的名称,t.init()表示如果模板对象t还没有common结构,就构造一个新的common组:

func (t *Template) init() {
    if t.common == nil {
        c := new(common)
        c.tmpl = make(map[string]*Template)
        c.parseFuncs = make(FuncMap)
        c.execFuncs = make(map[string]reflect.Value)
        t.common = c
    }
}

也就是说,template.New()函数不仅创建了一个模板,还创建了一个空的common结构(模板组)。需要注意,新创建的common是空的,只有进行模板解析(Parse(),ParseFiles()等操作)之后,才会将模板添加到common的tmpl字段(map结构)中。

tmpl := template.New("mytmpl1")

在template包中,很多涉及到操作Template的函数、方法,都会调用init()方法保证返回的Template都有一个有效的common结构。当然,因为init()方法中进行了判断,对于已存在common的模板,不会新建common结构。

假设现在执行了Parse()方法,将会把模板name添加到common tmpl字段的map结构中,其中模板name为map的key,模板为map的value。

例如:

func main() {
    t1 := template.New("test1")
    tmpl,_ := t1.Parse(
        `{{define "T1"}}ONE{{end}}
{{define "T2"}}TWO{{end}}
{{define "T3"}}{{template "T1"}} {{template "T2"}}{{end}}
{{template "T3"}}`)
    fmt.Println(t1)
    fmt.Println(tmpl)
    fmt.Println(t1.Lookup("test1"))  // 使用关联名称t1检索test1模板
    fmt.Println(t1.Lookup("T1"))
    fmt.Println(tmpl.Lookup("T2")) // 使用关联名称tmpl检索T2模板
    fmt.Println(tmpl.Lookup("T3"))
}

&{test1 0xc0420a6000 0xc0420640c0  }
&{test1 0xc0420a6000 0xc0420640c0  }
&{test1 0xc0420a6000 0xc0420640c0  }
&{T1 0xc0420a6100 0xc0420640c0  }
&{T2 0xc0420a6200 0xc0420640c0  }
&{T3 0xc0420a6300 0xc0420640c0  }

使用template.New()函数创建了一个名为test1的模板,同时创建了一个模板组(common),它们关联在t1变量上。

调用Parse()方法,在Parse()的待解析字符串中使用define又定义了3个新的模板对象,模板的name分别为T1、T2和T3,其中T1和T2嵌套在T3中,因为调用的是t1的Parse(),所以这3个新创建的模板都会关联到t1上。

现在t1上关联了4个模板:test1、T1、T2、T3,全都共享同一个common。因为已经执行了Parse()解析操作,这个Parse()会将test1、T1、T2、T3的name添加到common.tmpl的map中。common的tmpl字段的map结构中有4个元素。

虽然test1、T1、T2、T3都关联在t1上,但t1只能代表test1,因为t1是一个Template类型。可以认为test1、T1、T2、T3这4个模板共享一个组,但T1、T2、T3都是对外部不可见的,只能通过特殊方法的查询找到它们。

tmpl和t1这两个变量是完全等价的,都指向同一个template,即test1。

第一列为template name,第二个字段为parseTree的地址,第三列为common结构的地址。因为tmpl1、t1都指向test1模板,所以前3行结果完全一致。因为test1、T1、T2、T3共享同一个common,所以第三列全都相同。因为每个模板的解析树不一样,所以第二列全都不一样。

除了template.New()函数,还有一个Template.New()方法:

// New allocates a new, undefined template associated with the given one and with the same
// delimiters. The association, which is transitive, allows one template to
// invoke another with a {{template}} action.
func (t *Template) New(name string) *Template {
    t.init()
    nt := &Template{
        name:       name,
        common:     t.common,
        leftDelim:  t.leftDelim,
        rightDelim: t.rightDelim,
    }
    return nt
}

t.init()保证有一个有效的common结构,然后构造一个新的Template对象nt,这个nt除了name和解析树parse.Tree字段之外,其它所有内容都和t完全一致。换句话说,nt和t共享了common。

New()方法使得名为name的nt模板对象加入到了关联组中。通过调用t.New()方法,可以创建一个新的名为name的模板对象,并将此对象加入到t模板组中。

New()函数是构建新的模板对象并构建一个新的common结构,而New()方法则是构建一个新的模板对象,并加入到已有的common结构中。

New()出来的新对象在执行解析之前(如Parse()),它们暂时都还不会加入到common组中,在New()出来之后,仅仅只是让它指向已有的一个common结构。

golang.org/pkg/text/te…