【Go语言成长之路】编写web应用
文章目录
- 编写Web应用
- 一、介绍
- 二、创建项目
- 2.1 创建wiki数据结构
- 2.2 介绍net/http包(小插曲)
- 2.3 使用 net/http 提供 wiki 页面
- 2.4 编辑Pages
- 2.5 html/template包
- 2.6 处理不存在的页面
- 2.7 保存页面
- 2.8 错误处理
- 2.9 模板缓存
- 2.10 验证
- 2.11 函数文字和闭包简介
编写Web应用
一、介绍
本教程涵盖:
- 创建具有load和save方法的data结构体
- 使用net/http包来构建web应用
- 使用html/template包处理HTML模板
- 使用regexp包验证用户的输入
- 使用闭包
二、创建项目
~$ mkdir gowiki
~$ cd gowiki
~/gowiki$ go mod init example.com/wiki
go: creating new go.mod: module example.com/wiki
~/gowiki$ touch wiki.go # 在gowiki包内创建wiki.go文件
2.1 创建wiki数据结构
让我们从定义数据结构开始。wiki由多个相互关联的页面组成,每个页面都有一个标题和一个正文(页面内容)。在这里,我们将 Page 定义为一个结构体,具有表示标题和正文的字段。
type Page struct {
Title string
Body []byte // Body 元素是一个 []byte 而不是字符串,因为这是我们将使用的 io 库所期望的类型
}
Page描述了数据如何在内存中存储,但是如何持久存储呢?我们可以直接通过创建save方法来实现该功能:
// 这是一个名为 save 的方法,它接受一个指向 Page 的指针 p 作为其接收者。它不接受任何参数,并返回一个类型为 error 的值。此方法会将页面的正文保存到文本文件中。
// 若出现错误,则会返回相应的错误以便处理,如果一切顺利,Page.save() 将返回nil(指针、接口和其他一些类型的零值)。
func (p *Page) save() error {
// 为了简单起见,使用标题作为文件名
fileName := p.Title + ".txt"
// 0600 作为第三个参数传递给 WriteFile,指示应仅为当前用户创建具有读写权限的文件。
return os.WriteFile(fileName, p.Body, 0600)
}
除了保存页面之外,我们还需要加载页面:
// 返回指向使用正确的标题和主体值构造的 Page 文字的指针。
// 该函数的调用者现在可以检查第二个参数;如果为零,则已成功加载页面。如果不是,这将是一个错误,可以由调用者处理
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
}
接下来就是编写main函数的内容,用于测试save和load的功能:
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 元素打印到屏幕上。
~/gowiki$ go build wiki.go
~/gowiki$ ./wiki
This is a sample Page.
附: wiki.go的源码如下:
package main
import (
"fmt"
"os"
)
type Page struct {
Title string
Body []byte
}
func (p *Page) save() error {
fileName := p.Title + ".txt"
return os.WriteFile(fileName, p.Body, 0600)
}
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
}
func main() {
p1 := &Page{Title: "TestPage", Body: []byte("This is a sample Page.")}
p1.save()
p2, _ := loadPage("TestPage")
fmt.Println(string(p2.Body))
}
2.2 介绍net/http包(小插曲)
首先来看一个简单的web服务器的例子:
package main
import (
"fmt"
"log"
"net/http"
)
// 函数处理程序的类型为http.HandlerFunc。它采用 http.ResponseWriter 和 http.Request 作为其参数。
// http.ResponseWriter 值组装 HTTP 服务器的响应;通过写入它,我们将数据发送到 HTTP 客户端。
// http.Request 是表示客户端 HTTP 请求的数据结构。 r.URL.Path 是请求 URL 的路径组成部分。结尾的 [1:] 表示“创建从第一个字符到末尾的 Path 子片段”。这会删除路径名中的前导“/”。
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:])
}
func main() {
// main 函数首先调用 http.HandleFunc,它告诉 http 包使用处理程序处理对 Web 根(“/”)的所有请求。
http.HandleFunc("/", handler)
// 然后,它调用 http.ListenAndServe,指定它应该侦听任何接口上的端口 8080(“:8080”)该函数将阻塞,直到程序终止。
// ListenAndServe 始终返回错误,因为它仅在发生意外错误时返回。为了记录该错误,我们用 log.Fatal 包装函数调用。
log.Fatal(http.ListenAndServe(":8080", nil))
}
如果您运行该程序并访问 URL:
http://localhost:8080/monkeys
该程序将显示一个页面,其中包含:
Hi there, I love monkeys!
2.3 使用 net/http 提供 wiki 页面
首先,让我们创建一个处理程序 viewHandler,它将允许用户查看 wiki 页面。它将处理前缀为“/view/”的 URL:
func viewHandler(w http.ResponseWriter, r *http.Request) {
// 首先,该函数从 r.URL.Path(请求 URL 的路径组成部分)中提取页面标题。使用 [len("/view/"):] 重新分割路径,以删除请求路径的前导“/view/”组件。这是因为路径总是以“/view/”开头,这不是页面标题的一部分。
title := r.URL.Path[len("/view/"):]
p, _ := loadPage(title)
// 然后该函数加载页面数据,使用一串简单的 HTML 格式化页面,并将其写入 w(http.ResponseWriter)。
fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
}
之后重新编写main函数的内容以初始化该handler:
func main() {
http.HandleFunc("/view/", viewHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
创建并且编辑test.txt
文件,向该文件编写hello,world
, 之后运行代码:
~/gowiki$ go build wiki.go
~/gowiki$ ./wiki
运行此 Web 服务器后,访问 http://localhost:8080/view/test 应显示一个标题为“test”的页面,其中包含“helloworld”一词。
附: wiki.go源码:
package main
import (
"fmt"
"log"
"net/http"
"os"
)
type Page struct {
Title string
Body []byte
}
func (p *Page) save() error {
fileName := p.Title + ".txt"
return os.WriteFile(fileName, p.Body, 0600)
}
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
}
func viewHandler(w http.ResponseWriter, r *http.Request) {
// 首先,该函数从 r.URL.Path(请求 URL 的路径组成部分)中提取页面标题。使用 [len("/view/"):] 重新分割路径,以删除请求路径的前导“/view/”组件。这是因为路径总是以“/view/”开头,这不是页面标题的一部分。
title := r.URL.Path[len("/view/"):]
p, _ := loadPage(title)
// 然后该函数加载页面数据,使用一串简单的 HTML 格式化页面,并将其写入 w(http.ResponseWriter)。
fmt.Fprintf(w, "<h1>%s</h1><div>%s</div>", p.Title, p.Body)
}
func main() {
http.HandleFunc("/view/", viewHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
2.4 编辑Pages
让我们创建两个新的处理程序:一个名为 editHandler 来显示“编辑页面”表单,另一个名为 saveHandler 来保存通过表单输入的数据。
首先将handler添加到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 都很丑陋。当然,还有更好的方法。
2.5 html/template包
html/template 包是 Go 标准库的一部分。我们可以使用 html/template 将 HTML 保存在单独的文件中,从而允许我们更改编辑页面的布局,而无需修改底层 Go 代码。
让我们创建一个包含 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>
注: 模板指令括在双花括号内。 printf “%s” .Body 指令是一个函数调用,它将 .Body 作为字符串而不是字节流输出,与对 fmt.Printf 的调用相同。 html/template 包有助于保证模板操作仅生成安全且外观正确的 HTML。
修改 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}
}
// 函数 template.ParseFiles 将读取 edit.html 的内容并返回 *template.Template。
t, _ := template.ParseFiles("edit.html")
// 方法 t.Execute 执行模板,将生成的 HTML 写入 http.ResponseWriter。Title 和 .Body 点标识符指的是 p.Title 和 p.Body。
t.Execute(w, p)
}
由于我们现在正在使用模板,因此让我们为 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)
}
2.6 处理不存在的页面
如果访问 /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 函数将 HTTP 状态代码 http.StatusFound (302) 和 Location 标头添加到 HTTP 响应中。
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
renderTemplate(w, "view", p)
}
2.7 保存页面
函数 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)}
p.save()
http.Redirect(w, r, "/view/"+title, http.StatusFound)
}
2.8 错误处理
我们的程序中有几个地方会忽略错误。这是不好的做法,尤其是因为当错误确实发生时,程序将出现意外的行为。更好的解决方案是处理错误并向用户返回错误消息。这样,如果出现问题,服务器将按照我们想要的方式运行,并且可以通知用户。
首先,我们来处理一下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)
}
2.9 模板缓存
这段代码效率低下:每次渲染页面时,renderTemplate 都会调用 ParseFiles。更好的方法是在程序初始化时调用 ParseFiles 一次,将所有模板解析为单个 *Template。然后我们可以使用 ExecuteTemplate 方法来渲染特定的模板。
首先我们创建一个名为 templates 的全局变量,并使用 ParseFiles 对其进行初始化
var templates = template.Must(template.ParseFiles("edit.html", "view.html")) // 函数 template.Must 是一个方便的包装器,当传递一个非零错误值时,它会发生恐慌,否则返回不改变的 *Template 。恐慌在这里是适当的;如果无法加载模板,唯一明智的做法是退出程序。
// ParseFiles 函数采用任意数量的字符串参数来标识我们的模板文件,并将这些文件解析为以基本文件名命名的模板。如果我们要向程序添加更多模板,我们会将它们的名称添加到 ParseFiles 调用的参数中。
然后我们修改 renderTemplate 函数以使用适当模板的名称调用 templates.ExecuteTemplate 方法:
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
err := templates.ExecuteTemplate(w, tmpl+".html", p) // 请注意,模板名称是模板文件名,因此我们必须将“.html”附加到 tmpl 参数中。
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
2.10 验证
正如您可能已经观察到的,该程序存在严重的安全缺陷:用户可以提供在服务器上读取/写入的任意路径。为了缓解这种情况,我们可以编写一个函数来使用正则表达式验证标题。
首先,将“regexp”添加到导入列表中。然后我们可以创建一个全局变量来存储我们的验证表达式:
var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$") // 函数 regexp.MustCompile 将解析并编译正则表达式,并返回一个 regexp.Regexp。 MustCompile 与 Compile 的不同之处在于,如果表达式编译失败,它会发生混乱,而 Compile 将返回一个错误作为第二个参数。
现在,让我们编写一个使用 validPath 表达式来验证路径并提取页面标题的函数:
// 如果标题有效,它将与零错误值一起返回。如果标题无效,该函数将向 HTTP 连接写入“404 Not Found”错误,并向处理程序返回错误。要创建新错误,我们必须导入错误包。
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.
}
让我们在每个处理程序中调用 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)
}
2.11 函数文字和闭包简介
捕获每个处理程序中的错误条件会引入大量重复代码。如果我们可以将每个处理程序包装在一个执行验证和错误检查的函数中怎么办? 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)。闭包从请求路径中提取标题,并使用 validPath 正则表达式对其进行验证。如果标题无效,将使用 http.NotFound 函数将错误写入 ResponseWriter。如果标题有效,则将使用 ResponseWriter、Request 和标题作为参数来调用随附的处理函数 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)
}
最后,重新运行一遍代码:
~/gowiki$ go build wiki.go
~/gowiki$ ./wiki
访问 http://localhost:8080/view/ANewPage 应该会向您显示页面编辑表单。然后,您应该能够输入一些文本,单击“保存”,然后重定向到新创建的页面。
附: wiki.go源码:
package main
import (
"html/template"
"log"
"net/http"
"os"
"regexp"
)
type Page struct {
Title string
Body []byte
}
func (p *Page) save() error {
filename := p.Title + ".txt"
return os.WriteFile(filename, p.Body, 0600)
}
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
}
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)
}
var templates = template.Must(template.ParseFiles("edit.html", "view.html"))
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)
}
}
var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")
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])
}
}
func main() {
http.HandleFunc("/view/", makeHandler(viewHandler))
http.HandleFunc("/edit/", makeHandler(editHandler))
http.HandleFunc("/save/", makeHandler(saveHandler))
log.Fatal(http.ListenAndServe(":8080", nil))
}