Files
the-way-to-go_ZH_CN/eBook/15.6.md
Haigang Zhou 72f2eccbc5 第十五章修改 (#834)
Co-authored-by: Joe Chen <jc@unknwon.io>
2022-05-17 16:33:20 +08:00

182 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 15.6 用模板编写网页应用
以下程序是用 100 行以内代码实现可行的 wiki 网页应用,它由一组页面组成,用于阅读、编辑和保存。它是来自 Go 网站 codelab 的 wiki 制作教程,我所知的最好的 Go 教程之一,非常值得进行完整的实验,以见证并理解程序是如何被构建起来的([https://golang.org/doc/articles/wiki/](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 字段存放内容,由字节切片组成。
```go
type Page struct {
Title string
Body []byte
}
```
为了在可执行程序之外维护 wiki 页面内容,我们简单地使用了文本文件作为持久化存储。程序、必要的模板和文本文件可以在 [wiki](examples/chapter_15/wiki) 中找到。
示例 15.12 [wiki.go](examples/chapter_15/wiki/wiki.go)
```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
}
```
让我们来通读代码:
- 首先导入必要的包。由于我们在构建网页服务器,`http` 当然是必须的。不过还导入了 `io/ioutil` 来方便地读写文件,`regexp` 用于验证输入标题,以及 `template` 来动态创建 html 文档。
- 为避免黑客构造特殊输入攻击服务器,我们用如下正则表达式检查用户在浏览器上输入的 URL同时也是 wiki 页面标题):
```go
var titleValidator = regexp.MustCompile("^[a-zA-Z0-9]+$")
```
`makeHandler` 会用它对请求管控。
- 必须有一种机制把 `Page` 结构体数据插入到网页的标题和内容中,可以利用 `template` 包通过如下步骤完成:
1. 先在文本编辑器中创建 html 模板文件,例如 view.html
```html
<h1>{{.Title |html}}</h1>
<p>[<a href="/edit/{{.Title |html}}">edit</a>]</p>
<div>{{printf "%s" .Body |html}}</div>
```
把要插入的数据结构字段放在 `{{` 和 `}}` 之间,这里是把 `Page` 结构体数据 `{{.Title |html}}` 和 `{{printf "%s" .Body |html}}` 插入页面(当然可以是非常复杂的 html但这里尽可能地简化了以突出模板的原理。`{{.Title |html}}` 和 `{{printf "%s" .Body |html}}` 语法说明详见后续章节)。
2. `template.Must(template.ParseFiles(tmpl + ".html"))` 把模板文件转换为 `*template.Template` 类型的对象,为了高效,在程序运行时仅做一次解析,在 `init()` 函数中处理可以方便地达到目的。所有模板对象都被保持在内存中,存放在以 html 文件名作为索引的 `map` 中:
```go
templates = make(map[string]*template.Template)
```
这种技术被称为*模板缓存*,是推荐的最佳实践。
3. 为了真正从模板和结构体构建出页面,必须使用:
```go
templates[tmpl].Execute(w, p)
```
它基于模板执行,用 `Page` 结构体对象 `p` 作为参数对模板进行替换,并写入 `ResponseWriter` 对象 `w`。必须检查该方法的 `error` 返回值,万一有一个或多个错误,我们可以调用 `http.Error()` 来明示。在我们的应用程序中,这段代码会被多次调用,所以把它提取为单独的函数 `renderTemplate()`。
- 在 `main()` 中网页服务器用 `ListenAndServe()` 启动并监听 8080 端口。但正如 [15.2节](15.2.md) 那样,需要先为紧接在 URL `localhost:8080/` 之后, 以 `view`, `edit` 或 `save` 开头的 url 路径定义一些处理函数。在大多数网页服务器应用程序中,这形成了一系列 URL 路径到处理函数的映射,类似于 Ruby 和 RailsDjango 或 ASP.NET MVC 这样的 MVC 框架中的路由表。请求的 URL 与这些路径尝试匹配,较长的路径被优先匹配。如不与任何路径匹配,则调用 / 的处理程序。
在此定义了 3 个处理函数,由于包含重复的启动代码,我们将其提取到单独的 `makeHandler()` 函数中。这是一个值得研究的特殊高阶函数:其参数是一个函数,返回一个新的闭包函数:
```go
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++`)。`viewHandler``editHandler` 和 `saveHandler` 都是传入 `main()` 中 `makeHandler` 的参数,类型必须都与 `fn` 相同。
- `viewHandler` 尝试按标题读取文本文件,这是通过调用 `load()` 函数完成的,它会构建文件名并用 `ioutil.ReadFile` 读取内容。如果文件存在,其内容会存入字符串中。一个指向 `Page` 结构体的指针按字面量被创建:`&Page{Title: title, Body: body}`。
另外,该值和表示没有 error 的 `nil` 值一起返回给调用者。然后在 `renderTemplate` 中将该结构体与模板对象整合。
万一发生错误,也就是说 wiki 页面在磁盘上不存在,错误会被返回给 `viewHandler`,此时会自动重定向,跳转请求对应标题的编辑页面。
- `editHandler` 基本上也差不多:尝试读取文件,如果存在则用“编辑”模板来渲染;万一发生错误,创建一个新的包含指定标题的 `Page` 对象并渲染。
- 当在编辑页面点击“保存”按钮时,触发保存页面内容的动作。按钮须放在 html 表单中,它开头是这样的:
```html
<form action="/save/{{.Title |html}}" method="POST">
```
这意味着,当提交表单到类似 `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}}`。
## 链接
- [目录](directory.md)
- 上一节:[确保网页应用健壮](15.5.md)
- 下一节:[探索 template 包](15.7.md)