Go語言開發一個簡單的相簿網站

2020-07-16 10:04:49
本節我們將綜合之前介紹的網站開發相關知識,一步步介紹如何開發一個雖然簡單但五臟俱全的相簿網站。

新建工程

首先建立一個用於存放工程原始碼的目錄並切換到該目錄中去,隨後建立一個名為 photoweb.go 的檔案,用於後面編輯我們的程式碼:

$ mkdir -p photoweb/uploads
$ cd photoweb
$ touch photoweb.go

我們的範例程式不是再造一個 Flickr 那樣的網站或者比其更強大的圖片分享網站,雖然我們可能很想這麼玩。不過還是先讓我們快速開發一個簡單的網站小程式,暫且只實現以下最基本的幾個功能:
  • 支援圖片上傳;
  • 在網頁中可以檢視已上傳的圖片;
  • 能看到所有上傳的圖片列表;
  • 可以刪除指定的圖片。

功能不多,也很簡單。在大概了解上一節中的網頁輸出 Hello world 範例後,想必你已經知道可以引入 net/http 包來提供更多的路由分派並編寫與之對應的業務邏輯處理方法,只不過會比輸出一行 Hello, world! 多一些環節,還有些細節需要關注和處理。

使用 net/http 包提供網路服務

接下來,我們繼續使用 Go 標準庫中的 net/http 包來一步步構建整個相簿程式的網路服務。

1) 上傳圖片

先從最基本的圖片上傳著手,具體程式碼如下所示。
package main
import (
    "io"
    "log"
    "net/http"
)
func uploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "GET" {
        io.WriteString(w, "<form method="POST" action="/upload" "+
            " enctype="multipart/form-data">"+
            "Choose an image to upload: <input name="image" type="file" />"+
            "<input type="submit" value="Upload" />"+
            "</form>")
        return
    }
}
func main() {
    http.HandleFunc("/upload", uploadHandler)
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}
可以看到,結合 main() 和 uploadHandler() 方法,針對 HTTP GET 方式請求 /upload 路徑,程式將會往 http.ResponseWriter 型別的範例物件 w 中寫入一段 HTML 文字,即輸出一個 HTML 上傳表單。

如果我們使用瀏覽器存取這個地址,那麼網頁上將會是一個可以上傳檔案的表單。光有上傳表單還不能完成圖片上傳,伺服器端程式還必須有接收上傳圖片的相關處理。針對上傳表單提交過來的檔案,我們對 uploadHandler() 方法再新增些業務邏輯程式:
const (
    UPLOAD_DIR = "./uploads"
)
func uploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "GET" {
        io.WriteString(w, "<form method="POST" action="/upload" "+
            " enctype="multipart/form-data">"+
            "Choose an image to upload: <input name="image" type="file" />"+
            "<input type="submit" value="Upload" />"+
            "</form>")
        return
    }
    if r.Method == "POST" {
        f, h, err := r.FormFile("image")
        if err != nil {
            http.Error(w, err.Error(),
            http.StatusInternalServerError)
            return
        }
        filename := h.Filename
        defer f.Close()
        t, err := os.Create(UPLOAD_DIR + "/" + filename)
        if err != nil {
            http.Error(w, err.Error(),
            http.StatusInternalServerError)
            return
        }
        defer t.Close()
        if _, err := io.Copy(t, f); err != nil {
            http.Error(w, err.Error(),
            http.StatusInternalServerError)
            return
        }
        http.Redirect(w, r, "/view?id="+filename,
        http.StatusFound)
    }
}
如果是用戶端發起的 HTTP POST 請求,那麼首先從表單提交過來的欄位尋找名為 image 的檔案域並對其接值,呼叫 r.FormFile() 方法會返回 3 個值,各個值的型別分別是 multipart.File、*multipart.FileHeader 和 error。

如果上傳的圖片接收不成功,那麼在範例程式中返回一個 HTTP 伺服器端的內部錯誤給用戶端。如果上傳的圖片接收成功,則將該圖片的內容複製到一個臨時檔案裡。如果臨時檔案建立失敗,或者圖片副本儲存失敗,都將觸發伺服器端內部錯誤。

如果臨時檔案建立成功並且圖片副本儲存成功,即表示圖片上傳成功,就跳轉到檢視圖片頁面。此外,我們還定義了兩個 defer 語句,無論圖片上傳成功還是失敗,當 uploadHandler() 方法執行結束時,都會先關閉臨時檔案控制代碼,繼而關閉圖片上傳到伺服器檔案流的控制代碼。

