Go Web 编程之 模板(一)

概述

模板引擎是 Web 编程中必不可少的一个组件。模板能分离逻辑和数据,使得逻辑简洁清晰,而且模板可复用。引用第二篇文章《程序结构》一文中的图示,咱们能够看到模板引擎在 Web 程序结构中的位置:html

模板引擎按照功能能够划分为两种类型:git

  • 无逻辑模板引擎:此类模板引擎只进行字符串的替换,无其它逻辑;
  • 嵌入逻辑模板引擎:此类模板引擎能够在模板中嵌入逻辑,实现流程控制/循环等。

这两类模板引擎都比较极端。无逻辑模板引擎须要在处理器中额外添加不少逻辑用于生成替换的文本。而嵌入逻辑模板引擎则在模板中混入了大量逻辑,致使维护性较差。实用的模板引擎通常介于这二者之间。github

在Go 语言中,text/templatehtml/template两个库实现模板功能。golang

模板内容能够是 UTF-8 编码的任何内容。其中用{{}}包围的部分称为动做{{}}外的其它文本在输出保持不变。模板须要应用到数据,模板中的动做会根据数据生成响应的内容来替换。web

模板解析以后能够屡次执行,也能够并行执行,可是注意使用同一个Writer会致使输出交替出现。编程

模板的内容较多,我将分为两篇文章介绍。本文介绍text/template,包括 Go 模板的基本概念,用法和注意点。下篇文章介绍html/template数组

初体验

使用模板引擎通常有 3 个步骤:微信

  • 定义模板(直接使用字符串字面量或文件);
  • 解析模板(使用text/templatehtml/template中的方法解析);
  • 传入数据生成输出。
package main

import (
    "log"
    "os"
    "text/template"
)

type User struct {
    Name string
    Age  int
}

func stringLiteralTemplate() {
    s := "My name is {{ .Name }}. I am {{ .Age }} years old.\n"
    t, err := template.New("test").Parse(s)
    if err != nil {
        log.Fatal("Parse string literal template error:", err)
    }

    u := User{Name: "darjun", Age: 28}
    err = t.Execute(os.Stdout, u)
    if err != nil {
        log.Fatal("Execute string literal template error:", err)
    }
}

func fileTemplate() {
    t, err := template.ParseFiles("test")
    if err != nil {
        log.Fatal("Parse file template error:", err)
    }

    u := User{Name: "dj", Age: 18}
    err = t.Execute(os.Stdout, u)
    if err != nil {
        log.Fatal("Execute file template error:", err)
    }
}

func main() {
    stringLiteralTemplate()

    fileTemplate()
}

在可执行程序目录中新建模板文件test,并写入下面的内容:编程语言

My name is {{ .Name }}. I am {{ .Age }} years old.

首先调用template.New建立一个模板,参数为模板名。函数

而后调用Template类型的Parse方法,解析模板字符串,生成模板主体。这个方法返回两个值。若是模板语法正确,则返回模板对象自己和一个 nil 值。
若是有语法错误,则返回一个 error 类型的值做为第二个返回值,这时不该该使用第一个返回值。

最后,调用模板对象的Execute方法,传入参数。Execute执行模板中的动做,将结果输出到os.Stdout,即标准输出。最终咱们看到模板中{{ .Name }}uName字段替换,{{ .Age }}uAge字段替换,标准输出中显示下面一行字符串:

My name is darjun. I am 28 years old.

上面代码中,fileTemplate函数还演示了如何从文件中加载模板。其中template.ParseFiles方法会建立一个模板,并将用户指定的模板文件名用做这个新模板的名字:

t, err := template.ParseFiles("test")

至关于:

t := template.New("test")
t, err := t.ParseFiles("test")

动做

Go 模板中的动做就是一些嵌入在模板里面的命令。动做大致上能够分为如下几种类型:

  • 点动做;
  • 条件动做;
  • 迭代动做;
  • 设置动做;
  • 包含动做。

点动做

在介绍其它的动做以前,咱们先看一个很重要的动做,点动做{{ . }})。它其实表明是传递给模板的数据,其余动做或函数基本上都是对这个数据进行处理,以此来达到格式化和内容展现的目的。

