Go 語言中的 Web 編程(net/http) 與 模板渲染引擎


楔子

這次我們來看一下Go的Web編程,Go的生態里面也出現了很多優秀的Web框架,比如:Gin、Beego等等,但是這里我們使用的是標准庫net/http。雖然它是一個標准庫,但是代碼本身質量非常的高,即便是使用這個內置的庫也依舊可以讓你實現很高的並發量,下面我們就來看看吧。

快速入門

接下來我們編寫一個服務返回一個字符串,看看Go語言如何實現。

package main

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

func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "你好呀, 夏色祭")
}

func main() {
    http.HandleFunc("/", hello)
    if err := http.ListenAndServe("localhost:8889", nil); err != nil {
        log.Fatal(err)
    }
}

然后我們執行文件,一個服務就啟動了;然后打開瀏覽器,輸入localhost:8889,一個字符串就顯示在網頁上了。

下面我們來解釋一下上面那段程序都做了些什么?

http.HandleFunc 將函數 hello 注冊到 路徑/ 上面;hello 函數我們也叫做處理函數(在其它語言中也叫做視圖函數),它接收兩個參數:

  • 第一個參數是類型為http.ResponseWriter的接口, 響應就是通過它發送給客戶端的;
  • 第二個參數是類型為http.Request的結構體指針,客戶端發送的信息都可以通過它來獲取;

http.ListenAndServer 表示在指定的端口上監聽請求,一旦有請求過來便會進行路由匹配,不同的路由交由不同的處理函數進行處理。

多路復用器

一個典型的 Go Web 程序結構如下:

  • 客戶端發送請求;
  • 服務端的多路復用器收到請求;
  • 多路復用器根據請求的URL找到注冊的處理函數,將請求交由處理函數處理;
  • 處理函數執行任務邏輯,必要時和數據庫進行交互,得到處理結果;
  • 處理函數調用模板引擎將指定的模板和上一步得到的結果進行組合,渲染成客戶端可識別的數據格式(通常是HTML);
  • 最后將數據通過響應返回給客戶端;
  • 客戶端拿到數據,執行對應的操作,例如渲染出來呈現給用戶;

net/http包內置了一個默認的多路復用器:DefaultServeMux,定義如下:

// src/net/http/server.go

// DefaultServeMux is the default ServeMux used by Serve.
var DefaultServeMux = &defaultServeMux
var defaultServeMux ServeMux

net/http 包中很多方法都在內部調用DefaultServeMux的對應方法,如HandleFunc。我們知道,HandleFunc是為指定的URL注冊一個處理函數,其內部實現如下:

func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    DefaultServeMux.HandleFunc(pattern, handler)
}

實際上,http.HandleFunc方法是將處理函數注冊到DefaultServeMux中的,所以請求過來的時候會經過多路復用器,然后多路復用器根據URL找到對應處理函數;另外我們使用 "127.0.0.1:8889" 和 nil 做為參數調用 http.ListenAndServe 時,會創建一個默認的服務器:

func ListenAndServe(addr string, handler Handler) error {
    // 傳遞監聽的地址 和 handler, 創建一個服務器
    // 我們之前傳遞的的 handler 是 nil, 因為會有一個默認的多路復用器: DefaultServeMux, 我們注冊函數的時候也是注冊到這里面的
    server := &Server{Addr: addr, Handler: handler}
    return server.ListenAndServe()
}

這個服務器默認使用 DefaultServeMux 來處理器請求:

type serverHandler struct {
    srv *Server
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
    handler := sh.srv.Handler
    if handler == nil {
        handler = DefaultServeMux
    }
    handler.ServeHTTP(rw, req)
}

服務器收到的每個請求都會調用多路復用器(DefaultServeMux)的 ServeHTTP 方法,在該方法中會根據URL查找我們注冊的處理器函數,然后將請求交給它處理。

雖然默認的多路復用器很方便,但是在生產中不建議使用。因為 DefaultServeMux 是一個全局變量,所有代碼、包括第三方代碼都可以修改它,有些第三方代碼會在 DefaultServeMux 上注冊一些處理器,這可能和我們注冊的處理器沖突。

創建多路復用器

因此好的辦法是我們自己創建多路復用器,而不是使用默認的,而創建的方式也很簡單,直接調用 http.NewServeMux 即可,然后在新創建的多路復用器上注冊處理器。

package main

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

func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "你好呀, 夏色祭")
}


func main() {
    // 創建新的多路復用器, 叫mux
    mux := http.NewServeMux()
    // 將路由和處理函數注冊到我們自己創建的多路復用器中
    // 而且使用的是mux.HandleFunc, 要是http.HandleFunc的話會注冊到全局默認的多路復用器DefaultServeMux中
    mux.HandleFunc("/", hello)
    
    // 創建服務器, 默認創建的服務器使用DefaultServeMux這個多路復用器
    // 這里我們自己創建服務器, 並將多路復用器指定為我們自己創建的mux
    server := &http.Server{
        Addr: "localhost:8889",
        Handler: mux,
    }
    
    // 這里不通過http.ListenAndServe, 那樣的話會啟動默認的服務器
    // 這里使用server.ListenAndServe, 調用我們創建的服務器開啟監聽
    if err := server.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

然后啟動服務,依舊是可以正常訪問的,並且我們在創建服務器的時候還可以定制化更多的參數,比如:

server := &http.Server{
    Addr:         ":8080",
    Handler:      mux,
    ReadTimeout:  1 * time.Second,
    WriteTimeout: 1 * time.Second,
}

上面便指定了一個讀超時和寫超時均為1s的服務器,當然這里面的參數遠不止這些,有興趣可以自己去看看。

處理器和處理器函數

處理器和處理器函數之間有什么區別呢?我們說在多路復用器會根據URL找到對應的處理器函數,那么處理器是什么呢?其實,處理器不過是一個接口罷了,我們來看一下:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

這個接口里面只有一個函數,我們看一下這個函數,參數是不是和我們的處理器函數是一個樣子呢?沒錯,我們看一下我們在注冊路由和處理器函數的時候都做了些什么就知道了。

// 我們以http.HandleFunc為例, 當然我們自己創建的多路復用器也是一樣的
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    // 接收一個 pattern 和 一個處理器函數
    // 注冊到多路復用器中
    DefaultServeMux.HandleFunc(pattern, handler)
}

// 這里的ServeMux就是全局的多路復用器
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {	
    // pattern就是路由, handler就是處理函數
    if handler == nil {
        panic("http: nil handler")
    }
    // 重點來了, mux.Handler就是具體的注冊邏輯了, 不過我們不關心
    // 我們重點關注一下這個 HandlerFunc, 顯然這是將我們定義的處理函數轉成了HandlerFunc類型
    mux.Handle(pattern, HandlerFunc(handler))
}

// 我們看到這個 HandlerFunc 只不過是一個函數類型的別名罷了, 而且這個函數的格式和我們的處理函數是一樣的
// 有人覺得這不是脫褲子放屁嗎? 別急, 往下看
type HandlerFunc func(ResponseWriter, *Request)

// 然后這個HandlerFunc實現了一個方法, 注意: 不光結構體可以實現方法, 只要是通過type聲明的類型都是可以的
// 我們看到它實現了一個ServeHTTP方法, 但是調用的時候執行的是函數f, 顯然這里的f就是我們的處理函數
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

所以我們看到了,繞了這么一圈,最終執行的還是我們自己定義的處理函數。只不過它是將我們定義的處理函數使用 HandlerFunc 包裝了一下,並且實現了一個 ServeHTTP方法。這樣就實現了上面的接口Handler,然后通過Handler調用ServeHTTP,也就是HandlerFunc調用ServeHTTP,最終調用的是我們定義的處理函數。

而實現接口Handler的類型,其對象就是處理器,而最終執行的函數就是處理函數。

所以這便是Go語言中的接口,定義一個接口,不管多少個處理函數,只要實現了接口中的函數,我們就可以通過接口進行調用。而實現了接口的對象,我們需要通過Handle方法注冊, 而不是HandleFunc:

