这个教程覆盖的内容:
net/http
包构建web应用;html/template
包去处理HTML模版;regexp
包去验证用户的输入;假定知识:
当下,你需要有一个FreeBSD
,Linux,macOS,或Windows机器去运行Go。我们将使用$
代表命令提示符。
安装Go。
在你的GOPATH
下为你的教程创建一个新的目录,并cd
进入它。
$ mkdir gowiki
$ cd gowiki/
$
创建一个名称为wiki.go
的文件,在你喜爱的编辑器中打开它,并添加下面的行:
package main
import (
"fmt"
"os"
)
我们导入来自Go标准库的fmt
和os
包。随后,当我们导入额外的功能,我们将添加更多的包到import
声明中。
让我们从定义数据结构开始。一个wiki中包含一系列互联的页面组成,每一个都有一个标题和一个内容体(页面的内容)。这儿,我们定义Page
作为一个数据结构,带有两个字段分别表示标题和内容。
type Page struct {
Title string
Body []byte
}
类型[]type
意味着一个byte类型切片。内容的元素是一个[]byte
而不是一个string
,因为我们将使用的 io 库所期望的类型,正如我们将在下面看到。
Page
结构描述了一个page数据将被如何存储到内存中。但是如何进行持久化存储呢?我们能处理通过创建一个save
方法在基于Page
:
func (p *Page) save() error {
filename := p.Title + "*.txt"
return os.WriteFile(filename, p.Body, 0600)
}
这个方法的签名读作:这儿有一个叫"save"的方法,它的接受者p是一个指向Page的指针。它没有参数,并且返回一个错误类型的值。
这个方法将保存Page的内容到一个文本文件中。为了简单,我们使用Title
作为文件名。
这个方法返回一个error
值,因为这是WriteFile
(一个标准的函数库,用于写一个type切片到文件中)的返回类型。save方法返回一个error值,为了让应用处理在写文件中发生的任何错误。没有如果发生错误,Page.save
方法将返回nil
(一个为指针、接口和其它类型的零值)。
八进制的文本0600
作为第三个参数传递到WriteFile
中,表示被创建到文件,应该只对当前的用户,具有读写权限。
除了保存文件,我们也将加载页面:
func loadPage(title string) *Page {
filename := title + ".txt"
body,_ := os.ReadFile(filename)
return &Page{Title: title, Body: body}
}
loadPage
函数从标题参数构造文件名,读取文件的内容一个新的变量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,那么加载一个页面是成功的。如果没有,它将返回一个错误,能够被调用者处理。
到现在,我们拥有了一个数据结构,并能够保存和加载文件。让我们写一个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
元素将打印到屏幕中。
你能编译并运行程序,像这样:
$ ll
total 8
drwxr-xr-x 3 lifei staff 96 9 4 18:15 ./
drwxr-xr-x 10 lifei staff 320 9 4 18:12 ../
-rw-r--r-- 1 lifei staff 553 9 12 10:39 wiki.go
$ go build wiki.go
$ ll
total 3672
drwxr-xr-x 4 lifei staff 128 9 12 10:44 ./
drwxr-xr-x 10 lifei staff 320 9 4 18:12 ../
-rwxr-xr-x 1 lifei staff 1871952 9 12 10:44 wiki*
-rw-r--r-- 1 lifei staff 552 9 12 10:44 wiki.go
$ ./wiki
This is a sample Page.
$
net/http
包(一个插曲)这儿有一个完整简单的web 服务工作的例子:
// 忽略了 go build
package main
import (
"fmt"
"log"
"net/http"
)
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hi, here, I love %v!", r.URL.Path[1:])
}
func main() {
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
main函数从调用http.HandleFunc
开始,它告诉http
包处理所有请求到web根"/",带有handler。
它然后调用http.ListenAndSave
,指定它应该在任何接口 (“:8080”) 上侦听端口 8080(现在不用考虑第二个参数,它现在是nil)。这个函数将阻塞,直到程序终止。
ListenAndSave
总是返回一个错误,因为它仅仅当一个未检测到错误出现才返回。为了打印log,我们包装那个函数,使用log.Fatal
。
handler
函数是http.HandlerFunc
类型。它获取http.ResponseWriter
和http.Request
作为参数。
一个http.ResponseWriter
值,组装了Http 服务的响应。通过写到它里面,我们发送消息到客户端。
一个http.Request
是一个数据结构,代表客户端到请求。r.URL.Path
是请求URL的组合。尾随的[1:]
意味着,“创建从第一个字符到结尾的路径子片”。这会从路径名中删除前导“/”。
如果你运行程序,并使用下面的URL:
http://localhost:8080/monkeys
这个程序将返回一个页面,包含:
Hi, here, I love monkeys!
net/http
去服务wiki页面为了使用net/http
页面,必须导入:
import (
"fmt"
"log"
"os"
"net/http"
)
让我们创建一个handler,viewHandler
将允许用户去展示wiki页面。它将处理带有“/view/”前缀的URL。
func viewHandler(w http.ResponseWriter, r *http.Request) {
title := r.URL.Path[len("/view/"):]
p,_ := loadPage(title)
fmt.Fprintf(w, "%s
%s", p.Title, p.Body)
}
再次,注意使用“_”来忽略从loadPage返回的错误。这是为了简单起见,通常被认为是不好的做法。我们稍后会处理这个问题。
首先,这个函数提取页面的标题从URL路径,这个路径组成了请求路径URL。使用 [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
随着这个web服务的运行,参观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
页面(如果不存在,创建一个空页面),并且显示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, "编辑 %s
", p.Title, p.Title, p.Body)
}
这个代码工作良好,但是所有硬编码的HTML是丑陋的。当然,这儿有更好的方案。
html/template
包html/template
包是Go标准库的一部分。我们能使用html/template
将HTML放在独立的文件中,允许我们改变我们编辑页面的布局,不用修改Go代码。
首先,我们必须添加html/template
到imports
列表中。我们不再使用fmt
,因为我们移除它。
import (
"html/template"
"os"
"net/http"
)
让我们创建一个模版文件,包含html表单。打开新的文件,命名为edit.html
,并添加下面的行:
<h1>编辑 {{.Title}}h1>
<form action="/save/{{.Title}}" method="POST">
<div>
<textarea name="body">{{printf "%s" .Body}}textarea>
div>
<div>
<input type="submit" value="保存">
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}}">edita>]
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.StatusFound
(302)和一个Location
头到HTTP到响应。
saveHandler
函数将处理位于编辑页面的提交表单。在取消剩余main函数中的注释行之前,让我们实现这个handler:
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
,是存储在新的页面中。save()
方法将被调用用于写数据到文件中,并且让客户端重定向到/view/
页面。
被FormValue
返回到值是字符串类型。我们必须把它转化为[]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 错误值时会发生恐慌,否则返回 *Template 不变。恐慌在这里是合适的; 如果无法加载模板,唯一明智的做法是退出程序。
ParseFiles
函数获取任意数量的字符串参数,代表模版的名字。并将这些文件解析为以基本文件名命名的模板。如果我们添加更多的模版到我们的程序中,我们会将它们的名称添加到 ParseFiles 调用的参数中。
我们然后修改renderTemplate
函数去调用templates.ExecuteTemplate
方法使用合适的模版名字。
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)
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.MustComlie
将被解析和编译这个正则表达式,并返回一个regexp.MustComplie
。MustCompile 与 Compile 的不同之处在于,如果表达式编译失败,它将恐慌,而 Compile 作为第二个参数返回错误。
现在,让我们使用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 // 标题是第二个字表达式
}
如果标题有效,它将和nil值一起返回。如果标题无效,函数将写一个“404 Not Found”错误给HTTP 连接,并且返回一个错误给处理器。为了创建一个新的错误,需要导入errors
包。
让我们把getTitle
调用放到每一个处理器中:
func viewHandler(w http.ResponseWriter, r *http.Request) {
// title := r.URL.Path[len("/view/"):]
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 := r.URL.Path[len("/view/"):]
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 := r.URL.Path[len("/view/"):]
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) {
// 这儿将从请求中提炼标题
// 并且调用提供的handler函数
}
}
返回函数将被闭包调用,因为它包含在它之外定义的值。在这种情况下,变量fn
(makeHandler的单个参数)是被封闭在闭包中。变量fn
将是我们save、edit、view处理器。
现在让我们让这个代码获取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)
}
fn(w, r, m[2])
}
}
这个被makeHandler
返回的闭包,是一个获取了http.ResponseWriter
和http.Request
的函数(换句话说,是一个http.HandlerFunc
)。这个闭包从请求路径中提取了title
,并且验证它使用validPath
正则表达式。如果title
是无效的,使用http.NotFound
函数,把一个错误将被写到ResponseWriter
。如果title
是有效的,这个闭包处理函数fn
将被调用,带有ResponseWriter
、Request
、Title
参数。
现在,让我们包装处理函数,使用makeHandler, 在main函数中。在使用 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) {
// title := r.URL.Path[len("/view/"):]
// 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 string) {
// title := r.URL.Path[len("/view/"):]
// 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 string) {
// title := r.URL.Path[len("/view/"):]
// 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)
}
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)
}
fn(w, r, m[2])
}
}
$ go build wiki.go
$ ./wiki
http://localhost:8080/view/ANewPage
这儿有一些简单的任务,让你自己处理:
tmpl/
,页面数据存储到data/
;/view/FrontPage
;页面名称
。 (提示:您可以使用 regexp.ReplaceAllFunc 来执行此操作)