前言#
昨天我们简单的了解了fuzz即模糊测试
坏蛋Dan:go简单入门--day7:简单了解下模糊测试(fuzzing)
今天我们来简单的实现一个web server
web app#
包含内容#
我们将接触以下几部分
- 创建一个数据结构,它包含
load和save两个methods - 基于
net/http这个包创建web应用 - 使用
html/template这个包加工html模板 - 使用
regexp校验用户输入 - 使用闭包(
closures)
初始化项目#
mkdir gowiki
cd gowiki
go mod init example/gowiki
echo > main.go // 编码可能是`utf-16`,需要留意数据结构#
type Page struct {
Title string
Body []byte
}用于定义页面的数据结构
这里Body的类型是[]byte表示是byte切片,为什么这里不使用字符串呢?这里先按下不表,后面会知道原因。
关于切片,可以看这篇:https://golang.google.cn/doc/articles/slices_usage_and_internals.html
然后我们再给这个Page添加一个method
func (p *Page) save() error {
filename := p.Title + ".txt"
return os.WriteFile(filename, p.Body, 0600)
} 和之前学的Rust里的method一样,都和函数存在区别,专指某个struct的方法。Rust里的method和python中class的method类似,第一个参数都是self,而go里struct的method写法刚开始有些不好理解,这是正常的,不过还是可以理解的。
p是一个指针,指向Page,相当于self。
0600是一个八进制数,大家应该都接触过类Unix系统,我们通常都是直接chmod 755 xxx.txt这种方式修改文件的访问权限,这里的0600是同一个意思。
这个方法是用来将当前的内容写入Page.Title.txt文件中来解决数据持久化以及存储问题。
这里就回到了我们前面留的问题:Body为什么是[]type。
因为这里要调用os模块将body写入文件中,而这个过程是操作字节流的。
既然有save,那自然就会有个load,不过这个load没必要和Page关联。
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
} 这里的写法&Page{ Title: title, Body: body }和rust中的都是一个意思,返回这个struct的指针/引用,不过这样的写法在rust中是会报错的,因为rust中没有GC机制,所以当函数执行完出了调用栈之后里面的变量就都会自己drop掉释放内存,所以这个时候如果外面还有指针那指向的内存空间就不一定是对的了,一般称之为“悬浮指针”。(当然,也不一定,比如'static。)
而go有GC, 所以不必担心这个问题,不过写法上还是得加上&。。(个人感觉写法上不够整洁,不过可能还有其它底层原因导致需要这么写,我并没有系统性的学习,所以这里我并不清楚,如果说的不对麻烦评论区说下,谢谢~)
扯远了。。。回到我们的代码中,我们来试下这两个方法是否正常
func main() {
p1 := &Page{Title: "TestPage", Body: []byte("This is a sample Page.")}
p1.save()
p2, _ := loadPage("TestPage")
fmt.Println(string(p2.Body))
}然后我们运行下go run .

正常
net/http#
我们前面简单的接触过Gin,那个是web框架,而net/http是一个模块。
我们先来看下基础用法
//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))
} http.HandleFunc:注册/这个uri,如果匹配到这个路径就执行handler这个方法。http.ListenAndServe:看名字就知道是用来创建服务并且监听对应端口的。 这个方法只会返回error,所以一旦返回就表示监听出了错误,直接panic即可。r.URL.Path:是路径组件,表示当前请求的路径,[1:]表示从url的第二个字符开始切割(下标从0开始)出一个切片,也就是将/去掉之后的url。
至于http.ResponseWriter和http.Request这两个就不用多说了。
然后我们来接入到我们的项目中
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)
}
func main() {
http.HandleFunc("/view/", viewHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
go run .然后浏览器/curl访问localhost:8080/view/testPage

正常
编辑页面#
现在我们基本打通访问的流程了,那么就可以接收输入了。
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)
}
func main() {
http.HandleFunc("/view/", viewHandler)
http.HandleFunc("/edit/", editHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}提供一个form用于提交内容, 但现在我们并没有实现保存的逻辑。
我们再来实现这一部分
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 {
fmt.Fprintf(w, "save error: %s", err)
return
}
fmt.Fprintf(w, "save success")
}
func main() {
http.HandleFunc("/view/", viewHandler)
http.HandleFunc("/edit/", editHandler)
http.HandleFunc("/save/", saveHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}FormValue从post接口里的form中获取对应字段的数据。
现在edit逻辑应该是通了,我们重新运行下go run .,然后浏览器/curl访问localhost:8080/edit/test_save

表现正常
html/template#
前面我们用的都是fmt.Fprintf来格式化html template,但是很丑很不优雅,量大了问题也多。
所以我们这里用另一个包来处理。
底层估计也是使用的mustache[3]语法,我们直接新建一个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的逻辑
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)
}Execute的时候会将模板中{{}}里的字段替换成对应的数据。
我们再来处理view的,同样创建一个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 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)
}
func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
t, _ := template.ParseFiles(tmpl + ".html")
t.Execute(w, p)
}然后我们重新运行下

