Go 編寫 Web 應用

broqiang發表於2019-04-03

這是一篇官方的 Writing Web Applications 的翻譯

如果 golang.org 打不開的話,可以把所有連結中的 golang.org 更換成 golang.google.cn ,這是一個官方的國內映象,和 golang.org 的內容是一致的。

簡介

本教程包含下面內容

  • 通過 load 與 save 方法建立資料結構

  • 使用 net/http 去構建 web 應用

  • 使用 html/template 去處理 HTML 模板

  • 使用 regexp 包去驗證使用者的輸入

  • 使用閉包

需要已經掌握下面知識

  • 程式設計經驗

  • 瞭解基本的 web 技術(HTTP,HTML)

  • 會一些簡單 UNIX/DOS 命令列

入門

現在,你可以在 FreeBSD, Linux, OS X, 或 Windows 上執行 Go , 我們使用 $ 代表命令列的提示符, $ 開始的內容是輸入的命令。

安裝 Go (檢視 安裝文件 )

在 GOPATH 中為這個專案建立一個新的目錄,然後切換到這個目錄(cd)

$ cd $GOPATH/src
$ mkdir gowiki
$ cd go wiki

建立一個檔案 wiki.go , 用你喜歡的編輯器開啟它,用新增下面內容

package main

import (
    "fmt"
    "io/ioutil"
)

我們從 Go 的標準庫中匯入了 fmtio/ioutil 包, 稍後,當我們實現其他功能時,將會在 import 宣告中新增更多的包。

資料結構

我們從定義資料結構開始,一個 wiki 由許多相關的頁面組成,每一個頁面都包含一個 title 和 body (頁面的內容)。我們定義一個 Page 結構,包含兩個代表 title 和 body 的欄位。

type Page struct {
    Title string
    Body []byte
}

型別 []byte 就是一個 byte 切片(關於切片的更多資訊,檢視: Slices: usage and internals)。 Body 是元素是一個 []byte 而不是 string, 是因為將要使用的 io 庫需要這個型別,可以在下面看到。

Page 結構中體現瞭如何將頁面資料儲存到記憶體中,但是如果是持久儲存怎麼辦?我們可以在 Page 上建立一個 save 方法來解決:

func (p *Page) save() error {
    filename := p.Title + ".txt"

    return ioutil.WriteFile(filename, p.Body, 0600)
}

這個方法的簽名解讀:“這是一個名字叫 save 的方法,它的接收者 p 指向一個 Page 的指標。它不需要引數,並返回一個型別為 error 的值”

這個方法將儲存 Page 的 Body 到一個文字檔案。為了簡單,我們使用 Title 來做它的檔名。

save 方法返回了一個 error 型別的值,是因為它是 WriteFile 的返回型別(一個用於將位元組切片寫入到檔案的標準庫函式)。 save 方法返回的是一個 error 型別的值,所以在寫入檔案出現錯誤的時候應該去處理它。如果沒有出現錯誤,應該返回一個 nil (指標,介面和一些其他型別的零值)

八進位制整數 0600, 是傳給 WriteFile 的第三個引數,表示只有當前用於擁有檔案的讀寫許可權。(通過 Unix man page open(2)檢視詳細說明 “譯者注: Linux 上可以通過這個命令檢視: man 2 open ”)

除了儲存頁面,我們也需要載入頁面:

func loadPage(title string) *Page {
    filename := title + ".txt"
    body, _ := ioutil.ReadFile(filename)

    return &Page{Title: title, Body: body}
}

loadPage 通過引數 title 拼接了檔名,將檔案內容讀取到一個新的變數 body 中,並且返回了一個通過 title 和 body 構造的指向 Page 字面的指標。

函式可以返回多個值。標準庫函式 io.ReadFile 返回了一個 []byte 和一個 error 。在 loadPage 還沒有處理錯誤,通過使用空白符 _ 將返回的錯誤丟棄(就是將值賦給一個空)。

但是,如果 ReadFile 遇到一個錯誤會發生什麼? 例如,檔案不存在。 我們不應該忽略這個錯誤, 讓我們修改函式,來返回 *Page 和 error 。

func loadPage(title string) (*Page, error) {
    filename := title + ".txt"
    body, err := ioutil.ReadFile(filename)

    if err != nil {
        return nil, err
    }

    return &Page{Title: title, Body: body}, nil
}

此函式的呼叫者現在可以通過檢查第二個引數; 如果它是 nil ,表示成功載入了一個 Page 。如果不是,它將是一個 error ,並且可以由呼叫者處理(詳細檢視 語言規範 )。

現在,我們有了一個簡單的資料結構,可以儲存並載入一個檔案。讓我們寫一個 main 函式來測試我們寫的東西。

func main() {
    p1 := &Page{Title: "TestPage", Body: []byte("This is a simple Page.")}
    p1.save()
    p2, _ := loadPage("TestPage")
    fmt.Println(string(p2.Body))
}

編譯並執行這個程式碼,一個包含了 p1 的內容的名字是 TestPage.txt 的檔案將被建立。這個檔案將被讀進結構 p2 中,並將它的 Body 元素列印到螢幕上。

你可以像這樣編譯並執行這個程式:

$ go build wiki.go 
$ ./wiki 
This is a simple Page.

(如果你使用的是 Windows ,你必須輸入 wiki ,去掉 ./ 去執行這個程式)

點選這裡去檢視我們到現在寫的程式碼

net http 包簡介

這是一個簡單的 web 伺服器完整的工作示例:

package main

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

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:])
}

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

main 函式從呼叫 http.HandleFunc 開始,它告訴 http 包使用 handler 去處理所有訪問 web 根目錄("/")的請求。

然後它呼叫 http.ListenAndServe ,指定它在任意介面上監聽 8080 埠(“:8080”)。 (現在不用去管它的第二個引數, nil ) 這個函式將被阻塞,知道程式終止。

ListenAndServe 始終返回一個 error ,並且它只有發生意外錯誤時才會返回。為了記錄這個錯誤,我們在它的外面包裹一個叫 log.Fatal 的函式。

handler 是一個 http.HandlerFunc 型別的函式,它需要兩個引數,http.ResponseWriter 和 http.Request 。

http.ResponseWriter 的值集合了 HTTP 伺服器的響應。通過寫入它,我們將資料傳送到 HTTP 客戶端。

http.Request 是表示客戶端 HTTP 請求的資料結構。 r.URL.Path 是一個請求地址組成的路徑。它的後面跟隨 [1:] 的意思是: “建立一個從第一個字元到結尾的子切片” 。從路徑中刪除開頭的 /

如果你執行程式,並訪問這個地址:

http://localhost:8080/monkeys

程式將會顯示包含下面內容的頁面:

Hi there, I love monkeys!

使用 net http 去服務 wiki 頁面

要想使用 net/http 包,它必須被匯入:

import (
"fmt"
"io/ioutil"
"net/http"
)

我們來建立一個叫 viewHandler 的 handler, 使用者可以通過它去檢視 wiki 頁面。它將去處理包含字首 /view/ 的 URL 。

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
}

再次注意,使用 _ 來忽略 loadPage 返回的錯誤。這裡是為了簡單,但這是一個壞的習慣,稍後我們會處理它。

首先,這個函式從請求 URL 的 path 元件 r.URL.Path 中提取頁面的標題。再通過切片 [len("/view/"):] 去掉前面路徑前面的 /view/ ,這是因為路徑總是以 /view/ 開始,它不是頁面的一部分。

然後函式去載入頁面資料,格式化成一個簡單的 HTML 格式的字串並寫入到 http.ResponseWriter w

要使用這個 handler ,我們重寫我們的 main 函式,去初始化 http 通過 viewHandler 去處理每一個 /view/ 下面的請求。