// 我們看到Handle的第二個參數接收的是一個Handler, 我們說它是一個接口, 只要實現了ServeHTTP的對象都可以傳遞給它
func (mux *ServeMux) Handle(pattern string, handler Handler) {
}    

// 而HandleFunc本質上還是調用了Handle, 只不過第二個參數不一樣
func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {	
    if handler == nil {
        panic("http: nil handler")
    }
    // HandleFunc的第二個參數傳入一個處理函數即可, 會自動使用HandlerFunc幫你包裝, 而HandlerFunc實現了ServeHTTP方法
    mux.Handle(pattern, HandlerFunc(handler))
}

我們程序中演示一下,就通過結構體的方式吧:

package main

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

type Girl struct {
    handlerFunc func (http.ResponseWriter, *http.Request)
}

func (g *Girl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    g.handlerFunc(w, r)
}

func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "你好呀, 夏色祭")
}

func main() {
    mux := http.NewServeMux()
    
    // 這里使用Handle注冊, 然后將hello函數傳遞到Girl里面創建結構體, 因為Girl這個結構體實現了ServeHTTP方法
    mux.Handle("/", &Girl{hello})

    server := &http.Server{
        Addr:    "localhost:8889",
        Handler: mux,
    }
    
    if err := server.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

依舊可以正常訪問的,怎么樣是不是很簡單呢。

這里不得不提一句,我本人既用Python、也用Go。個人的感覺是Go語言確實比Python語言要簡單,雖然Go是編譯型語言,但它確實很容易。

而且個人感受最深的就是查看源碼的時候,你使用Goland這樣的IDE(Jetbrains公司針對的其它語言的IDE也是同理),你都可以通過Ctrl加上鼠標左鍵跳轉到對應的源碼的指定位置進行查看。對於Go語言而言,不管程序多復雜,你都可以追根溯源進行跳轉;而Python的話,你跳着跳着發現跳不動了,當然這不是對應的Python IDE做的不好,而是動態語言的特性使得你沒辦法這樣。

所以沒事可以多看看源碼,尤其是Go標准庫,寫的非常的棒。比如這里的http庫,雖然是標准庫,但是代碼質量很高,可以讓你輕松獲得很高的並發量。

另外,我們發現這里面的幾個接口和方法名是不是很容易混淆呢?比如:Handler、Handle、HandleFunc、HandlerFunc,下面再來總結區分一下:

  • Handler: 處理器接口, 實現該接口的類型, 其對象可以注冊到多路復用器中;
  • Handle: 注冊處理器的方法;
  • HandleFunc: 注冊處理器函數的方法;
  • HandlerFunc: 底層類型為 func (w ResponseWriter, r *Request) 的新類型, 實現了 Handler 接口, 它連接了處理器函數與處理器;

URL 匹配

⼀般的 Web 服務器會綁定非常多的 URL,不同的 URL 對應不同的處理器(處理函數)。但是服務器是怎么決定使用哪個處理器的呢?例如,我們現在綁定了 3 個 URL, /、 /hello 和 /hello/world。

顯然:

  • 如果請求的URL是/, 那么會調用/對應的處理器;
  • 如果請求的URL是/hello, 那么會調用/hello對應的處理器;
  • 如果請求的URL是/hello/world, 那么會調用/hello/world對應的處理器;

但是,如果請求的是 /hello/others ,那么使用哪一個處理器呢? 匹配遵循以下規則:

  • 首先, 精確匹配; 即查找是否有/hello/others對應的處理器, 如果有, 則查找結束; 如果沒有, 執行下一步;
  • 將路徑的最后一個部分去掉, 再次查找; 即查找/hello/對應的處理器, 如果有, 則查找結束; 如果沒有, 繼續執行下一步, 即查找/對應的處理器;

這里有一個注意點,如果注冊的 URL 不是以 / 結尾的,那么它只能精確匹配請求的 URL。反之,即使請求的 URL 只有前綴與被綁定的 URL 相同, ServeMux 也認為它們是匹配的。

比如當路由是 /hello 時,那么我們請求 /hello 可以匹配,但是請求 /hello/ 則無法匹配,因為 /hello 不以 / 結尾,需要精確匹配才可以;同理當我們訪問 /hello/others,那么在匹配不到時也會退而求其次尋找 /hello/,而不是/hello。

反之,如果我們綁定的URL為 /hello/,那么訪問 /hello 和 /hello/ 都是可以得到返回的;當然在 /hello/others 匹配不到的時候,也會退而求其次尋找 /hello/。

所以總結一下:

  • URL為/hello, 訪問時只能通過/hello, 不能通過/hello/; 同理/hello/others在無法匹配的時候會嘗試匹配/hello/, 不會匹配匹配/hello;
  • URL為/hello/, 訪問時既可以通過/hello/也可以通過/hello; 在/hello/others無法匹配的時候會匹配/hello/;

舉個栗子:

package main

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

func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "你好呀, 夏色祭")
}

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("/hello1", hello)
    mux.HandleFunc("/hello2/", hello)

    server := &http.Server{
        Addr: "localhost:8889",
        Handler: mux,
    }

    if err := server.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

/hello1可以訪問,/hello1/無法訪問,/hello1/xxx無法訪問;/hello2、/hello2/、/hello2/xxx、/hello2/xxx/xxx均可訪問。

如果我們再給 / 定義一個處理函數,那么 /hello1/ 、/hello1/xxx、/hello1/xxx/xxx 都會執行 / 對應的處理函數,因為會不斷去掉路徑中的最后一個部分、向上查找。

http 請求

我們在處理函數中的第二個參數是 r *http.Request,這個r是一個結構體,對應客戶端的請求;客戶端傳遞的數據都可以通過這個r來獲取,我們看一下它的結構:

type Request struct {
	Method string
	URL *url.URL
	Proto      string // "HTTP/1.0"
	ProtoMajor int    // 1
	ProtoMinor int    // 0
	Header Header
	Body io.ReadCloser
	GetBody func() (io.ReadCloser, error)
	ContentLength int64
	TransferEncoding []string
	Close bool
	Host string
	Form url.Values
	PostForm url.Values
	MultipartForm *multipart.Form
	Trailer Header
	RemoteAddr string
	RequestURI string
	TLS *tls.ConnectionState
	Cancel <-chan struct{}
	Response *Response
	ctx context.Context
}

解釋一下里面的幾個字段:

Method:

表示客戶端調用的方法,比如:GET/POST/PUT/DELETE等等;服務端會根據不同的方法進行不同的處理, 例如GET方法是獲取信息, POST方法創建新的資源等等

URL:

統一資源定位符。URL有兩部分組成,一部分表示資源的名稱,即:統一資源名稱;另一部分表示資源的位置,即:統一資源定位符。

我們來看一下定義:

type URL struct {
	Scheme     string
	Opaque     string    // encoded opaque data
	User       *Userinfo // username and password information
	Host       string    // host or host:port
	Path       string    // path (relative paths may omit leading slash)
	RawPath    string    // encoded path hint (see EscapedPath method)
	ForceQuery bool      // append a query ('?') even if RawQuery is empty
	RawQuery   string    // encoded query values, without '?'
	Fragment   string    // fragment for references, without '#'
}

可以通過請求對象中的 URL 字段獲取這些信息。

func urlHandler(w http.ResponseWriter, r *http.Request) {
    URL := r.URL

    fmt.Fprintf(w, "Scheme: %s\n", URL.Scheme)
    fmt.Fprintf(w, "Host: %s\n", URL.Host)
    fmt.Fprintf(w, "Path: %s\n", URL.Path)
    fmt.Fprintf(w, "RawPath: %s\n", URL.RawPath)
    fmt.Fprintf(w, "RawQuery: %s\n", URL.RawQuery)
    fmt.Fprintf(w, "Fragment: %s\n", URL.Fragment)
}

此外我們還可以通過 URL 結構體得到一個 URL 字符串,以及解析出里面的查詢參數:

URL := url.URL{
        Scheme:   "http",
        Host:     "example.com",
        Path:     "/posts",
        RawQuery: "page=1&count=10",
        Fragment: "main",
    }
fmt.Println(URL.String())  // http://example.com/posts?page=1&count=10#main
// 返回的是一個map[string][]string
fmt.Println(URL.Query())   // map[count:[10] page:[1]]

Proto:

Proto 表示 HTTP 協議版本,如 HTTP/1.1 , ProtoMajor 表示大版本, ProtoMinor 表示小版本。

func protoFunc(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Proto: %s\n", r.Proto)
    fmt.Fprintf(w, "ProtoMajor: %d\n", r.ProtoMajor)
    fmt.Fprintf(w, "ProtoMinor: %d\n", r.ProtoMinor)
}
// 這里是在main函數中的, 但為了表達直觀, 我直接寫在外面了, 后面同理
// 當然你也可以自己隨便定義一個路由
mux.HandleFunc("/proto", protoFunc)

訪問對應路由會得到如下輸出:

Proto: HTTP/1.1
ProtoMajor: 1
ProtoMinor: 1

Header:

Header 中存放的客戶端發送過來的首部信息,鍵值對的形式。Header 類型底層其實是 map[string][]string

type Header map[string][]string

注意到 Header 值為 []string 類型,存放相同的鍵的多個值。瀏覽器發起 HTTP 請求的時候,會自動添加一些首部。我們編寫一個程序來看看:

func headerHandler(w http.ResponseWriter, r *http.Request) {
    for key, value := range r.Header {
        fmt.Fprintf(w, "%s: %v\n", key, value)
    }
}
mux.HandleFunc("/header", headerHandler)

啟動服務器,瀏覽器請求 localhost:8080/header 返回:

Accept-Enreading: [gzip, deflate, br]
Sec-Fetch-Site: [none]
Sec-Fetch-Mode: [navigate]
Connection: [keep-alive]
Upgrade-Insecure-Requests: [1]
User-Agent: [Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36(KHTML, like Gecko) Chrome/79.0.1904.108 Safari/537.36]
Sec-Fetch-User: [?1]
Accept:
[text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/
*;q=0.8,application/signed-exchange;v=b3]
Accept-Language: [zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7]

我當前使用的是 Chrome 瀏覽器,不同的瀏覽器添加的首部不完全相同,常見的首部有:

  • Accept: 客戶端想要服務器發送的內容類型;
  • Accept-Charset: 表示客戶端能接受的字符編碼;
  • Content-Length: 請求主體的字節長度, 一般在POST/PUT請求中較多;
  • Content-Type: 當包含請求主體的時候, 這個首部用於記錄主體內容的類型。在發送POST或PUT請求時, 內容的類型默認為x-www-form-urlencoded。但是在上傳文件時, 應該設置類型為 multipart/form-data;
  • User-Agent: 用於描述發起請求的客戶端信息, 比如瀏覽器的種類等等;

Content-Length/Body:

Content-Length 表示請求體的字節長度,請求體的內容可以從 Body 字段中讀取。細心的小伙伴可能發現了 Body 字段是一個 io.ReadCloser 接口。在讀取之后要關閉它,否則會有資源泄露。可以使用 defer 簡化代碼編寫:

func bodyHandler(w http.ResponseWriter, r *http.Request) {
    data := make([]byte, r.ContentLength)
    r.Body.Read(data) // 忽略錯誤處理
    defer r.Body.Close()

    fmt.Fprintln(w, string(data))
}
mux.HandleFunc("/body", bodyHandler)

上面代碼將客戶端傳來的請求體內容回傳給客戶端,當然還可以使用 io/ioutil 包簡化讀取操作:

data, _ := ioutil.ReadAll(r.Body)

直接在瀏覽器中輸入 URL 發起的是 GET 請求,無法攜帶請求體。但是有很多種方式可以發起帶請求體的請求,比如使用表單,一會說。關於 Body字段,我們后面也還會說。

Form:

使用 x-www-form-urlencoded 編碼的請求體,在處理時首先調用請求的 ParseForm 方法解析,然后從 Form 字段中取數據:

func indexHandler(w http.ResponseWriter, r *http.Request) {
    // 如果是GET請求, 那么輸入表單
    if r.Method == "GET" {
        fmt.Fprint(w, `
<html>
 <head>
 <title>Go Web</title>
 </head>
 <body>
 <form method="post" action="/body?a=123&b=456" enctype="application/x-www-form-urlencoded">
 <label for="username">⽤戶名:</label>
 <input type="text" id="username" name="username">
 <label for="email">郵箱:</label>
 <input type="text" id="email" name="email">
 <button type="submit">提交</button>
 </form>
 </body>
</html>
`)
    } else {
        // 否則是POST請求, 解析剛才在表單中輸入的數據
        r.ParseForm()
        Form := r.Form
        RawQuery := r.URL.RawQuery
        fmt.Fprintf(w, "Form: %v\nRawQuery: %v\n", Form, RawQuery)
    }
}

注意表單里面的action,里面自帶了查詢參數,這個參數會同時體現在URL和表單中。啟動服務器,進入主頁 localhost:8080/body,顯示表單。填寫完成后,點擊提交。瀏覽器向服務器發送 POST 請求,URL 為 /body , bodyHandler 處理完成后將包體回傳給客戶端。上面的數據使用了 x-www-form-urlencoded 編碼,這是表單的默認編碼。

我們點擊提交,看看結果:

我們看到表單實際上就是一個map,而且值是一個切片;另外查詢參數,它除了體現在URL中,對於POST請求也會體現在表單中。

action 表示提交表單時請求的 URL, method 表示請求的方法。如果使用 GET 請求,由於 GET 方法沒有請求體,參數將會拼接到 URL 尾部;

enctype 指定請求體的編碼方式,默認為 application/x-www-form-urlencoded 。如果需要發 送文件,必須指定為 multipart/form-data;

PostForm:

如果一個請求,同時有 URL 鍵值對和表單數據,而用戶只想獲取表單數據,可以使用 PostForm 字段。 使用 PostForm 只會返回表單數據,不包括 URL 鍵值,我們在上面的例子中再輸出一個PostForm對比一下就清楚了。這里代碼就不貼了,只是多了一個輸出而已。

Form: map[a:[123] b:[456] email:[satori@gmail.com] username:[satori]]
RawQuery: a=123&b=456
PostForm: map[email:[satori@gmail.com] username:[satori]]

我們看到查詢參數沒有體現在PostForm中。

MultipartForm:

如果要處理上傳的文件,那么就必須使用 multipart/form-data 編碼。與之前的 Form/PostForm 類 似,處理 multipart/form-data 編碼的請求時,也需要先解析后使用。只不過使用的方法不同,解析使用的不再是 r.ParseForm,而是 r.ParseMultipartForm,之后從 MultipartForm 字段中取值。

func multipartFormHandler(w http.ResponseWriter, r *http.Request) {
    // GET請求的話, 直接輸入表單
    if r.Method == "GET" {
        fmt.Fprint(w, `
    <!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Go Web</title>
</head>
<body>
    <form action="/multipartform" method="post"
          enctype="multipart/form-data">
        <label>name:</label>
        <input type="text" name="name" />
        <label>age:</label>
        <input type="text" name="age" />
        <label>file:</label>
        <input type="file" name="uploaded" />
        <button type="submit">提交</button>
    </form>
</body>
</html>
`)
    } else {
        // 上傳之后, 進行解析; 接收一個整型, 表示上傳文件大小的最大值
        r.ParseMultipartForm(1024)

        // r.MultipartForm表示上傳的文件信息, 它是一個結構體
        // 里面一個 Value map[string][]string, 一個File map[string][]*FileHeader
        // r.MultipartForm.Value顯然是表單信息, r.MultipartForm.File是文件信息
        fmt.Fprintln(w, r.MultipartForm)  // 直接把信息返回
        
        // 文件內容都在r.MultipartForm.File里面, 但是我們看到File這個map的值對應的是[]*FileHeader, 都是指針, 所以直接返回的話只是一個地址
        // 但是我們通過地址是可以得到文件內容的
        // 調用File["uploaded"], 獲取對應的[]*FileHeader, "uploaded"是表單中的名字
        // 同理因為可以上傳多個文件, 所以是一個切片; 這里我們只上傳一個文件, 所以獲取第一個元素即可; 
        // 但如果不上傳文件的話顯然會索引越界, 因此還可以再做一層檢測, 看看切片長度是否為0, 這里我就不做檢測了
        fileHeader := r.MultipartForm.File["uploaded"][0]

        // 調用Open方法, 返回一個io.Reader 和 一個error
        file, err := fileHeader.Open()
        if err != nil {
            fmt.Fprintf(w, "Open failed: %v", err)
            return
        }
        // 直接讀取
        data, _ := ioutil.ReadAll(file)
        fmt.Fprintln(w, string(data))  // 將文件內容也返回給客戶端
    }
}
mux.HandleFunc("/", multipartFormHandler)

我們輸入內容,上傳文件,然后提交:

我們看到 r.MultipartForm 顯示在了頁面上,里面是兩個map,一個Value、一個File;Value存放的是表單數據,File存放的是文件流;然后我們將文件內容顯示在了頁面上,並且我們看到Fprint、Fprintf、Fprintln這些函數可以調用多次,都會返回給客戶端。當然它們不是調用一個返回一個,只是將內容都寫在了w http.ResponseWriter中,等函數結束之后再一次性將全部內容交給客戶端。所以第一次調用Fprintf的時候,如果結尾沒有 \n,那么再寫入的時候就連在了一起,和我們平時使用Printf、Print、Println是一樣的,只不過我們將內容寫在了w http.ResponseWriter中。

關於上傳的文件,我們說它是一個FileHeader指針,通過這個FileHeader我們還可以獲取其它的文件屬性。

type FileHeader struct {
    Filename string  // 文件名
    Header   textproto.MIMEHeader  // 文件頭部信息
    Size     int64  // 文件大小
    content  []byte  // 文件內容
    tmpfile  string  // 臨時文件
}

我們看到content是文件內容,所以本來我們使用string(fileHeader.content)也是可以獲取文件內容的,只不過該成員沒有被導出,所以我們無法這么做。不過還記得之前說過了unsafe包嗎?它可以突破一些限制,讓我們可以訪問結構體中未被導出的成員,我們來試一下。

func multipartFormHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "GET" {
        fmt.Fprint(w, `
    <!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Go Web</title>
</head>
<body>
    <form action="/multipartform" method="post"
          enctype="multipart/form-data">
        <label>name:</label>
        <input type="text" name="name" />
        <label>age:</label>
        <input type="text" name="age" />
        <label>file:</label>
        <input type="file" name="uploaded" />
        <button type="submit">提交</button>
    </form>
</body>
</html>
`)
    } else {
        r.ParseMultipartForm(1024)
        fileHeader := r.MultipartForm.File["uploaded"][0]
        // 一個string、一個textproto.MIMEHeader、一個int64, 跳過這些字節便是content
        data := *(*[]byte)(unsafe.Pointer(uintptr(unsafe.Pointer(fileHeader)) +
            unsafe.Sizeof("") + unsafe.Sizeof(textproto.MIMEHeader{}) + unsafe.Sizeof(int64(0))))
        fmt.Fprintln(w, string(data))  // 將文件內容返回給客戶端
    }
}

怎么樣,是不是很有趣呢?當然最好還是不要這么做,因為把這個成員設置成未導出的,就證明不希望你訪問,所以還是按照開發者提供的方法讀取吧。

如果沒有填寫表單,那么對應的切片是空切片,注意:是空切片,不是nil。

FormValue/PostFormValue:

為了方便地獲取值,net/http 包提供了 FormValue/PostFormValue 方法,它們在需要時會自動調用 ParseForm/ParseMultipartForm 方法。

FormValue 方法返回請求的 Form 字段中指定鍵的值,如果同一個鍵對應多個值,那么返回第一個。如果需要獲取全部值,直接使用 Form 字段。舉例說明:

fmt.Fprintln(w, r.FormValue("name"), r.Form["name"][0], r.Form["name"])

本質上是一樣的,如果確定只有一個值的話可以直接使用FormValue;PostFormValue也是同理,我們說前者會同時作用在查詢參數和表單上面,后者只會作用在表單中。

注意:當編碼被指定為 multipart/form-data 時,FormValue / PostFormValue 將不會返回任何值, 它們讀取的是 Form/PostForm 字段,而 ParseMultipartForm 將數據寫入 MultipartForm 字段。

常見的字段我們就說到這里,至於其它的一些字段可以自己嘗試一下。

其它格式,通過 AJAX 之類的技術可以發送其它格式的數據,例如 application/json 等。這種情況下:

  • 首先通過Content-Type來獲取數據的格式;
  • 通過r.Body讀取字節流;
  • 解碼使用;

http 響應

http.Response 對應返回給客戶端的響應,http.Request 對應來自客戶端的請求

// ResponseWriter是一個接口
func (w http.ResponseWriter, r *http.Request)

接下來是如何響應客戶端的請求,最簡單的方式是通過 http.ResponseWriter 發送字符串給客戶端,但是這種方式僅限於發送字符串。我們看一下ResponseWriter的結構吧。

type ResponseWriter interface {
    Header() Header
    Write([]byte) (int, error)
    WriteHeader(statusCode int)
}

我們響應客戶端請求都是通過該接口的 3 個方法進行的,例如之前的 fmt.Fprintln,底層就是調用了 Write 方法。

收到請求后,多路復用器會自動創建一個 http.Response 對象,它實現了 http.ResponseWriter 接口,然后將該對象和請求對象作為參數傳給處理器。但為什么請求對象使用的是結構體指針 *http.Request ,而響應卻不使用指針呢?

實際上,請求對象使用指針是為了能在處理邏輯中更方面地獲取請求信息,而響應使用接口來操作,底層也是對象指針,可以保存修改。

接口 ResponseWriter 有 3 個方法:

  • Write;
  • WriteHeader;
  • Header;

Write:

由於接口 ResponseWriter 擁有方法 Write([]byte) (int, error),所以實現了 ResponseWriter 接口的對象也實現了 io.Writer 接口:

type Writer interface {
	Write(p []byte) (n int, err error)
}

這也是為什么 http.ResponseWriter 類型的變量 w 能在下面的代碼中使用:

// 第一個參數接收的一個 io.Writer 接口
fmt.Fprintln(w, "Hello World")

當然我們也可以直接調用 Write 方法來向響應中寫入數據:

func writeHandler(w http.ResponseWriter, r *http.Request) {
    str := `<html>
<head><title>Go Web</title></head>
<body><h1>直接使⽤ Write ⽅法<h1></body>
</html>`
    w.Write([]byte(str))
}

我們返回的內容會有相應的首部信息,比如:Content-Type: text/html; charset=utf8,當我們返回一個字符串的時候就是這樣子。但是我們這里沒有設置啊,說明 net/http 會自動推斷,它是通過讀取響應體前面的若干個字節來推斷的,只是這種推斷並不是百分之百准確(准確率挺高的)。

那我們如何才能自己設置響應內容的類型 以及 狀態碼呢?答案就是通過:WriteHeader 和 Header 兩個方法。


WriteHeader:

WriteHeader 方法的名字帶有一點誤導性,它並不能用於設置響應首部。 WriteHeader 接收一個整數,並將這個整數作為 HTTP 響應的狀態碼返回。調用這個返回之后,可以繼續對 ResponseWriter 進行寫入,但是不能對響應的首部進行任何修改操作。如果用戶在調用 Write 方法之前沒有執行過 WriteHeader 方法,那么程序默認會使用 200 作為響應的狀態碼。

如果,我們定義了一個 API,還未定義其實現。那么請求這個 API 時,可以返回一個 501 Not Implemented 作為狀態碼。

package main

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

func writeHeaderHandler1(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(501)
    fmt.Fprintln(w, "你好呀, 這個API還沒有實現呢")
}

func writeHeaderHandler2(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(280)
    fmt.Fprintln(w, "這是我隨便設置的一個狀態碼")
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/writeheader1", writeHeaderHandler1)
    mux.HandleFunc("/writeheader2", writeHeaderHandler2)

    server := &http.Server{
        Addr:    "localhost:8889",
        Handler: mux,
    }

    if err := server.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}


Header:

Header 方法其實返回的是一個 http.Header 類型,該類型的底層類型為 map[string][]string:

// src/net/http/header.go
type Header map[string][]string

類型 Header 定義了 CRUD 方法,可以通過這些方法操作首部:

func headerHandler(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Location", "http://baidu.com")
	w.WriteHeader(302)
}
mux.HandleFunc("/header", headerHandler)

我們知道 302 表示重定向,瀏覽器收到該狀態碼時會再發起一個請求到首部中 Location 指向的地址。如果我們訪問 /header,那么會重定向到百度首頁。

接下來,我們看看如何設置自定義的內容類型。通過 Header.Set 方法設置響應的首部 Contet-Type 即可,我們編寫一個返回 JSON 數據的處理器函數:

package main

import (
    "encoding/json"
    "log"
    "net/http"
)

type Girl struct {
    FirstName string   `json:"first_name"`
    LastName  string   `json:"last_name"`
    Age       int      `json:"age"`
    Hobbies   []string `json:"hobbies"`
}

var g = &Girl{
    FirstName: "夏色",
    LastName:  "祭",
    Age:       16,
    Hobbies:   []string{"斯哈斯哈", "呼吸"},
}

func jsonHandler1(w http.ResponseWriter, r *http.Request) {
    data, _ := json.MarshalIndent(g, "", "\t")
    w.Write(data)
}

func jsonHandler2(w http.ResponseWriter, r *http.Request) {
    data, _ := json.MarshalIndent(g, "", "\t")
    w.Header().Set("Content-Type", "application/json")
    w.Write(data)
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/head1", jsonHandler1)
    mux.HandleFunc("/head2", jsonHandler2)

    server := &http.Server{
        Addr:    "localhost:8889",
        Handler: mux,
    }

    if err := server.ListenAndServe(); err != nil {
        log.Fatal(err)
    }
}

兩者唯一的區別就是,是否設置了Content-Type,我們測試一下:

我們看到默認是純文本類型,也就是:text/plain。

此時是我們設置的類型。

類似的Content-Type還有 xml( application/xml )、html( application/html )、pdf( application/pdf )、png( image/png )等等

設置cookie

cookie 的出現是為了解決 HTTP 協議的無狀態性的,客戶端通過 HTTP 協議與服務器通信,多次請求之間無法記錄狀態。服務器可以在響應中設置 cookie,客戶端保存這些 cookie。然后每次請求時都帶上 這些 cookie,服務器就可以通過這些 cookie 記錄狀態,辨別用戶身份等。

整個計算機行業的收入都建立在 cookie 機制之上,廣告領域更是如此。說法雖然有些誇張,但是可見 cookie 的重要性。

我們知道廣告是互聯網最常見的盈利方式。其中有一個很厲害的廣告模式,叫做聯盟廣告。最常見的就是,剛剛在百度上搜索了某個關鍵字,然后打開淘寶或京東后發現相關的商品已經被推薦到首頁或邊欄了。這是由於這些網站組成了廣告聯盟,只要加入它們,就可以共享用戶瀏覽器的 cookie 數據。

Go 中 cookie 使用 http.Cookie 結構體表示,在 net/http 包中定義:

type Cookie struct {
    Name       string
    Value      string
    Path       string
    Domain     string
    Expires    time.Time
    RawExpires string
    MaxAge     int
    Secure     bool
    HttpOnly   bool
    SameSite   SameSite
    Raw        string
    Unparsed   []string
}

Name/Value:cookie的鍵值對,都是字符串類型;

沒有設置 Expires 字段的 cookie 被稱為 會話cookie 或 臨時cookie,這種 cookie 在瀏覽器關閉時就會自動刪除。設置了 Expires 字段的 cookie 稱為 持久cookie,這種 cookie 會一直存在,直到指定的時間來臨或手動刪除;

HttpOnly 字段設置為 true 時,該 cookie 只能通過 HTTP 訪問,不能使用其它方式操作,如 JavaScript,可以提高安全性;

Expires 和 MaxAge 都可以用於設置 cookie 的過期時間,Expires 字段設置的是 cookie 在什么 時間點過期,而 MaxAge 字段表示 cookie 自創建之后能夠存活多少秒。雖然 HTTP 1.1 中廢棄了 Expires ,推薦使用 MaxAge 代替。但是幾乎所有的瀏覽器都仍然支持 Expires ;而且,微軟的 IE6/IE7/IE8 都不支持 MaxAge 。所以為了更好的可移植性,可以只使用 Expires 或同時使用這兩個字段。

cookie 需要通過響應的首部發送給客戶端,瀏覽器收到 Set-Cookie 首部時,會將其中的值解析成 cookie 格式保存在瀏覽器中。下面我們來具體看看如何設置 cookie:

func setCookie(w http.ResponseWriter, r *http.Request) {
    c1 := &http.Cookie {
        Name: "name",
        Value: "matsuri",
        HttpOnly: true,
    }
    c2 := &http.Cookie {
        Name: "age",
        Value: "18",
        HttpOnly: true,
    }
    w.Header().Set("Set-Cookie", c1.String())
    w.Header().Add("Set-Cookie", c2.String())
    fmt.Fprintln(w, c1.String())
    fmt.Fprintln(w, c2.String())
}

上面構造 cookie 的代碼中,有幾點需要注意:

  • 首部名稱為Set-Cookie;
  • 首部的值需要是字符串;
  • 設置第一個cookie調用Set方法, 添加的時候調用Add方法; 如果添加的時候也調用Set方法, 那么會將同名的鍵覆蓋掉, 也就是第一個cookie將被覆蓋;

為了使用的便捷, net/http 包還提供了 SetCookie 方法。用法如下:

c1 := &http.Cookie {
        Name: "name",
        Value: "matsuri",
        HttpOnly: true,
}
c2 := &http.Cookie {
    Name: "age",
    Value: "18",
    HttpOnly: true,
}
http.SetCookie(w, c1)
http.SetCookie(w, c2)

如果收到的響應中有 cookie 信息,瀏覽器會將這些 cookie 保存下來。只有沒有過期,在向同一個主機發送請求時都會帶上這些 cookie。在服務端,我們可以從請求的 Header 字段讀取 Cookie 屬性來獲得 cookie:

func setCookie(w http.ResponseWriter, r *http.Request) {
    c1 := &http.Cookie {
        Name: "name",
        Value: "matsuri",
        HttpOnly: true,
    }
    c2 := &http.Cookie {
        Name: "age",
        Value: "18",
        HttpOnly: true,
    }
    http.SetCookie(w, c1)
    http.SetCookie(w, c2)
}

func getCookie(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, r.Header["Cookie"])
}

mux.HandleFunc("/set_cookie", setCookie)
mux.HandleFunc("/get_cookie", getCookie)

先請求一次 localhost:8080/set_cookie ,然后再次請求 localhost:8080/get_cookie ,瀏覽器就將 cookie 傳過來了。

r.Header["Cookie"] 返回一個切片,這個切片又包含了一個字符串,而這個字符串又包含了客戶端發送的任意多個 cookie。如果想要取得單個鍵值對格式的 cookie,就需要解析這個字符串。 為 此,net/http 包在 http.Request 上提供了一些方法使我們更容易地獲取 cookie:

func getCookie2(w http.ResponseWriter, r *http.Request) {
    name, err := r.Cookie("name")
    if err != nil {
        fmt.Fprintln(w, "沒有找到該cookie")
    }
    cookies := r.Cookies()
    fmt.Fprintln(w, name)
    fmt.Fprintln(w, cookies)
}

Cookie 方法返回以傳入參數為鍵的 cookie,如果該 cookie 不存在,則返回⼀個錯誤;

Cookies 方法返回客戶端傳過來的所有 cookie。

另外需要注意的是,Cookie是和主機名綁定的,不和端口綁定。如果我們再啟動一個8888端口,那么依舊可以獲取到cookie,可以自己嘗試一下。

