[翻译]用Go语言编写web应用

原文来自官网,如有侵权请联系我,我会立刻删除。

介绍

本文中包含的内容有:

  • 通过载入和保存方法创建一个数据结构
  • 使用net/http包来创建web应用
  • 使用html/template包来使用HTML模板
  • 使用regexp包来验证用户输入
  • 使用闭包
    假定你已有的知识:
  • 编程经验
  • 了解基本的web技术
  • 一些UNIX/DOS命令行知识

开始

现在,你需要一台装有FreeBSD/Linux/OS X/Windows系统的电脑来运行Go。下文中,我们将使用$符号来表示命令行。

安装Go(请看安装指南)。

GOPATH下为本教程创建一个新的目录,然后使用cd命令进入:

1
2
$ mkdir gowiki
$ cd gowiki

创建wiki.go文件,用你最喜欢的编辑器打开,然后输入下列代码:

1
2
3
4
5
6
package main

import (
"fmt"
"io/ioutil"
)

我们从标准库中导入了fmtioutil包。每一个百科网站应该包含一系列可交互的页面,每个页面要有一个题目和一个正文部分(页面内容)。这里,我们定义Page为使用包含两个域分别表示题目和正文的结构体。

1
2
3
4
type Page struct {
Title string
Body []byte
}

[]byte类型表示“一个byte类型的切片”。(请阅读切片:用法与内部实现来了解更多信息)Body元素是一个[]byte而不是string因为这是io库所要求的类型,下文中你会看到。

Page结构体描述了页面数据将如何保存在内存中。但是如何将数据持久化存储呢?我们可以通过为Page创建save方法来解决:

1
2
3
4
func (p *Page) save() error {
filename := p.Title + ".txt"
return ioutil.WriteFile(filename, p.Body, 0600)
}

这个方法的签名是这样写的:“这是一个叫做save的方法,它的接收者是p,一个指向Page的指针。它没有参数,返回值是一个error类型的值。”

这个方法将把PageBody域存到一个文本文件中。简单起见,我们将Title作为文件的名称。

save方法会返回一个error值因为WriteFile方法(用于将字节切片写到文件中的标准库函数)会返回一个error值,来让应用处理写文件过程中的错误。如果一切运行正常,Page.save()将返回nil(指针类型/接口类型和其他一些类型的零值)。

八进制整数字面值0600,作为第三个参数传给WriteFile,表明这个文件应当以只有当前用户只读的权限创建。(请阅读Unix的man page open(2)来了解细节)

除了保存页面,我们还需要载入页面:

1
2
3
4
5
func loadPage(title string) *Page {
filename := title + ".txt"
body, _ := ioutil.ReadFile(filename)
return &Page{Title: title, Body: body}
}

loadPage使用题目参数构造文件名,然后将文件内容读入到新的变量body中,然后使用题目参数和内容构造一个Page字面值,最后返回指向它的指针。

函数可以返回多个值。标准库函数io.ReadFile返回[]byteerror。在loadPage中,错误不会被处理;一个用下划线表示的占位符将错误返回值丢弃(本质是将错误返回值赋给一个不存在的变量)。

但是当ReadFile发生错误时会发生什么事?举个例子,文件可能不存在。我们不应该忽略这样的错误。让我们修改函数来返回*Pageerror:

1
2
3
4
5
6
7
8
func loadPage(title string) (*Page, error) {
filename := title + ".txt"
body, err := ioutil.ReadFile(filename)
if err != nil {
return nil, err
}
return &Page{Title: title, Body: body}, nil
}

这个函数的调用者可以检查第二个返回值(译者注:原文为’the second parameter’,怀疑是笔误);如果为nil说明成功载入了一个页面。否则,error可以由调用者进行处理(请阅读语言规范来了解更多细节)。

这是,我们有了简单的数据结构和向文件中保存以及载入的能力。让我们写一个main函数来测试我们写过的:

1
2
3
4
5
6
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域应当被输出到了屏幕上。

我们可以这样编译运行程序:

1
2
3
$ go build wiki.go
$ ./wiki
This is a sample page.

(如果你使用Windows你必须输入不带”./“的wiki来运行程序。)

点击这里来浏览我们已经写过的代码

net/http包的介绍(一个小插曲)

这是一个简单的web服务器的完整工作代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"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)
http.ListenAndServe(":8080", nil)
}

main函数通过调用http.HandleFunc函数开始,这个函数会告诉http包使用handler来处理所有对根域名(“/“)的访问请求。

