這是一篇官方的 Writing Web Applications 的翻譯
如果 golang.org
打不開的話,可以把所有連結中的 golang.org
更換成 golang.google.cn
,這是一個官方的國內映象,和 golang.org
的內容是一致的。
簡介
本教程包含下面內容
-
通過 load 與 save 方法建立資料結構
-
使用
net/http
去構建 web 應用 -
使用
html/template
去處理 HTML 模板 -
使用
regexp
包去驗證使用者的輸入 -
使用閉包
需要已經掌握下面知識
-
程式設計經驗
-
瞭解基本的 web 技術(HTTP,HTML)
-
會一些簡單 UNIX/DOS 命令列
入門
現在,你可以在 FreeBSD, Linux, OS X, 或 Windows 上執行 Go , 我們使用 $
代表命令列的提示符, $
開始的內容是輸入的命令。
安裝 Go (檢視 安裝文件 )
在 GOPATH 中為這個專案建立一個新的目錄,然後切換到這個目錄(cd)
$ cd $GOPATH/src
$ mkdir gowiki
$ cd go wiki
建立一個檔案 wiki.go
, 用你喜歡的編輯器開啟它,用新增下面內容
package main
import (
"fmt"
"io/ioutil"
)
我們從 Go 的標準庫中匯入了 fmt
和 io/ioutil
包, 稍後,當我們實現其他功能時,將會在 import
宣告中新增更多的包。
資料結構
我們從定義資料結構開始,一個 wiki
由許多相關的頁面組成,每一個頁面都包含一個 title 和 body (頁面的內容)。我們定義一個 Page 結構,包含兩個代表 title 和 body 的欄位。
type Page struct {
Title string
Body []byte
}
型別 []byte
就是一個 byte 切片(關於切片的更多資訊,檢視: Slices: usage and internals)。 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 的返回型別(一個用於將位元組切片寫入到檔案的標準庫函式)。 save 方法返回的是一個 error 型別的值,所以在寫入檔案出現錯誤的時候應該去處理它。如果沒有出現錯誤,應該返回一個 nil (指標,介面和一些其他型別的零值)
八進位制整數 0600
, 是傳給 WriteFile 的第三個引數,表示只有當前用於擁有檔案的讀寫許可權。(通過 Unix man page open(2)檢視詳細說明 “譯者注: Linux 上可以通過這個命令檢視: man 2 open ”)
除了儲存頁面,我們也需要載入頁面:
func loadPage(title string) *Page {
filename := title + ".txt"
body, _ := ioutil.ReadFile(filename)
return &Page{Title: title, Body: body}
}
loadPage 通過引數 title 拼接了檔名,將檔案內容讀取到一個新的變數 body 中,並且返回了一個通過 title 和 body 構造的指向 Page 字面的指標。
函式可以返回多個值。標準庫函式 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 ,表示成功載入了一個 Page 。如果不是,它將是一個 error ,並且可以由呼叫者處理(詳細檢視 語言規範 )。
現在,我們有了一個簡單的資料結構,可以儲存並載入一個檔案。讓我們寫一個 main 函式來測試我們寫的東西。
func main() {
p1 := &Page{Title: "TestPage", Body: []byte("This is a simple Page.")}
p1.save()
p2, _ := loadPage("TestPage")
fmt.Println(string(p2.Body))
}
編譯並執行這個程式碼,一個包含了 p1 的內容的名字是 TestPage.txt 的檔案將被建立。這個檔案將被讀進結構 p2 中,並將它的 Body 元素列印到螢幕上。
你可以像這樣編譯並執行這個程式:
$ go build wiki.go
$ ./wiki
This is a simple Page.
(如果你使用的是 Windows ,你必須輸入 wiki
,去掉 ./
去執行這個程式)
net http 包簡介
這是一個簡單的 web 伺服器完整的工作示例:
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 始終返回一個 error ,並且它只有發生意外錯誤時才會返回。為了記錄這個錯誤,我們在它的外面包裹一個叫 log.Fatal 的函式。
handler 是一個 http.HandlerFunc 型別的函式,它需要兩個引數,http.ResponseWriter 和 http.Request 。
http.ResponseWriter 的值集合了 HTTP 伺服器的響應。通過寫入它,我們將資料傳送到 HTTP 客戶端。
http.Request 是表示客戶端 HTTP 請求的資料結構。 r.URL.Path 是一個請求地址組成的路徑。它的後面跟隨 [1:]
的意思是: “建立一個從第一個字元到結尾的子切片” 。從路徑中刪除開頭的 /
。
如果你執行程式,並訪問這個地址:
http://localhost:8080/monkeys
程式將會顯示包含下面內容的頁面:
Hi there, I love monkeys!
使用 net http 去服務 wiki 頁面
要想使用 net/http 包,它必須被匯入:
import (
"fmt"
"io/ioutil"
"net/http"
)
我們來建立一個叫 viewHandler 的 handler, 使用者可以通過它去檢視 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 返回的錯誤。這裡是為了簡單,但這是一個壞的習慣,稍後我們會處理它。
首先,這個函式從請求 URL 的 path 元件 r.URL.Path
中提取頁面的標題。再通過切片 [len("/view/"):]
去掉前面路徑前面的 /view/
,這是因為路徑總是以 /view/
開始,它不是頁面的一部分。
然後函式去載入頁面資料,格式化成一個簡單的 HTML 格式的字串並寫入到 http.ResponseWriter w
。
要使用這個 handler ,我們重寫我們的 main 函式,去初始化 http 通過 viewHandler 去處理每一個 /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 。我們來建立兩個新的 handler, 一個叫 editHandler ,用來顯示編輯頁面的 form 表單,另一個叫 saveHandler ,用來儲存 form 表單提交的資料。
首先,我們新增它們到 main() 中:
func main() {
http.HandleFunc("/view/", viewHandler)
http.HandleFunc("/edit/", editHandler)
http.HandleFunc("/save/", saveHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
editHandler 函式載入頁面(或者,它不存在時建立一個空的 Page 結構), 並且顯示一個 HTML form 表單。
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 新增到 import 的列表中。我們也不會再使用 fmt 包了,所以將它刪除。
import (
"html/template"
"io/ioutil"
"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 被生成,它會自動轉譯大於號符號 >
,替換成 >
,確保使用者資料不會破壞表單資料。
我們現在已經使用模板了,就再建立一個用於 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)
}
注意,我們在兩個 handler
中使用了幾乎完全一樣的模板程式碼。我們將模板程式碼放到一個單獨的函式中,來去除這個重複:
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
t, _ := template.ParseFiles(tmpl + ".html")
t.Execute(w, p)
}
修改連個 handler 來使用這個函式:
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 函式中註冊的未實現的 save
handler
,然後就可以重新編輯並測試我們的程式了。
處理不存在的頁面
如果你輸入 /view/APageThatDoesntExist ,你將看到一個包含 HTML 的頁面,這是因為它忽略了 loadPage
error
返回值,並繼續去嘗試填充了一個沒有資料的模板去替換,如果請求的頁面不存在,它應該重定向到編輯頁面,這樣就可以建立內容。
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) 和 一個地址到 http 響應。
儲存頁面
saveHandler
函式將處理編輯頁面 form 表單提交的資料,在 mian 函式中取消和此相關的註釋,然後實現這個 handler 。
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)
}
這個頁面的 title
(由 URL 提供)和表單中的唯一欄位 Body
被儲存到一個新的 Page
,然後呼叫 save()
方法寫入資料到一個檔案,並且將客戶端重定向到 /view/
頁面。
FormValue
的返回值是一個字串型別,我們在將它填充到 Page 前必選轉換成 []byte,使用 []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 狀態碼(示例中是 Internal Server Error
)和錯誤訊息。這個抽離出一個單獨的函式是一個多麼明智的做法,否則要改多個地方了。
現在,我們來修改 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
函式接受任意數量的用來識別我們的模板檔案的字串引數,然後將這些檔案解析到 templates
,並且通過基礎的檔名命名。如果我們要新增更多的模板到我們的程式,我們需要將它們的名字新增到 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)
}
}
注意,模板名稱是模板的檔名,所以我們必須在 teml 引數後面新增 .html
。
驗證
可能你已經注意到,這個程式有一個嚴重的安全漏洞:使用者可以通過任意路徑在伺服器上讀/寫。為了解決這個,我們可以編寫一個函式,通過正規表示式來驗證 title 。
首先,在 import
列表新增 regexp
,然後我們可以建立一個全域性變數去儲存我們的正規表示式。
var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")
函式 regexp.MustCompile
將解析並編譯正規表示式,返回一個 regexp.Regexp
。MustCompile
區別於 Compile
的是,當正規表示式編譯失敗的時候,它將產生一個 panic
, 而 Compile
會通過第二個引數返回一個 error
。
現在,我們編寫一個函式來使用 validPath
來驗證路徑並提取頁面的 title 。
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 // title 是第二個子表示式
}
如果 title 是有效的,它將和一個值為 nil
的 error
一起返回。如果 title 是無效的,函式將寫入一個 404 Not Found
錯誤到 HTTP 連結,並且返回一個錯誤到 handler 。要建立一個新的 error
,我們還必須要匯入 errors
包。
我們將 getTitle
呼叫放到每一個 handler 中:
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)
}
函式文字和閉包簡介
捕獲每一個函式中的錯誤處理條件會產生大量的冗餘程式碼。如果我們將每一個 handler 包裝進一個函式去處理驗證和錯誤檢查呢? Go 的函式字面提供了一個強大的抽象功能可以幫助我們實現它。
首先,我們重寫每一個 handler 函式的定義,接收一個字串型別的 title 引數:
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) {
// 這裡將從 Request 中提取 title
// 並且呼叫提供的 handler 'fn'
}
}
這個返回的函式叫閉包,因為它包含了在它外部定義的值。在這種情況,變數 fn (傳給 makeHandler 的單個引數) 包裹在閉包中, 變數 fn 將會是我們的 save
,edit
或 view
handler 中的一個。
現在我們可以從 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 ,並且通過 title 驗證器 regexp
驗證它。 如果標題是無效的,一個 error
將通過 http.NotFound
函式寫入到 ResponseWriter
。如果 title 是有效的,它包裹的 handler 函式 fn 將傳入 ResponseWriter, Request, 和 title 引數被呼叫。
現在,在 main 函式中,我們可以在 handler
被註冊到 http
包之前,通過 makeHandler
來包裝它們:
func main() {
http.HandleFunc("/view/", makeHandler(viewHandler))
http.HandleFunc("/edit/", makeHandler(editHandler))
http.HandleFunc("/save/", makeHandler(saveHandler))
log.Fatal(http.ListenAndServe(":8080", nil))
}
最後,我們從 handler 中刪除對 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/
。 -
新增一個 handler 將 web 根目錄重定向到
/view/FrontPage
。 -
完善頁面模板,使它們成為一個有效的 HTML 並新增一些 CSS 規則。
-
通過 [PageName] 例項實現一些頁面間的連結,
<a href="/view/PageName">PageName</a>
(提示: 你可以通過regexp.ReplaceAllFunc
來實現這個)。
本文章來自: broqiang.com 可以隨意轉載。