对前面的代码示例稍做修改:

func main() {
    s := "The user is {{ . }}."
    t, err := template.New("test").Parse(s)
    if err != nil {
        log.Fatal("Parse error:", err)
    }

    u := User{Name: "darjun", Age: 28}
    err = t.Execute(os.Stdout, u)
    if err != nil {
        log.Fatal("Execute error:", err)
    }
}

运行程序,标准输出显示:

The user is {darjun 28}.

实际上,{{ . }}会被替换为传给给模板的数据的字符串表示。这个字符串与以数据为参数调用fmt.Sprint函数获得的内容相同。咱们能够为User结构编写一个方法:

func (u User) String() string {
    return fmt.Sprintf("(name:%s age:%d)", u.Name, u.Age)
}

这样替换的字符串就是格式化以后的内容了:

The user is (name:darjun age:28).

注意:为了使用的方便和灵活,在模板中不一样的上下文内,.的含义可能会改变,下面在介绍不一样的动做时会进行说明。

条件动做

在介绍动做的语法时,我采用 Go 标准库中的写法。我以为这样写更严谨。
其中pipeline表示管道,后面会有详细的介绍,如今能够将它理解为一个值。
T1/T2等形式表示语句块,里面能够嵌套其它类型的动做。最简单的语句块就是不包含任何动做的字符串

条件动做的语法与编程语言中的if语句语法相似,有几种形式:

形式一:

{{ if pipeline }} T1 {{ end }}

若是管道计算出来的值不为空,执行T1。不然,不生成输出。下面都表示空值:

  • false、0、空指针或接口;
  • 长度为 0 的数组、切片、map或字符串。

形式二:

{{ if pipeline }} T1 {{ else }} T2 {{ end }}

若是管道计算出来的值不为空,执行T1。不然,执行T2

形式三:

{{ if pipeline1 }} T1 {{ else if pipeline2 }} T2 {{ else }} T3 {{ end }}

若是管道pipeline1计算出来的值不为空,则执行T1。反之若是管道pipeline2的值不为空,执行T2。若是都为空,执行T3

举个栗子:

type AgeInfo struct {
    Age           int
    GreaterThan60 bool
    GreaterThan40 bool
}

func main() {
    t, err := template.ParseFiles("test")
    if err != nil {
        log.Fatal("Parse error:", err)
    }

    rand.Seed(time.Now().Unix())
    age := rand.Intn(100)
    info := AgeInfo {
        Age:           age,
        GreaterThan60: age > 60,
        GreaterThan40: age > 40,
    }
    err = t.Execute(os.Stdout, info)
    if err != nil {
        log.Fatal("Execute error:", err)
    }
}

在可执行程序的目录下新建模板文件test,键入下面的内容:

Your age is: {{ .Age }}
{{ if .GreaterThan60 }}
Old People!
{{ else if .GreaterThan40 }}
Middle Aged!
{{ else }}
Young!
{{ end }}

运行程序,会随机一个年龄,而后根据年龄区间选择性输出Old People!/Middle Age!/Young!其中一个。下面是我运行两次运行的输出:

Your age is: 7

Young!
Your age is: 79

Old People!

这个程序有一个问题,会有多余的空格!咱们以前说过,除了动做以外的任何文本都会原样保持,包括空格和换行!针对这个问题,有两种解决方案。第一种方案是删除多余的空格和换行,test文件修改成:

Your age is: {{ .Age }}
{{ if .GreaterThan60 }}Old People!{{ else if .GreaterThan40 }}Middle Aged!{{ else }}Young!{{ end }}

显然,这个方法会致使模板内容很难阅读,不够理想。为此,Go 提供了针对空白符的处理。若是一个动做以{{- (注意有一个空格),那么该动做与它前面相邻的非空文本或动做间的空白符将会被所有删除。相似地,若是一个动做以 -}}结尾,那么该动做与它后面相邻的非空文本或动做间的空白符将会被所有删除。例如:

{{23 -}} < {{- 45}}

