介绍
本教程涵盖的内容:
- 创建一个带有加载和保存方法的数据结构
- 使用
net/http包构建Web应用程序 - 使用
html/template包处理HTML模板 - 使用
regexp包验证用户输入 - 使用闭包
预备知识:
- 编程经验
- 了解基本的Web技术(HTTP、HTML)
- 一些UNIX/DOS命令行知识
入门
目前,你需要有一台FreeBSD、Linux、macOS或Windows机器来运行Go。
我们将使用 $ 来表示命令提示符。
安装Go(请参阅安装说明)。
在你的 GOPATH 目录内为本教程创建一个新目录,并切换到该目录:
$ mkdir gowiki $ cd gowiki
创建一个名为 wiki.go 的文件,用你最喜欢的编辑器打开它,并添加以下几行:
package main
import (
"fmt"
"os"
)
我们从Go标准库导入了 fmt 和 os 包。稍后,当我们实现额外功能时,我们将向这个 import 声明中添加更多包。
数据结构
让我们从定义数据结构开始。一个wiki由一系列相互关联的页面组成,每个页面都有一个标题和一个正文(页面内容)。在这里,我们将 Page 定义为一个包含两个字段的结构体,分别表示标题和正文。
类型 []byte 表示“一个 byte 切片”。
(关于切片的更多信息,请参阅切片:用法与内部机制。)
Body 元素是 []byte 而不是 string,因为这是我们将使用的 io 库所期望的类型,如下文所示。
Page 结构体描述了页面数据在内存中如何存储。那么持久化存储呢?我们可以通过在 Page 上创建一个 save 方法来解决这个问题:
这个方法的签名可以读作:“这是一个名为 save 的方法,它接收一个指向 Page 的指针 p 作为其接收者。它不接受任何参数,并返回一个 error 类型的值。”
此方法将把 Page 的 Body 保存到一个文本文件中。为简单起见,我们将使用 Title 作为文件名。
save 方法返回一个 error 值,因为这是 WriteFile(一个将字节切片写入文件的标准库函数)的返回类型。save 方法返回错误值,以便应用程序在文件写入过程中出现任何问题时可以处理它。如果一切顺利,Page.save() 将返回 nil(指针、接口和一些其他类型的零值)。
八进制整数字面量 0600 作为第三个参数传递给 WriteFile,表示文件应以仅限当前用户可读写的权限创建。(详情请参阅Unix手册页 open(2)。)
除了保存页面,我们还需要能够加载页面:
{{code "part1-noerror.go" `/^func loadPage/` `/^}/`}}
函数 loadPage 根据标题参数构造文件名,将文件内容读入一个新变量 body,然后返回一个指向用正确的标题和正文值构造的 Page 字面量的指针。
函数可以返回多个值。标准库函数 os.ReadFile 返回 []byte 和 error。在 loadPage 中,错误尚未被处理;用下划线 (_) 符号表示的“空白标识符”用于丢弃错误返回值(本质上,将该值赋值给无物)。
但如果 ReadFile 遇到错误会怎样?例如,文件可能不存在。我们不应该忽略此类错误。让我们修改函数,使其返回 *Page 和 error。
此函数的调用者现在可以检查第二个参数;如果它是 nil,则表示已成功加载一个页面。如果不是,它将是一个可以由调用者处理的 error(详情请参阅语言规范)。
此时,我们有了一个简单的数据结构,以及从文件保存和加载的能力。让我们编写一个 main 函数来测试我们编写的内容:
编译并执行此代码后,将创建一个名为 TestPage.txt 的文件,其中包含 p1 的内容。然后,该文件将被读入结构体 p2,其 Body 元素将被打印到屏幕上。
你可以这样编译和运行程序:
$ go build wiki.go $ ./wiki This is a sample Page.
(如果你使用的是Windows,你必须输入“wiki”而不是“./”来运行程序。)
介绍 net/http 包(一个插曲)
这是一个简单Web服务器的完整工作示例:
{{code "http-sample.go"}} ```
main 函数首先调用
http.HandleFunc,该函数告知 http 包
使用 handler 来处理所有到 Web 根路径("/")的请求。
随后,它调用 http.ListenAndServe,指定程序应在
任意网络接口的 8080 端口(":8080")上监听。(暂时不必担心其
第二个参数 nil。)
此函数将阻塞,直到程序被终止。
ListenAndServe 始终返回一个错误,因为它仅在发生意外错误时才返回。
为了记录该错误,我们用 log.Fatal 包裹函数调用。
函数 handler 的类型是 http.HandlerFunc。
它接受一个 http.ResponseWriter 和一个 http.Request 作为
参数。
一个 http.ResponseWriter 值用于组装 HTTP 服务器的响应;通过写入
它,我们将数据发送给 HTTP 客户端。
一个 http.Request 是一个表示客户端
HTTP 请求的数据结构。r.URL.Path 是请求 URL 的路径组件。
末尾的 [1:] 表示
“从 Path 的第 1 个字符到末尾创建一个子切片。”
这会从路径名中去除开头的 "/"。
如果你运行此程序并访问 URL:
http://localhost:8080/monkeys
程序将显示一个包含以下内容的页面:
Hi there, I love monkeys!
使用 net/http 提供 wiki 页面服务
要使用 net/http 包,必须将其导入:
import (
"fmt"
"os"
"log"
"net/http"
)
让我们创建一个处理函数 viewHandler,它将允许用户
查看一个 wiki 页面。它将处理以 "/view/" 为前缀的 URL。
再次注意使用 _ 来忽略 loadPage 返回的 error
值。此处这样做是为了简化,通常被认为是不良实践。我们稍后会处理这个问题。
首先,此函数从 r.URL.Path(请求 URL 的路径组件)
中提取页面标题。
Path 被重新切片为 [len("/view/"):],以去除
请求路径开头的 "/view/" 部分。
这是因为路径总是以 "/view/" 开头,
而这并非页面标题的一部分。
然后,该函数加载页面数据,用一个简单的 HTML 字符串格式化页面,
并将其写入 w,即 http.ResponseWriter。
为了使用此处理函数,我们重写 main 函数,
使其使用 viewHandler 来处理
路径 /view/ 下的所有请求,从而初始化 http。
让我们创建一些页面数据(作为 test.txt),编译代码,
并尝试提供一个 wiki 页面服务。
在编辑器中打开 test.txt 文件,并将字符串 "Hello world"(不带引号)
保存其中。
$ go build wiki.go $ ./wiki
(如果你使用的是 Windows,你必须输入 "wiki" 而不带
"./" 来运行程序。)
在此 Web 服务器运行时,访问 http://localhost:8080/view/test
应会显示一个标题为 "test" 的页面,其中包含 "Hello world" 字样。
编辑页面
一个 wiki 如果没有编辑页面的能力就算不上是 wiki。让我们创建两个新的
处理函数:一个名为 editHandler,用于显示“编辑页面”表单,
另一个名为 saveHandler,用于保存通过表单输入的数据。
首先,将它们添加到 main():
函数 editHandler 加载页面
(如果页面不存在,则创建一个空的 Page 结构体),
并显示一个 HTML 表单。
此函数运行良好,但所有硬编码的 HTML 代码很丑陋。 当然,有更好的方法。
html/template 包
html/template 包是 Go 标准库的一部分。
我们可以使用 html/template 将 HTML 放在单独的文件中,
从而允许我们更改编辑页面的布局,而无需修改底层的
Go 代码。
首先,我们必须将 html/template 添加到导入列表中。我们
也不再使用 fmt,因此必须将其移除。
import (
"html/template"
"os"
"net/http"
)
让我们创建一个包含 HTML 表单的模板文件。
打开一个名为 edit.html 的新文件,并添加以下行:
修改 editHandler 以使用模板,而不是硬编码的
HTML:
函数 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。例如,它会自动转义任何大于号(>),
将其替换为 >,以确保用户数据不会破坏表单 HTML。
既然我们正在处理模板,接下来为
viewHandler 创建一个名为 view.html 的模板:
相应地修改 viewHandler:
注意,在两个处理函数中我们使用了几乎完全相同的模板代码。 让我们将模板代码移至独立函数以消除这种重复:
{{code "final-template.go" `/^func renderTemplate/` `/^}/`}}并修改处理函数以使用该函数:
{{code "final-template.go" `/^func viewHandler/` `/^}/`}} {{code "final-template.go" `/^func editHandler/` `/^}/`}}
如果我们在 main 中注释掉尚未实现的保存处理函数的注册,
就可以再次构建和测试程序。
点击此处查看我们目前编写的代码。
处理不存在的页面
如果访问
/view/APageThatDoesntExist 会怎样?你会看到一个包含 HTML 的页面。
这是因为代码忽略了 loadPage 返回的错误,
并继续尝试用空数据填充模板。正确的做法是,如果请求的页面不存在,
应将客户端重定向到编辑页面以便创建内容:
http.Redirect 函数会向 HTTP 响应中添加状态码
http.StatusFound(302)和 Location 头。
保存页面
saveHandler 函数将处理编辑页面上表单的提交。
在取消注释 main 中的相关代码行后,让我们实现这个处理函数:
页面标题(在 URL 中提供)和表单的唯一字段 Body 被存储到新的 Page 中。
然后调用 save() 方法将数据写入文件,
并将客户端重定向到 /view/ 页面。
FormValue 返回的值类型为 string。
我们必须将该值转换为 []byte 才能将其存入
Page 结构体。我们使用 []byte(body) 进行转换。
错误处理
程序中有几处错误被忽略了。这是不良实践, 尤其因为当错误实际发生时,程序将产生非预期行为。 更好的解决方案是处理错误并向用户返回错误信息。 这样,如果出现问题,服务器将按预期运行, 用户也能得到通知。
首先,处理 renderTemplate 中的错误:
http.Error 函数会发送指定的 HTTP 响应代码
(此处为“内部服务器错误”)和错误消息。
将此逻辑置于独立函数中已初见成效。
现在修复 saveHandler:
p.save() 过程中发生的任何错误都将报告给用户。
模板缓存
此代码存在效率问题:每次渲染页面时,
renderTemplate 都会调用 ParseFiles。
更好的方法是在程序初始化时调用一次 ParseFiles,
将所有模板解析到单个 *Template 中。
然后我们可以使用
ExecuteTemplate
方法渲染特定模板。
首先,创建一个名为 templates 的全局变量,
并用 ParseFiles 初始化它。
template.Must 函数是一个便捷包装器,
当传入非空 error 值时会触发 panic,
否则返回未修改的 *Template。
此处 panic 是合理的;如果模板无法加载,
唯一的合理做法就是退出程序。
ParseFiles 函数接受任意数量的字符串参数来标识模板文件,
并根据基础文件名解析这些文件为模板。
如果要向程序添加更多模板,
只需将其名称添加到 ParseFiles 调用的参数中。
然后修改 renderTemplate 函数,
调用 templates.ExecuteTemplate 方法并传入相应模板的名称:
注意模板名称即模板文件名,
因此必须在 tmpl 参数后追加 ".html"。
输入验证
如你所观察到的,此程序存在严重安全缺陷: 用户可在服务器上读取/写入任意路径。 为缓解此问题,我们可以编写函数通过正则表达式验证标题。
首先,在 import 列表中添加 "regexp"。
然后创建一个全局变量存储验证表达式:
函数 regexp.MustCompile 会解析并编译正则表达式,然后返回一个 regexp.Regexp 对象。
MustCompile 与 Compile 的区别在于:若表达式编译失败,MustCompile 会触发恐慌(panic),而 Compile 则返回 error 作为第二个参数。
现在,我们编写一个函数来使用 validPath 表达式验证路径并提取页面标题:
若标题有效,函数将返回该标题及 nil 错误值;若标题无效,函数会向 HTTP 连接写入 "404 Not Found" 错误,并向处理器返回错误信息。为创建新的错误,需要导入 errors 包。
接下来,在每个处理器中添加对 getTitle 的调用:
函数字面量与闭包
在每个处理器中捕获错误条件会产生大量重复代码。如果能将每个处理器封装在一个执行验证和错误检查的函数中呢?Go 语言的 函数字面量 提供了强大的抽象功能,正好能解决这个问题。
首先,将每个处理器的函数定义修改为接收标题字符串参数:
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) {
// 此处将从请求中提取页面标题
// 并调用传入的处理器 'fn'
}
}
返回的函数被称为闭包,因为它封装了外部定义的变量。此处 makeHandler 的唯一参数 fn 被闭包所封装,该变量将对应我们的保存、编辑或查看处理器之一。
现在可以将 getTitle 中的代码移植至此(稍作修改):
makeHandler 返回的闭包函数接收 http.ResponseWriter 和 http.Request 参数(即 http.HandlerFunc 类型)。
该闭包会从请求路径中提取 title,并通过 validPath 正则表达式进行验证。若 title 无效,将通过 http.NotFound 函数向 ResponseWriter 写入错误信息;若有效,则以 ResponseWriter、Request 和 title 为参数调用封装的处理器函数 fn。
现在可以在 main 函数中,使用 makeHandler 封装处理器函数,再将其注册到 http 包:
最后,从处理器函数中移除对 getTitle 的调用,使其代码更加简洁:
动手实践!
重新编译代码并运行应用:
$ go build wiki.go $ ./wiki
访问 http://localhost:8080/view/ANewPage 应会显示页面编辑表单。输入文本后点击"保存",即可跳转到新创建的页面。
扩展任务
以下是一些可独立完成的进阶任务:
- 将模板存储在
tmpl/目录,页面数据存储在data/目录。 - 添加处理器使网站根目录重定向至
/view/FrontPage。 - 优化页面模板,使其符合 HTML 规范并添加 CSS 样式规则。
- 实现页面间链接转换,将
[PageName]实例转换为
<a href="/go/view/PageName">PageName</a>。 (提示:可使用regexp.ReplaceAllFunc实现)