使用 Go 建立 Web 應用程式

環境

  • macOS
  • Go 1.13.4

前言

本文為 Go 官方文件 Writing Web Applications 的學習筆記,實作與原文有些許差異。

建立專案

新增並進入 wiki 資料夾。

1
2
mkdir wiki
cd wiki

新增 main.go 檔:

1
touch main.go

結構體

首先需要建立一個 Page 結構體,一個 Wiki 頁面由標題(Title)和內容(Body)所組成。由於標準庫 io 期望接收到的是一個位元組切片,因此將 Body 的型別設為 []byte

1
2
3
4
type Page struct {
Title string
Body []byte
}

主要方法

引入標準庫 io/ioutil

1
2
3
import (
"io/ioutil"
)

Page 結構體新增一個 save() 方法,用來儲存頁面:

1
2
3
4
func (p *Page) save() error {
filename := p.Title + ".txt"
return ioutil.WriteFile(filename, p.Body, 0600)
}
  • ioutil.WriteFile() 方法的第三個參數表示檔案的存取權限。

再新增一個 load() 方法,用來載入頁面:

1
2
3
4
5
func load(title string) *Page {
filename := title + ".txt"
body, _ := ioutil.ReadFile(filename)
return &Page{Title: title, Body: body}
}

由於 Go 的方法可以回傳多個值,所以讓 load() 方法也將錯誤一起回傳:

1
2
3
4
5
6
7
8
func load(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
}

然後建立一個 main() 方法:

1
2
3
4
5
6
func main() {
p1 := &Page{Title: "TestPage", Body: []byte("This is a sample Page.")}
p1.save()
p2, _ := load("TestPage")
fmt.Println(string(p2.Body))
}

執行應用,會產生一個 TestPage.txt 檔。

1
go run main.go

控制器

引入標準庫 net/http

1
2
3
4
import (
"io/ioutil"
"net/http"
)

新增一個 viewHandler() 方法,用來查看 Wiki 頁面。

1
2
3
4
5
6
7
8
9
func viewHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/view/"):]
p, err := load(title)
if err != nil {
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
}
  • r.URL.Path 屬性可以獲取當前 URL 路徑。

新增一個 editHandler() 方法,用來修改 Wiki 頁面。

1
2
3
4
5
6
7
8
9
10
11
12
13
func editHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/edit/"):]
p, err := load(title)
if err != nil {
p = &Page{Title: title}
}
fmt.Fprintf(w, "<h1>Editing %s</h1>"+
"<form action=\"/store/%s\" method=\"POST\">"+
"<textarea name=\"body\">%s</textarea><br>"+
"<input type=\"submit\" value=\"Save\">"+
"</form>",
p.Title, p.Title, p.Body)
}

新增一個 storeHandler() 方法,用來儲存 Wiki 頁面。

1
2
3
4
5
6
7
func storeHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/store/"):]
body := r.FormValue("body")
p := &Page{Title: title, Body: []byte(body)}
p.save()
http.Redirect(w, r, "/view/"+title, http.StatusFound)
}

路由

引入標準庫 log

1
2
3
4
5
import (
"io/ioutil"
"log"
"net/http"
)

main.go 檔修改如下:

1
2
3
4
5
6
func main() {
http.HandleFunc("/view/", viewHandler)
http.HandleFunc("/edit/", editHandler)
http.HandleFunc("/store/", storeHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}

執行應用。

1
go run main.go

前往 http://localhost:8080/view/TestPage 瀏覽。

模板

引入標準庫 html/template

1
2
3
4
5
import (
"html/template"
"io/ioutil"
"net/http"
)

為了將 HTML 從 Go 程式碼中分離,因此新增一個 renderTemplate() 方法。

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

新增一個 view.html 檔:

1
2
3
4
5
<h1>{{.Title}}</h1>

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

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

修改 viewHandler() 方法如下:

1
2
3
4
5
func viewHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/view/"):]
p, _ := load(title)
renderTemplate(w, "view", p)
}

新增一個 edit.html 檔:

1
2
3
4
5
6
7
8
9
10
<h1>Editing {{.Title}}</h1>

<form action="/store/{{.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() 方法如下:

1
2
3
4
5
6
7
8
func editHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/edit/"):]
p, err := load(title)
if err != nil {
p = &Page{Title: title}
}
renderTemplate(w, "edit", p)
}

執行應用。

1
go run main.go

前往 http://localhost:8080/view/TestPage 瀏覽。

錯誤處理

修改 renderTemplate() 方法,不要去忽略錯誤:

1
2
3
4
5
6
7
8
9
10
11
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)
}
}

修改 storeHandler() 方法,不要去忽略錯誤:

1
2
3
4
5
6
7
8
9
10
11
func storeHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/store/"):]
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)
}

快取

定義一個全域變數,讓 ParseFiles() 方法在應用程式啟動後只執行一次。Must() 方法會在 ParseFiles() 方法返回 err 不為 nil 時調用 panic

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

使用 ExecuteTemplate() 方法來渲染樣板,修改 renderTemplate() 方法:

1
2
3
4
5
6
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)
}
}

驗證

引入標準庫 regexp

1
2
3
4
5
6
7
import (
"io/ioutil"
"log"
"net/http"
"regexp"
"text/template"
)

定義一個全域變數,讓路由必須符合特定格式。

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

引入標準庫 errors

1
2
3
4
5
6
7
8
import (
"errors"
"io/ioutil"
"log"
"net/http"
"regexp"
"text/template"
)

新增一個 getTitle() 方法,驗證並取得標題名稱。

1
2
3
4
5
6
7
8
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
}

修改 viewHandler() 方法:

1
2
3
4
5
6
7
8
9
func viewHandler(w http.ResponseWriter, r *http.Request) {
title, err := getTitle(w, r)
p, err := load(title)
if err != nil {
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
renderTemplate(w, "view", p)
}

修改 editHandler() 方法:

1
2
3
4
5
6
7
8
func editHandler(w http.ResponseWriter, r *http.Request) {
title, err := getTitle(w, r)
p, err := load(title)
if err != nil {
p = &Page{Title: title}
}
renderTemplate(w, "edit", p)
}

修改 storeHandler() 方法:

1
2
3
4
5
6
7
8
9
10
11
func storeHandler(w http.ResponseWriter, r *http.Request) {
title, err := getTitle(w, r)
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)
}

封裝

刪除 getTitle() 方法和標準庫 errors 的引用。新增一個 makeHandler() 方法,它接收一個閉包,這個閉包會在驗證通過後被呼叫。

1
2
3
4
5
6
7
8
9
10
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])
}
}

修改 main() 方法:

1
2
3
4
5
6
func main() {
http.HandleFunc("/view/", makeHandler(viewHandler))
http.HandleFunc("/edit/", makeHandler(editHandler))
http.HandleFunc("/store/", makeHandler(storeHandler))
log.Fatal(http.ListenAndServe(":8080", nil))
}

修改 viewHandler() 方法:

1
2
3
4
5
6
7
8
func viewHandler(w http.ResponseWriter, r *http.Request, title string) {
p, err := load(title)
if err != nil {
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
renderTemplate(w, "view", p)
}

修改 editHandler() 方法:

1
2
3
4
5
6
7
func editHandler(w http.ResponseWriter, r *http.Request, title string) {
p, err := load(title)
if err != nil {
p = &Page{Title: title}
}
renderTemplate(w, "edit", p)
}

修改 storeHandler() 方法:

1
2
3
4
5
6
7
8
9
10
func storeHandler(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)
}

執行應用。

1
go run main.go

前往 http://localhost:8080/view/TestPage 瀏覽。

程式碼

參考資料