15.6 用模板编写网页应用

以下程序是用 100 行以内代码实现可行的 wiki 网页应用,它由一组页面组成,用于阅读、编辑和保存。它是来自 Go 网站 codelab 的 wiki 制作教程,我所知的最好的 Go 教程之一,非常值得进行完整的实验,以见证并理解程序是如何被构建起来的(https://golang.org/doc/articles/wiki/)。这里,我们将以自顶向下的视角,从整体上给出程序的补充说明。程序是网页服务器,它必须从命令行启动,监听某个端口,例如 8080。浏览器可以通过请求 URL 阅读 wiki 页面的内容,例如:http://localhost:8080/view/page1

接着,页面的文本内容从一个文件中读取,并显示在网页中。它包含一个超链接,指向编辑页面(http://localhost:8080/edit/page1)。编辑页面将内容显示在一个文本域中,用户可以更改文本,点击“保存”按钮保存到对应的文件中。然后回到阅读页面显示更改后的内容。如果某个被请求阅读的页面不存在(例如:http://localhost:8080/edit/page999),程序可以作出识别,立即重定向到编辑页面,如此新的 wiki 页面就可以被创建并保存。

wiki 页面需要一个标题和文本内容,它在程序中被建模为如下结构体,Body 字段存放内容,由字节切片组成。

type Page struct {
    Title string
    Body  []byte
}

为了在可执行程序之外维护 wiki 页面内容,我们简单地使用了文本文件作为持久化存储。程序、必要的模板和文本文件可以在 wiki 中找到。

示例 15.12 wiki.go

package main

import (
    "net/http"
    "io/ioutil"
    "log"
    "regexp"
    "text/template"
)

const lenPath = len("/view/")

var titleValidator = regexp.MustCompile("^[a-zA-Z0-9]+$")
var templates = make(map[string]*template.Template)
var err error

type Page struct {
    Title string
    Body  []byte
}

func init() {
    for _, tmpl := range []string{"edit", "view"} {
        templates[tmpl] = template.Must(template.ParseFiles(tmpl + ".html"))
    }
}

func main() {
    http.HandleFunc("/view/", makeHandler(viewHandler))
    http.HandleFunc("/edit/", makeHandler(editHandler))
    http.HandleFunc("/save/", makeHandler(saveHandler))
    err := http.ListenAndServe("localhost:8080", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err.Error())
    }
}

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)
    }
}

func viewHandler(w http.ResponseWriter, r *http.Request, title string) {
    p, err := load(title)
    if err != nil { // page not found
        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 := load(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)
}

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
    err := templates[tmpl].Execute(w, p)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
}

func (p *Page) save() error {
    filename := p.Title + ".txt"
    // file created with read-write permissions for the current user only
    return ioutil.WriteFile(filename, p.Body, 0600)
}

func load(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
}

让我们来通读代码:

在此定义了 3 个处理函数,由于包含重复的启动代码,我们将其提取到单独的 makeHandler() 函数中。这是一个值得研究的特殊高阶函数:其参数是一个函数,返回一个新的闭包函数:

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)
    }
}
- 闭包封闭了函数变量 fn 来构造其返回值。但在此之前,它先用 titleValidator.MatchString(title) 验证输入标题 title 的有效性。如果标题包含了字母和数字以外的字符,就触发 NotFound 错误(例如:尝试 localhost:8080/view/page++)。viewHandlereditHandlersaveHandler 都是传入 main()makeHandler 的参数,类型必须都与 fn 相同。 - viewHandler 尝试按标题读取文本文件,这是通过调用 load() 函数完成的,它会构建文件名并用 ioutil.ReadFile 读取内容。如果文件存在,其内容会存入字符串中。一个指向 Page 结构体的指针按字面量被创建:&Page{Title: title, Body: body}

另外,该值和表示没有 error 的 nil 值一起返回给调用者。然后在 renderTemplate 中将该结构体与模板对象整合。

万一发生错误,也就是说 wiki 页面在磁盘上不存在,错误会被返回给 viewHandler,此时会自动重定向,跳转请求对应标题的编辑页面。

这意味着,当提交表单到类似 http://localhost/save/{Title} 这样的 URL 格式时,一个 POST 请求被发往网页服务器。针对这样的 URL 我们已经定义好了处理函数:saveHandler()。在 request 对象上调用 FormValue() 方法,可以提取名称为 body 的文本域内容,用这些信息构造一个 Page 对象,然后尝试通过调用 save() 方法保存其内容。万一运行失败,执行 http.Error 以将错误显示到浏览器。如果保存成功,重定向浏览器到该页的阅读页面。save() 函数非常简单,利用 ioutil.WriteFile(),写入 Page 结构体的 Body 字段到文件 filename 中,之后会被用于模板替换占位符 {{printf "%s" .Body |html}}

链接