```markdown

介绍

本教程涵盖的内容:

预备知识:

入门

目前,你需要有一台FreeBSD、Linux、macOS或Windows机器来运行Go。 我们将使用 $ 来表示命令提示符。

安装Go(请参阅安装说明)。

在你的 GOPATH 目录内为本教程创建一个新目录,并切换到该目录:

$ mkdir gowiki
$ cd gowiki

创建一个名为 wiki.go 的文件,用你最喜欢的编辑器打开它,并添加以下几行:

package main

import (
    "fmt"
    "os"
)

我们从Go标准库导入了 fmtos 包。稍后,当我们实现额外功能时,我们将向这个 import 声明中添加更多包。

数据结构

让我们从定义数据结构开始。一个wiki由一系列相互关联的页面组成,每个页面都有一个标题和一个正文(页面内容)。在这里,我们将 Page 定义为一个包含两个字段的结构体,分别表示标题和正文。

{{code "part1.go" `/^type Page/` `/}/`}}

类型 []byte 表示“一个 byte 切片”。 (关于切片的更多信息,请参阅切片:用法与内部机制。) Body 元素是 []byte 而不是 string,因为这是我们将使用的 io 库所期望的类型,如下文所示。

Page 结构体描述了页面数据在内存中如何存储。那么持久化存储呢?我们可以通过在 Page 上创建一个 save 方法来解决这个问题:

{{code "part1.go" `/^func.*Page.*save/` `/}/`}}

这个方法的签名可以读作:“这是一个名为 save 的方法,它接收一个指向 Page 的指针 p 作为其接收者。它不接受任何参数,并返回一个 error 类型的值。”

此方法将把 PageBody 保存到一个文本文件中。为简单起见,我们将使用 Title 作为文件名。

save 方法返回一个 error 值,因为这是 WriteFile(一个将字节切片写入文件的标准库函数)的返回类型。save 方法返回错误值,以便应用程序在文件写入过程中出现任何问题时可以处理它。如果一切顺利,Page.save() 将返回 nil(指针、接口和一些其他类型的零值)。

八进制整数字面量 0600 作为第三个参数传递给 WriteFile,表示文件应以仅限当前用户可读写的权限创建。(详情请参阅Unix手册页 open(2)。)

除了保存页面,我们还需要能够加载页面:

{{code "part1-noerror.go" `/^func loadPage/` `/^}/`}}

函数 loadPage 根据标题参数构造文件名,将文件内容读入一个新变量 body,然后返回一个指向用正确的标题和正文值构造的 Page 字面量的指针。

函数可以返回多个值。标准库函数 os.ReadFile 返回 []byteerror。在 loadPage 中,错误尚未被处理;用下划线 (_) 符号表示的“空白标识符”用于丢弃错误返回值(本质上,将该值赋值给无物)。

但如果 ReadFile 遇到错误会怎样?例如,文件可能不存在。我们不应该忽略此类错误。让我们修改函数,使其返回 *Pageerror

{{code "part1.go" `/^func loadPage/` `/^}/`}}

此函数的调用者现在可以检查第二个参数;如果它是 nil,则表示已成功加载一个页面。如果不是,它将是一个可以由调用者处理的 error(详情请参阅语言规范)。

此时,我们有了一个简单的数据结构,以及从文件保存和加载的能力。让我们编写一个 main 函数来测试我们编写的内容:

{{code "part1.go" `/^func 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。

{{code "part2.go" `/^func viewHandler/` `/^}/`}}

再次注意使用 _ 来忽略 loadPage 返回的 error 值。此处这样做是为了简化,通常被认为是不良实践。我们稍后会处理这个问题。

首先,此函数从 r.URL.Path(请求 URL 的路径组件) 中提取页面标题。 Path 被重新切片为 [len("/view/"):],以去除 请求路径开头的 "/view/" 部分。 这是因为路径总是以 "/view/" 开头, 而这并非页面标题的一部分。

然后,该函数加载页面数据,用一个简单的 HTML 字符串格式化页面, 并将其写入 w,即 http.ResponseWriter

为了使用此处理函数,我们重写 main 函数, 使其使用 viewHandler 来处理 路径 /view/ 下的所有请求,从而初始化 http

