撰寫 Web 應用程式

簡介

本教學課程涵蓋的內容

假設知識

入門

目前,您需要一台 FreeBSD、Linux、macOS 或 Windows 電腦來執行 Go。我們將使用 $ 來表示命令提示字元。

安裝 Go(請參閱 安裝說明)。

在您的 GOPATH 內為本教學建立一個新目錄,並使用 cd 進入該目錄

$ mkdir gowiki
$ cd gowiki

建立一個名為 wiki.go 的檔案,在您最喜歡的編輯器中開啟它,並加入以下程式碼行

package main

import (
    "fmt"
    "os"
)

我們從 Go 標準函式庫匯入 fmtos 套件。稍後,當我們實作其他功能時,我們會將更多套件加入此 import 宣告。

資料結構

讓我們從定義資料結構開始。Wiki 由一系列相互連結的頁面組成,每個頁面都有標題和內文(頁面內容)。在此,我們將 Page 定義為一個結構,其中包含兩個欄位,分別代表標題和內文。

type Page struct {
    Title string
    Body  []byte
}

類型 []byte 表示「一個 byte 切片」。(請參閱 切片:用法和內部結構 以進一步了解切片。)Body 元素是 []byte 而不是 string,因為這是我們將在下方使用的 io 函式庫所預期的類型。

Page 結構描述了頁面資料將如何儲存在記憶體中。但是持久性儲存呢?我們可以透過在 Page 上建立一個 save 方法來解決這個問題

func (p *Page) save() error {
    filename := p.Title + ".txt"
    return os.WriteFile(filename, p.Body, 0600)
}

此方法的簽章寫法為:「這是一個名為 save 的方法,它將 p(一個指向 Page 的指標)作為接收器。它不接受任何參數,並傳回一個 error 類型的值。」

此方法將儲存 PageBody 到一個文字檔案中。為了簡化起見,我們將 Title 用作檔案名稱。

save 方法會傳回一個 error 值,因為這是 WriteFile (標準函式庫函式,用於將位元組片段寫入檔案) 的傳回類型。save 方法會傳回錯誤值,讓應用程式在寫入檔案時發生任何問題時,都能處理錯誤。如果一切順利,Page.save() 會傳回 nil (指標、介面和一些其他類型的零值)。

傳遞給 WriteFile 做為第三個參數的八進位整數文字 0600,表示檔案應該只建立具有目前使用者讀寫權限的檔案。(詳情請參閱 Unix 手冊頁 open(2)。)

除了儲存頁面,我們也會想要載入頁面

func loadPage(title string) *Page {
    filename := title + ".txt"
    body, _ := os.ReadFile(filename)
    return &Page{Title: title, Body: body}
}

函式 loadPage 會從標題參數建立檔案名稱,將檔案內容讀取到新的變數 body 中,並傳回一個指標,指向使用適當的標題和內文值建立的 Page 文字。

函式可以傳回多個值。標準函式庫函式 os.ReadFile 會傳回 []byteerror。在 loadPage 中,錯誤尚未處理;空白識別碼 (_) 符號代表的「空白識別碼」用於捨棄錯誤傳回值 (實質上是將值指定為無)。

但如果 ReadFile 遇到錯誤會發生什麼事?例如,檔案可能不存在。我們不應該忽略此類錯誤。讓我們修改函式,傳回 *Pageerror

func loadPage(title string) (*Page, error) {
    filename := title + ".txt"
    body, err := os.ReadFile(filename)
    if err != nil {
        return nil, err
    }
    return &Page{Title: title, Body: body}, nil
}

此函式的呼叫者現在可以檢查第二個參數;如果它是 nil,表示已成功載入 Page。如果不是,它會是一個錯誤,呼叫者可以處理 (詳情請參閱 語言規格)。

在這個階段,我們有一個簡單的資料結構,以及儲存到檔案和從檔案載入的能力。讓我們撰寫一個 main 函式來測試我們已撰寫的內容

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

編譯並執行此程式碼後,會建立一個名為 TestPage.txt 的檔案,其中包含 p1 的內容。然後會將該檔案讀取到結構 p2 中,並將其 Body 元素印到螢幕上。

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

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

(如果你使用 Windows,你必須輸入「wiki」,而不要輸入「./」來執行程式。)

按一下這裡查看我們到目前為止寫的程式碼。

介紹 net/http 套件(插曲)

以下是簡單網路伺服器的完整工作範例

//go:build ignore

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 處理所有對網路根目錄 ("/") 的要求。

然後它會呼叫 http.ListenAndServe,指定它應該在任何介面上 (":8080") 的 8080 埠上偵聽。(現在不用擔心它的第二個參數 nil。)此函式會封鎖,直到程式終止。

ListenAndServe 永遠會傳回錯誤,因為它只會在發生意外錯誤時傳回。為了記錄該錯誤,我們使用 log.Fatal 包住函式呼叫。

函式 handlerhttp.HandlerFunc 類型。它將 http.ResponseWriterhttp.Request 作為其引數。

http.ResponseWriter 值組成 HTTP 伺服器的回應;透過寫入它,我們將資料傳送到 HTTP 客戶端。

http.Request 是表示客戶端 HTTP 要求的資料結構。r.URL.Path 是要求 URL 的路徑組成部分。尾端的 [1:] 表示「建立 Path 的子切片,從第 1 個字元到結尾」。這會從路徑名稱中移除開頭的「/」。

如果你執行此程式並存取 URL

http://localhost:8080/monkeys

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

Hi there, I love monkeys!

使用 net/http 提供 wiki 頁面

要使用 net/http 套件,必須匯入它

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

讓我們建立一個處理常式 viewHandler,它將允許使用者檢視 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)
}

