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))
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章