然后程序调用了http.ListenAndServe,说明程序将监听8080端口。(现在还不用管第二个参数nil)这个函数将会阻塞直至程序被终止。

函数handlerhttp.HandlerFunc类型的。这个类型有一个http.ResponseWriter参数和一个http.Request参数。

一个http.ResponseWriter值包含了一个HTTP服务器响应。通过向它写入,我们可以向HTTP客户端发送数据。

一个http.Request是一个表示用户HTTP请求的数据结构。r.URL.Path是请求URL的路径部分。结尾的[1:]的意思是“建立一个Path的子切片,包含其从第一个字母到最后一个字母。”这会丢弃掉路径开头的”/“。

如果你运行程序并访问URL:

1
http://localhost:8080/monkeys

程序会展示包含以下内容的页面:

1
Hi there, I love monkeys!

使用net/http支持百科页面

为了使用net/http包,我们必须导入:

1
2
3
4
5
import (
"fmt"
"io/ioutil"
"net/http"
)

让我们创建一个处理函数,viewHandler将允许用户浏览一个百科页面。它将处理以”/view/“为前缀的URL。

1
2
3
4
5
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)
}

首先,这个函数从r.URL.Path中提取了页面题目。Path会使用[len(“/view/“)]来重新切片,丢弃掉请求URL开头的”/view/“部分,这部分不是页面 题目的一部分。

这个函数接下来会载入页面数据,使用一条简单的HTML为页面格式化,然后将它写入http.ResponseWriter类型的w中。

我们再一次使用下划线来忽略loadPageerror返回值。此处是为了简便,这是一个公认的坏写法。我们将在后文中注意这一点。

为了使用这个处理函数,我们 重写了main函数,使用viewHandler来初始化http来处理对’/view/‘子路径的请求。

1
2
3
4
func main() {
http.HandleFunc("/view/", viewHandler)
http.ListenAndServe(":8080", nil)
}

点击这里来查看我们已经写过的代码

让我们创建一些页面数据(比如text.txt),编译代码,然后试着开启百科页面服务。

用你的编辑器打开text.txt,然后写入字符串”Hello world”(没有引号)。

1
2
$ go build wiki.go
$ ./wiki

(如果你使用Windows你必须输入不带”./“的wiki来运行程序。)

开启这个web服务器之后,访问http://localhost:8080/view/test应该能够看到页面题目为”test”内容为”Hello world”。

编辑页面

一个百科页面是必须要有编辑功能的。让我们创建两个新的处理函数:一个叫做editHandler用于显示编辑页面表单,另一个叫saveHandler用于通过表单保存输入的数据。

首先,让我们将它们加入到main()中:

1
2
3
4
5
6
func main() {
http.HandleFunc("/view/", viewHandler)
http.HandleFunc("/edit/", editHandler)
http.HandleFunc("/save/", saveHandler)
http.ListenAndServe(":8080", nil)
}

editHandler函数载入页面(如果页面不存在,创建一个新的Page结构体),然后显示表单。

1
2
3
4
5
6
7
8
9
10
11
12
13
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了,所以必须删掉它。

1
2
3
4
5
import (
"html/template"
"io/ioutil"
"net/http"
)

让我们创建一个包含HTML表单的模板文件。打开一个叫做edit.html的新文件,加入以下句子:

1
2
3
4
5
6
<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写死到Go代码中。

1
2
3
4
5
6
7
8
9
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语句。比如,它会自动将大于号(‘>’)转义,用”>”代替,来保证用户数据不会注入表单HTML。

我们学会使用模板之后,就可以为viewHandler创建一个叫做view.html的模板文件:

1
2
3
4
5
<h1>{{.Title}}</h1>

<p>[<a href="/edit/{{.Title}}">edit</a>]</p>

<div>{{printf "%s" .Body}}</div>

然后修改viewHandler:

1
2
3
4
5
6
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)
}

注意以上的两个处理函数生成模板的代码几乎是一样的。我们可以将这部分重复的代码移到一个单独的函数中:

1
2
3
4
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
t, _ := template.ParseFiles(tmpl + ".html")
t.Execute(w, p)
}

然后使用这个函数修改两个处理器:

1
2
3
4
5
func viewHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/view/"):]
p, _ := loadPage(title)
renderTemplate(w, "view", p)
}

1
2
3
4
5
6
7
8
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会怎么样?你会看到一个页面。这是因为我们忽视了loadPage返回的错误码,同时尝试去将空数据填入模板中。在访问的页面不存在时,我们应当将客户端重定向到编辑页面,这样内容就能被创建了。

