1. 程式人生 > 其它 >Golang語言社群--go語言編寫Web程式

Golang語言社群--go語言編寫Web程式

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 {
          title        string
          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, "
%s
%s
", 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, "
Editing %s
"+
                  "
"+
                  "
%s

"+
                  "
"Save"
"+
                  "
",
                  p.title, p.title, p.body)
  }

這個函式能夠工作,但是硬編碼的HTML非常醜陋。當然,我們有更好的辦法。

7. template包

template包是GO語言標準庫的一個部分。我們使用template將HTML存放在一個單獨的檔案中,可以更改編輯頁面的佈局而不用修改相關的GO程式碼。

首先,我們必須將template新增到匯入列表:

import (
          "http"
          "io/ioutil"
          "os"
          "template"
  )

建立一個包含HTML表單的模板檔案。開啟一個名為edit.html的新檔案,新增下面的行:

Editing {title}

  
  

  

{body|html}

  

  

修改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。

{title}

  
  
[edit]


  
  
{body}

修改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. 驗證

你可能已經發現,程式中有一個嚴重的安全漏洞:使用者可以提供任意的路徑在伺服器上執行讀寫操作。為了消除這個問題,我們使用正則表示式驗證頁面的標題。

首先,新增"regexp"到匯入列表。然後建立一個全域性變數儲存我們的驗證正則表示式:

函式regexp.MustCompile解析並且編譯正則表示式,返回一個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]修改為PageName。(提示:可以使用regexp.ReplaceAllFunc達到這個效果)