別忘了在程式開頭引入 io/ioutil 這個包,因為範例程式中用到了 ioutil.TempFile() 這個方法。

當圖片上傳成功後,我們即可在網頁上檢視這張圖片,順便確認圖片是否真正上傳到了伺服器端。接下來在網頁中呈現這張圖片。

2) 在網頁上顯示圖片

要在網頁中顯示圖片,必須有一個可以存取到該圖片的網址。在前面的範例程式碼中,圖片上傳成功後會跳轉到 /view?id=<ImageId> 這樣的網址,因此我們的程式要能夠將對 /view 路徑的存取對映到某個具體的業務邏輯處理方法。

首先,在 photoweb 程式中新增一個名為 viewHanlder() 的方法,其程式碼如下:
func viewHandler(w http.ResponseWriter, r *http.Request) {
    imageId = r.FormValue("id")
    imagePath = UPLOAD_DIR + "/" + imageId
    w.Header().Set("Content-Type", "image")
    http.ServeFile(w, r, imagePath)
}
在上述程式碼中,我們首先從用戶端請求中對引數進行接值。r.FormValue("id") 即可得到用戶端請求傳遞的圖片唯一 ID,然後我們將圖片 ID 結合之前儲存圖片用的目錄進行組裝,即可得到檔案在伺服器上的存放路徑。

接著,呼叫 http.ServeFile() 方法將該路徑下的檔案從磁碟中讀取並作為伺服器端的返回資訊輸出給用戶端。同時,也將 HTTP 響應頭輸出格式預設為 image 型別。

這是一種比較簡單的示意寫法,實際上應該嚴謹些,準確解析出檔案的 MimeType 並將其作為 Content-Type 進行輸出,具體可參考 Go語言標準庫中的 http.DetectContentType() 方法和 mime 包提供的相關方法。

完成 viewHandler() 的業務邏輯後,我們將該方法註冊到程式的 main() 方法中,與 /view 路徑存取形成對映關聯。main() 方法的程式碼如下:
func main() {
    http.HandleFunc("/view", viewHandler)
    http.HandleFunc("/upload", uploadHandler)
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}
這樣當用戶端(瀏覽器)存取 /view 路徑並傳遞 id 引數時,即可直接以 HTTP 形式看到圖片的內容。在網頁上,將會呈現一張視覺化的圖片。

3) 處理不存在的圖片存取

理論上,只要是 uploads/ 目錄下有的圖片,都能夠存取到,但我們還是假設有意外情況,比如網址中傳入的圖片 ID 在 uploads/ 沒有對應的檔案,這時,我們的 viewHandler() 方法就顯得很脆弱了。

不管是給出友好的錯誤提示還是返回 404 頁面,都應該對這種情況作相應處理。我們不妨先以最簡單有效的方式對其進行處理,修改 viewHandler() 方法,具體如下:
func viewHandler(w http.ResponseWriter, r *http.Request) {
    imageId = r.FormValue("id")
    imagePath = UPLOAD_DIR + "/" + imageId
    if exists := isExists(imagePath);!exists {
        http.NotFound(w, r)
        return
    }
    w.Header().Set("Content-Type", "image")
    http.ServeFile(w, r, imagePath)
}
func isExists(path string) bool {
    _, err := os.Stat(path)
    if err == nil {
        return true
    }
    return os.IsExist(err)
}
同時,我們增加了 isExists() 輔助函數,用於檢查檔案是否真的存在。

4) 列出所有已上傳圖片

應該有個入口,可以看到所有已上傳的圖片。對於所有列出的這些圖片,我們可以選擇進行檢視或者刪除等操作。下面假設在存取首頁時列出所有上傳的圖片。

