JSON、文本模板、HTML模板

JSON

JSON是一种发送和接收格式化信息的标准。JSON不是唯一的标准,XML、ASN.1 和 Google 的 Protocol Buffer 都是相似的标准。Go通过标准库 encoding/json、encoding/xml、encoding/asn1 和其他的库对这些格式的编码和解码提供了非常好的支持,这些库都拥有相同的API。

序列化输出

首先定义一组数据:

type Movie struct {
    Title  string
    Year   int  `json:"released"`
    Color  bool `json:"color,omitempty"`
    Actors []string
}

var movies = []Movie{
    {Title: "Casablanca", Year: 1942, Color: false,
        Actors: []string{"Humphrey Bogart", "Ingrid Bergman"}},
    {Title: "Cool Hand Luke", Year: 1967, Color: true,
        Actors: []string{"Paul Newman"}},
    {Title: "Bullitt", Year: 1968, Color: true,
        Actors: []string{"Steve McQueen", "Jacqueline Bisset"}},
}

然后通过 json.Marshal 进行编码:

data, err := json.Marshal(movies)
if err != nil {
    log.Fatalf("JSON Marshal failed: %s", err)
}
fmt.Printf("%s\n", data)

/* 执行结果
[{"Title":"Casablanca","released":1942,"Actors":["Humphrey Bogart","Ingrid Bergman"]},{"Title":"Cool Hand Luke","released":1967,"color":true,"Actors":["Paul Newman"]},{"Title":"Bullitt","released":1968,"color":true,"Actors":["Steve McQueen","Jacqueline Bisset"]}]
*/

这种紧凑的表示方法适合传输,但是不方便阅读。有一个 json.MarshalIndent 的变体可以输出整齐格式化过的结果。多传2个参数,第一个是定义每行输出的前缀字符串,第二个是定义缩进的字符串:

data, err := json.MarshalIndent(movies, "", "    ")
if err != nil {
    log.Fatalf("JSON Marshal failed: %s", err)
}
fmt.Printf("%s\n", data)

/* 执行结果
[
    {
        "Title": "Casablanca",
        "released": 1942,
        "Actors": [
            "Humphrey Bogart",
            "Ingrid Bergman"
        ]
    },
    {
        "Title": "Cool Hand Luke",
        "released": 1967,
        "color": true,
        "Actors": [
            "Paul Newman"
        ]
    },
    {
        "Title": "Bullitt",
        "released": 1968,
        "color": true,
        "Actors": [
            "Steve McQueen",
            "Jacqueline Bisset"
        ]
    }
]
*/

只有可导出的成员可以转换为JSON字段,上面的例子中用的都是大写。
成员标签(field tag),是结构体成员的编译期间关联的一些元素信息。标签值的第一部分指定了Go结构体成员对应的JSON中字段的名字。
另外,Color标签还有一个额外的选项 omitempty,它表示如果这个成员的值是零值或者为空,则不输出这个成员到JSON中。所以Title为"Casablanca"的JSON里没有color。

反序列化

反序列化操作将JSON字符串解码为Go数据结构。这个是由 json.Unmarshal 实现的。

var titles []struct{ Title string }
if err := json.Unmarshal(data, &titles); err != nil {
    log.Fatalf("JSON unmarshaling failed: %s", err)
}
fmt.Println(titles)

/* 执行结果
[{Casablanca} {Cool Hand Luke} {Bullitt}]
*/

这里接收数据时定义的结构体只有一个Title字段,这样当函数 Unmarshal 调用完成后,将填充结构体切片中的 Title 值,而JSON中其他的字段就丢弃了。

Web 应用

很多的 Web 服务器都提供 JSON 接口,通过发送HTTP请求来获取想要得到的JSON信息。下面通过查询Github提供的 issue 跟踪接口来演示一下。

定义结构体

首先,定义好类型,顺便还有常量:

// ch4/github/github.go
// https://api.github.com/ 提供了丰富的接口
// 提供查询GitHub的issue接口的API
// GitHub上有详细的API使用说明:https://developer.github.com/v3/search/#search-issues-and-pull-requests
package github

import "time"

const IssuesURL = "https://api.github.com/search/issues"