将会生成输出:

23<45

回到咱们的例子中,咱们能够将test文件稍做修改:

Your age is: {{ .Age }}
{{ if .GreaterThan60 -}}
"Old People!"
{{- else if .GreaterThan40 -}}
"Middle Aged!"
{{- else -}}
"Young!"
{{- end }}

这样,输出的文本就不会包含多余的空格了。

迭代动做

迭代其实与编程语言中的循环遍历相似。有两种形式:

形式一:

{{ range pipeline }} T1 {{ end }}

管道的值类型必须是数组、切片、map、channel。若是值的长度为 0,那么无输出。不然,.被设置为当前遍历到的元素,而后执行T1,即在T1.表示遍历的当前元素,而非传给模板的参数。若是值是 map 类型,且键是可比较的基本类型,元素将会以键的顺序访问

形式二:

{{ range pipeline }} T1 {{ else }} T2 {{ end }}

与前一种形式基本同样,若是值的长度为 0,那么执行T2

举个栗子:

type Item struct {
    Name    string
    Price    int
}

func main() {
    t, err := template.ParseFiles("test")
    if err != nil {
        log.Fatal("Parse error:", err)
    }

    items := []Item {
        { "iPhone", 5499 },
        { "iPad", 6331 },
        { "iWatch", 1499 },
        { "MacBook", 8250 },
    }

    err = t.Execute(os.Stdout, items)
    if err != nil {
        log.Fatal("Execute error:", err)
    }
}

在可执行程序目录下新建模板文件test,键入内容:

Apple Products:
{{ range . }}
{{ .Name }}: ¥{{ .Price }}
{{ else }}
No Products!!!
{{ end }}

运行程序,获得下面的输出:

Apple Products:

iPhone: ¥5499

iPad: ¥6331

iWatch: ¥1499

MacBook: ¥8250

range语句循环体内,.被设置为当前遍历的元素,能够直接使用{{ .Name }}{{ .Price }}访问产品名称和价格。在程序中,将nil传给Execute方法会获得下面的输出:

Apple Products:

No Products!!!

设置动做

设置动做使用with关键字重定义.。在with语句内,.会被定义为指定的值。通常用在结构嵌套很深时,能起到简化代码的做用。

形式一:

{{ with pipeline }} T1 {{ end }}

若是管道值不为空,则将.设置为pipeline的值,而后执行T1。不然,不生成输出。

形式二:

{{ with pipeline }} T1 {{ else }} T2 {{ end }}

与前一种形式的不一样之处在于当管道值为空时,不改变.执行T2。举个栗子:

type User struct {
    Name string
    Age  int
}

type Pet struct {
    Name  string
    Age   int
    Owner User
}

func main() {
    t, err := template.ParseFiles("test")
    if err != nil {
        log.Fatal("Parse error:", err)
    }

    p := Pet {
        Name:  "Orange",
        Age:   2,
        Owner: User {
            Name: "dj",
            Age:  28,
        },
    }

    err = t.Execute(os.Stdout, p)
    if err != nil {
        log.Fatal("Execute error:", err)
    }
}

模板文件内容:

Pet Info:
Name: {{ .Name }}
Age: {{ .Age }}
Owner:
{{ with .Owner }}
  Name: {{ .Name }}
  Age: {{ .Age }}
{{ end }}

运行程序,获得下面的输出:

Pet Info:
Name: Orange
Age: 2
Owner:

  Name: dj
  Age: 28

可见,在with语句内,.被替换成了Owner字段的值。

包含动做

包含动做能够在一个模板中嵌入另外一个模板,方便模板的复用。

形式一:

{{ template "name" }}

形式二:

{{ template "name" pipeline }}

其中name表示嵌入的模板名称。第一种形式,将使用nil做为传入内嵌模板的参数。第二种形式,管道pipeline的值将会做为参数传给内嵌的模板。举个栗子:

package main

import (
    "log"
    "os"
    "text/template"
)

func main() {
    t, err := template.ParseFiles("test1", "test2")
    if err != nil {
        log.Fatal("Parse error:", err)
    }

    err = t.Execute(os.Stdout, "test data")
    if err != nil {
        log.Fatal("Execute error:", err)
    }
}