由於我們將用戶端上傳的圖片全部儲存在工程的 ./uploads 目錄下,所以程式中應該有個名叫 listHandler() 的方法,用於在網頁上列出該目錄下存放的所有檔案。暫時我們不考慮以縮圖的形式列出所有已上傳圖片,只需列出可供存取的檔名稱即可。下面我們就來實現這個 listHandler() 方法:
func listHandler(w http.ResponseWriter, r *http.Request) {
    fileInfoArr, err := ioutil.ReadDir("./uploads")
    if err != nil {
        http.Error(w, err.Error(),
        http.StatusInternalServerError)
        return
    }
    var listHtml string
    for _, fileInfo := range fileInfoArr {
        imgid := fileInfo.Name
        listHtml += "<li><a href="/view?id="+imgid+"">imgid</a></li>"
    }
    io.WriteString(w, "<ol>"+listHtml+"</ol>")
}
從上面的 listHandler() 方法中可以看到,程式先從 ./uploads 目錄中遍歷得到所有檔案並賦值到 fileInfoArr 變數裡。fileInfoArr 是一個陣列,其中的每一個元素都是一個檔案物件。

然後,程式遍歷 fileInfoArr 陣列並從中得到圖片的名稱,用於在後續的 HTML 片段中顯示檔名和傳入的引數內容。listHtml 變數用於在 for 循序中將圖片名稱一一串聯起來生成一段 HTML,最後呼叫 io.WriteString() 方法將這段 HTML 輸出返回給用戶端。

然後在 photoweb. go 程式的 main() 方法中,我們將對首頁的存取對映到 listHandler() 方法。main() 方法的程式碼如下:
func main() {
    http.HandleFunc("/", listHandler)
    http.HandleFunc("/view", viewHandler)
    http.HandleFunc("/upload", uploadHandler)
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}
這樣在存取網站首頁的時候,即可看到已上傳的所有圖片列表了。

不過,你是否注意到一個事實,我們在 photoweb.go 程式的 uploadHandler() 和 listHandler() 方法中都使用 io.WriteString() 方法輸出 HTML。

正如你想到的那樣,在業務邏輯處理程式中混雜 HTML 可不是什麼好事情,程式碼多起來後會導致程式不夠清晰,而且改動程式裡邊的 HTML 文字時,每次都要重新編譯整個工程的原始碼才能看到修改後的效果。

正確的做法是,應該將業務邏輯程式和表現層分離開來,各自單獨處理。這時候,就需要使用網頁模板技術了。

Go 標準庫中的 html/template 包對網頁模板有著良好的支援。接下來,讓我們來了解如何在 photoweb.go 程式中用上 Go 的模板功能。

渲染網頁模板

使用 Go 標準庫提供的 html/template 包,可以讓我們將 HTML 從業務邏輯程式中抽離出來形成獨立的模板檔案,這樣業務邏輯程式只負責處理業務邏輯部分和提供模板需要的資料,模板檔案負責資料要表現的具體形式。

然後模板解析器將這些資料以定義好的模板規則結合模板檔案進行渲染,最終將渲染後的結果一併輸出,構成一個完整的網頁。

下面我們把 photoweb.go 程式的 uploadHandler() 和 listHandler() 方法中的 HTML 文字 抽出,生成模板檔案。

新建一個名為 upload.html 的檔案,內容如下:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Upload</title>
</head>
<body>
    <form method="POST" action="/upload" enctype="multipart/form-data">
        Choose an image to upload: <input name="image" type="file" />
        <input type="submit" value="Upload" />
    </form>
</body>
</html>
然後新建一個名為 list.html 的檔案,內容如下:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>List</title>
</head>
<body>
    <ol>
        {{range $.images}}
            <li><a href="/view?id={{.|urlquery}}">{{.|html}}</a></li>
        {{end}}
    </ol>
</body>
</html>
在上述模板中,雙大括號 {{}} 是區分模板程式碼和 HTML 的分隔符,括號裡邊可以是要顯示輸出的資料,或者是控制語句,比如 if 判斷式或者 range 迴圈體等。

range 語句在模板中是一個迴圈過程體,緊跟在 range 後面的必須是一個 array、slice 或 map 型別的變數。在 list.html 模板中,images 是一組 string 型別的切片。