流程總結

梳理一下net/http代碼的執行流程:

首先調用 Http.HandleFunc:

1. 調用了DefaultServeMux的HandleFunc;

2. 調用了DefaultServeMux的Handle;

3. 往DefaultServeMux的map[string]muxEntry中增加對應的handler和路由規則;

其次調用http.ListenAndServe(":8080", nil)

1. 實例化Server;

2. 調用Server的ListenAndServe();

3. 調用net.Listen("tcp", addr)監聽端口;

4. 啟動一個for循環,在循環體中Accept請求;

5. 對每個請求實例化一個Conn,並且開啟一個goroutine為這個請求進行服務,執行go c.serve();

6. 讀取每個請求的內容w, err := c.readRequest()

7. 判斷handler是否為空,如果沒有設置handler(這個例子就沒有設置handler),handler 就設置為DefaultServeMux;

8. 調用handler的ServeHttp;

9. 在這個例子中,下面就進入到DefaultServeMux.ServeHttp;

10. 根據request選擇handler,並且進入到這個handler的ServeHTTP;

模板引擎

模板引擎是 Web 編程中必不可少的一個組件。模板能分離邏輯和數據,使得邏輯簡潔清晰,並且模板可復用。

在早期的Web編程中一般都會定義大量的模板(html文件),然后后台服務將模板讀取之后進行渲染、然后返回。但是隨着前后端分離的流行,這種做法越來越少了;因為模板文件中需要涉及后端語法,那么這就要求前端人員懂后端邏輯,或者后端人員懂前端邏輯,從而導致耦合比較嚴重。但是現在前后端分離之后,后端不再關注頁面樣式如何。以我本人所在公司為例,個人負責編寫后端服務,但是我只需要返回一個json即可,前端會根據json數據自動渲染頁面。

所以這就使得分工變得明確,不然的話,當增加需求或者需求變更導致修改html文件時,那么這個文件是交給前端修改還是后端修改呢?不過話雖如此,但並不代表模板渲染引擎就沒有用了,搭建一個小型的Web服務使用這種方式還是很輕松的。而且模板引擎我們不光可以用在Web上,拿來做字符串格式化也是不錯的。

模板引擎按照功能可以划分為兩種類型:

  • 無邏輯模板引擎: 此類模板引擎只進行字符串的替換, 無其它邏輯;
  • 嵌入邏輯模板引擎: 此類模板引擎可以在模板中嵌入邏輯, 實現流程控制、循環等等;

這兩類模板引擎都比較極端。無邏輯模板引擎需要在處理器中額外添加很多邏輯用於生成替換的文本,而嵌入邏輯模板引擎則在模板中混入了大量邏輯,導致維護性較差。常用的模板引擎一般介於這兩者之間。

在 Go 標准庫中,可以通過 text/template 和 html/template 這兩個庫實現模板功能。

模板內容可以是 UTF-8 編碼的任何內容。其中使用 {{ }} 包圍的部分稱為動作, {{ }} 外的其它文本在輸出保持不變。模板需要應用到數據,模板中的動作會根據數據生成的響應內容來進行替換。模板解析之后可以多次執行,也可以並行執行,但是注意:使用同一個 Writer 會導致輸出交替出現。

使用模板引擎一般有 3 個步驟:

  • 定義模板: 可以是字符串或者文件;
  • 解析模板: 使用 text/template 或 html/template 中的方法解析;
  • 傳入數據生成輸出;
package main

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

type Girl struct {
    Name string
    Age int64
}

func stringLiteralTemplate() {
    s := "姓名: {{ .Name }}, 年齡: {{ .Age }}"
    t, err := template.New("test").Parse(s)
    if err != nil {
        log.Fatal("解析模板失敗")
    }
    
    g := Girl{"夏色祭", 16}
    err = t.Execute(os.Stdout, g)
    if err != nil {
        log.Fatal("執行模板失敗")
    }
}

func main() {
    stringLiteralTemplate()  // 姓名: 夏色祭, 年齡: 16
}

解釋一下:

  • 首先調用 template.New 創建一個模板, 參數為模板名(相當於給模板起一個名字, 可以不用鳥它), 然后會得到一個 *Template;
  • 調用 *Template 的 Parse 方法, 傳入字符串進行解析, 會得到 *Template 和 error; 如果模板語法正確, 則返回模板對象本身 和 一個nil;
  • 最后調用模板對象的 Execute 方法, 傳入參數; 第一個參數是 接口 io.Writer, 第二個參數是 interface{}; 我們當前傳遞的是一個結構體, 但是很明顯還可以是其它類型, 后面會說; 如果是結構體的話, 模板中的 {{ .Name }} 會被Name成員的值替換、{{ .Age }} 會被Age成員替換, 然后輸出到指定的第一個參數中, 這里我們指定的是 os.Stdout, 顯然還可以是 *strings.Builder、bytes.Buffer等等
package main

import (
    "fmt"
    "html/template"
    "strings"
)

type Girl struct {
    Name string
    Age int64
}

func stringLiteralTemplate() {
    buf := &strings.Builder{}
    s := "姓名: {{ .Name }}, 年齡: {{ .Age }}"
    t, _ := template.New("test").Parse(s)
    
    _ = t.Execute(buf, Girl{"夏色祭", 16})
    fmt.Println(buf.String())
}

func main() {
    stringLiteralTemplate()  // 姓名: 夏色祭, 年齡: 16
}

有一點需要注意:模板中一旦出現了 {{ }},那么結構體中必須要有成員能夠進行對應,否則 t.Execute 會返回一個 error。因為在 調用 t.Execute 的時候,會從左往右掃描字符串,然后遇見了 {{ }}會進行替換,而一旦找不到可以替換的值就會終止掃描。

如果將結構體Girl中的成員 Age 改成 Age1的話,那么 buf.String() 得到的就是 姓名: 夏色祭, 年齡:,因為掃描的時候發現結構體中沒有 Age 這個成員,所以就終止掃描了;同理如果將 Name 改成 Name1,那么 buf.String() 得到的就是姓名:,因為掃描是從左往右掃描,而 Name 成員不存在,所以直接終止掃描了;

但結構體成員並不一定都要出現在模板文件中,{{ }}要能在結構體成員中找到,但是結構體成員可以不出現在模板文件的 {{ }} 中。

我們上面是以字符串字面量作為模板的,模板顯然也可以是一個文件。

定義一個文件,假設就叫 1.txt 吧,里面寫上如下內容:

name: {{ .Name }}
age: {{ .Age }}
gender: {{ .Gender }}

然后我們看看如何渲染:

package main

import (
    "fmt"
    "html/template"
    "strings"
)

type Girl struct {
    Name string
    Age int64
    Gender string
}

func FileTemplate() {
    buf := &strings.Builder{}
    t, _ := template.ParseFiles("1.txt")
    _ = t.Execute(buf, Girl{"matsuri", 16, "female"})
    fmt.Println(buf.String())
}

func main() {
    FileTemplate()
    /*
    name: matsuri
    age: 16
    gender: female
     */
}

模板動作

Go 模板中的動作就是一些嵌入在模板里面的命令,動作大體上可以分為以下幾種類型:

  • 點動作
  • 條件動作
  • 迭代動作
  • 設置動作
  • 包含動作

點動作

在介紹其它的動作之前,我們先看一個很重要的動作,點動作{{ . }}。它其實代表是傳遞給模板的數據,其他動作或函數基本上都是對這個數據進行處理,以此來達到格式化和內容展示的目的。

package main

import (
    "fmt"
    "html/template"
    "strings"
)

type Girl struct {
    Name string
    Age int64
    Gender string
}

func main() {
    s1 := "{{ . }}"
    s2 := "{{ .Name }} {{ .Age }} {{ .Gender }}"
    t1, _ := template.New("test").Parse(s1)
    t2, _ := template.New("test").Parse(s2)

    g := Girl{Name: "夏色祭", Age: 16, Gender: "female"}
    
    buf := new(strings.Builder)
    _ = t1.Execute(buf, g)
    fmt.Println(buf.String())  // {夏色祭 16 female}
    
    // 清空緩存
    buf.Reset()
    _ = t2.Execute(buf, g)
    fmt.Println(buf.String())  // 夏色祭 16 female
}

