撰寫 Web 應用程式
簡介
本教學課程涵蓋的內容
- 建立具有載入和儲存方法的資料結構
- 使用
net/http
套件建置 Web 應用程式 - 使用
html/template
套件處理 HTML 範本 - 使用
regexp
套件驗證使用者輸入 - 使用閉包
假設知識
- 程式設計經驗
- 了解基本的網路技術 (HTTP、HTML)
- 一些 UNIX/DOS 命令列知識
入門
目前,您需要一台 FreeBSD、Linux、macOS 或 Windows 電腦來執行 Go。我們將使用 $
來表示命令提示字元。
安裝 Go(請參閱 安裝說明)。
在您的 GOPATH
內為本教學建立一個新目錄,並使用 cd 進入該目錄
$ mkdir gowiki $ cd gowiki
建立一個名為 wiki.go
的檔案,在您最喜歡的編輯器中開啟它,並加入以下程式碼行
package main import ( "fmt" "os" )
我們從 Go 標準函式庫匯入 fmt
和 os
套件。稍後,當我們實作其他功能時,我們會將更多套件加入此 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
類型的值。」
此方法將儲存 Page
的 Body
到一個文字檔案中。為了簡化起見,我們將 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
會傳回 []byte
和 error
。在 loadPage
中,錯誤尚未處理;空白識別碼 (_
) 符號代表的「空白識別碼」用於捨棄錯誤傳回值 (實質上是將值指定為無)。
但如果 ReadFile
遇到錯誤會發生什麼事?例如,檔案可能不存在。我們不應該忽略此類錯誤。讓我們修改函式,傳回 *Page
和 error
。
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
包住函式呼叫。
函式 handler
是 http.HandlerFunc
類型。它將 http.ResponseWriter
和 http.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) }
再次注意使用 _
來忽略 loadPage
的 error
傳回值。這樣做是為了簡潔起見,而且通常被認為是不良做法。我們稍後會處理這個問題。
首先,此函式從 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
」,而不要輸入「./
」來執行程式。)
當此網路伺服器執行時,瀏覽 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.Title
和 p.Body
。
範本指令包含在雙大括號中。printf "%s" .Body
指令是一個函式呼叫,它會將 .Body
輸出為字串,而不是位元組串流,這與呼叫 fmt.Printf
相同。html/template
套件有助於確保範本動作只會產生安全且外觀正確的 HTML。例如,它會自動跳脫任何大於符號 (>
),並將其替換為 >
,以確保使用者資料不會損毀表單 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.Regexp
。MustCompile
與 Compile
的不同之處在於,如果表達式編譯失敗,它會引發恐慌,而 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' } }
傳回的函式稱為閉包,因為它包含在它外部定義的值。在本例中,變數 fn
(makeHandler
的單一引數)被閉包包含。變數 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.ResponseWriter
和 http.Request
(換句話說,是一個 http.HandlerFunc
)。封閉函式會從請求路徑中萃取 title
,並使用 validPath
正則表示法驗證它。如果 title
無效,會使用 http.NotFound
函式將錯誤寫入 ResponseWriter
。如果 title
有效,封閉的處理函式 fn
會以 ResponseWriter
、Request
和 title
作為引數被呼叫。
現在我們可以在 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 應該會顯示頁面編輯表單。然後你應該可以輸入一些文字,按一下「儲存」,然後重新導向到新建立的頁面。
其他任務
以下是一些你可能想要自己處理的簡單任務
- 將範本儲存在
tmpl/
中,將頁面資料儲存在data/
中。 - 新增一個處理函式,讓 Web 根目錄重新導向到
/view/FrontPage
。 - 透過讓它們成為有效的 HTML 並新增一些 CSS 規則來整理頁面範本。
- 透過將
[PageName]
的執行個體轉換為
<a href="/view/PageName">PageName</a>
來實作頁面間連結。(提示:你可以使用regexp.ReplaceAllFunc
來執行此操作)