ParseFiles方法接收可变参数,可将任意多个文件名传给该方法。

模板test1:

This is in test1.
{{ template "test2" }}

{{ template "test2" . }}

模板test2:

This is in test2.
Get: {{ . }}.

运行程序获得输出:

This is in test1.
This is in test2.
Get: <no value>.

This is in test2.
Get: test data.

前一个嵌入模板,没有传递参数。后一个传入.,即传给test1模板的参数。

其它元素

在介绍了几种动做以后,咱们回过头来看几种基本组成部分。

注释

注释只有一种语法:

{{ /* 注释 */ }}

注释的内容不会呈如今输出中,它就像代码注释同样,是为了让模板更易读。

参数

一个参数就是模板中的一个值。它的取值有多种:

  • 布尔值、字符串、字符、整数、浮点数、虚数和复数等字面量
  • 结构中的一个字段或 map 中的一个键。结构的字段名必须是导出的,即大写字母开头,map 的键名则没必要
  • 一个函数或方法。必须只返回一个值,或者只返回一个值和一个错误。若是返回了非空的错误,则Execute方法执行终止,返回该错误给调用者;
  • 等等等等。

上面几种形式能够结合使用:

{{ .Field1.Key1.Method1.Field2.Key2.Method2 }

其实,咱们已经用过不少次参数了。下面看一个方法调用的栗子:

type User struct {
    FirstName     string
    LastName    string
}

func (u User) FullName() string {
    return u.FirstName + " " + u.LastName
}

func main() {
    t, err := template.ParseFiles("test")
    if err != nil {
        log.Fatal("Parse error:", err)
    }

    err = t.Execute(os.Stdout, User{FirstName: "lee", LastName: "darjun"})
    if err != nil {
        log.Fatal("Execute error:", err)
    }
}

模板文件test

My full name is {{ .FullName }}.

模板执行会使用FullName方法的返回值替换{{ .FullName }},输出:

My full name is lee darjun.

关于参数的几个要点:

  • 参数能够是任何类型;
  • 若是参数为指针,实现会根据须要取其基础类型;
  • 若是参数计算获得一个函数类型,它不会自动调用。例如{{ .Method1 }},若是Method1方法返回一个函数,那么返回值函数不会调用。若是要调用它,使用内置的call函数。

管道

管道的语法与 Linux 中的管道相似,即命令的链式序列:

{{ p1 | p2 | p3 }}

每一个单独的命令(即p1/p2/p3...)能够是下面三种类型:

  • 参数,见上面;
  • 可能带有参数的方法调用;
  • 可能带有参数的函数调用。

在一个链式管道中,每一个命令的结果会做为下一个命令的最后一个参数。最后一个命令的结果做为整个管道的值。

管道必须只返回一个值,或者只返回一个值和一个错误。若是返回了非空的错误,那么Execute方法执行终止,并将该错误返回给调用者。

在迭代程序的基础上稍做修改:

type Item struct {
    Name  string
    Price float64
    Num   int
}

func (item Item) Total() float64 {
    return item.Price * float64(item.Num)
}

func main() {
    t, err := template.ParseFiles("test")
    if err != nil {
        log.Fatal("Parse error:", err)
    }

    item := Item {"iPhone", 5499.99, 2 }

    err = t.Execute(os.Stdout, item)
    if err != nil {
        log.Fatal("Execute error:", err)
    }
}

模板文件test

Product: {{ .Name }}
Price: ¥{{ .Price }}
Num: {{ .Num }}
Total: ¥{{ .Total | printf "%.2f" }}

先调用Item.Total方法计算商品总价,而后使用printf格式化,保留两位小数。最终输出:

Product: iPhone
Price: ¥5499.99
Num: 2
Total: ¥10999.98

printf是 Go 模板内置的函数,这样的函数还有不少。

变量

在动做中,能够用管道的值定义一个变量。

$variable := pipeline

$variable为变量名,声明变量的动做不生成输出。

相似地,变量也能够从新赋值:

$variable = pipeline

range动做中能够定义两个变量:

range $index, $element := range pipeline

这样就能够在循环中经过$index$element访问索引和元素了。

变量的做用域持续到定义它的控制结构的{{ end }}动做。若是没有这样的控制结构,则持续到模板结束。模板调用不继承变量。

执行开始时,$被设置为传入的数据参数,即.的值。

函数

Go 模板提供了大量的预约义函数,若是有特殊需求也能够实现自定义函数。模板执行时,遇到函数调用,先从模板自定义函数表中查找,然后查找全局函数表。

预约义函数

预约义函数分为如下几类:

  • 逻辑运算,and/or/not
  • 调用操做,call
  • 格式化操做,print/printf/println,与用参数直接调用fmt.Sprint/Sprintf/Sprintln获得的内容相同;
  • 比较运算,eq/ne/lt/le/gt/ge

在上面条件动做的示例代码中,咱们在代码中计算出大小关系再传入模板,这样比较繁琐,能够直接使用比较运算简化。

有两点须要注意:

  • 因为是函数调用,全部的参数都会被求值,没有短路求值
  • 比较运算只做用于基本类型,且没有 Go 语法那么严格,例如能够比较有符号和无符号整数。

自定义函数

默认状况下,模板中无自定义函数,可使用模板的Funcs方法添加。下面咱们实现一个格式化日期的自定义函数:

package main

import (
    "log"
    "os"
    "text/template"
    "time"
)

func formatDate(t time.Time) string {
    return t.Format("2016-01-02")
}

func main() {
    funcMap := template.FuncMap {
        "fdate": formatDate,
    }
    t := template.New("test").Funcs(funcMap)
    t, err := t.ParseFiles("test")
    if err != nil {
        log.Fatal("Parse errr:", err)
    }

    err = t.Execute(os.Stdout, time.Now())
    if err != nil {
        log.Fatal("Exeute error:", err)
    }
}

模板文件test

Today is {{ . | fdate }}.

模板的Func方法接受一个template.FuncMap类型变量,键为函数名,值为实际定义的函数。
能够一次设置多个自定义函数。自定义函数要求只返回一个值,或者返回一个值和一个错误。
设置以后就能够在模板中使用fdate了,输出:

Today is 7016-01-07.

这里不能使用template.ParseFiles,由于在解析模板文件的时候fdate未定义会致使解析失败。必须先建立模板,调用Funcs设置自定义函数,而后再解析模板。

模板的几种建立方式

咱们前面学习了两种模板的建立方式:

  • 先调用template.New建立模板,而后使用Parse/ParseFiles解析模板内容;
  • 直接使用template.ParseFiles建立并解析模板文件。

第一种方式,调用template.New建立模板时须要传入一个模板名字,后续调用ParseFiles能够传入一个或多个文件,这些文件中必须有一个基础名(即去掉路径部分)与模板名相同。若是没有文件名与模板名相同,则Execute调用失败,返回错误。例如:

package main

import (
    "log"
    "os"
    "text/template"
)

func main() {
    t := template.New("test")
    t, err := t.ParseFiles("test1")

    if err != nil {
        log.Fatal("Parse error:", err)
    }

    err = t.Execute(os.Stdout, nil)
    if err != nil {
        log.Fatal("Execute error:", err)
    }
}

上面代码先建立模板test,而后解析文件test1。执行该程序会出现下面的错误:

Execute error:template: test: "test" is an incomplete or empty template

Why?

咱们先来看看模板的结构:

// src/text/template.go
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
}

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

模板结构Template中有一个字段commoncommon中又有一个字段tmpl保存名字到模板的映射。其实,最外层的Template结构是主模板,咱们调用Execute方法时执行的就是主模板。
执行ParseFiles方法时,每一个文件都会生成一个模板。只有文件基础名与模板名相同时,该文件的内容才会解析到主模板中。这也是上面的程序执行失败的缘由——主模板为空。
其它文件解析生成关联模板,存储在字段tmpl中。关联模板能够是在主模板中经过{{ define }}动做定义,或者在非主模板文件中定义。关联模板也能够执行,可是须要使用ExecuteTemplate方法,显式传入模板名:

func main()
    t := template.New("test")
    t, err := t.ParseFiles("test1")
    
    if err != nil {
        log.Fatal("in associatedTemplate Parse error:", err)
    }
    
    err = t.ExecuteTemplate(os.Stdout, "test1", nil)
    if err != nil {
        log.Fatal("in associatedTemplate Execute error:", err)
    }
}

第二种方式将建立和解析两步合并在一块儿了。template.ParseFiles方法将传入的第一个文件名做为模板名称,其他的文件(若是有的话)解析后存放在tmpl中。

t, err := template.ParseFiles("file1", "file2", "file3")

其实就等价于:

t := template.New("file1")
t, err := t.ParseFiles("file1", "file2", "file3")

