编写 Web 应用程序

介绍

本教程涵盖:

假设知识:

入门

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

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

GOPATH中为本教程创建一个新目录,然后cd到它:

$ 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 的方法,它接受一个 指向 Page 的指针p。它不带任何参数,并返回 一个error 类型的值."

此方法会将PageBody保存到文本文件中。为简单起见,我们将使用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 返回 []byteerror。在loadPage中,错误尚未处理;由下划线 (_) 符号表示的"空白标识符"用于丢弃错误返回值(实质上,将值赋给nothing)。

但是,如果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:

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.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值时,它会恐慌,否则将返回*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.RegexpMustCompileCompile 的不同之处在于,如果表达式编译失败,它将恐慌,而 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'
	}
}

返回的函数称为闭包,因为它包含在其外部定义的值。在这种情况下,变量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,并将 ResponseWriterRequest, 和 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应该会显示页面编辑表单。然后您应该能够输入一些文本,单击“保存”,然后重定向到新创建的页面.

其他任务

以下是您可能想要自己解决的一些简单任务: