编写 Web 应用程序
编写 Web 应用程序
本教程涵盖:
- 创建具有加载和保存方法的数据结构
- 使用
net/http
包构建 Web 应用程序 - 使用
html/template
包处理 HTML 模板 - 使用
regexp
包验证用户输入 - 使用闭包
假设知识:
- 编程经验
- 了解基本的网络技术(HTTP、HTML)
- 一些 UNIX/DOS 命令行知识
为您的代码创建一个文件夹
在你的GOPATH
中为本教程创建一个新目录:
$ 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
的方法,它的接收者p是一个指向Page
的指针。它不接受任何参数,并返回一个error
类型的值。”
此方法会将Page's Body
保存到文本文件中。为简单起见,我们将使用Title
作为文件名。
该save
方法返回一个error
值,因为这是WriteFile
(将字节切片写入文件的标准库函数)的返回类型。该save
方法返回错误值,让应用程序在写入文件出现任何问题时处理它。如果一切顺利,Page.save()
将返回 nil
(指针、接口和其他一些类型的零值)。
八进制整数文字0600
,作为第三个参数传递给 WriteFile
,表示创建文件时应仅对当前用户具有读写权限。(有关详细信息,请参见 Unix man page 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
中,尚未处理错误;下划线(_) 符号表示的“空白标识符”用于丢弃错误返回值(本质上,将值赋值为空)。
但是如果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:]
意味着“创建从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
。这是为了简单起见,通常被认为是不好的做法。我们稍后会处理这个问题。
首先,此函数从请求 URL 的路径组件 r.URL.Path
中提取页面标题。使用[len("/view/"):]
将Path
重新切片以删除请求路径的前导"/view/"
组件。这是因为路径总是以 "/view/"
开头,它不是页面标题的一部分。
然后该函数加载页面数据,用一串简单的 HTML 格式化页面,并将其写入http.ResponseWriter
对象w
.
要使用这个处理程序,我们重写我们的main
函数来初始化http
使用viewHandler
来处理/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”
才能运行程序。)
随着这个网络服务器的运行,访问 http://localhost:8080/view/test 应该会显示一个标题为“test”的页面,其中包含“Hello world”这个词。
编辑页面
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
中注释掉未实现的保存处理程序(saveHandler)的注册,我们可以再次构建和测试我们的程序。单击此处查看到目前为止我们编写的代码。
处理不存在的页面
如果你访问 http://localhost:8080/view/APageThatDoesntExist 您将看到一个包含 HTML 的页面。这是因为它忽略了错误返回值,并继续尝试填写没有数据的模板。相反,如果请求的页面不存在,它应该将客户端重定向到编辑页面,以便可以创建内容:
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
是一个方便的包装器,当传递一个非零error
值时会发生恐慌(panic),否则返回未更改的值*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
不同之处在于,如果表达式编译失败,它将恐慌(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 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.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
访问 http://localhost:8080/view/ANewPage 应该会显示页面编辑表单。然后,您应该能够输入一些文本,单击“保存”,然后被重定向到新创建的页面。
其他任务
以下是您可能希望自己解决的一些简单任务:
- 将模板存储在
tmpl/
中,将页面数据存储在data/
中。 - 添加一个处理程序以使 Web 根重定向到
/view/FrontPage
。 - 通过使它们成为有效的 HTML 并添加一些 CSS 规则来美化页面模板。
- 通过将
[PageName]
的实例转换为<a href="/view/PageName">PageName</a>
(提示:你可以用regexp.ReplaceAllFunc
来做这个) 来实现页面间链接。