type IssuesSearchResult struct {
    TotalCount int `json:"total_count"`
    Items      []*Issue
}

type Issue struct {
    Number   int
    HTMLURL  string `json:"html_url"`
    Title    string
    State    string
    User     *User
    CreateAt time.Time `json:"created_at"`
    Body     string    // Markdown 格式
}

type User struct {
    Login   string
    HTMLURL string `json:"html_url"`
}

关于字段名称,即使对应的JSON字段的名称都是小写的,但是结构体中的字段必须首字母大写(不可导出的字段也无法把JSON数据导入)。这种情况很普遍,这里可以偷个懒。在 Unmarshal 阶段,JSON字段的名称关联到Go结构体成员的名称是忽略大小写的,这里也不需要考虑序列化的问题,所以很多地方都不需要写成员标签。不过,小写的变量在需要分词的时候,可能会使用下划线分割,这种情况下,还是要用一下成员标签的。
这里也是选择性地对JSON中的字段进行解码,因为相对于这里演示的内容,GitHub的查询返回的信息是相当多的。

请求获取JSON并解析

函数 SearchIssues 发送HTTP请求并将返回的JSON字符串进行解析。
关于Get请求的参数,参数中可能会出现URL格式里的特殊字符,比如 ?、&。因此要使用 url.QueryEscape 函数进行转义。

// ch4/github/search.go
package github

import (
    "encoding/json"
    "fmt"
    "net/http"
    "net/url"
    "strings"
)

// 查询GitHub的issue接口
func SearchIssues(terms []string) (*IssuesSearchResult, error) {
    q := url.QueryEscape(strings.Join(terms, " "))
    resp, err := http.Get(IssuesURL + "?q=" + q)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("search query failed: %s", resp.Status)
    }

    var result IssuesSearchResult
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, err
    }
    return &result, nil
}

流式解码噐
之前是使用 json.Unmarshal 进行解码,而这里使用流式解码噐。它可以依次从字节流中解码出多个JSON实体,不过这里没有用到该功能。另外还有对应的 json.Encoder 的流式编码器。
调用 Decode 方法后,就完成了对变量 result 的填充。

调用执行

最后就是将 result 中的内容进行格式化输出,这里用了固定宽度的方法将结果输出为类似表格的形式:

// ch4/issues/main.go
// 将符合条件的issue输出为一个表格
package main

import (
    "fmt"
    "gopl/ch4/github"
    "log"
    "os"
)

func main() {
    result, err := github.SearchIssues(os.Args[1:])
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%d issue: \n", result.TotalCount)
    for _, item := range result.Items {
        fmt.Printf("#%-5d %9.9s %.55s\n", item.Number, item.User.Login, item.Title)
    }
}

使用命令行参数指定搜索条件,该命令搜索 Go 项目里的 issue 接口,查找 open 状态的列表。由于返回的还是很多,后面的参数是对内容再进行筛选:

PS H:\Go\src\gopl\ch4\issues> go run main.go repo:golang/go is:open json decoder tag
6 issue:
#28143 Carpetsmo proposal: encoding/json: add "readonly" tag
#14750 cyberphon encoding/json: parser ignores the case of member names
#17609 nathanjsw encoding/json: ambiguous fields are marshalled
#22816 ganelon13 encoding/json: include field name in unmarshal error me
#19348 davidlaza cmd/compile: enable mid-stack inlining
#19109  bradfitz proposal: cmd/go: make fuzzing a first class citizen, l
PS H:\Go\src\gopl\ch4\issues>

文本模板

进行简单的格式化输出,使用fmt包就足够了。但是要实现更复杂的格式化输出,并且有时候还要求格式和代码彻底分离。这可以通过 text/templat 包和 html/template 包里的方法来实现,通过这两个包,可以将程序变量的值代入到模板中。

模板表达式

模板,是一个字符串或者文件,它包含一个或者多个两边用双大括号包围的单元,这称为操作。大多数字符串是直接输出的,但是操作可以引发其他的行为。
每个操作在模板语言里对应一个表达式,功能包括:

  • 输出值
  • 选择结构体成员
  • 调用函数和方法
  • 描述控逻辑
  • 实例化其他的模板