在使用 range 語句遍歷的過程中,. 即表示該迴圈體中的當前元素,.|formatter 表示對當前這個元素的值以 formatter 方式進行格式化輸出,比如 .|urlquery} 即表示對當前元素的值進行轉換以適合作為 URL 一部分,而 {{.|html 表示對當前元素的值進行適合用於 HTML 顯示的字元轉化,比如">"會被跳脫成"&gt;"。

如果 range 關鍵字後面緊跟的是 map 這樣的多維複合結構,迴圈體中的當前元素可以用 .key1.key2.keyN 這樣的形式表示。

如果要更改模板中預設的分隔符,可以使用 template 包提供的 Delims() 方法。

在了解模板語法後,接著我們修改 photoweb.go 原始檔,引入 html/template 包,並修改 uploadHandler() 和 listHandler() 方法,具體如下所示。
package main
import (
    "io"
    "log"
    "net/http"
    "io/ioutil"
    "html/template"
)
func uploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "GET" {
        t, err := template.ParseFiles("upload.html")
        if err != nil {
            http.Error(w, err.Error(),http.StatusInternalServerError)
            return
        }
        t.Execute(w, nil)
        return
    }
    if r.Method == "POST" {
        // ...
    }
}
func listHandler(w http.ResponseWriter, r *http.Request) {
    fileInfoArr, err := ioutil.ReadDir("./uploads")
    if err != nil {
        http.Error(w, err.Error(),
        http.StatusInternalServerError)
        return
    }
    locals := make(map[string]interface{})
    images := []string{}
    for _, fileInfo := range fileInfoArr {
        images = append(images, fileInfo.Name)
    }
    locals["images"] = images t, err := template.ParseFiles("list.html")
    if err != nil {
        http.Error(w, err.Error(),
        http.StatusInternalServerError)
        return
    }
    t.Execute(w, locals)
}
在上面的程式碼中,template.ParseFiles() 函數將會讀取指定模板的內容並且返回一個 *template.Template 值。

t.Execute() 方法會根據模板語法來執行模板的渲染,並將渲染後的結果作為 HTTP 的返回資料輸出。

在 uploadHandler() 方法和 listHandler() 方法中,均呼叫了 template.ParseFiles() 和 t.Execute() 這兩個方法。根據 DRY(Don’t Repeat Yourself)原則,我們可以將模板渲染程式碼分離出來,單獨編寫一個處理常式,以便其他業務邏輯處理常式都可以使用。於是,我們可以定義一個名為 renderHtml() 的方法用來渲染模板:
func renderHtml(w http.ResponseWriter, tmpl string, locals map[string]interface{})
err error {
    t, err = template.ParseFiles(tmpl + ".html")
    if err != nil {
        return
    }
    err = t.Execute(w, locals)
}
有了 renderHtml() 這個通用的模板渲染方法,uploadHandler() 和 listHandler() 方法的程式碼可以再精簡些,如下:
func uploadHandler(w http.ResponseWriter, r *http.Request){
    if r.Method == "GET" {
        if err := renderHtml(w, "upload", nil); err != nil{
            http.Error(w, err.Error(),
            http.StatusInternalServerError)
            return
        }
    }
    if r.Method == "POST" {
        // ...
    }
}
func listHandler(w http.ResponseWriter, r *http.Request) {
    fileInfoArr, err := ioutil.ReadDir("./uploads")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    locals := make(map[string]interface{})
    images := []string{}
    for _, fileInfo := range fileInfoArr {
        images = append(images, fileInfo.Name)
    }
    locals["images"] = images
    if err = renderHtml(w, "list", locals); err != nil {
        http.Error(w, err.Error(),
        http.StatusInternalServerError)
    }
}
當我們引入了 Go 標準庫中的 html/template 包,實現了業務邏輯層與表現層分離後,對模板渲染邏輯去重,編寫並使用通用模板渲染方法 renderHtml(),這讓業務邏輯處理層的程式碼看起來確實要清晰簡潔許多。

不過,直覺敏銳的你可能已經發現,無論是重構後的 uploadHandler() 還是 listHandler() 方法,每次呼叫這兩個方法時都會重新讀取並渲染模板。很明顯,這很低效,也比較浪費資源,有沒有一種辦法可以讓模板只載入一次呢?

答案是肯定的,聰明的你可能已經想到怎麼對模板進行快取了。

模板快取

對模板進行快取,即指一次性預載入模板。我們可以在 photoweb 程式初始化執行的時候,將所有模板一次性載入到程式中。正好 Go 的包載入機制允許我們在 init() 函數中做這樣的事情,init() 會在 main() 函數之前執行。

首先,我們在 photoweb 程式中宣告並初始化一個全域性變數 templates,用於存放所有模板內容:

templates := make(map[string]*template.Template)

templates 是一個 map 型別的複合結構,map 的鍵(key)是字串型別,即模板的名字,值(value)是 *template.Template 型別。

接著,我們在 photoweb 程式的 init() 函數中一次性載入所有模板:
func init() {
    for _, tmpl := range []string{"upload", "list"} {
        t := template.Must(template.ParseFiles(tmpl + ".html"))
        templates[tmpl] = t
    }
}
在上面的程式碼中,我們在 template.ParseFiles() 方法的外層強制使用 template.Must() 進行封裝,template.Must() 確保了模板不能解析成功時,一定會觸發錯誤處理流程。之所以這麼做,是因為倘若模板不能成功載入,程式能做的唯一有意義的事情就是退出。

在 range 語句中,包含了我們希望載入的 upload.html 和 list.html 兩個模板,如果我們想載入更多模板,只需往這個陣列中新增更多元素即可。當然,最好的辦法應該是將所有 HTML 模板檔案統一放到一個子資料夾中,然後對這個模板資料夾進行遍歷和預載入。

如果需要載入新的模板,只需在這個資料夾中新建模板即可。這樣做的好處是不用反復修改程式碼即可重新編譯程式,而且實現了業務層和表現層真正意義上的分離。

不妨讓我們這樣試試看!

首先建立一個名為 ./views 的目錄,然後將當前目錄下所有 html 檔案移動到該目錄下:

$ mkdir ./views $ mv *.html ./views

接著適當地對 init() 方法中的程式碼進行改寫,好讓程式初始化時即可預載入該目錄下的所有模板檔案,如下列程式碼所示:
const (
    TEMPLATE_DIR = "./views"
)
templates := make(map[string]*template.Template)
func init() {
    fileInfoArr, err := ioutil.ReadDir(TEMPLATE_DIR)
    if err != nil {
        panic(err)
        return
    }
    var templateName, templatePath string
    for _, fileInfo := range fileInfoArr {
        templateName = fileInfo.Name
        if ext := path.Ext(templateName); ext != ".html" {
            continue
        }
        templatePath = TEMPLATE_DIR + "/" + templateName
        log.Println("Loading template:", templatePath)
        t := template.Must(template.ParseFiles(templatePath))
        templates[tmpl] = t
    }
}
同時,別忘了對 renderHtml() 的程式碼進行相應的調整:
func renderHtml(w http.ResponseWriter, tmpl string, locals map[string]interface{})
    err error {
    err = templates[tmpl].Execute(w, locals)
}
此時,renderHtml() 函數的程式碼也變得更為簡潔。還好我們之前單獨封裝了 renderHtml() 函數,這樣全域性程式碼中只需更改這一個地方,這無疑是程式碼解耦的好處之一!

錯誤處理

在前面的程式碼中,有不少地方對於出錯處理都是直接返回 http.Error() 50x 系列的伺服器端內部錯誤。從 DRY 的原則來看,不應該在程式中到處使用一樣的程式碼。我們可以定義一個名為 check() 的方法,用於統一捕獲 50x 系列的伺服器端內部錯誤:
func check(err error) {
    if err != nil {
        panic(err)
    }
}
此時,我們可以將 photoweb 程式中出現的以下程式碼:
if err != nil {
    http.Error(w, err.Error(),http.StatusInternalServerError)
    return
}
統一替換為 check() 處理:

check(err)

錯誤處理雖然簡單很多,但是也帶來一個問題。由於發生錯誤觸發錯誤處理流程必然會引發程式停止執行,這種改法有點像搬起石頭砸自己的腳。

其實我們可以換一種思維方式。儘管我們從書寫上能保證大多數錯誤都能得到相應的處理,但根據墨菲定律,有可能出問題的地方就一定會出問題,在計算機程式裡尤其如此。如果程式中我們正確地處理了 99 個錯誤,但若有一個系統錯誤意外導致程式出現異常,那麼程式同樣還是會終止執行。

我們不能預計一個工程裡邊會出現多少意外的情況,但是不管什麼意外,只要會觸發錯誤處理流程,我們就有辦法對其進行處理。如果這樣思考,那麼前面這種改法又何嘗不是置死地而後生呢?

接下來,讓我們了解如何處理 panic 導致程式崩潰的情況。

巧用閉包避免程式執行時出錯崩潰

Go 支援閉包。閉包可以是一個函數裡邊返回的另一個匿名函數,該匿名函數包含了定義在它外面的值。使用閉包,可以讓我們網站的業務邏輯處理程式更安全地執行。

我們可以在 photoweb 程式中針對所有的業務邏輯處理常式(listHandler()、viewHandler() 和 uploadHandler())再進行一次包裝。

在如下的程式碼中,我們定義了一個名為 safeHandler() 的函數,該函數有一個引數並且返回一個值,傳入的引數和返回值都是一個函數,且都是http.HandlerFunc型別,這種型別的函數有兩個引數:http.ResponseWriter 和 *http.Request。

函數規格同 photoweb 的業務邏輯處理常式完全一致。事實上,我們正是要把業務邏輯處理常式作為引數傳入到 safeHandler() 方法中,這樣任何一個錯誤處理流程向上回溯的時候,我們都能對其進行攔截處理,從而也能避免程式停止執行:
func safeHandler(fn http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if e, ok := recover().(error); ok {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                // 或者輸出自定義的 50x 錯誤頁面
                // w.WriteHeader(http.StatusInternalServerError)
                // renderHtml(w, "error", e)
                // logging
                log.Println("WARN: panic in %v - %v", fn, e)
                log.Println(string(debug.Stack()))
            }
        }()
        fn(w, r)
    }
}
在上述這段程式碼中,我們巧妙地使用了 defer 關鍵字搭配 recover() 方法終結 panic 的肆行。safeHandler() 接收一個業務邏輯處理常式作為引數,同時呼叫這個業務邏輯處理常式。該業
務邏輯函數執行完畢後,safeHandler() 中 defer 指定的匿名函數會執行。