正常
处理不存在的页面#
现在我们仅注册了几个路径,如果用户访问的是一个没有注册的路径,比如/view/xxxx,那么这个时候我们应该要返回一个页面不存在的提示或者直接跳到编辑页。
我们来调整下viewHandler的逻辑,让它重定向到编辑页
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)
}StatusFound是302,一般就是重定向的状态码。
我们重新跑下

直接重定向到edit/xxxx了。
完善save#
我们前面写的saveHandler方法只是简单的返回了信息,非常的简陋。
我们来完善一下,让它保存之后直接去到/view/xxx。
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)
}然后我们重新跑下

表现正常
错误处理#
前面写的代码大多都没有关注这一块的内容,实际上错误处理是非常重要的一部分。
我们先来处理模板这块的
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)
}
}当文档解析发生错误的时候直接返回对应的错误信息以及服务器500的状态码。
然后是save的
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)
}save可能发生错误,所以也需要处理。
模板缓存#
我们在/view/xx的时候每次都会执行parseFiles这个方法,实际上是没必要的,模板是不会变的,所以我们直接在初始化阶段就parse所有或者按需parse,第二次读取对应模板直接拿之前parse的即可。
我们这里直接省事都处理了。
var templates = template.Must(template.ParseFiles("edit.html", "view.html"))
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)
}
}template.Must:这是一个wrapper方法,出错的时候直接panic,否则返回对应处理后的结果。类似于rust中的Result.expect等方法。templates.ExecuteTemplate:和前面的t.Execute功能一样,都是替换对应的字段为数据
这里就涉及到了一些抽象语法树(AST)的概念了,parse就是将html字符串转换成抽象语法树,而execute则是将抽象语法树里的某个node也就是节点替换成对应的数据,然后再render变成html字符串。
关于抽象语法树相关的知识如果感兴趣可以去看下我之前分析vue/compiler-core源码的文章。
扯远了,我们回到代码中,现在每次都只会去处理抽象语法树,而不会再次解析模板。
校验#
目前我们的路径就只支持view/edit/save三种类型,但是我们并不能限制用户在浏览器输入什么。所以这个时候我们只能是去处理用户输入的内容。
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 // The title is the second subexpression.
}这里使用的是regexp[4]这个标准库里的包,看名字就知道是正则表达式相关的。
regexp.MustCompile:解析正则表达式并且返回一个regexp.Regexp类型的值。和前面template.Must类似,如果遇到问题也是直接panic。validPath.FindStringSubmatch:这个自然不必多说,就是正则匹配。
我们直接来用下,如果请求路径不符合/view/edit/save开头的直接给跳到404。
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) {
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)
}
var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$")
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])
}
}
func main() {
http.HandleFunc("/view/", makeHandler(viewHandler))
http.HandleFunc("/edit/", makeHandler(editHandler))
http.HandleFunc("/save/", makeHandler(saveHandler))
log.Fatal(http.ListenAndServe(":8080", nil))
}我们将view/edit/saveHandler作为参数传递给makeHandler函数,然后它返回一个函数,是一个wrapper。
实际上这是一个高阶函数,接受一个函数并且返回一个函数。
这里优化的点是校验的逻辑,这样就没必要去每个函数里面写了。
当然,也不是非得这么写才能优化。实际上我们可以通过别的方式去实现,比如写前置路由拦截等。
最终代码#
- The Go Programming Language (google.cn)
总结#
这只是一个非常简单的demo,但是可以明显的感觉到开发和上手难度都不高。
参考#
- ^net/http https://pkg.go.dev/net/http
- ^html/template https://pkg.go.dev/html/template
- ^mustache http://mustache.github.io/
- ^regexp https://pkg.go.dev/regexp
发布于 2023-04-14 13:54・IP 属地广东
