编写 Web 应用程序
介绍
本教程涵盖:
- 使用load和save方法创建数据结构
- 使用
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
声明添加更多包.
数据结构
让我们从定义数据结构开始。wiki 由一系列相互关联的页面组成,每个页面都有一个标题和一个正文(页面内容)。在这里,我们定义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
的方法,它接受一个 指向 Page
的指针p
。它不带任何参数,并返回 一个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
从 title 参数构造文件名,将文件的内容读入新的变量body
,并返回指向使用正确的标题和正文值构造的 Page
文本的指针.
函数可以返回多个值。标准库函数os.ReadFile
返回 []byte
和 error
。在loadPage
中,错误尚未处理;由下划线 (_
) 符号表示的"空白标识符"用于丢弃错误返回值(实质上,将值赋给nothing)。
但是,如果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:
http://localhost: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 的路径组件中提取页面标题,。使用 [len("/view/"):]
重新切片 Path
,以删除请求路径的前导"/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服务器后,访问 http://localhost:8080/view/test
应显示标题为"test"的页面,其中包含"Hello world"字样。
编辑页面
维基不是没有编辑页面能力的维基。让我们创建两个新的处理程序:一个命名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 都很丑陋。当然,还有更好的方法。
The 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 状态代码 http.StatusFound
(302) 和一个Location
标头。
保存页面
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
是一个方便的包装器,当传递非 nil error
值时,它会恐慌,否则将返回*Template
不变。恐慌在这里是恰当的;如果无法加载模板,唯一明智的做法是退出程序。
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
的不同之处在于,如果表达式编译失败,它将恐慌,而 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 Not Found"错误,并向处理程序返回错误。要创建新错误,我们必须导入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.HandlerFunc
):
func makeHandler(fn func (http.ResponseWriter, *http.Request, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // 在这里,我们将从请求中提取页面标题, // 并调用提供的处理程序'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
有效,则将调用随附的处理程序函数 fn
,并将 ResponseWriter
、Request
, 和 title
作为参数.
现在,我们可以在将处理函数注册到 http
包之前,在 main
中使用 makeHandler
包装它们:
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
访问http://localhost:8080/view/ANewPage应该会显示页面编辑表单。然后您应该能够输入一些文本,单击“保存”,然后重定向到新创建的页面.
其他任务
以下是您可能想要自己解决的一些简单任务:
- 将模板存储在
tmpl/
中,将页面数据存储在data/
中. - 添加处理程序以使 Web 根目录重定向到
/view/FrontPage
. - 通过使页面模板成为有效的 HTML 并添加一些 CSS 规则来修饰页面模板.
- 通过将
[PageName]
的实例转换为
<a href="/view/PageName">PageName</a>
来实现页面间链接.(提示:你可以使用regexp.ReplaceAllFunc
来做这个)