Go的简单入门:写一个Web应用

写一个Web应用

一、介绍

这个教程覆盖的内容:

  • 创建一个数据结构,带有load和save方法;
  • 使用net/http包构建web应用;
  • 使用html/template包去处理HTML模版;
  • 使用regexp包去验证用户的输入;
  • 使用关闭;

假定知识:

  • 编程经验;
  • 理解基础的Web技术(HTTP、HTMLL)
  • 一些UNIX/DOS命令行知识;

二、开始

当下,你需要有一个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
}

类型[]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返回[]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,那么加载一个页面是成功的。如果没有,它将返回一个错误,能够被调用者处理。

到现在,我们拥有了一个数据结构,并能够保存和加载文件。让我们写一个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.ResponseWriterhttp.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, "<h1>%s</h1><div>%s</div>", 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, "<h1>编辑 %s</h1><form action=\"/save/%s\" method=\"POST\"><textarea name=\"body\">%s</textarea><input type=\"submit\" value=\"Save\"></form>", p.Title, p.Title, p.Body)
}

这个代码工作良好,但是所有硬编码的HTML是丑陋的。当然,这儿有更好的方案。

七、html/template

html/template 包是Go标准库的一部分。我们能使用html/template将HTML放在独立的文件中,允许我们改变我们编辑页面的布局,不用修改Go代码。

首先,我们必须添加html/templateimports列表中。我们不再使用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.Titlep.Body引用。

模板指令用双花括号括起来。printf "%s" .Body 指令是一个函数调用,它将 .Body 作为字符串而不是字节流输出,与对 fmt.Printf 的调用相同。html/template包有助于确保模板操作仅生成安全且外观正确的 HTML。例如,它会自动转义任何大于号 (>),将其替换为 >,以确保用户数据不会破坏 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.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.ResponseWriterhttp.Request的函数(换句话说,是一个http.HandlerFunc)。这个闭包从请求路径中提取了title,并且验证它使用validPath正则表达式。如果title是无效的,使用http.NotFound函数,把一个错误将被写到ResponseWriter。如果title是有效的,这个闭包处理函数fn将被调用,带有ResponseWriterRequestTitle参数。

现在,让我们包装处理函数,使用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
  • 通过使它们成为有效的 HTML 并添加一些 CSS 规则来美化页面模板。
  • 通过将 [PageName] 的实例转换为
    <a href="/view/PageName">页面名称</a>。 (提示:您可以使用 regexp.ReplaceAllFunc 来执行此操作)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值