編寫 Web 應用程式

引言

本教程涵蓋內容

前置知識

入門

目前,您需要一臺 FreeBSD、Linux、macOS 或 Windows 機器來執行 Go。我們將使用 $ 表示命令提示符。

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

在您的 GOPATH 中為本教程建立一個新目錄並進入該目錄

$ 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(指標、介面和其他一些型別的零值)。

八進位制整數文字 0600 作為第三個引數傳遞給 WriteFile,表示該檔案應僅以當前使用者的讀寫許可權建立。(有關詳細資訊,請參閱 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,則表示已成功載入頁面。如果不是,它將是一個 error,可以由呼叫者處理(有關詳細資訊,請參閱語言規範)。

至此,我們有了一個簡單的資料結構以及儲存和載入檔案的能力。讓我們編寫一個 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 包(插曲)

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

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

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

ListenAndServe 總是返回一個錯誤,因為它只在發生意外錯誤時才返回。為了記錄該錯誤,我們用 log.Fatal 包裝了函式呼叫。

函式 handler 的型別是 http.HandlerFunc。它接受一個 http.ResponseWriter 和一個 http.Request 作為其引數。

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

http.Request 是一個表示客戶端 HTTP 請求的資料結構。 r.URL.Path 是請求 URL 的路徑元件。末尾的 [1:] 表示“從第 1 個字元到末尾建立 Path 的子切片”。這將從路徑名稱中刪除開頭的“/”。

如果您執行此程式並訪問 URL

https://: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 字串格式化頁面,並將其寫入 w,即 http.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”而不是“./”來執行程式。)

在此 Web 伺服器執行的情況下,訪問 https://: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 狀態碼 http.StatusFound (302) 和 Location 頭部。

儲存頁面

函式 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 值時會引發 panic,否則會原樣返回 *Template。這裡引發 panic 是合適的;如果模板無法載入,唯一明智的做法就是退出程式。

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

請注意,模板名稱是模板檔名,因此我們必須在 tmpl 引數後新增 ".html"

驗證

正如您可能已經觀察到的,該程式存在一個嚴重的安全缺陷:使用者可以提供任意路徑以在伺服器上讀取/寫入。為了緩解這種情況,我們可以編寫一個函式來使用正則表示式驗證標題。

首先,將 "regexp" 新增到 import 列表中。然後我們可以建立一個全域性變數來儲存我們的驗證表示式

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

函式 regexp.MustCompile 將解析並編譯正則表示式,並返回一個 regexp.RegexpMustCompileCompile 的區別在於,如果表示式編譯失敗,它會 panic,而 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 錯誤值一起返回。如果標題無效,函式將向 HTTP 連線寫入“404 Not Found”錯誤,並向處理程式返回一個錯誤。要建立新錯誤,我們必須匯入 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

訪問 https://:8080/view/ANewPage 應該會顯示頁面編輯表單。然後您應該可以輸入一些文字,單擊“儲存”,然後重定向到新建立的頁面。

其他任務

以下是一些您可能希望自行解決的簡單任務