這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
- 英文: http://golang.org/doc/articles/wiki/
簡介
本教程將討論:
- 建立一個支援載入和儲存的資料結構
- 使用 net/http 包來構建web應用程式
- 使用 html/template 包來處理HTML模板
- 使用 regexp 包來驗證使用者輸入
- 使用閉包
基本知識:
- 有一定的編程經驗
- 瞭解基本的web技術(HTTP、HTML)
- 一些UNIX/DOS命令列知識
開始
目前、你需要一個運行FreeBSD、Linux、OS X 或 Windows的機器。 我們將使用 $ 來代表命令提示字元。
安裝Go語言環境(參考 安裝說明)。
為本教程建立一個目錄,將建立目錄添加到GOPATH環境變數,然後命令列切換到建立目錄:
$ mkdir gowiki$ cd gowiki
建立一個名為wiki.go的源檔案,使用你喜歡的編輯器開啟,並添加以下代碼:
package mainimport ( "fmt" "io/ioutil")
我們從標準庫匯入了fmt和ioutil包。 後面我們將實現更多的功能,到時候我們會添加更多的包到import聲明。
資料結構
我們現定義資料結構。一個wiki通常有一些列相互關聯的頁面組成,每個頁面有一個標題和一個主體(頁面的內容)。 在這裡,我們定的Page結構體包含標題和主體兩個成員。
type Page struct { Title string Body []byte}
類型 []byte
表示“一個byte切片”。 (參見 Go切片:用法和本質) 我們將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函數的傳回型別 一致(將byte切片寫入檔案的標準庫函數)。程式可以通過save方法返回的 error值判斷寫檔案時是否遇到錯誤。如果寫檔案一切正常,Page.save() 將返回nil(對應指標、介面等類型的零值)。
傳遞給WriteFile函數的第三個參數0600是一個八進位整數面值, 表示新建立的檔案只對目前使用者是讀寫權限。(更多資訊請參考Unix手冊 open(2))
除了儲存頁面,我們還需要載入頁面:
func loadPage(title string) *Page { filename := title + ".txt" body, _ := ioutil.ReadFile(filename) return &Page{Title: title, Body: body}}
函數loadPage從title參數構造檔案名稱,然後讀取檔案的內容到 新的變數body,最後返回兩個值:一個指向由title和body構造的 Page面值並且錯誤傳回值為nil。
函數可以返回多個值。標準庫函數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表示成功載入頁面。否則, 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$ ./wikiThis is a sample page.
(如果是使用Windows系統則不需要“wiki”前面的“./”。)
點擊這裡瀏覽完整代碼。
瞭解net/http包(插曲)
這裡是一個簡要Web伺服器的完整代碼:
package mainimport ( "fmt" "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) http.ListenAndServe(":8080", nil)}
main函數開始調用http.HandleFunc
,告訴http
包用handler
函數處理所以針對跟目錄的訪問請求(“/“)。
然後調用http.ListenAndServe
,指定監聽連接埠為8080(“:8080”)。 (目前先忽略第二個參數nil。)這個函數會阻塞直到程式終止。
函數handler的類型是http.HandlerFunc。它的參數是 一個http.ResponseWriter
和一個http.Request
。
參數http.ResponseWriter
匯總HTTP伺服器的響應;向它寫入的資料會發送 到HTTP客服端。
參數http.Request是用戶端請求資料對應的資料結構。 r.URL.Path
表示用戶端請求的URL地址。後面的[1:]
含義是 “從Path的第一個字元到 末尾建立一個子切片。” 這樣可以忽略URL路徑中的開始的“/”字元。
如果你運行程式並訪問一些URL地址:
http://localhost:8080/monkeys
程式會返回一個包含以下內容的頁面:
Hi there, I love monkeys!
基於net/http包提供wiki頁面
使用前需要匯入net/http包:
import ( "fmt" "net/http" "io/ioutil")
然後我們建立一個viewHandler函數,用於處理瀏覽wiki頁面。它會處理所有以”/view/“為首碼的URL地址。
const lenPath = len("/view/")func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[lenPath:] p, _ := loadPage(title) fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)}
首先,該函數從r.URL.Path中取出要瀏覽頁面的標題。 全域常量lenPath是URL首碼”/view/“的長度。 Path的切片[lenPath:]
用於忽略前面的6個字元。 這是因為URL地址是以”/view/“為首碼,它們不是頁面標題的組成部分。
接著載入頁面資料,然後格式化為一個簡單的HTML頁面,寫入到http.ResponseWriter
類型的w參數。
這裡又一次使用了_
來忽略loadPage返回的錯誤error
。 這裡只是為了簡化代碼,它並不是好的編程實踐。稍後我們會繼續完善這個部分。
要使用這個函數,我們需要修改main函數中的http初始化代碼, 使用viewHandler
函數處理對應/view/地址的請求。
func main() { http.HandleFunc("/view/", viewHandler) http.ListenAndServe(":8080", nil)}
點擊這裡瀏覽完整代碼。
我們建立一些測試頁面(例如test.txt),然後嘗試提供一個wiki頁面:
使用編輯器開啟test.txt檔案,輸入“Hello world”內容並儲存(忽略雙引號)。
$ go build wiki.go$ ./wiki
如果是使用Windows系統則不需要“wiki”前面的“./”。
啟動web伺服器後,瀏覽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) http.ListenAndServe(":8080", nil)}
函數editHandler載入頁面,然後顯示一個HTML編輯頁面。
func editHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[lenPath:] 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是標準庫中的包。我們使用html/template 包可以將HTML代碼分離到一個檔案,然後我們可以在不改變底層代碼前提下調整和完善編輯頁面。
首先,我們匯入html/template包。現在我們已經不再使用fmt包了, 因此需要刪除它。
import ( "html/template" "http" "io/ioutil" "os")
我們需要為編輯頁面建立一個模板檔案。建立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[lenPath:] 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結構.
需要注意的是我們移除了fmt.Fprintf
語句, 因此也移除了"fmt"
包的匯入語句.
現在我們已經是基於模板方式的, 可以針對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[lenPath:] p, _ := loadPage(title) t, _ := template.ParseFiles("view.html") t.Execute(w, p)}
觀察可以發現前面是否模板的方式非常相似. 因此我們將模板獨立大一個函數:
func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[lenPath:] p, _ := loadPage(title) renderTemplate(w, "view", p)}func editHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[lenPath:] p, err := loadPage(title) if err != nil { p = &Page{Title: title} } renderTemplate(w, "edit", p)}func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) { t, _ := template.ParseFiles(tmpl + ".html") t.Execute(w, p)}
現在的處理函數更加清晰簡短.
處理不存在的頁面
如果訪問/view/APageThatDoesntExist
會發生什麼情況? 程式會崩潰掉.
這是因為程式忽略了loadPage
返回的錯誤資訊. 為了處理頁面不存在的情況,
程式會重新導向到一個新頁面的編輯頁面:
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)}
http.Redirect
函數會添加http.StatusFound (302)
狀態, 並且重新置放.
儲存頁面
函數saveHandler
用於處理提交的表單.
func saveHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[lenPath:] body := r.FormValue("body") p := &Page{Title: title, Body: []byte(body)} p.save() http.Redirect(w, r, "/view/"+title, http.StatusFound)}
頁面的標題(URL提供)和表單的內容將作為一個新頁面儲存.
調用save()
方法將頁面寫到檔案, 然後重新導向到/view/
頁面.
FormValue
方法返回的傳回值是字串類型. 我們需要先轉換為[]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
函數返回一個具體的錯誤碼(這裡是屬於"伺服器錯誤"類型)和錯誤資訊.
看來剛才決定將模板處理獨立到一個函數是一個正確的決定.
下面是修複後的saveHandler
:
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)}
p.save()
時發生的錯誤資訊也將報告給使用者.
緩衝模板
前面的實現有一個效能缺陷: renderTemplate
每次都會調用ParseFiles
函數.
更好的最佳化思路是只在初始化的使用調用一次ParseFiles
, 將全部要處理的模板
放到一個*Template
中. 然後可以使用ExecuteTemplate
渲染指定的模板.
首先建立一個名位templates
全域變數, 然後用ParseFiles
進行初始化.
var templates = template.Must(template.ParseFiles("edit.html", "view.html"))
template.Must
只是一個簡便的封裝, 當傳遞非nil
的錯誤是拋出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) }}
需要注意的是模板名字對於模板檔案的名字, 因此這裡添加了”.html"尾碼名.
驗證
你可能以及發現, 這個程式有嚴重的安全缺陷: 使用者可以在伺服器上讀寫任意獨立路徑.
為了降低這種風險, 我們編寫一個函數以Regex的方式在驗證標題的合法性.
首先, 要匯入"regexp"
包. 然後建立一個全域變數儲存用於驗證的Regex:
var titleValidator = regexp.MustCompile("^[a-zA-Z0-9]+$")
函數regexp.MustCompile
將分析和編譯Regex, 返回regexp.Regexp
.
MustCompile
和Compile
有些不同, MustCompile
遇到錯誤時會拋出panic
異常,
而Compile
在遇到錯誤時通過第二個傳回值返回錯誤.
現在, 讓我們寫一個函數getTitle
, 從請求的URL提取標題, 並且測試是否是有效運算式:
func getTitle(w http.ResponseWriter, r *http.Request) (title string, err error) { title = r.URL.Path[lenPath:] if !titleValidator.MatchString(title) { http.NotFound(w, r) err = errors.New("Invalid Page Title") } return}
如果標題是有效, 將返回nil
錯誤值. 如果標題無效, 函數會輸出"404 Not Found"錯誤.
讓我們將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) { title := r.URL.Path[lenPath:] if !titleValidator.MatchString(title) { http.NotFound(w, r) return } fn(w, r, title) }}
makeHandler
返回的是一個持有http.ResponseWriter
和http.Request
參數的閉包函數
(其實就是http.HandlerFunc
類型). 閉包函數提取頁面的標題, 並通過TitleValidator
驗證
標題是否符合Regex. 如果是無效的標題, 那麼將使用http.NotFound
輸出錯誤的響應.
如果是有效標題, 那麼fn
處理函數將會被調用.
現在我們可以在main
函數註冊的時候使用makeHandler
封裝具體的處理函數:
func main() { http.HandleFunc("/view/", makeHandler(viewHandler)) http.HandleFunc("/edit/", makeHandler(editHandler)) http.HandleFunc("/save/", makeHandler(saveHandler)) http.ListenAndServe(":8080", nil)}
Finally we remove the calls to getTitle from the handler functions, making them much simpler:
最後我們刪除處理函數對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/
目錄.
- 增加一個根目錄的處理函數, 重新導向到
/view/FrontPage
.
- Spruce up the page templates by making them valid HTML and adding some CSS rules.
- 完善頁面模板, 讓它們輸出有效HTML, 並且添加一些CSS規則。
- 通過將
[PageName]
轉換位<a href="/view/PageName">PageName</a>
實現頁面之間的連結.
(提示: 可以使用regexp.ReplaceAllFunc
實現該功能)