1. 簡介
這個例子涉及到的技術:
- 建立一個資料類型,含有load和save函數
- 基於http包建立web程式
- 基於template包的html模板技術
- 使用regexp包驗證使用者輸入
- 使用閉包
假設讀者有以下知識:
- 基本的編程經驗
- web程式的基礎技術(HTTP, HTML)
- UNIX 命令列
2. 開始
首先,要有一個Linux, OS X, or FreeBSD系統,可以運行go程式。如果沒有的話,可以安裝一個虛擬機器(如VirtualBox)或者 Virtual Private Server。
安裝Go環境: (請參考 Installation Instructions).
建立一個新的目錄,並且進入該目錄:
$ mkdir ~/gowiki $ cd ~/gowiki
建立一個wiki.go檔案,用你喜歡的編輯器開啟,然後添加以下代碼:
package main import ( "fmt" "io/ioutil" "os" )
我們從go的標準庫匯入fmt, ioutil 和 os包。 以後,當實現其他功能時,我們會根據需要匯入更多包。
3. 資料結構
我們先定義一個結構類型,用於儲存資料。wiki系統由一組互聯的wiki頁面組成,每個wiki頁麵包含內容和標題。我們定義wiki頁面為結構page, 如下:
type page struct { titlestring body[]byte }
類型[]byte表示一個byte slice。(參考Effective Go瞭解slices的更多資訊) 成員body之所以定義為[]byte而不是string類型,是因為[]byte可以直接使用io包的功能。
結構體page描述了一個頁面在記憶體中的儲存方式。但是,如果要將資料儲存到磁碟的話,還需要給page類型增加save方法:
func (p *page) save() os.Error { filename := p.title + ".txt" return ioutil.WriteFile(filename, p.body, 0600) }
類型方法的簽名可以這樣解讀:“save為page類型的方法,方法的調用者為page類型的指標變數p。該成員函數沒有參數,傳回值為os.Error,表示錯誤資訊。”
該方法會將page結構的body部分儲存到文字檔中。為了簡單,我們用title作為文字檔的名字。
方法save的傳回值類型為os.Error,對應WriteFile(標準庫函數,將byte slice寫到檔案中)的傳回值。通過返回os.Error值,可以判斷髮生錯誤的類型。如果沒有錯誤,那麼返回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根據頁面標題從對應檔案讀取頁面的內容,並且構造一個新的 page變數——對應一個頁面。
go中函數(以及成員方法)可以返回多個值。標準庫中的io.ReadFile在返回[]byte的同時還返回os.Error類型的錯誤資訊。前面的代碼中我們用底線“_”丟棄了錯誤資訊。
但是ReadFile可能會發生錯誤,例如請求的檔案不存在。因此,我們給函數的傳回值增加一個錯誤資訊。
func loadPage(title string) (*page, os.Error) { filename := title + ".txt" body, err := ioutil.ReadFile(filename) if err != nil { return nil, err } return &page{title: title, body: body}, nil }
現在調用者可以檢測第二個傳回值,如果為nil就表示成功裝載頁面。否則,調用者可以得到一個os.Error對象。(關於錯誤的更多資訊可以參考os package documentation)
現在,我們有了一個簡單的資料結構,可以儲存到檔案中,或者從檔案載入。我們建立一個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,並且將p2的值列印到 螢幕。
可以用類似以下命令編譯運行程式:
$ 8g wiki.go $ 8l wiki.8 $ ./8.out This is a sample page.
(命令8g和8l對應GOARCH=386。如果是amd64系統,可以用6g和6l)
點擊這裡查看我們當前的代碼。
4. 使用http包
下面是一個完整的web server例子:
package main import ( "fmt" "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設定所有對根目錄請求的處理函數為handler。
然後調用http.ListenAndServe,在8080連接埠開始監聽(第二個參數暫時可以忽略)。然後程式將阻塞,直到退出。
函數handler為http.HandlerFunc類型,它包含http.Conn和http.Request兩個類型的參數。
其中http.Conn對應伺服器的http串連,我們可以通過它向用戶端發送資料。
類型為http.Request的參數對應一個用戶端請求。其中r.URL.Path 為請求的地址,它是一個string類型變數。我們用[1:]在Path上建立 一個slice,對應"/"之後的路徑名。
啟動該程式後,通過瀏覽器訪問以下地址:
http://localhost:8080/monkeys
會看到以下輸出內容:
Hi there, I love monkeys!
5. 基於http提供wiki頁面
要使用http包,先將其匯入:
import ( "fmt" "http" "io/ioutil" "os" )
然後建立一個用於瀏覽wiki的函數:
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(請求URL的path部分)中解析頁面標題。全域常量lenPath儲存"/view/"的長度,它是請求路徑的首碼部分。Path總是以"/view/"開頭,去掉前面的6個字元就可以得到頁面標題。
然後載入頁面資料,格式化為簡單的HTML字串,寫到c中,c是一個http.Conn類型的參數。
注意這裡使用底線“_”忽略loadPage的os.Error傳回值。 這不是一種好的做法,此處是為了保持簡單。我們將在後面考慮這個問題。
為了使用這個處理函數(handler),我們建立一個main函數。它使用viewHandler初始化http,把所有以/view/開頭的請求轉寄給viewHandler處理。
func main() { http.HandleFunc("/view/", viewHandler) http.ListenAndServe(":8080", nil) }
點擊這裡查看我們當前的代碼。
讓我們建立一些頁面資料(例如as test.txt),編譯,運行。
$ echo "Hello world" > test.txt $ 8g wiki.go $ 8l wiki.8 $ ./8.out
當伺服器啟動並執行時候,訪問http://localhost:8080/view/test將顯示一個頁面,標題為“test”,內容為“Hello world”。
6. 編輯頁面
編輯功能是wiki不可缺少的。現在,我們建立兩個新的處理函數(handler):editHandler顯示"edit page"表單(form),saveHandler儲存表單(form)中的資料。
首先,將他們添加到main()函數中:
func main() { http.HandleFunc("/view/", viewHandler) http.HandleFunc("/edit/", editHandler) http.HandleFunc("/save/", saveHandler) http.ListenAndServe(":8080", nil) }
函數editHandler載入頁面(或者,如果頁面不存在,建立一個空page 結構)並且顯示為一個HTML表單(form)。
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非常醜陋。當然,我們有更好的辦法。
7. template包
template包是GO語言標準庫的一個部分。我們使用template將HTML存放在一個單獨的檔案中,可以更改編輯頁面的布局而不用修改相關的GO代碼。
首先,我們必須將template添加到匯入列表:
import ( "http" "io/ioutil" "os" "template" )
建立一個包含HTML表單的模板檔案。開啟一個名為edit.html的新檔案,添加下面的行:
<h1>Editing {title}</h1> <form action="/save/{title}" method="POST"> <div><textarea name="body" rows="20" cols="80">{body|html}</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.ParseFile("edit.html", nil) t.Execute(p, w) }
函數template.ParseFile讀取edit.html的內容,返回*template.Template類型的資料。
方法t.Execute用p.title和p.body的值替換模板中所有的{title}和{body},並且把結果寫到http.Conn。
注意,在上面的模板中我們使用{body|html}。|html部分請求模板引擎在輸出body的值之前,先將它傳到html格式化器(formatter),轉義HTML字元(比如用>替換>)。 這樣做,可以阻止使用者資料破壞表單HTML。
既然我們刪除了fmt.Sprintf語句,我們可以刪除匯入列表中的"fmt"。
使用模板技術,我們可以為viewHandler建立一個模板,命名為view.html。
<h1>{title}</h1> <p>[<a href="/edit/{title}">edit</a>]</p> <div>{body}</div>
修改viewHandler:
func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[lenPath:] p, _ := loadPage(title) t, _ := template.ParseFile("view.html", nil) t.Execute(p, w) }
注意,在兩個處理函數(handler)中使用了幾乎完全相同的模板處理代碼,我們可以把模板處理代碼寫成一個單獨的函數,以消除重複。
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.ParseFile(tmpl+".html", nil) t.Execute(p, w) }
現在,處理函數(handler)代碼更短、更加簡單。
8. 處理不存在的頁面
當你訪問/view/APageThatDoesntExist的時候會發生什嗎?程式將會崩潰。因為我們忽略了loadPage返回的錯誤。請求頁不存在的時候,應該重新導向用戶端到編輯頁,這樣新的頁面將會建立。
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) }
函數http.Redirect添加HTTP狀態代碼http.StatusFound (302)和前序Location到HTTP響應。
9. 儲存頁面
函數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中)和表單中唯一的欄位,body,儲存在一個新的page中。然後調用save()方法將資料寫到檔案中,並且將客戶重新導向到/view/頁面。
FormValue傳回值的類型是string,在將它添加到page結構前,我們必須將其轉換為[]byte類型。我們使用[]byte(body)執行轉換。
10. 錯誤處理
在我們的程式中,有幾個地方的錯誤被忽略了。這是一種很糟糕的方式,特別是在錯誤發生後,程式會崩潰。更好的方案是處理錯誤並返回錯誤訊息給使用者。這樣做,當錯誤發生後,伺服器可以繼續運行,使用者也會得到通知。
首先,我們處理renderTemplate中的錯誤:
func renderTemplate(w http.ResponseWriter, tmpl string, p *page) { t, err := template.ParseFile(tmpl+".html", nil) if err != nil { http.Error(w, err.String(), http.StatusInternalServerError) return } err = t.Execute(p, w) if err != nil { http.Error(w, err.String(), http.StatusInternalServerError) } }
函數http.Error發送一個特定的HTTP響應碼(在這裡表示“Internal Server Error”)和錯誤訊息。
現在,讓我們修複saveHandler:
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.String(), http.StatusInternalServerError) return } http.Redirect(w, r, "/view/"+title, http.StatusFound) }
p.save()中發生的任何錯誤都將報告給使用者。
11. 模板緩衝
代碼中有一個低效率的地方:每次顯示一個頁面,renderTemplate都要調用ParseFile。更好的做法是在程式初始化的時候對每個模板調用ParseFile一次,將結果儲存為*Template類型的值,在以後使用。
首先,我們建立一個全域map,命名為templates。templates用於儲存*Template類型的值,使用string索引。
然後,我們建立一個init函數,init函數會在程式初始化的時候調用,在main函數之前。函數template.MustParseFile是ParseFile的一個封裝,它不返回錯誤碼,而是在錯誤發生的時候拋出(panic)一個錯誤。拋出錯誤(panic)在這裡是合適的,如果模板不能載入,程式唯一能做的有意義的事就是退出。
func init() { for _, tmpl := range []string{"edit", "view"} { templates[tmpl] = template.MustParseFile(tmpl+".html", nil) } }
使用帶range語句的for逐一查看一個常量數組中的每一個元素,這個常量數組中包含了我們想要載入的所有模板的名稱。如果我們想要添加更多的模板,只要把模板名稱添加的數組中就可以了。
修改renderTemplate函數,在templates中相應的Template上調用Execute方法:
func renderTemplate(w http.ResponseWriter, tmpl string, p *page) { err := templates[tmpl].Execute(p, w) if err != nil { http.Error(w, err.String(), http.StatusInternalServerError) } }
12. 驗證
你可能已經發現,程式中有一個嚴重的安全性漏洞:使用者可以提供任意的路徑在伺服器上執行讀寫操作。為了消除這個問題,我們使用Regex驗證頁面的標題。
首先,添加"regexp"到匯入列表。然後建立一個全域變數儲存我們的驗證Regex:
函數regexp.MustCompile解析並且編譯Regex,返回一個regexp.Regexp對象。和template.MustParseFile類似,當運算式編譯錯誤時,MustCompile拋出一個錯誤,而Compile在它的第二個返回參數中返回一個os.Error。
現在,我們編寫一個函數,它從請求URL解析中解析頁面標題,並且使用titleValidator進行驗證:
func getTitle(w http.ResponseWriter, r *http.Request) (title string, err os.Error) { title = r.URL.Path[lenPath:] if !titleValidator.MatchString(title) { http.NotFound(w, r) err = os.NewError("Invalid Page Title") } return }
如果標題有效,它返回一個nil錯誤值。如果無效,它寫"404 Not Found"錯誤到HTTP串連中,並且返回一個錯誤對象。
修改所有的處理函數,使用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.String(), http.StatusInternalServerError) return } http.Redirect(w, r, "/view/"+title, http.StatusFound) }
13. 函數文本和閉包
處理函數(handler)中捕捉錯誤是一些類似的重複代碼。如果我們想將捕捉錯誤的代碼封裝成一個函數,應該怎麼做?GO的函數文本提供了強大的抽象能力,可以幫我們做到這點。
首先,我們重寫每個處理函數的定義,讓它們接受標題字串:
定義一個封裝函數,接受上面定義的函數類型,返回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是我們的處理函數,save、edit、或view。
我們可以把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.Conn和http.Request(因此,它是http.HandlerFunc)。閉包從請求路徑解析title,使用titleValidator驗證標題。如果title無效,使用函數http.NotFound將錯誤寫到Conn。如果title有效,封裝的處理函數fn將被調用,參數為Conn, Request, 和title。
在main函數中,我們用makeHandler封裝所有處理函數:
func main() { http.HandleFunc("/view/", makeHandler(viewHandler)) http.HandleFunc("/edit/", makeHandler(editHandler)) http.HandleFunc("/save/", makeHandler(saveHandler)) 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.String(), http.StatusInternalServerError) return } http.Redirect(w, r, "/view/"+title, http.StatusFound) }
14. 試試!
點擊這裡查看最終的代碼
重新編譯代碼,運行程式:
$ 8g wiki.go $ 8l wiki.8 $ ./8.out
訪問http://localhost:8080/view/ANewPage將會出現一個編輯表單。你可以輸入一些文版,點擊“Save”,重新導向到新的頁面。
15. 其他任務
這裡有一些簡單的任務,你可以自己解決:
- 把模板檔案存放在tmpl/目錄,頁面資料存放在data/目錄。
- 增加一個處理函數(handler),將對根目錄的請求重新導向到/view/FrontPage。
- 修飾頁面模板,使其成為有效HTML檔案。添加CSS規則。
- 實現頁內連結。將[PageName]修改為<a href="/view/PageName">PageName</a>。(提示:可以使用regexp.ReplaceAllFunc達到這個效果)