{{code "part2.go" `/^func main/` `/^}/`}}

点击此处查看我们目前编写的代码。

让我们创建一些页面数据(作为 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()

{{code "final-noclosure.go" `/^func main/` `/^}/`}}

函数 editHandler 加载页面 (如果页面不存在,则创建一个空的 Page 结构体), 并显示一个 HTML 表单。

{{code "notemplate.go" `/^func editHandler/` `/^}/`}}

此函数运行良好,但所有硬编码的 HTML 代码很丑陋。 当然,有更好的方法。

html/template

html/template 包是 Go 标准库的一部分。 我们可以使用 html/template 将 HTML 放在单独的文件中, 从而允许我们更改编辑页面的布局,而无需修改底层的 Go 代码。

首先,我们必须将 html/template 添加到导入列表中。我们 也不再使用 fmt,因此必须将其移除。

import (
    "html/template"
    "os"
    "net/http"
)

让我们创建一个包含 HTML 表单的模板文件。 打开一个名为 edit.html 的新文件,并添加以下行:

{{code "edit.html"}}

修改 editHandler 以使用模板,而不是硬编码的 HTML:

{{code "final-noerror.go" `/^func editHandler/` `/^}/`}}

函数 template.ParseFiles 将读取 edit.html 的内容并返回一个 *template.Template

方法 t.Execute 执行模板,将生成的 HTML 写入 http.ResponseWriter。 带点的标识符 .Title.Body 指的是 p.Titlep.Body

模板指令包含在双花括号内。 printf "%s" .Body 指令是一个函数调用, 它将 .Body 作为字符串而非字节流输出, 这与调用 fmt.Printf 效果相同。 html/template 包有助于确保模板操作仅生成安全且 外观正确的 HTML。例如,它会自动转义任何大于号(>), 将其替换为 >,以确保用户数据不会破坏表单 HTML。

既然我们正在处理模板,接下来为 viewHandler 创建一个名为 view.html 的模板:

{{code "view.html"}}

相应地修改 viewHandler

{{code "final-noerror.go" `/^func viewHandler/` `/^}/`}}

注意,在两个处理函数中我们使用了几乎完全相同的模板代码。 让我们将模板代码移至独立函数以消除这种重复:

{{code "final-template.go" `/^func renderTemplate/` `/^}/`}}

并修改处理函数以使用该函数:

{{code "final-template.go" `/^func viewHandler/` `/^}/`}} {{code "final-template.go" `/^func editHandler/` `/^}/`}}

如果我们在 main 中注释掉尚未实现的保存处理函数的注册, 就可以再次构建和测试程序。 点击此处查看我们目前编写的代码。

处理不存在的页面

如果访问 /view/APageThatDoesntExist 会怎样?你会看到一个包含 HTML 的页面。 这是因为代码忽略了 loadPage 返回的错误, 并继续尝试用空数据填充模板。正确的做法是,如果请求的页面不存在, 应将客户端重定向到编辑页面以便创建内容:

{{code "part3-errorhandling.go" `/^func viewHandler/` `/^}/`}}

http.Redirect 函数会向 HTTP 响应中添加状态码 http.StatusFound(302)和 Location 头。

保存页面

saveHandler 函数将处理编辑页面上表单的提交。 在取消注释 main 中的相关代码行后,让我们实现这个处理函数:

{{code "final-template.go" `/^func saveHandler/` `/^}/`}}

页面标题(在 URL 中提供)和表单的唯一字段 Body 被存储到新的 Page 中。 然后调用 save() 方法将数据写入文件, 并将客户端重定向到 /view/ 页面。

FormValue 返回的值类型为 string。 我们必须将该值转换为 []byte 才能将其存入 Page 结构体。我们使用 []byte(body) 进行转换。

错误处理

程序中有几处错误被忽略了。这是不良实践, 尤其因为当错误实际发生时,程序将产生非预期行为。 更好的解决方案是处理错误并向用户返回错误信息。 这样,如果出现问题,服务器将按预期运行, 用户也能得到通知。

首先,处理 renderTemplate 中的错误:

{{code "final-parsetemplate.go" `/^func renderTemplate/` `/^}/`}}

http.Error 函数会发送指定的 HTTP 响应代码 (此处为“内部服务器错误”)和错误消息。 将此逻辑置于独立函数中已初见成效。

现在修复 saveHandler

{{code "part3-errorhandling.go" `/^func saveHandler/` `/^}/`}}

p.save() 过程中发生的任何错误都将报告给用户。

模板缓存

此代码存在效率问题:每次渲染页面时, renderTemplate 都会调用 ParseFiles。 更好的方法是在程序初始化时调用一次 ParseFiles, 将所有模板解析到单个 *Template 中。 然后我们可以使用 ExecuteTemplate 方法渲染特定模板。

首先,创建一个名为 templates 的全局变量, 并用 ParseFiles 初始化它。

{{code "final.go" `/var templates/`}}

template.Must 函数是一个便捷包装器, 当传入非空 error 值时会触发 panic, 否则返回未修改的 *Template。 此处 panic 是合理的;如果模板无法加载, 唯一的合理做法就是退出程序。

ParseFiles 函数接受任意数量的字符串参数来标识模板文件, 并根据基础文件名解析这些文件为模板。 如果要向程序添加更多模板, 只需将其名称添加到 ParseFiles 调用的参数中。

然后修改 renderTemplate 函数, 调用 templates.ExecuteTemplate 方法并传入相应模板的名称:

{{code "final.go" `/func renderTemplate/` `/^}/`}}

注意模板名称即模板文件名, 因此必须在 tmpl 参数后追加 ".html"

输入验证

如你所观察到的,此程序存在严重安全缺陷: 用户可在服务器上读取/写入任意路径。 为缓解此问题,我们可以编写函数通过正则表达式验证标题。

首先,在 import 列表中添加 "regexp"。 然后创建一个全局变量存储验证表达式:

{{code "final-noclosure.go" `/^var validPath/`}}

函数 regexp.MustCompile 会解析并编译正则表达式,然后返回一个 regexp.Regexp 对象。 MustCompileCompile 的区别在于:若表达式编译失败,MustCompile 会触发恐慌(panic),而 Compile 则返回 error 作为第二个参数。

现在,我们编写一个函数来使用 validPath 表达式验证路径并提取页面标题:

{{code "final-noclosure.go" `/func getTitle/` `/^}/`}}

若标题有效,函数将返回该标题及 nil 错误值;若标题无效,函数会向 HTTP 连接写入 "404 Not Found" 错误,并向处理器返回错误信息。为创建新的错误,需要导入 errors 包。

接下来,在每个处理器中添加对 getTitle 的调用:

{{code "final-noclosure.go" `/^func viewHandler/` `/^}/`}} {{code "final-noclosure.go" `/^func editHandler/` `/^}/`}} {{code "final-noclosure.go" `/^func saveHandler/` `/^}/`}}

函数字面量与闭包

在每个处理器中捕获错误条件会产生大量重复代码。如果能将每个处理器封装在一个执行验证和错误检查的函数中呢?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 中的代码移植至此(稍作修改):

{{code "final.go" `/func makeHandler/` `/^}/`}}

makeHandler 返回的闭包函数接收 http.ResponseWriterhttp.Request 参数(即 http.HandlerFunc 类型)。 该闭包会从请求路径中提取 title,并通过 validPath 正则表达式进行验证。若 title 无效,将通过 http.NotFound 函数向 ResponseWriter 写入错误信息;若有效,则以 ResponseWriterRequesttitle 为参数调用封装的处理器函数 fn

现在可以在 main 函数中,使用 makeHandler 封装处理器函数,再将其注册到 http 包:

{{code "final.go" `/func main/` `/^}/`}}

最后,从处理器函数中移除对 getTitle 的调用,使其代码更加简洁:

{{code "final.go" `/^func viewHandler/` `/^}/`}} {{code "final.go" `/^func editHandler/` `/^}/`}} {{code "final.go" `/^func saveHandler/` `/^}/`}}

动手实践!

点击此处查看最终代码列表。

重新编译代码并运行应用:

$ go build wiki.go
$ ./wiki

访问 http://localhost:8080/view/ANewPage 应会显示页面编辑表单。输入文本后点击"保存",即可跳转到新创建的页面。

扩展任务

以下是一些可独立完成的进阶任务: