0. 概述
掌握了Go的基础语法后,让我们开始动手实战,尝试写一个 简易的wiki 小应用,它是一个 web 应用项目(网页应用)。
本文涉及下面的技术点:
- 定义一个 struct 类型,和通过操作文件实现“读取”和“保存”方法
- 使用 net/http包 构建web应用
- 使用 html/template包 处理 HTML 模板
- 使用 regexp包 正则表达式 验证用户输入
- 闭包
预计我们分步骤进行:
- 第一阶段:实基本功能现功能,像文本地存储,网页查看,编辑等。
- 第二阶段:改进,处理不存在的页面,改进错误处理,和模板缓存。
- 第三阶段:重构,进行正则表达式验证和使用闭包来重构
本文结构:
1. 第一阶段:实基本功能现功能
1.1 开始之前
1.2 定义数据类型,和实现“读取”和“保存”方法
1.2.1 保存文章
1.2.2 读取文章
1.3 实现web应用
1.3.1 处理请求:查看文章
1.3.2 处理请求:编辑文章
1.3.3 保存:处理 form 表单
2. 第二阶段:改进,处理不存在的页面,改进错误处理,和模板缓存
2.1处理不存在的页面
2.2 异常处理
2.2.1 读取模板失败时的异常和执行模板转换时的异常
2.2.2 模板转换时的异常
2.2.3 保存文章失败异常
2.3 优化模板缓存
3. 第三阶段:重构,进行正则表达式验证和使用闭包来重构
3.1 正则表达式验证
3.2 引入函数和闭包
3.3 重构 模板绑定html 的冗余
4.完整代码
1. 第一阶段:实现基本功能
1.1 开始之前
假设我们的应用名字叫“gowiki”,先创建个文件夹
$ mkdir gowiki
$ cd gowiki
导入要用的包
package main
import (
"fmt"
"io/ioutil"
)
1.2 定义数据类型,和实现“读取”和“保存”方法
一篇文章应该有 “标题”和内容“,那么,我们定义一个叫 Page 的结构体,它有 标题,和文件内容字段。
type Page struct {
Title string //标题
Body []byte //内容,字节类型比string类型要方便,性能好
}
这个 Page 是在内存中存储的格式,那怎么实现持久化存储呢,我通过 Go 的操作文件的函数来实现。
- 保存文章,就是将写入到文件。
- 读取文章,就是读文件
1.2.1 保存文章
/* 保存 page 到 文件 */
func (p *Page) save() error{
fileName := p.Title + ".txt"
return ioutil.WriteFile(fileName, p.Body, 0x600)
}
如上,用 文章的标题 来作为文件名。 ioutil.WriteFile 是写入文件的方法。 0x600是个常量,表示需要读写权限。
1.2.2 读取文章
我们 文章标题就是,那么按这个规则来作为文件名来读取。
func loadPage(title string) (*Page,error){
fileName := title + ".txt"
body,err := ioutil.ReadFile(fileName)
if err != nil {
return nil,err
}
return &Page{fileName, body},nil
}
outil.ReadFile 是读取文件的方法。它返回两个返回值:文件内容和可能发生的错误。如果读取成功,err为空。我们通过判断err是否是nil来判定 读取文件是否成功。 当读取完成,我们再构建一个 Page 对象作为返回值。
1.3 实现web应用
我们需要3个handle 分别对应,查看,编辑和保存。先看main函数,像这样:
func main(){
http.HandleFunc("/view/",viewHandler)
http.HandleFunc("/edit/",editHandler)
http.HandleFunc("/save/",saveHandler)
http.ListenAndServe(":8080",nil)
}
下面我们来挨个实现这 viewHandler,editHandler,saveHandler。
1.3.1 处理请求:查看文章
写个 viewHandler,接收这样的网页请求
http://localhost:8080/view/ttt
忽略前面的域名和对口对应的是 /view/ttt 这样REST风格的URL,这里的 ttt 表示文章的标题。具体实现如下:
func viewHandler(w http.ResponseWriter, r *http.Request){
fmt.Println("path:",r.URL.Path)
title := r.URL.Path[len("/view/"):]
p,_ := loadPage(title) // 注意这里有个BUG,文章如果不存在就显示成空白,我们稍后再处理
t,_ := template.ParseFiles("view.html") // 注意这里用了 模板template
t.Execute(w, p)
}
上面的示例:
1.先从 URL 中拿到 ttt 作为 title,然后调用上一节我们完成的 loadPage 方法来保存到具体文件中。
2.构造一个模板 template,它需要指定一个 本地html文件路径。使用构造好的模板,执行 Execute 方法,传入 写入流(即:w),和参数(即: page 对象)
view.html 的代码如下,它是具体的html的实现,它以一种“绑定”的机制运作。
<h1>{{.Title}}</h1>
<p>[<a href="/edit/{{.Title}}">edit</a>]</p>
<div>{{printf "%s" .Body}}</div>
注意上面的 {{.Title}} 写法,这是一种特殊的表达,它表示,读取 Title 属性的值 放到这里。
注意上面的 {{printf "%s" .Body}}, 这个表达式表示把 Body的属性的值,按照 字符串格式输出。它和printf 函数很类似。
1.3.2 处理请求:编辑文章
类似上面,写个 viewHandler,接收这样的网页请求
http://localhost:8080/edit/ttt
就是要处理来自 /edit/ttt 的请求。
func editHandler(w http.ResponseWriter, r *http.Request){
fmt.Println("path:",r.URL.Path)
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)
}
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>
1.3.3 保存:处理 form 表单
类似上面,写个 viewHandler,接收这样的网页请求
http://localhost:8080/edit/ttt
就是要处理 /edit/ttt 的请求。
func saveHandler(w http.ResponseWriter, r *http.Request){
fmt.Println("path:",r.URL.Path)
title := r.URL.Path[len("/save/"):]
body := r.FormValue("body") // 注意 body 是个字符串
p := &Page{title,[]byte(body)} // 将body字符串转型为字节
p.save()
http.Redirect(w, r, "/view/"+title, http.StatusFound)
}
至此,一个简单的 web 应用就完成了,它具有查看和编辑文章的功能,虽然看起来很简陋。
2. 第二阶段:改进,处理不存在的页面,改进错误处理,和模板缓存
2.1处理不存在的页面
我们回头再看下 viewHandler 的方法实现:
p,_ := loadPage(title) // 注意, 错误被隐藏了 #1
t,_ := template.ParseFiles("view.html")
t.Execute(w, p)
当在网址输入这个页面 "/view/不存在的页面" 会显示一篇空白页,因为不存在这篇文章,尝试去读物理文件会失败。虽然程序不至于崩溃,这样的响应也是个糟糕的用户体验。
我们来改进它,当指定的文章不存在时,直接跳转到 编辑页面。通过 http.Redirect() 来实现跳转功能。代码如下:
// p,_ := loadPage(title) // 旧的代码,注释掉
p,err := loadPage(title) // 接收 err,再判断
if err !=nil { //如果发生了 异常,触发跳转
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
t,_ := template.ParseFiles("view.html")
t.Execute(w, p)
http.Redirect() 函数的前两个参数是 w http.ResponseWriter, r *http.Request ,表示写入流和请求,无需多说。第三个参数是 要跳转到的目的地页面,第四个参数是http的响应code。
再次在浏览器里输入 http://localhost:8080/view/sssss ,如果sssss文章不存在,将跳转到 http://localhost:8080/edit/sssss。 可看到浏览器里网址的变化了。
2.2 异常处理
上面我们写的代码里,很多代码使用了 “空白标识符”(即 “_" 下划线符号),来隐藏了隐藏,这是很糟糕了,一旦发生了异常,就不知道问题出在哪里了。我们来修正它,当发生这样的异常时,我们识别它,并告知用户(使用者)发生的异常。
2.2.1 读取模板失败时的异常和执行模板转换时的异常
读取模板失败时的异常
上面的 viewHandler 的实现,有下面这样的代码
t,_ := template.ParseFiles("edit.html") //注意这里
t.Execute(w, p)
空白标识符隐藏了异常,我们来修正它:
t,err := template.ParseFiles("view.html") // 模板文件可能不存在
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
t.Execute(w, p)
http.Error() 函数的第一个参数是 写入流,第二个参数是错误说明的字符串,第三个参数是 http的状态码,http.StatusInternalServerError 表示 500,服务内部异常。
那么,当遇到模板文件不存在,就会返回 500异常的响应,和错误信息。
2.2.2 模板转换时的异常
让我们继续看上面的代码,模板的执行方法 t.Execute(w, p) 如果发生了异常,导致无法正确返回web页,这也要做个处理。
err = t.Execute(w, p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
t.Execute(w, p) 的返回值是个 error 类型,我们判断它如果不为nil,则 调用 http.Error() 方法告知用户发生了异常。
2.2.3 保存文章失败异常
在 saveHandler 中 ,有下面的代码,它调用了save 方法,而未处理 save 方法异常发生的判断。
p := &Page{title,[]byte(body)} // 将body字符串转型为字节
p.save()
同理,我们 接收 save() 方法返回值,并判断。
err := p.save()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
修改后,当save() 失败时,会将失败告知到用户。
2.3 优化模板缓存
回顾上面的代码里我们解析构造模板的方法,我们在 viewHandler 函数里调用这个方法:
t,_ := template.ParseFiles("edit.html")
由于在 viewHandler函数 在每次“打开查看文章页面”时都调用,将导致每次都解析构造很模板,然而,每次创建模板是不需要的损耗。我们可以在 全局变量
里调用一次就好了,示例:
var templates = template.Must(template.ParseFiles("edit.html", "view.html"))
函数template.Must是一个方便的包装器,在传入错误值时会崩溃。这里应该出现panic;如果无法加载模板,那还是退出程序吧。
示例代码:
var templates = template.Must(template.ParseFiles("view.html","edit.html"))
/* 构建 web app 处理 */
func viewHandler(w http.ResponseWriter, r *http.Request){
fmt.Println("path:",r.URL.Path)
title := r.URL.Path[len("/view/"):]
p,err := loadPage(title)
if err !=nil {
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
// t,err := template.ParseFiles("view.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)
// }
err = templates.ExecuteTemplate(w, "view.html", p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
templates.ExecuteTemplate函数 的第二个参数表示 模板名,即模板文件名。它的返回值是 error 类型。
模板的缓存改造:
- 全局变量取代 局部变量
- template.Must 取代 template.ParseFiles 方法。
- templates.ExecuteTemplate 取代 template.ParseFiles
现在,第二阶段完成。我们还有些事情要做,比如做一些用户合法性验证。
3. 第三阶段:重构,进行正则表达式验证和使用闭包来重构
你应该注意到了,这个程序有个缺陷,用户可以到达任意页面,文章标题也很随意。它可能带来不期望的结果,我们来使用正则表达式来做一些验证。
导入 正则表达式的 包:导入 regexp,和 errors包
3.1 正则表达式验证
构造正则表达式
var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")
MustCompile 接受 正则表达式的字符串作为参数,如果不合法的字符串 会触发 panic。
编写判断方法,示例:
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// title 位于第二个位置
}
- 这个正则 ^/(edit|save|view)/([a-zA-Z0-9]+)$ ,第一个括号里的 edit|save|view 表示这三个字符串中的任何一个都被匹配。 第二个括号里表示接受常规字符串和数字。
- validPath.FindStringSubmatch 来判定是否合法,如果为空,则认为不匹配。如果识别匹配,第二个参数是 title
- 调用 http.NotFound(w,r) 将返回: 404 页面未找到
现在,我们在 中调用 getTitle ,代码如下:
func viewHandler(w http.ResponseWriter, r *http.Request){
fmt.Println("path:",r.URL.Path)
// 调用 getTitle 验证 URL 是否合法,且同时获得 title 的值
title,err := getTitle(w,r)
if err != nil{
return
}
// 有了正则,下面这个 字符串截取获得title 的方法就不需要了
//title := r.URL.Path[len("/view/"):]
p,err := loadPage(title)
if err !=nil {
http.Redirect(w, r, "/edit/"+title, http.StatusFound)
return
}
err = templates.ExecuteTemplate(w, "view.html", p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
这样完成后,如果用户输入了不是 /edit/, /save/, /view/这三个字符串开头的网址,或者不合法的 title 字符串,都将会收到 “404,页面为找到”
3.2 引入函数和闭包
上面的方法中我们写了个 getTitle() ,它需要在 viewHandler, editHandler, saveHandler 这3个方法中调用,每次都写那么一个方法和判断err很繁琐,我们抽离公共部分来避免代码冗余重复。
Go 里面的函数 可以作为函数中的参数传递,我们可以利用这一特性来实现函数的调用代理。
我们先修改下 viewHandler 等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)
上面我们增加了一个参数 title,我们是想先取得title,后把title的值传入这样的函数中。
我们写一个 makeHandler 方法,它来构造一个 合适的Handler作为返回值,这个返回值 是 http.HandlerFunc 类型。
func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { // 注意这里#1
m := validPath.FindStringSubmatch(r.URL.Path)
if m == nil {
http.NotFound(w, r)
return
}
fn(w, r, m[2]) //注意这里#2,fn是个函数,做为参数传递而来
}
}
上面的代码有个注意的地方:
return func( 参数) {
具体代码
}
这是一个闭包的写法,这里将构建一个 匿名函数 ,并作为返回值传递出去。
在这个闭包里,是可以直接使用它所在的函数 makeHandler 的参数的。在这里,我们把上面的getTitle 方法的代码写在这里,先验证 URL 合法行,利用正则取得 title 的值。最后作为参数传递而来的fn,并调用 fn函数。
你应该注意到了,这个 fn的函数的签名,和我们刚刚修改的 viewHandler 等3个方法的函数签名一模一样。是的,函数将被作为参数传递到这里。
3.3 重构 模板绑定html 的冗余
上面的viewHandler 和 editHandler 都要 模板绑定 html的代码,也有重复代码,我们再处理下它,和让参数名更具有 语义,原来的代码:
err = templates.ExecuteTemplate(w, "edit.html", p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
重构后:
func renderTemplate(w http.ResponseWriter, templateName string, p *Page){
err := templates.ExecuteTemplate(w, templateName+".html", p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
当需要显示html时,在 viewHandler中 这么调用它:
renderTemplate(w,"edit",p)
至此,重构完毕。
4.完整代码
方便阅读,最后完成的代码如下:
package main
import(
"fmt"
"io/ioutil"
"net/http"
"html/template"
"regexp"
"errors"
)
type Page struct {
Title string
Body []byte
}
/* 保存 page 到 文件 */
func (p *Page) save() error{
fileName := p.Title + ".txt"
return ioutil.WriteFile(fileName, p.Body, 0x600)
}
func loadPage(title string) (*Page,error){
fileName := title + ".txt"
body,err := ioutil.ReadFile(fileName)
if err != nil {
return nil,err
}
return &Page{title, body},nil
}
/*********************************/
/* 正则 */
var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")
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// title 位于第二个位置
}
/*********************************/
var templates = template.Must(template.ParseFiles("view.html","edit.html"))
/* 请求处理; 构建 web app 处理 */
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") // 注意 body 是个字符串
p := &Page{title,[]byte(body)} // 将body字符串转型为字节
err := p.save()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+title, http.StatusFound)
}
func renderTemplate(w http.ResponseWriter, templateName string, p *Page){
err := templates.ExecuteTemplate(w, templateName+".html", p)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request){
fmt.Println("path:",r.URL.Path)
title,err := getTitle(w,r)
if err != nil{
return
}
fn(w,r,title)
}
}
func main(){
http.HandleFunc("/view/",makeHandler(viewHandler))
http.HandleFunc("/edit/",makeHandler(editHandler))
http.HandleFunc("/save/",makeHandler(saveHandler))
fmt.Println("server running!")
http.ListenAndServe(":8080",nil)
}
END