倘若業務邏輯處理常式裡邊引發了 panic,則呼叫 recover() 對其進行檢測,若為一般性的錯誤,則輸出 HTTP 50x 出錯資訊並記錄紀錄檔,而程式將繼續良好執行。

要應用 safeHandler() 函數,只需在 main() 中對各個業務邏輯處理常式做一次包裝,如下面的程式碼所示:
func main() {
    http.HandleFunc("/", safeHandler(listHandler))
    http.HandleFunc("/view", safeHandler(viewHandler))
    http.HandleFunc("/upload", safeHandler(uploadHandler))
    err := http.ListenAndServe(":8080", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}

動態請求和靜態資源分離

你一定還有一個疑問,那就是前面的業務邏輯層都是動態請求,但若是針對靜態資源(比如 CSS 和 JavaScript 等),是沒有業務邏輯處理的,只需提供靜態輸出。在 Go 裡邊,這當然是可行的。

還記得前面我們在 viewHandler() 函數裡邊有用到 http.ServeFile() 這個方法嗎?net/http 包提供的這個 ServeFile() 函數可以將伺服器端的一個檔案內容讀寫到 http.Response-Writer 並返回給請求來源的 *http.Request 用戶端。

用前面介紹的閉包技巧結合這個 http.ServeFile() 方法,我們就能輕而易舉地實現業務邏輯的動態請求和靜態資源的完全分離。

假設我們有 ./public 這樣一個存放 css/、js/、images/ 等靜態資源的目錄,原則上所有如下的請求規則都指向該 ./public 目錄下相對應的檔案:

[GET] /assets/css/*.css
[GET] /assets/js/*.js
[GET] /assets/images/*.js

然後,我們定義一個名為 staticDirHandler() 的方法,用於實現上述需求:
const (
    ListDir = 0x0001
)
func staticDirHandler(mux *http.ServeMux, prefix string, staticDir string, flags int)
{
    mux.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) {
        file := staticDir + r.URL.Path[len(prefix)-1:]
        if (flags & ListDir) == 0 {
            if exists := isExists(file); !exists {
                http.NotFound(w, r)
                return
            }
        }
        http.ServeFile(w, r, file)
    })
}
最後,我們需要稍微改動下 main() 函數:
func main() {
    mux := http.NewServeMux()
    staticDirHandler(mux, "/assets/", "./public", 0)
    mux.HandleFunc("/", safeHandler(listHandler))
    mux.HandleFunc("/view", safeHandler(viewHandler))
    mux.HandleFunc("/upload", safeHandler(uploadHandler))
    err := http.ListenAndServe(":8080", mux)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}
如此即完美實現了靜態資源和動態請求的分離。

當然,我們要思考是否確實需要用 Go 來提供靜態資源的存取。如果使用外部 Web 伺服器(比如 Nginx 等),就沒必要使用 Go 編寫的靜態檔案服務了。在本機做開發時有一個程式內建的靜態檔案伺服器還是很實用的。

重構

經過前面對 photoweb 程式一一重整之後,整個工程的目錄結構如下:

├── photoweb.go
├── public
    ├── css
    ├── images
    └── js
├── uploads
└── views
    ├── list.html
    └── upload.html

photoweb.go 程式的原始碼最終如下所示。
package main
import (
    "io"
    "log"
    "path"
    "net/http"
    "io/ioutil"
    "html/template"
    "runtime/debug"
)
const (
    ListDir = 0x0001
    UPLOAD_DIR = "./uploads"
    TEMPLATE_DIR = "./views"
)
templates := make(map[string]*template.Template)
func init() {
    fileInfoArr, err := ioutil.ReadDir(TEMPLATE_DIR)
    check(err)
    var templateName, templatePath string
    for _, fileInfo := range fileInfoArr {
        templateName = fileInfo.Name
        if ext := path.Ext(templateName); ext != ".html" {
            continue
        }
        templatePath = TEMPLATE_DIR + "/" + templateName
        log.Println("Loading template:", templatePath)
        t := template.Must(template.ParseFiles(templatePath))
        templates[tmpl] = t
    }
}
func check(err error) {
    if err != nil {
        panic(err)
    }
}
func renderHtml(w http.ResponseWriter, tmpl string, locals map[string]interface{}) {
    err := templates[tmpl].Execute(w, locals)
    check(err)
}
func isExists(path string) bool {
    _, err := os.Stat(path)
    if err == nil {
        return true
    }
    return os.IsExist(err)
}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == "GET" {
        renderHtml(w, "upload", nil);
    }
    if r.Method == "POST" {
        f, h, err := r.FormFile("image")
        check(err)
        filename := h.Filename
        defer f.Close()
        t, err := ioutil.TempFile(UPLOAD_DIR, filename)
        check(err)
        defer t.Close()
        _, err := io.Copy(t, f)
        check(err)
        http.Redirect(w, r, "/view?id="+filename,
            http.StatusFound)
    }
}
func viewHandler(w http.ResponseWriter, r *http.Request) {
    imageId = r.FormValue("id")
    imagePath = UPLOAD_DIR + "/" + imageId
    if exists := isExists(imagePath);!exists {
        http.NotFound(w, r)
        return
    }
    w.Header().Set("Content-Type", "image")
    http.ServeFile(w, r, imagePath)
}
func listHandler(w http.ResponseWriter, r *http.Request) {
    fileInfoArr, err := ioutil.ReadDir("./uploads")
    check(err)
    locals := make(map[string]interface{})
    images := []string{}
    for _, fileInfo := range fileInfoArr {
        images = append(images, fileInfo.Name)
    }
    locals["images"] = images
    renderHtml(w, "list", locals)
}
func safeHandler(fn http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if e, ok := recover().(error); ok {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                // 或者輸出自定義的50x錯誤頁面
                // w.WriteHeader(http.StatusInternalServerError)
                // renderHtml(w, "error", e)
                // logging
                log.Println("WARN: panic in %v. - %v", fn, e)
                log.Println(string(debug.Stack()))
            }
        }()
        fn(w, r)
    }
}
func staticDirHandler(mux *http.ServeMux, prefix string, staticDir string, flags int)
{
    mux.HandleFunc(prefix, func(w http.ResponseWriter, r *http.Request) {
        file := staticDir + r.URL.Path[len(prefix)-1:]
        if (flags & ListDir) == 0 {
            if exists := isExists(file); !exists {
                http.NotFound(w, r)
                return
            }
        }
        http.ServeFile(w, r, file)
    })
}
func main() {
    mux := http.NewServeMux()
    staticDirHandler(mux, "/assets/", "./public", 0)
    mux.HandleFunc("/", safeHandler(listHandler))
    mux.HandleFunc("/view", safeHandler(viewHandler))
    mux.HandleFunc("/upload", safeHandler(uploadHandler))
    err := http.ListenAndServe(":8080", mux)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}

更多資源

Go 的第三方庫很豐富,無論是對於關係型資料庫驅動還是非關係型的鍵值儲存系統的接入,都有著良好的支援,而且還有豐富的 Go語言 Web 開發框架以及用於 Web 開發的相關工具包。可以存取 http://godashboard.appspot.com/project,了解更多第三方庫的詳細資訊。