相信此時你已經看到區別了,單純的 {{ . }} 的話,就代表 Execute 第二個參數整體,再舉個栗子:

package main

import (
    "fmt"
    "html/template"
    "strings"
)

type Girl struct {
    Name string
    Age int64
    Gender string
}

func main() {
    s := "{{ . }} {{ . }} {{ .Name }}"
    t, _ := template.New("test").Parse(s)

    g := Girl{Name: "夏色祭", Age: 16, Gender: "female"}
    
    buf := new(strings.Builder)
    _ = t.Execute(buf, g)
    fmt.Println(buf.String())  // {夏色祭 16 female} {夏色祭 16 female} 夏色祭
}

注意:為了使用的方便和靈活,在模板中不同的上下問內,. 的含義可能會改變。

條件動作

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。

非常簡單,就是簡單的條件語句嘛。

舉個栗子:

你的年齡: {{ .Age }}
{{ if .GreaterThan60}}
老人
{{ else if .GreaterrThan40 }}
中年人
{{ else }}
年輕人
{{ end }}

以上是模板文件,下面我們來隨機生成一個數字測試一下:

package main

import (
    "fmt"
    "html/template"
    "math/rand"
    "strings"
)

type AgeInfo struct {
    Age int
    GreaterThan60 bool
    GreaterThan40 bool
}

func main() {
    age := rand.Intn(100)
    g := AgeInfo{age, age > 60, age > 40}
    t, _ := template.ParseFiles("1.txt")

    buf := new(strings.Builder)
    _ = t.Execute(buf, g)
    fmt.Println(buf.String())  
    /*
    你的年齡: 81

    老人

    */
}

顯示的81歲,打印結果為老人,かわいそうぉぉぉぉ。不過這里有一個問題,那就是它把空行也打印了,因為除了動作之外的任何文本都會原樣保持,包括空格和換行。針對這個問題,有兩種解決方案。第一種方案是刪除多余的空格和換行,也就是將所有的內容寫在一行,但這個方法會導致模板內容很難閱讀,不夠理想。

為此,Go 提供了針對空白符的處理。如果一個動作以 {{- 開頭,那么該動作與它前面相鄰的非空文本(或動作)間的空白符將會被全部刪除。類似地,如果⼀個動作以 -}} 結尾,那么該動作與它后面相鄰的非空文本(或動作)間的空白符將會被全部刪除。例如:

{{23 -}} < {{- 45}} 會輸出 23<45

回到我們的例子中,我們可以將模板文件稍作修改:

你的年齡: {{ .Age }}
{{ if .GreaterThan60 -}}
老人
{{- else if .GreaterrThan40 -}}
中年人
{{- else -}}
年輕人
{{- end }}

這樣輸出的內容就不會有多余的空格了。

注意:無論是 {{- 還是 -}},和里面的模板變量之間都必須至少有一個空格。比如:{{- if XXX}} 是合法的,但 {{-if XXX}} 不合法。

迭代動作

迭代其實與編程語言中的循環遍歷類似,有兩種形式:

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

管道的值類型必須是數組、切片、map、channel。如果值的長度為 0,那么無輸出。

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

與前一種形式基本一樣,如果值的長度為 0,那么執行 T2 。

我們舉個栗子,首先還是定義一個模板文件:

Vtuber:
{{ range . }}
{{ .Name }}:
    {{ range .Nickname -}}
    {{ . }}
    {{ end -}}
{{- end -}}

然后我們來測試一下:

package main

import (
    "fmt"
    "html/template"
    "strings"
)

type Girl struct {
    Name string
    Nickname []string
}

func main() {
    g := []Girl{{"神樂mea", []string{"屑女仆", "吊人"}},
        {"夏色祭", []string{"夏哥", "祭妹"}},
        {"神樂七奈", []string{"狗媽", "媽"}},
    }
    t, _ := template.ParseFiles("1.txt")
    buf := new(strings.Builder)
    _ = t.Execute(buf, g)
    fmt.Println(buf.String())
    /*
    Vtuber:

    神樂mea:
        屑女仆
        吊人

    夏色祭:
        夏哥
        祭妹

    神樂七奈:
        狗媽
        媽
    */
}

解釋一下這個模板:

  • 首先外層的 {{ range . }} 表示遍歷整個結構體數組, 也就是我們傳遞的g;
  • 然后 {{ .Name}} 中的 . 就對應里面一個一個的結構體, 所以 .Name 得到的就是對應的Name;
  • 然后 {{ range .Nickname}} 表示遍歷每一個結構體中的數組, {{ . }} 自然就是數組中的每一個值

所以這里面嵌套了兩層 range 循環,並且從這里我們也能看出在不同的上下文中,. 可以代表不同的含義。不過從廣義上來講,含義是一樣的,就表示"當前元素"。只不過不同的上下文,"當前元素"的含義不一樣。舉個栗子:最外層 {{ range . }}.代指的當前元素就是我們傳遞的結構體數組;{{ .Name }}中的.代指的當前元素就是對應的結構體,所以.Name才能拿到元素;中間的{{ . }}里面的.代指的當前元素顯然就是Nickname成員對應的每一個元素,因為外層是{{ .Nickname}}。所以只需要記住:.指代當前元素,而當前元素到底是什么則取決於上下文。

設置動作

設置動作使用 with 關鍵字重定義,在 with 語句內, . 會被定義為指定的值。一般在結構嵌套很深的時候,能起到簡化代碼的作用。

Girl:
    name: {{ .Name }}
    nickname: {{ .Nickname }}

Company:
    {{- with .Company }}
    name: {{ .Name }}
    where: {{ .Where }}
    {{- end -}}

我們來測試一下,注意里面的 {{- with .Company }}

package main

import (
    "fmt"
    "html/template"
    "strings"
)

type Company struct {
    Name string
    Where string
}

type Girl struct {
    Name string
    Nickname []string
    Company Company
}

func main() {
    g := Girl{"夏色祭", []string{"夏哥", "祭妹"}, Company{"hololive", "japan"}}
    t, _ := template.ParseFiles("1.txt")
    buf := new(strings.Builder)
    _ = t.Execute(buf, g)
    fmt.Println(buf.String())
    /*
    Girl:
        name: 夏色祭
        nickname: [夏哥 祭妹]

    Company:
        name: hololive
        where: japan
    */
}

我們說 . 表示當前元素,對於當前例子而言顯然就是這個 Girl 這結構體實例,但是 {{ with .Company }} 之后,這個 . 就指代了里面 Company 這個結構體實例,當然僅限 with 塊內有效,如果出了 with 塊,那么 . 代指的當前元素依舊是 Girl對應的結構體實例,可以自己試一下,結尾新添加一個 {{ .Name }},看看打印的是 夏色祭、還是 hololive

其它元素

注釋

注釋只有一種語法:

{{ /* 注釋 */ }}

注釋的內容不會呈現在輸出中,它就像代碼注釋一樣,是為了讓模板更易讀。

參數

一個參數就是模板中的一個值,它的取值有多種:

  • 布爾值、字符串、字符、整數、浮點數、虛數、和復數等字面量;
  • 結構體中的一個字段或者map中的一個值, 結構體的字段必須是可導出的, 即大寫字母開頭, map的鍵則無此要求;
  • 一個函數或者方法, 必須只返回一個值, 或者只返回一個值和一個錯誤;如果返回了非空的錯誤, 那么Execute執行終止, 並將錯誤返回給調用方;
  • 等等等等;

其實,我們已經用過很多次參數了。下面看一個方法調用的栗子:

package main

import (
    "fmt"
    "html/template"
    "strings"
)

type Girl struct {
    FirstName string
    LastName string
}

func (g Girl) FullName () string {
    return g.FirstName + g.LastName
}

func main() {
    g := Girl{"夏色", "祭"}
    t, _ := template.New("test").Parse("FullName is {{ .FullName }}")
    buf := new(strings.Builder)
    _ = t.Execute(buf, g)
    fmt.Println(buf.String())  // FullName is 夏色祭
}