1
2
3
4
5
6
7
8
9
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中对应的注释后,让我们来实现这个处理函数:

1
2
3
4
5
6
7
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类型的。我们必须在将它存到Page中之前将它转换为[]byte类型。我们使用[]byte(body)来实现这次转换。

错误处理

我们的代码中有许多处错误被忽视了。这是个坏的实践,尤其是因为程序中出现的错误会导致难以预料的行为。一个好的解决方法是处理错误然后向用户返回错误信息。这样,在出错的时候,服务器会按照我们想要的方式反应同时用户会被通知。

首先,让我们在renderTemplate中处理错误:

1
2
3
4
5
6
7
8
9
10
11
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状态码(这种情况下会返回”Internal Server Error”)和错误信息。将这些放到一个单独的函数中是值得的。

现在让我们来修改saveHandler

1
2
3
4
5
6
7
8
9
10
11
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来初始化:

1
var templates = template.Must(template.ParseFiles("edit.html", "view.html"))

函数template.Must是一个很方便的包装器,它在被传入一个非空的error值时会触发恐慌,否则会返回一个没有发生变化的*Template。恐慌是很适合在这使用的;如果模板没法被载入,那么能做的唯一有意义的事就是退出程序。

ParseFiles函数能够传入任何数量的string类型参数来表示模板文件,然后将这些文件解析为模板。如果我们为程序添加了更多的模板,只需要在ParseFiles的参数列表中加入更多的参数。

我们现在修改renderTemplate函数来调用templates.ExecuteTemplate方法,传入的参数是合适的模板的名字:

1
2
3
4
5
6
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”包。然后我们创建一个全局变量来储存用于校验的表达式。

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

函数regexp.MustCompile会解析并编译正则表达式,然后返回一个regexp.RegexpMustCompileCompile不同之处在于,当表达式编译过程出错时,前者会触发一个恐慌,而后者会将错误码作为第二个返回值返回。

现在,让我们使用validPath表达式来验证路径并提取出页面标题:

1
2
3
4
5
6
7
8
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调用:

1
2
3
4
5
6
7
8
9
10
11
12
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)
}

1
2
3
4
5
6
7
8
9
10
11
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)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
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语言的函数字面值提供了一个有利的工具进行抽象函数化处理。

首先,我们重写每个函数的定义来接受一个标题字符串:

1
2
3
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函数)

1
2
3
4
5
6
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中移出并在这里使用(经过了一些微小的修改):

1
2
3
4
5
6
7
8
9
10
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)。这个闭包会从请求URL中提取title,然后使用TitleValidator正则表达式来校验。如果title是不可用的,一个错误值就会被使用http.NotFound写入到ResponseWriter中。如果title是可用的,被封闭的处理函数fn就会被传入ResponseWriterRequesttitle参数调用。

现在在main函数中,我们可以在将处理函数注册到http包中前,使用makeHandler包装它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
flag.Parse()
http.HandleFunc("/view/", makeHandler(viewHandler))
http.HandleFunc("/edit/", makeHandler(editHandler))
http.HandleFunc("/save/", makeHandler(saveHandler))

if *addr {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
log.Fatal(err)
}
err = ioutil.WriteFile("final-port.txt", []byte(l.Addr().String()), 0644)
if err != nil {
log.Fatal(err)
}
s := &http.Server{}
s.Serve(l)
return
}

http.ListenAndServe(":8080", nil)
}

最后,我们将getTitle从处理函数中移除,简化处理函数:

1
2
3
4
5
6
7
8
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)
}

1
2
3
4
5
6
7
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)
}
1
2
3
4
5
6
7
8
9
10
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)
}

尝试执行它!

点击这里来查看我们最终的代码。

重新编译代码并运行应用:

1
2
$ go build wiki.go
$ ./wiki

访问http://localhost:8080/view/ANewPage应该能看到一个页面编辑表单。你应当可以输入一些文本,点击Save,然后重定向到新创建的页面。

其他一些任务

这还有一些你能自己完成的简单任务:

  • tmpl/文件夹中储存模板,在data/中储存数据。
  • 添加一个处理函数来使根URL重定向到/view/FrontPage
  • 使用正确的HTML语句和CSS规则来装饰你的页面模板。
  • 通过将”[PageName]”的实例替换为”PageName“来实现内部页面链接。(提示:你可以使用regexp.ReplaceAllFunc来实现)