这篇里有表达式的介绍: https://blog.51cto.com/steed/2321827

继续使用 GitHub 的 issue 接口返回的数据,这次使用模板来输出。一个简单的字符串模板如下所示:

const templ = `{{.TotalCount}} issues:
{{range .Items}}----------------------------------------
Number: {{.Number}}
User:   {{.User.Login}}
Title:  {{.Title | printf "%.64s"}}
Age:    {{.CreatedAt | daysAgo}} days
{{end}}`

点号(.)表示当前值的标记。最开始的时候表示模板里的参数,也就是 github.IssuesSearchResult。
操作 {{.TotalCount}} 就是 TotalCount 字段的值。
{{range .Items}} 和 {{end}} 操作创建一个循环,这个循环内部的点号(.)表示Items里的每一个元素。
在操作中,管道符(|)会将前一个操作的结果当做下一个操作的输入,这个和UNIX里的管道类似。
{{.Title | printf "%.64s"}},这里的第二个操作是printf函数,在包里这个名称对应的就是fmt.Sprintf,所以会按照fmt.Sprintf函数返回的样式输出。
{{.CreatedAt | daysAgo}},这里的第二个操作数是 daysAgo,这是一个自定义的函数,具体如下:

func daysAgo(t time.Time) int {
    return int(time.Since(t).Hours() / 24)
}

模板输出的过程

通过模板输出结果需要两个步骤:

  1. 解析模板并转换为内部表示的方法
  2. 在指定的输入上执行(就是执行并输出)

解析模板只需要执行一次。下面的代码创建并解析上面定义的文本模板:

report, err := template.New("report").
    Funcs(template.FuncMap{"daysAgo": daysAgo}).
    Parse(templ)
if err != nil {
    log.Fatal(err)
}

这里使用了方法的链式调用。template.New 函数创建并返回一个新的模板。
Funcs 方法将自定义的 daysAgo 函数到内部的函数列表中。之前提到的printf实际对应的是fmt.Sprintf,也是在包内默认就已经在这个函数列表里了。如果有更多的自定义函数,就多次调用这个方法添加。
最后就是调用Parse进行解析。
上面的代码完成了创建模板,添加内部可调用的 daysAgo 函数,解析(Parse方法),检查(检查err是否为空)。现在就可以调用report的 Execute 方法,传入数据源(github.IssuesSearchResult,这个需要先调用github.SearchIssues函数来获取),并指定输出目标(使用 os.Stdout):

if err := report.Execute(os.Stdout, result); err != nil {
    log.Fatal(err)
}

之前的代码比较凌乱,下面出完整可运行的代码:

package main

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

    "gopl/ch4/github"
)

const templ = `{{.TotalCount}} issues:
{{range .Items}}----------------------------------------
Number: {{.Number}}
User:   {{.User.Login}}
Title:  {{.Title | printf "%.64s"}}
Age:    {{.CreatedAt | daysAgo}} days
{{end}}`

// 自定义输出格式的方法
func daysAgo(t time.Time) int {
    return int(time.Since(t).Hours() / 24)
}

func main() {
    // 解析模板
    report, err := template.New("report").
        Funcs(template.FuncMap{"daysAgo": daysAgo}).
        Parse(templ)
    if err != nil {
        log.Fatal(err)
    }
    // 获取数据
    result, err := github.SearchIssues(os.Args[1:])
    if err != nil {
        log.Fatal(err)
    }
    // 输出
    if err := report.Execute(os.Stdout, result); err != nil {
        log.Fatal(err)
    }
}

这个版本还可以改善,下面对解析错误的处理进行了改进

帮助函数 Must

由于目标通常是在编译期间就固定下来的,因此无法解析将会是一个严重的bug。上面的版本如果无法解析(去掉个大括号试试),只会以比较温和的方式报告出来。
这里推荐使用帮助函数 template.Must,模板错误会Panic:

package main

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

    "gopl/ch4/github"
)

const templ = `{{.TotalCount}} issues:
{{range .Items}}----------------------------------------
Number: {{.Number}}
User:   {{.User.Login}}
Title:  {{.Title | printf "%.64s"}}
Age:    {{.CreatedAt | daysAgo}} days
{{end}}`