再次注意使用 _ 來忽略 loadPageerror 傳回值。這樣做是為了簡潔起見,而且通常被認為是不良做法。我們稍後會處理這個問題。

首先,此函式從 r.URL.Path(請求 URL 的路徑組成部分)中擷取頁面標題。Path 使用 [len("/view/"):] 重新切片,以捨棄請求路徑中開頭的 "/view/" 組成部分。這是因為路徑一定會以 "/view/" 開頭,而這並非頁面標題的一部分。

然後,此函式載入頁面資料,使用簡單 HTML 的字串格式化頁面,並將其寫入 whttp.ResponseWriter)。

要使用此處理常式,我們改寫 main 函式,使用 viewHandler 初始化 http,以處理路徑 /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。讓我們建立兩個新的處理常式:一個名為 editHandler,用於顯示「編輯頁面」表單,另一個名為 saveHandler,用於儲存透過表單輸入的資料。

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

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

函式 editHandler 會載入頁面(或是在頁面不存在時,建立一個空的 Page 結構),並顯示一個 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}
    }
    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 加入到匯入清單中。我們也不會再使用 fmt,因此我們必須將它移除。

import (
    "html/template"
    "os"
    "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。例如,它會自動跳脫任何大於符號 (>),並將其替換為 &gt;,以確保使用者資料不會損毀表單 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)
}

請注意,我們在兩個處理常式中幾乎使用了完全相同的範本程式碼。讓我們將範本程式碼移至其自己的函式中,以移除此重複

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

並修改處理常式以使用該函式

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 中註解掉未實作的儲存處理常式的註冊,我們可以再次建置並測試我們的程式。 按一下這裡檢視我們到目前為止撰寫的程式碼。

處理不存在的頁面

如果你造訪 /view/APageThatDoesntExist 會怎樣?你會看到包含 HTML 的頁面。這是因為它忽略了 loadPage 的錯誤回傳值,並繼續嘗試填寫沒有資料的範本。相反地,如果請求的頁面不存在,它應該將客戶端重新導向至編輯頁面,以便建立內容

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) 和 Location 標頭新增至 HTTP 回應。

儲存頁面

函式 saveHandler 會處理位於編輯頁面上的表單提交。在取消註解 main 中的相關行後,讓我們實作處理常式

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)
}

頁面標題(在 URL 中提供)和表單的唯一欄位 Body 儲存在新的 Page 中。然後呼叫 save() 方法將資料寫入檔案,並將客戶端重新導向至 /view/ 頁面。

FormValue 回傳的值為 string 類型。我們必須將該值轉換為 []byte,才能放入 Page 結構中。我們使用 []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 回應代碼(在本例中為「內部伺服器錯誤」)和錯誤訊息。將此函數放入獨立函數的決定已經發揮作用。

現在讓我們修正 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 是便利包裝器,當傳遞非 nil error 值時會引發恐慌,否則會原樣傳回 *Template。在此引發恐慌是適當的;如果無法載入範本,唯一明智的做法就是結束程式。

ParseFiles 函數會採用任何數量的字串引數來識別我們的範本檔案,並將這些檔案剖析成以基本檔案名稱命名的範本。如果要將更多範本新增到我們的程式,我們會將它們的名稱新增到 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)
    }
}

請注意,範本名稱是範本檔案名稱,因此我們必須將 ".html" 附加到 tmpl 引數。

驗證

您可能已經觀察到,這個程式有一個嚴重的安全漏洞:使用者可以提供任意路徑在伺服器上讀取/寫入。為了減輕這個問題,我們可以撰寫一個函數,使用正規表示式驗證標題。

首先,將 "regexp" 加入 import 清單中。然後,我們可以建立一個全域變數來儲存我們的驗證表達式

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

函式 regexp.MustCompile 會剖析並編譯正規表達式,然後傳回一個 regexp.RegexpMustCompileCompile 的不同之處在於,如果表達式編譯失敗,它會引發恐慌,而 Compile 會將 error 作為第二個參數傳回。

現在,我們來撰寫一個使用 validPath 表達式來驗證路徑並萃取頁面標題的函式

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 // The title is the second subexpression.
}

如果標題有效,它會與 nil 錯誤值一起傳回。如果標題無效,函式會將「404 找不到」錯誤寫入 HTTP 連線,並傳回一個錯誤給處理常式。若要建立一個新的錯誤,我們必須匯入 errors 套件。

我們在每個處理常式中呼叫 getTitle

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)
}

函式文字與閉包簡介

在每個處理常式中捕捉錯誤條件會產生許多重複的程式碼。如果我們能將每個處理常式包裝在一個執行此驗證與錯誤檢查的函式中,會如何?Go 的 函式文字 提供了抽象化功能的強大方式,有助於我們處理這個問題。

首先,我們重新撰寫每個處理常式的函式定義,以接受標題字串

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) {
        // Here we will extract the page title from the Request,
        // and call the provided handler 'fn'
    }
}

傳回的函式稱為閉包,因為它包含在它外部定義的值。在本例中,變數 fnmakeHandler 的單一引數)被閉包包含。變數 fn 會是我們的儲存、編輯或檢視處理常式之一。

現在,我們可以從 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,並使用 validPath 正則表示法驗證它。如果 title 無效,會使用 http.NotFound 函式將錯誤寫入 ResponseWriter。如果 title 有效,封閉的處理函式 fn 會以 ResponseWriterRequesttitle 作為引數被呼叫。

現在我們可以在 main 中使用 makeHandler 包裝處理函式,在它們向 http 套件註冊之前

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

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

最後我們從處理函式中移除對 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 應該會顯示頁面編輯表單。然後你應該可以輸入一些文字,按一下「儲存」,然後重新導向到新建立的頁面。

其他任務

以下是一些你可能想要自己處理的簡單任務