func main() {
    http.HandleFunc("/view/", viewHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

點選這裡去檢視我們到現在寫的程式碼

我們建立一些測試頁面資料(如: test.txt),編譯我們的程式碼,並嘗試伺服器 wiki 頁面。

使用編輯器開啟 test.txt 檔案,寫入 Hello world

$ go build wiki.go
$ ./wiki

(如果你使用的是 Windows ,你必須輸入 wiki ,去掉 ./ 去執行這個程式)

服務啟動後,訪問 http://localhost:8080/view/test 將會顯示頁面,標題是 test ,內容是 Hello world 。

編輯頁面

wiki 不是一個不能編輯頁面的 wiki 。我們來建立兩個新的 handler, 一個叫 editHandler ,用來顯示編輯頁面的 form 表單,另一個叫 saveHandler ,用來儲存 form 表單提交的資料。

首先,我們新增它們到 main() 中:

func main() {
    http.HandleFunc("/view/", viewHandler)
    http.HandleFunc("/edit/", editHandler)
    http.HandleFunc("/save/", saveHandler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

editHandler 函式載入頁面(或者,它不存在時建立一個空的 Page 結構), 並且顯示一個 HTML form 表單。

func editHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/edit/"):]
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    fmt.Fprintf(w, "<h1>Editing %s</h1>"+
        "<form action=\"/save/%s\" method=\"POST\">"+
        "<textarea name=\"body\">%s</textarea><br>"+
        "<input type=\"submit\" value=\"Save\">"+
        "</form>",
        p.Title, p.Title, p.Body)
}

這個函式可以很好的工作,但是所有硬編碼的 HTML 都是非常醜的,所以還有更好的辦法。

html template 包

html/template 是 Go 標準庫的一部分。我們使用 html/template ,可以將 HTML 儲存到一個單獨的檔案中,允許我們在不改動底層 Go 程式碼的情況下改變我們的編輯頁面的佈局。

首先,我們必須將 html/template 新增到 import 的列表中。我們也不會再使用 fmt 包了,所以將它刪除。

import (
    "html/template"
    "io/ioutil"
    "net/http"
)

我們來建立一個包含 HTML 表單的模板檔案。 開啟一個檔案,命名為 edit.html 並且新增下面內容:

<h1>Editing {{.Title}}</h1>

<form action="/save/{{.Title}}" method="POST">
<div><textarea name="body" rows="20" cols="80">{{printf "%s" .Body}}</textarea></div>
<div><input type="submit" value="Save"></div>
</form>

修改 editHandler 去使用模板,替換硬編碼的 HTML:

func editHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/edit/"):]
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    t, _ := template.ParseFiles("edit.html")
    t.Execute(w, p)
}

template.ParseFiles 函式將讀取 edit.html 的內容,並返回一個 *template.Template

執行模板的 t.Execute 方法,將生成的 HTML 寫入到 http.ResponseWriter 。.Title.Body 的點符號參考 p.Titlep.Body

模板指定用雙大括號括起來,printf "%s" .Body 是一個函式呼叫,它將輸出的 .Body 的位元組流替換成字串,與呼叫 fmt.Printf 相同。html/template 可以保證模板操作只有安全的並且正確的 HTML 被生成,它會自動轉譯大於號符號 >,替換成 >,確保使用者資料不會破壞表單資料。

我們現在已經使用模板了,就再建立一個用於 viewHandler 函式呼叫的 view.html 模板:

<h1>{{.Title}}</h1>

<p>[<a href="/edit/{{.Title}}">edit</a>]</p>

<div>{{printf "%s" .Body}}</div>

修改對應的 viewHandler 函式:

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    t, _ := template.ParseFiles("view.html")
    t.Execute(w, p)
}

注意,我們在兩個 handler 中使用了幾乎完全一樣的模板程式碼。我們將模板程式碼放到一個單獨的函式中,來去除這個重複:

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
    t, _ := template.ParseFiles(tmpl + ".html")
    t.Execute(w, p)
}

修改連個 handler 來使用這個函式:

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, _ := loadPage(title)
    renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/edit/"):]
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    renderTemplate(w, "edit", p)
}

我們可以先註釋掉在 main 函式中註冊的未實現的 save handler,然後就可以重新編輯並測試我們的程式了。

點選這裡去檢視我們到現在寫的程式碼

處理不存在的頁面

如果你輸入 /view/APageThatDoesntExist ,你將看到一個包含 HTML 的頁面,這是因為它忽略了 loadPage error 返回值,並繼續去嘗試填充了一個沒有資料的模板去替換,如果請求的頁面不存在,它應該重定向到編輯頁面,這樣就可以建立內容。

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/view/"):]
    p, err := loadPage(title)
    if err != nil {
        http.Redirect(w, r, "/edit/"+title, http.StatusFound)
        return
    }
    renderTemplate(w, "view", p)
}

http.Redirect 新增一個 http 狀態碼 http.StatusFound (302) 和 一個地址到 http 響應。

儲存頁面

saveHandler 函式將處理編輯頁面 form 表單提交的資料,在 mian 函式中取消和此相關的註釋,然後實現這個 handler 。

func saveHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/save/"):]
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    p.save()
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

這個頁面的 title (由 URL 提供)和表單中的唯一欄位 Body 被儲存到一個新的 Page ,然後呼叫 save() 方法寫入資料到一個檔案,並且將客戶端重定向到 /view/ 頁面。

FormValue 的返回值是一個字串型別,我們在將它填充到 Page 前必選轉換成 []byte,使用 []byte(body) 可以執行轉換。

錯誤處理

在我們的程式中,有幾個地方忽略了錯誤。這種做法非常不好,尤其是當錯誤發生時,我們的程式會出現意外的行為。一個好的方案是去處理錯誤,並將錯誤訊息返回給使用者。這樣,當出現錯誤的時候,伺服器將按照我們想要的方式執行,並且通知給使用者。

首先,我們在 renderTemplate 中處理錯誤:

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
    t, err := template.ParseFiles(tmpl + ".html")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    err = t.Execute(w, p)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
}

http.Error 函式傳送一個指定的 HTTP 狀態碼(示例中是 Internal Server Error)和錯誤訊息。這個抽離出一個單獨的函式是一個多麼明智的做法,否則要改多個地方了。

現在,我們來修改 saveHandler

func saveHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/save/"):]
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    err := p.save()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

p.save() 時發生任何錯誤,將會報告給使用者。

模板快取

這是一段低效的程式碼: 頁面每一次展示的時候,renderTemplate 都會呼叫 ParseFiles 。好的方式是在程式初始化的時候只呼叫一個 ParseFiles ,解析所有的模板到一個單一的 *Template ,然後我們可以使用 ExecuteTemplate 方法去渲染指定的模板。

首先,建立一個全域性變數 templates ,並且通過 ParseFiles 初始化它。

var templates = template.Must(template.ParseFiles("edit.html", "view.html"))

template.Must 函式是一個方便的包裝器,當傳入一個不是 nilerror 時會產生一個 panic,除此之外,返回的 *Template 不會改變。一個 panic 在這裡是合適,如果模板不能被載入只有退出程式才是明智的。

ParseFiles 函式接受任意數量的用來識別我們的模板檔案的字串引數,然後將這些檔案解析到 templates,並且通過基礎的檔名命名。如果我們要新增更多的模板到我們的程式,我們需要將它們的名字新增到 ParseFiles 的引數中。

然後我們修改 renderTemplate 函式,通過 templates.ExecuteTemplate 去呼叫與名稱相對應的模板。

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
    err := templates.ExecuteTemplate(w, tmpl+".html", p)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
}

注意,模板名稱是模板的檔名,所以我們必須在 teml 引數後面新增 .html

驗證

可能你已經注意到,這個程式有一個嚴重的安全漏洞:使用者可以通過任意路徑在伺服器上讀/寫。為了解決這個,我們可以編寫一個函式,通過正規表示式來驗證 title 。

首先,在 import 列表新增 regexp ,然後我們可以建立一個全域性變數去儲存我們的正規表示式。

var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")

函式 regexp.MustCompile 將解析並編譯正規表示式,返回一個 regexp.RegexpMustCompile 區別於 Compile 的是,當正規表示式編譯失敗的時候,它將產生一個 panic, 而 Compile 會通過第二個引數返回一個 error

現在,我們編寫一個函式來使用 validPath 來驗證路徑並提取頁面的 title 。

func getTitle(w http.ResponseWriter, r *http.Request) (string, error) {
    m := validPath.FindStringSubmatch(r.URL.Path)
    if m == nil {
        http.NotFound(w, r)
        return "", errors.New("Invalid Page Title")
    }
    return m[2], nil // title 是第二個子表示式
}

如果 title 是有效的,它將和一個值為 nilerror 一起返回。如果 title 是無效的,函式將寫入一個 404 Not Found 錯誤到 HTTP 連結,並且返回一個錯誤到 handler 。要建立一個新的 error ,我們還必須要匯入 errors 包。

我們將 getTitle 呼叫放到每一個 handler 中:

func viewHandler(w http.ResponseWriter, r *http.Request) {
    title, err := getTitle(w, r)
    if err != nil {
        return
    }
    p, err := loadPage(title)
    if err != nil {
        http.Redirect(w, r, "/edit/"+title, http.StatusFound)
        return
    }
    renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request) {
    title, err := getTitle(w, r)
    if err != nil {
        return
    }
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    renderTemplate(w, "edit", p)
}
func saveHandler(w http.ResponseWriter, r *http.Request) {
    title, err := getTitle(w, r)
    if err != nil {
        return
    }
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    err = p.save()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

函式文字和閉包簡介

捕獲每一個函式中的錯誤處理條件會產生大量的冗餘程式碼。如果我們將每一個 handler 包裝進一個函式去處理驗證和錯誤檢查呢? Go 的函式字面提供了一個強大的抽象功能可以幫助我們實現它。

首先,我們重寫每一個 handler 函式的定義,接收一個字串型別的 title 引數:

func viewHandler(w http.ResponseWriter, r *http.Request, title string)
func editHandler(w http.ResponseWriter, r *http.Request, title string)
func saveHandler(w http.ResponseWriter, r *http.Request, title string)

現在我們定義一個引數是上面函式型別的包裝函式,並且返回一個型別為 http.HandlerFunc 的函式( 這個返回值是為了滿足 http.HandleFunc 的引數 )。

func makeHandler(fn func (http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // 這裡將從 Request 中提取 title
        // 並且呼叫提供的 handler 'fn'
    }
}

這個返回的函式叫閉包,因為它包含了在它外部定義的值。在這種情況,變數 fn (傳給 makeHandler 的單個引數) 包裹在閉包中, 變數 fn 將會是我們的 save,editview handler 中的一個。

現在我們可以從 getTitle 中提取程式碼,並在此處使用它(稍作修改):

func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        m := validPath.FindStringSubmatch(r.URL.Path)
        if m == nil {
            http.NotFound(w, r)
            return
        }
        fn(w, r, m[2])
    }
}

makeHandler 返回的閉包是一個包含 http.ResponseWriterhttp.Request 引數的函式( 換句話說,一個 http.HandlerFunc )。這個閉包從請求路徑中提取 title ,並且通過 title 驗證器 regexp 驗證它。 如果標題是無效的,一個 error 將通過 http.NotFound 函式寫入到 ResponseWriter 。如果 title 是有效的,它包裹的 handler 函式 fn 將傳入 ResponseWriter, Request, 和 title 引數被呼叫。

現在,在 main 函式中,我們可以在 handler 被註冊到 http 包之前,通過 makeHandler 來包裝它們:

func main() {
    http.HandleFunc("/view/", makeHandler(viewHandler))
    http.HandleFunc("/edit/", makeHandler(editHandler))
    http.HandleFunc("/save/", makeHandler(saveHandler))

    log.Fatal(http.ListenAndServe(":8080", nil))
}

最後,我們從 handler 中刪除對 getTitle 的呼叫,使它們更簡單:

func viewHandler(w http.ResponseWriter, r *http.Request, title string) {
    p, err := loadPage(title)
    if err != nil {
        http.Redirect(w, r, "/edit/"+title, http.StatusFound)
        return
    }
    renderTemplate(w, "view", p)
}
func editHandler(w http.ResponseWriter, r *http.Request, title string) {
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    renderTemplate(w, "edit", p)
}
func saveHandler(w http.ResponseWriter, r *http.Request, title string) {
    body := r.FormValue("body")
    p := &Page{Title: title, Body: []byte(body)}
    err := p.save()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

試試看

點選這裡可以檢視最終的程式碼

重新編譯程式碼並執行它:

$ go build wiki.go
$ ./wiki

訪問 http://localhost:8080/view/ANewPage 將顯示頁面編輯表單, 你應該可以輸入一些文字,點選 save,然後會重定向到新建立的頁面。

其他任務

下面是希望你可能希望自己解決的任務清單:

  • 將模板儲存到 tmpl/ 目錄並且將頁面資料儲存到 data/

  • 新增一個 handler 將 web 根目錄重定向到 /view/FrontPage

  • 完善頁面模板,使它們成為一個有效的 HTML 並新增一些 CSS 規則。

  • 通過 [PageName] 例項實現一些頁面間的連結,<a href="/view/PageName">PageName</a> (提示: 你可以通過 regexp.ReplaceAllFunc 來實現這個)。

本文章來自: broqiang.com 可以隨意轉載。

相關文章