编写 Web 应用程序
简介
本教程中介绍的内容
- 创建具有加载和保存方法的数据结构
- 使用
net/http
软件包构建 Web 应用程序 - 使用
html/template
软件包处理 HTML 模板 - 使用
regexp
软件包验证用户输入 - 使用闭包
假设的知识
- 编程经验
- 了解基本 Web 技术(HTTP、HTML)
- 一些 UNIX/DOS 命令行知识
开始
目前,您需要一台 FreeBSD、Linux、macOS 或 Windows 机器来运行 Go。我们将使用 $
来表示命令提示符。
安装 Go(请参阅 安装说明)。
在 GOPATH
中为本教程创建一个新目录并 cd 到该目录
$ mkdir gowiki $ cd gowiki
创建一个名为 wiki.go
的文件,在您喜欢的编辑器中打开它,并添加以下行
package main import ( "fmt" "os" )
我们从 Go 标准库中导入 fmt
和 os
包。稍后,当我们实现其他功能时,我们将向此 import
声明中添加更多包。
数据结构
我们从定义数据结构开始。维基由一系列相互连接的页面组成,每个页面都有一个标题和一个正文(页面内容)。在此,我们定义 Page
为一个结构,其中包含两个字段,分别表示标题和正文。
type Page struct { Title string Body []byte }
类型 []byte
表示“一个 byte
切片”。(有关切片的更多信息,请参阅 切片:用法和内部。)Body
元素是 []byte
而不是 string
,因为这是我们将在下面看到 io
库期望的类型。
Page
结构描述了页面数据将在内存中存储的方式。但持久化存储呢?我们可以通过在 Page
上创建一个 save
方法来解决此问题
func (p *Page) save() error { filename := p.Title + ".txt" return os.WriteFile(filename, p.Body, 0600) }
此方法的签名为:“这是一个名为 save
的方法,它以 p
为接收器,即指向 Page
的指针。它不接受任何参数,并返回类型为 error
的值。”
此方法将把 Page
的 Body
保存到文本文件中。为简单起见,我们将使用 Title
作为文件名。
save
方法返回一个 error
值,因为这是 WriteFile
(一个将字节切片写入文件的标准库函数)的返回类型。save
方法返回错误值,以便应用程序在写入文件时出现任何问题时对其进行处理。如果一切顺利,Page.save()
将返回 nil
(指针、接口和其他一些类型的零值)。
传递给 WriteFile
作为第三个参数的八进制整数文字 0600
表示应仅为当前用户创建具有读写权限的文件。(有关详细信息,请参阅 Unix 手册页 open(2)
。)
除了保存页面之外,我们还希望加载页面
func loadPage(title string) *Page { filename := title + ".txt" body, _ := os.ReadFile(filename) return &Page{Title: title, Body: body} }
函数 loadPage
从标题参数构造文件名,将文件内容读入新变量 body
,并返回使用正确的标题和正文值构造的 Page
文字的指针。
函数可以返回多个值。标准库函数 os.ReadFile
返回 []byte
和 error
。在 loadPage
中,错误尚未得到处理;由下划线 (_
) 符号表示的“空白标识符”用于丢弃错误返回值(本质上,将值分配给无)。
但是,如果 ReadFile
遇到错误会发生什么情况?例如,文件可能不存在。我们不应该忽略此类错误。我们修改函数以返回 *Page
和 error
。
func loadPage(title string) (*Page, error) { filename := title + ".txt" body, err := os.ReadFile(filename) if err != nil { return nil, err } return &Page{Title: title, Body: body}, nil }
此函数的调用者现在可以检查第二个参数;如果它为 nil
,则它已成功加载一个页面。如果不是,它将成为一个 error
,调用者可以处理它(有关详细信息,请参阅 语言规范)。
此时,我们有了一个简单的数据结构,并且能够保存到文件并从文件中加载。让我们编写一个 main
函数来测试我们编写的内容
func main() { p1 := &Page{Title: "TestPage", Body: []byte("This is a sample Page.")} p1.save() p2, _ := loadPage("TestPage") fmt.Println(string(p2.Body)) }
编译并执行此代码后,将创建一个名为 TestPage.txt
的文件,其中包含 p1
的内容。然后将文件读入结构 p2
,并将它的 Body
元素打印到屏幕上。
您可以像这样编译并运行程序
$ go build wiki.go $ ./wiki This is a sample Page.
(如果您使用 Windows,您必须键入 "wiki
" 而不用 "./
" 来运行程序。)
介绍 net/http
包(插曲)
这是一个简单的 Web 服务器的完整工作示例
//go:build ignore
package main
import (
"fmt"
"log"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:])
}
func main() {
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
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:]
表示“从第 1 个字符到末尾创建 Path
的子切片”。这会从路径名称中删除前导“/”。
如果您运行此程序并访问 URL
https://#:8080/monkeys
该程序将显示一个包含以下内容的页面
Hi there, I love monkeys!
使用 net/http
提供 wiki 页面
要使用 net/http
包,必须导入它
import ( "fmt" "os" "log" "net/http" )
我们来创建一个处理程序 viewHandler
,它允许用户查看一个 wiki 页面。它将处理以 "/view/" 为前缀的 URL。
func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/view/"):] p, _ := loadPage(title) fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body) }
再次注意,使用 _
忽略了 loadPage
的 error
返回值。这样做是为了简单起见,通常被认为是不良做法。我们稍后会解决这个问题。
首先,此函数从 r.URL.Path
(请求 URL 的路径组件)中提取页面标题。Path
使用 [len("/view/"):]
重新切片,以删除请求路径的前导 "/view/"
组件。这是因为路径总是以 "/view/"
开头,而这并不是页面标题的一部分。
然后,该函数加载页面数据,使用简单的 HTML 字符串格式化页面,并将其写入 w
(http.ResponseWriter
)。
要使用此处理程序,我们重写 main
函数,使用 viewHandler
初始化 http
,以处理路径 /view/
下的任何请求。
func main() { http.HandleFunc("/view/", viewHandler) log.Fatal(http.ListenAndServe(":8080", nil)) }
我们来创建一些页面数据(作为 test.txt
),编译我们的代码,并尝试提供一个 wiki 页面。
在编辑器中打开 test.txt
文件,并保存字符串 "Hello world"(不带引号)。
$ go build wiki.go $ ./wiki
(如果您使用 Windows,您必须键入 "wiki
" 而不用 "./
" 来运行程序。)
在这个 web 服务器运行时,访问 https://#:8080/view/test
应该显示一个标题为 "test" 的页面,其中包含 "Hello world" 字样。
编辑页面
如果没有编辑页面的能力,wiki 就不是 wiki。我们来创建两个新的处理程序:一个名为 editHandler
的处理程序,用于显示“编辑页面”表单;另一个名为 saveHandler
的处理程序,用于保存通过表单输入的数据。
首先,我们将其添加到 main()
func main() { http.HandleFunc("/view/", viewHandler) http.HandleFunc("/edit/", editHandler) http.HandleFunc("/save/", saveHandler) log.Fatal(http.ListenAndServe(":8080", nil)) }
函数 editHandler
加载页面(或者,如果页面不存在,则创建一个空的 Page
结构),并显示一个 HTML 表单。
func editHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/edit/"):] p, err := loadPage(title) if err != nil { p = &Page{Title: title} } fmt.Fprintf(w, "<h1>Editing %s</h1>"+ "<form action=\"/save/%s\" method=\"POST\">"+ "<textarea name=\"body\">%s</textarea><br>"+ "<input type=\"submit\" value=\"Save\">"+ "</form>", p.Title, p.Title, p.Body) }
此函数将正常工作,但所有这些硬编码的 HTML 都很丑陋。当然,有更好的方法。
html/template
包
html/template
包是 Go 标准库的一部分。我们可以使用 html/template
将 HTML 保存在一个单独的文件中,这样我们就可以更改编辑页面的布局,而无需修改底层的 Go 代码。
首先,我们必须将 html/template
添加到导入列表中。我们也不会再使用 fmt
,因此我们必须将其删除。
import ( "html/template" "os" "net/http" )
我们来创建一个包含 HTML 表单的模板文件。打开一个名为 edit.html
的新文件,并添加以下行
<h1>Editing {{.Title}}</h1> <form action="/save/{{.Title}}" method="POST"> <div><textarea name="body" rows="20" cols="80">{{printf "%s" .Body}}</textarea></div> <div><input type="submit" value="Save"></div> </form>
修改 editHandler
以使用模板,而不是硬编码的 HTML
func editHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/edit/"):] p, err := loadPage(title) if err != nil { p = &Page{Title: title} } t, _ := template.ParseFiles("edit.html") t.Execute(w, p) }
函数 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
的模板
<h1>{{.Title}}</h1> <p>[<a href="/edit/{{.Title}}">edit</a>]</p> <div>{{printf "%s" .Body}}</div>
相应地修改 viewHandler
func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/view/"):] p, _ := loadPage(title) t, _ := template.ParseFiles("view.html") t.Execute(w, p) }
请注意,我们在两个处理程序中使用了几乎完全相同的模板代码。让我们将模板代码移到它自己的函数中,以消除这种重复
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) { t, _ := template.ParseFiles(tmpl + ".html") t.Execute(w, p) }
并修改处理程序以使用该函数
func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/view/"):] p, _ := loadPage(title) renderTemplate(w, "view", p) }
func editHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/edit/"):] p, err := loadPage(title) if err != nil { p = &Page{Title: title} } renderTemplate(w, "edit", p) }
如果我们在 main
中注释掉未实现的保存处理程序的注册,我们就可以再次构建和测试我们的程序。 单击此处查看我们到目前为止编写的代码。
处理不存在的页面
如果你访问 /view/APageThatDoesntExist
会怎样?你会看到一个包含 HTML 的页面。这是因为它忽略了 loadPage
的错误返回值,并继续尝试用没有数据填充模板。相反,如果请求的页面不存在,它应该将客户端重定向到编辑页面,以便可以创建内容
func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/view/"):] p, err := loadPage(title) if err != nil { http.Redirect(w, r, "/edit/"+title, http.StatusFound) return } renderTemplate(w, "view", p) }
http.Redirect
函数将 HTTP 状态代码 http.StatusFound
(302) 和 Location
标头添加到 HTTP 响应中。
保存页面
函数 saveHandler
将处理位于编辑页面上的表单的提交。在取消对 main
中相关行的注释后,让我们实现处理程序
func saveHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/save/"):] body := r.FormValue("body") p := &Page{Title: title, Body: []byte(body)} p.save() http.Redirect(w, r, "/view/"+title, http.StatusFound) }
页面标题(在 URL 中提供)和表单的唯一字段 Body
存储在一个新的 Page
中。然后调用 save()
方法将数据写入文件,并将客户端重定向到 /view/
页面。
FormValue
返回的值的类型为 string
。我们必须将该值转换为 []byte
,然后才能将其放入 Page
结构中。我们使用 []byte(body)
来执行转换。
错误处理
在我们的程序中,有几个地方忽略了错误。这是一个不好的做法,尤其是当错误确实发生时,程序将出现意外行为。一个更好的解决方案是处理错误并向用户返回错误消息。这样,如果确实出了问题,服务器将完全按照我们希望的方式运行,并且可以通知用户。
首先,让我们处理 renderTemplate
中的错误
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) { t, err := template.ParseFiles(tmpl + ".html") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } err = t.Execute(w, p) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } }
http.Error
函数发送指定的 HTTP 响应代码(在本例中为“内部服务器错误”)和错误消息。将此放入单独函数中的决定已经得到了回报。
现在让我们修复 saveHandler
func saveHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/save/"):] 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) }
在 p.save()
期间发生的任何错误都将报告给用户。
模板缓存
此代码中存在效率低下:renderTemplate
在每次呈现页面时都会调用 ParseFiles
。更好的方法是在程序初始化时调用 ParseFiles
一次,将所有模板解析为单个 *Template
。然后,我们可以使用 ExecuteTemplate
方法来呈现特定模板。
首先,我们创建一个名为 templates
的全局变量,并使用 ParseFiles
对其进行初始化。
var templates = template.Must(template.ParseFiles("edit.html", "view.html"))
函数 template.Must
是一个便利包装器,当传递非空 error
值时会引发 panic,否则会原样返回 *Template
。此处 panic 是合适的;如果无法加载模板,唯一明智的做法是退出程序。
ParseFiles
函数接受任意数量的字符串参数,这些参数标识我们的模板文件,并将这些文件解析为以基本文件名命名的模板。如果要向程序中添加更多模板,我们将把它们的名称添加到 ParseFiles
调用的参数中。
然后,我们修改 renderTemplate
函数以使用 templates.ExecuteTemplate
方法调用适当模板的名称
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) { err := templates.ExecuteTemplate(w, tmpl+".html", p) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } }
请注意,模板名称是模板文件名,因此我们必须将 ".html"
附加到 tmpl
参数。
验证
您可能已经观察到,此程序存在严重的安全漏洞:用户可以提供要在服务器上读/写的任意路径。为了缓解此问题,我们可以编写一个函数来使用正则表达式验证标题。
首先,将 "regexp"
添加到 import
列表。然后,我们可以创建一个全局变量来存储我们的验证表达式
var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")
函数 regexp.MustCompile
将解析和编译正则表达式,并返回 regexp.Regexp
。MustCompile
与 Compile
的不同之处在于,如果表达式编译失败,它将引发 panic,而 Compile
会将 error
作为第二个参数返回。
现在,让我们编写一个函数,该函数使用 validPath
表达式验证路径并提取页面标题
func getTitle(w http.ResponseWriter, r *http.Request) (string, error) {
m := validPath.FindStringSubmatch(r.URL.Path)
if m == nil {
http.NotFound(w, r)
return "", errors.New("invalid Page Title")
}
return m[2], nil // The title is the second subexpression.
}
如果标题有效,它将与 nil
错误值一起返回。如果标题无效,该函数将向 HTTP 连接写入“404 未找到”错误,并向处理程序返回错误。要创建新错误,我们必须导入 errors
包。
让我们在每个处理程序中调用 getTitle
func viewHandler(w http.ResponseWriter, r *http.Request) { title, err := getTitle(w, r) if err != nil { return } p, err := loadPage(title) if err != nil { http.Redirect(w, r, "/edit/"+title, http.StatusFound) return } renderTemplate(w, "view", p) }
func editHandler(w http.ResponseWriter, r *http.Request) { title, err := getTitle(w, r) if err != nil { return } p, err := loadPage(title) if err != nil { p = &Page{Title: title} } renderTemplate(w, "edit", p) }
func saveHandler(w http.ResponseWriter, r *http.Request) { title, err := getTitle(w, r) if err != nil { return } 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) }
引入函数字面量和闭包
在每个处理程序中捕获错误条件会引入大量重复的代码。如果我们能用一个执行此验证和错误检查的功能包装每个处理程序,会怎么样?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) { // Here we will extract the page title from the Request, // and call the provided handler 'fn' } }
返回的函数被称为闭包,因为它封装了在其外部定义的值。在此情况下,变量 fn
(makeHandler
的单个参数)被闭包封装。变量 fn
将是我们的保存、编辑或查看处理程序之一。
现在,我们可以从 getTitle
中获取代码并在此处使用它(稍作修改)
func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { m := validPath.FindStringSubmatch(r.URL.Path) if m == nil { http.NotFound(w, r) return } fn(w, r, m[2]) } }
makeHandler
返回的闭包是一个函数,它接受 http.ResponseWriter
和 http.Request
(换句话说,一个 http.HandlerFunc
)。闭包从请求路径中提取 title
,并使用 validPath
正则表达式验证它。如果 title
无效,将使用 http.NotFound
函数将错误写入 ResponseWriter
。如果 title
有效,则将使用 ResponseWriter
、Request
和 title
作为参数调用封闭的处理程序函数 fn
。
现在,我们可以在 main
中用 makeHandler
包装处理程序函数,然后再将它们注册到 http
包
func main() { http.HandleFunc("/view/", makeHandler(viewHandler)) http.HandleFunc("/edit/", makeHandler(editHandler)) http.HandleFunc("/save/", makeHandler(saveHandler)) log.Fatal(http.ListenAndServe(":8080", nil)) }
最后,我们从处理程序函数中删除对 getTitle
的调用,使它们变得更简单
func viewHandler(w http.ResponseWriter, r *http.Request, title string) { p, err := loadPage(title) if err != nil { 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 := loadPage(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) }
试一试!
重新编译代码,并运行该应用程序
$ go build wiki.go $ ./wiki
访问 https://#:8080/view/ANewPage 应该会显示页面编辑表单。然后,你应该能够输入一些文本,点击“保存”,并重定向到新创建的页面。
其他任务
以下是一些你可能想要自己解决的简单任务
- 将模板存储在
tmpl/
中,并将页面数据存储在data/
中。 - 添加一个处理程序,使 Web 根目录重定向到
/view/FrontPage
。 - 通过使页面模板有效 HTML 并添加一些 CSS 规则来美化页面模板。
- 通过将
[PageName]
实例转换为以下内容来实现页面间链接
<a href="/view/PageName">PageName</a>
。(提示:你可以使用regexp.ReplaceAllFunc
来执行此操作)