编写 Web 应用程序

引言

本教程涵盖内容

前置知识

入门

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

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

在您的 GOPATH 中为本教程创建一个新目录并进入该目录

$ mkdir gowiki
$ cd gowiki

创建一个名为 wiki.go 的文件,在您喜欢的编辑器中打开它,并添加以下行

package main

import (
    "fmt"
    "os"
)

我们从 Go 标准库中导入了 fmtos 包。稍后,当我们实现附加功能时,我们将向此 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 类型的值。”

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

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

八进制整数文字 0600 作为第三个参数传递给 WriteFile,表示该文件应仅以当前用户的读写权限创建。(有关详细信息,请参阅 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 返回 []byteerror。在 loadPage 中,错误尚未处理;下划线(_)符号表示的“空白标识符”用于丢弃错误返回值(本质上,将值赋给空)。

但是如果 ReadFile 遇到错误会发生什么?例如,文件可能不存在。我们不应该忽略这些错误。让我们修改函数以返回 *Pageerror

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)
}

再次注意,使用 _ 忽略 loadPageerror 返回值。这里为了简单起见,通常认为这是不好的做法。我们稍后会处理这个问题。

首先,此函数从 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.Titlep.Body

模板指令用双花括号括起来。 printf "%s" .Body 指令是一个函数调用,它将 .Body 输出为字符串而不是字节流,与调用 fmt.Printf 相同。 html/template 包有助于确保模板操作仅生成安全且外观正确的 HTML。例如,它会自动转义任何大于号 (>),将其替换为 &gt;,以确保用户数据不会损坏表单 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 值时会引发 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)
    }
}

请注意,模板名称是模板文件名,因此我们必须在 tmpl 参数后添加 ".html"

验证

正如您可能已经观察到的,该程序存在一个严重的安全缺陷:用户可以提供任意路径以在服务器上读取/写入。为了缓解这种情况,我们可以编写一个函数来使用正则表达式验证标题。

首先,将 "regexp" 添加到 import 列表中。然后我们可以创建一个全局变量来存储我们的验证表达式

var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")

函数 regexp.MustCompile 将解析并编译正则表达式,并返回一个 regexp.RegexpMustCompileCompile 的区别在于,如果表达式编译失败,它会 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'
    }
}

返回的函数称为闭包,因为它包含了在其外部定义的值。在这种情况下,变量 fnmakeHandler 的唯一参数)被闭包包含。变量 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.ResponseWriterhttp.Request 的函数(换句话说,一个 http.HandlerFunc)。该闭包从请求路径中提取 title,并使用 validPath 正则表达式对其进行验证。如果 title 无效,将使用 http.NotFound 函数将错误写入 ResponseWriter。如果 title 有效,则调用包含的处理程序函数 fn,并传入 ResponseWriterRequesttitle 作为参数。

现在我们可以在 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 应该会显示页面编辑表单。然后您应该可以输入一些文本,单击“保存”,然后重定向到新创建的页面。

其他任务

以下是一些您可能希望自行解决的简单任务