// 自定义输出格式的方法
func daysAgo(t time.Time) int {
    return int(time.Since(t).Hours() / 24)
}

// 使用帮助函数
var report = template.Must(template.New("issuelist").
    Funcs(template.FuncMap{"daysAgo": daysAgo}).
    Parse(templ))

func main() {
    result, err := github.SearchIssues(os.Args[1:])
    if err != nil {
        log.Fatal(err)
    }
    if err := report.Execute(os.Stdout, result); err != nil {
        log.Fatal(err)
    }
}

和上个版本的区别就是解析的过程外再包了一层 template.Must 函数。而效果就是原本解析错误是调用 log.Fatal(err) 来退出,这个调用也是自己的代码里指定的。
而现在是调用 panic(err) 来退出,并且会看到一个更加严重的错误报告(错误信息是一样的),并且这个也是包内部提供的并且推荐的做法。
最后是输出的结果:

PS H:\Go\src\gopl\ch4\issuesreport> go run main.go repo:golang/go is:open json decoder tag
6 issues:
----------------------------------------
Number: 28143
User:   Carpetsmoker
Title:  proposal: encoding/json: add "readonly" tag
Age:    135 days
----------------------------------------
Number: 14750
User:   cyberphone
Title:  encoding/json: parser ignores the case of member names
Age:    1079 days
----------------------------------------
...

HTML 模板

接着看 html/template 包。它使用和 text/template 包里一样的 API 和表达式语法,并且额外地对出现在 HTML、JavaScript、CSS 和 URL 中的字符串进行自动转义。这样可以避免在生成 HTML 是引发一些安全问题。

使用模板输出页面

下面是一个将 issue 输出为 HTML 表格代码。由于两个包里的API是一样的,所以除了模板本身以外,GO代码没有太大的差别:

package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
)

import (
    "gopl/ch4/github"
    "html/template"
)

var issueList = template.Must(template.New("issuelist").Parse(`
<h1>{{.TotalCount}} issues</h1>
<table>
<tr style='text-align: left'>
  <th>#</th>
  <th>State</th>
  <th>User</th>
  <th>Title</th>
</tr>
{{range .Items}}
<tr>
  <td><a href='{{.HTMLURL}}'>{{.Number}}</a></td>
  <td>{{.State}}</td>
  <td><a href='{{.User.HTMLURL}}'>{{.User.Login}}</a></td>
  <td>{{.Title}}</td>
</tr>
{{end}}
</table>
`))

func main() {
    result, err := github.SearchIssues(os.Args[1:])
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("http://localhost:8000")
    handler := func(w http.ResponseWriter, r *http.Request) {
        showIssue(w, result)
    }
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe("localhost:8000", nil))
}

func showIssue(w http.ResponseWriter, result *github.IssuesSearchResult) {
    if err := issueList.Execute(w, result); err != nil {
        log.Fatal(err)
    }
}

template.HTML 类型

通过模板的操作导入的字符串,默认都会按照原样显示出来。就是会把HTML的特殊字符自动进行转义,效果就是无法通过模板导入的内容生成html标签。
如果就是需要通过模板的操作再导入一些HTML的内容,就需要使用 template.HTML 类型。使用 template.HTML 类型后,可以避免模板自动转义受信任的 HTML 数据。同样的类型还有 template.CSS、template.JS、template.URL 等,具体可以查看源码。
下面的操作演示了普通的 string 类型和 template.HTML 类型在导入一个 HTML 标签后显示效果的差别:

package main

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

func main() {
    const templ = `<p>A: {{.A}}</p><p>B: {{.B}}</p>`
    t := template.Must(template.New("escape").Parse(templ))
    var data struct {
        A string        // 不受信任的纯文本
        B template.HTML // 受信任的HTML
    }
    data.A = "<b>Hello!</b>"
    data.B = "<b>Hello!</b>"

    fmt.Println("http://localhost:8000")
    handler := func(w http.ResponseWriter, r *http.Request) {
        if err := t.Execute(w, data); err != nil {
            log.Fatal(err)
        }
    }
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe("localhost:8000", nil))
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章