少了不一致的可能性,因此调用Execute方法时不会出现上面的错误。

还有一种建立方式,使用ParseGlob函数。ParseGlob会对匹配给定模式的全部文件进行语法分析。

func main() {
    t, err := template.ParseGlob("tmpl*.glob")
    if err != nil {
        log.Fatal("in globTemplate parse error:", err)
    }

    err = t.Execute(os.Stdout, nil)
    if err != nil {
        log.Fatal(err)
    }

    for i := 1; i <= 3; i++ {
        err = t.ExecuteTemplate(os.Stdout, fmt.Sprintf("tmpl%d.glob", i), nil)
        if err != nil {
            log.Fatal(err)
        }
    }
}

ParseGlob返回的模板以匹配的第一个文件基础名做为名称。ParseGlob解析时会对同一个目录下的文件进行排序,因此第一个文件老是固定的。

咱们建立三个模板文件,tmpl1.glob

In glob template file1.

tmpl2.glob

In glob template file2.

tmpl3.glob

In glob template file3.

最终输出为:

In glob template file1.
In glob template file1.
In glob template file2.
In glob template file3.

注意,若是多个不一样路径下的文件名相同,那么后解析的会覆盖以前的。

嵌套模板

在一个模板文件中还能够经过{{ define }}动做定义其它的模板,这些模板就是嵌套模板。模板定义必须在模板内容的最顶层,像 Go 程序中的全局变量同样。

嵌套模板通常用于布局(layout)。不少文本的结构其实很是固定,例如邮件有标题和正文,网页有首部、正文和尾部等。
咱们能够为这些固定结构的每部分定义一个模板。

定义模板文件layout.tmpl

{{ define "layout" }}
This is body.
{{ template "content" . }}
{{ end }}

{{ define "content" }}
This is {{ . }} content.
{{ end }}

上面定义了两个模板layoutcontentlayout中使用了content。执行这种方式定义的模板必须使用ExecuteTemplate方法:

func main() {
    t, err := template.ParseFiles("layout.tmpl")
    if err != nil {
        log.Fatal("Parse error:", err)
    }

    err = t.ExecuteTemplate(os.Stdout, "layout", "amazing")
    if err != nil {
        log.Fatal("Execute error:", err)
    }
}

嵌套模板在网页布局中应用很是普遍,下一篇文章介绍html/template时还会讲到。

块动做

块动做其实就是定义一个默认模板,语法以下:

{{ block "name" arg }}
T1
{{ end }}

其实它就等价于定义一个模板,而后当即使用它:

{{ define "name" }}
T1
{{ end }}

{{ template "name" arg }}

若是后面定义了模板content,那么使用后面的定义,不然使用默认模板。

例如上面的示例中,咱们将模板修改以下:

{{ define "layout" }}
This is body.
{{ block "content" . }}
This is default content.
{{ end }}
{{ end }}

去掉后面的content模板定义,执行layout时,content部分会显示默认值。

总结

本文介绍了 Go 提供的模板text/template。模板比较简单易用,对于一些细节须要多加留意。代码在Github上。

参考

  1. Go Web 编程
  2. text/template文档

个人博客

欢迎关注个人微信公众号【GoUpUp】,共同窗习,一块儿进步~

相关文章
相关标签/搜索