我們看到在調用函數的時候沒有加括號,因為會自動執行並獲取返回值;

管道

管道的語法與 Linux 中的管道類似,即命令的鏈式序列:

{{ p1 | p2 | p3 }}

在一個鏈式管道中,每個命令的結果會作為下一個命令的最后一個參數,最后一個命令的結果作為整個管道的值。

管道必須只返回一個值,或者只返回一個值和一個錯誤;如果返回了非空的錯誤,那么Execute執行終止,並將錯誤返回給調用方。

package main

import (
    "fmt"
    "html/template"
    "strings"
)

type Girl struct {
    FirstName string
    LastName  string
}

func (g Girl) FullName() string {
    return g.FirstName + g.LastName
}


func main() {
    g := Girl{"夏色", "祭"}
    t, _ := template.New("test").Parse("FullName is {{ .FullName|printf \"%s\" }}")
    buf := new(strings.Builder)
    _ = t.Execute(buf, g)
    fmt.Println(buf.String()) // FullName is 夏色祭
    
}

printf是Go語言模板的內置函數,類似這樣的函數還有很多。

變量

在動作中,可以定義一個變量,然后在后續中都可以使用這個變量。

{{ $variable := .xxx }}

我們來舉個栗子:

package main

import (
    "fmt"
    "html/template"
    "log"
    "strings"
)

type Girl struct {
    Name string
}

func main() {
    s := "{{ $name := .Name}}name: {{ $name }}"
    t, _ := template.New("text").Parse(s)
    buf := strings.Builder{}
    if err := t.Execute(&buf, Girl{"夏色祭"}); err != nil {
        log.Fatal(err)
    }
    fmt.Println(buf.String())  // name: 夏色祭
}

類似的,我們還可以通過range來定義兩個變量:

range $index, $element := .xxx

我們使用map演示一下:

package main

import (
    "fmt"
    "html/template"
    "log"
    "strings"
)

func main() {
    s := `{{ range $k, $v := . }}
{{- $k }}: {{ $v }}
{{ end -}}`
    t, _:= template.New("text").Parse(s)
    buf := strings.Builder{}
    if err := t.Execute(&buf, map[string]string{"name": "夏色祭", "age": "16", "gender": "female"}); err != nil {
        log.Fatal(err)
    }
    fmt.Println(buf.String())
    /*
    age: 16
    gender: female
    name: 夏色祭

    */
}

變量的作用域持續到定義它的控制結構的 {{ end }} 動作。如果沒有這樣的控制結構,則持續到模板結束,並且模板調用不繼承變量。

並且我們還可以使用 $ 本身,對於當前例子而言就指代傳入的 map,也就是 .

當然 range塊 也支持 else 語句,如果range 后面為空,則執行 else 語句:

package main

import (
    "fmt"
    "html/template"
    "log"
    "strings"
)

func main() {
    s := `{{ range $k, $v := . }}
{{- $k }}: {{ $v }} {{ $  }}
{{ else -}}
空
{{ end -}}`
    t, _:= template.New("text").Parse(s)
    buf := strings.Builder{}
    if err := t.Execute(&buf, map[string]string{}); err != nil {
        log.Fatal(err)
    }
    fmt.Println(buf.String())
    /*
    空

    */
}

函數

Go 模板提供了大量的預定義函數,如果有特殊需求也可以實現自定義函數。模板執行時,遇到函數調,先從模板自定義函數表中查找,而后查找全局函數表。預定義函數分為以下幾類:

  • 邏輯運算, and/or/not;
  • 調用操作, call;
  • 格式化操作, print/printf/println, 與用參數直接調用fmt.Sprint/fmt.Sprintf/fmt.Sprintln得到的內容相同;
  • 比較運算, eq/ne/lt/le/gt/ge;

在上面條件動作的示例代碼中,我們在代碼中計算出大小關系再傳入模板,這樣比較繁瑣,可以直接使用比較運算簡化。

有兩點需要注意:

  • 由於是函數調用, 所有的參數都會被求值, 沒有短路求值; {{ if p1 or p2 }};
  • 比較運算只作用於基本類型, 且沒有Go語法那么嚴格, 例如有符號整數可以和無符號整數進行比較;

自定義函數

默認情況下,模板中無自定義函數,可以使用模板的 Funcs 方法添加。下面我們實現一個格式化日期的自定義函數:

package main

import (
    "html/template"
    "os"
    "time"
)

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

func main() {
    funcMap := template.FuncMap{"fdate": formatDate}
    t := template.New("test")
    // 這里可以鏈式調用, 因為返回的都是模板對象
    t.Funcs(funcMap).Parse("today is {{ . | fdate }}")
    t.Execute(os.Stdout, time.Now())  // today is 2020-11-12
}

模板的 Func 方法接受一個 template.FuncMap 類型變量,鍵為函數名,值為實際定義的函數。 可以一次設置多個自定義函數。自定義函數要求只返回一個值,或者返回一個值和一個錯誤。

如果解析的是模板呢?也很簡單:

template.ParseFiles()  
// 等價於
template.New("test").ParseFiles()
// 所以和處理普通字符串一樣的處理方式, 在調用ParseFiles之前先調用一下Funcs即可

創建模板

模板的創建方式:

  • 先調用 template.New 創建模板, 然后 Parse/parseFiles 解析模板內容;
  • 直接使用 template.ParseFiles 創建並解析模板文件;

第一種方式,調用 template.New 創建模板時需要傳入一個模板名字,后續調用 ParseFiles 可以傳入一個或多個文件,這些文件中必須有一個基礎名(即去掉路徑部分)與模板名相同。如果沒有文件名與 模板名相同,則 Execute 調用失敗,返回錯誤。例如:

package main

import (
    "fmt"
    "html/template"
    "os"
    "time"
)

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

func main() {
    funcMap := template.FuncMap{"fdate": formatDate}
    t := template.New("test")
    t.Funcs(funcMap).ParseFiles("test.txt")
    err := t.Execute(os.Stdout, time.Now())  
    if err != nil {
        fmt.Println(err)  // template: "test" is an incomplete or empty template
    }
}

所以一般情況下,直接將New里面的名字和ParseFiles里面指定的文件名保持一致即可。

package main

import (
    "fmt"
    "html/template"
    "os"
    "time"
)

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

func main() {
    funcMap := template.FuncMap{"fdate": formatDate}
    t := template.New("test.txt")
    // 這里可以鏈式調用, 因為返回的都是模板對象
    t.Funcs(funcMap).ParseFiles("test.txt")
    err := t.Execute(os.Stdout, time.Now())  // today is 2020-11-12
    if err != nil {
        fmt.Println(err)  
    }
}

嵌套模板

在一個模板文件中還可以通過 {{ define }} 動作定義其它的模板,這些模板就是嵌套模板。模板定義必須在模板內容的最頂層,像 Go 程序中的全局變量一樣。

嵌套模板一般用於布局(layout)。很多文本的結構其實非常固定,例如郵件有標題和正文,網頁有首部、正文和尾部等。 我們可以為這些固定結構的每部分定義一個模板:

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

上面定義了兩個模板 layout 和 content , layout 中使用了 content 。執行這種方式定義的模板必須 使用 ExecuteTemplate 方法:

package main

import (
    "fmt"
    "html/template"
    "os"
)


func main() {
    t, _ := template.ParseFiles("test.txt")
    err := t.ExecuteTemplate(os.Stdout,  "layout", "amazing")
    if err != nil {
        fmt.Println(err)
    }
    /*

    This is body.

    This is amazing content.

    */
}

小結

Go 標准庫真的涵蓋了方方面面,尤其是內置的 net/http 和 html/template,幾乎都可以不依賴任何第三方庫直接做Web開發了。在Python中,模板渲染引擎一般都使用jinja2,這是一個第三方庫。但是Go里面我們可以直接使用標准庫,只是由於Go里面沒有關鍵字參數,使得在用起來的時候沒有jinja2那么方便。但是Go的標准庫的質量的確非常的高,可以多研究一下。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM