最近一段时间学习了Golang,为了总结学习成果,学习更多知识,提升代码能力,所以开启了手写Gee框架的项目。
一、Http基础
1.标准库启动Web服务
首先,让我们用Go语言内置的http库撸一个Web项目:
go
func main() {
http.HandleFunc("/", indexHandler)
http.HandleFunc("/hello", helloHandler)
log.Fatal(http.ListenAndServe(":9999", nil))
}
func indexHandler(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
}
func helloHandler(w http.ResponseWriter, req *http.Request) {
for k, v := range req.Header {
fmt.Fprintf(w, "Header[%q]\n", k, v)
}
}
非常容易理解,我们定义了两个路由,分别是"/"和"/hello"。走"/"这条路由时,会调用indexHandler函数,走"/hello"这条路由时,会调用helloHandler函数。然后http.ListenAndServe函数帮我们开启9999端口,运行即可。
说明:
(1)、Printf()、Sprintf()、Fprintf() 函数都是输出格式化字符串,只是输出到的目标不一样:
① Printf() 是把格式化字符串输出到标准到标准输出(一般是屏幕,可以重定向)
②Sprintf() 是把格式化字符串输出到指定的字符串中,可以用一个变量来接受,然后在打印
③Fprintf() 是把格式字符串输出到指定的文件设备中,所以参数比Printf 多一个文件指针*File。主要用于文件操作,Fprintf() 是格式化输出到一个 Stream ,通常是一个文件
(2)、我们的req到底拥有哪些属性呢,不妨查看一下http.Request的源码,为了方便阅读,我在每个字段后加上注释。
go
type Request struct {
Method string //指定HTTP方法(GET,POST,PUT等)
URL *url.URL //URL指定要请求的URI(对于服务器请求)或要访问的URL(用于客户请求)
Proto string // "HTTP/1.0"
ProtoMajor int // 1
ProtoMinor int // 0
Header Header //Http请求头
Body io.ReadCloser //Http请求体
GetBody func() (io.ReadCloser, error) //客户端使用的方法的类型
ContentLength int64 //记录请求关联内容的长度
TransferEncoding []string //列出从最外层到最内层的传输编码
Close bool //表示在服务端回复请求或者客户端读取到响应后是否要关闭连接
Host string //指定URL所在的主机
Form url.Values //包含已解析的表单数据
PostForm url.Values //包含来自PATCH,POST的已解析表单数据或PUT主体参数
MultipartForm *multipart.Form //已解析的多部分表单数据
Trailer Header
RemoteAddr string //允许HTTP服务器和其他软件记录发送请求的网络地址
RequestURI string //未修改的request-target客户端发送的请求行
TLS *tls.ConnectionState
Cancel <-chan struct{}
Response *Response //
ctx context.Context
}
2.实现http.Hander接口
观察http.ListenAndServe关于第二个参数的源码,也就是我们最初填的nil
,可以看到:
go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
我们发现,Handler是一个接口,需要实现ServeHTTP(ResponseWriter, *Request)
这样一个方法。所以定义一个实现此方法的结构体传进去,那么所有的HTTP请求,都会交给我们定义的方法处理。
go
type Engine struct{}
func (e Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/":
fmt.Fprintf(w, "URL.Path=%q\n", req.URL.Path)
case "/hello":
for k, v := range req.Header {
fmt.Fprintf(w, "Header[%q]=%q\n", k, v)
}
default:
fmt.Fprintf(w, "404 NOT FOUND:%s\n", req.URL)
}
}
func main() {
engine := new(Engine)
log.Fatal(http.ListenAndServe(":9999", engine))
}
可以看到Engine结构体实现了方法ServeHTTP,在ListenAndServe方法中传入了engine后,所有的HTTP请求都会被实现的ServeHttp函数拦截,进行switch-case匹配。
Gee框架雏形
代码架构如下所示:
css
gee/
|--gee.go
main.go
接下来让我们看一下自己手撸的gee框架,gee.go
go
type HandlerFunc func(http.ResponseWriter, *http.Request)
type Engine struct {
router map[string]HandlerFunc
}
func (e *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
key := req.Method + "-" + req.URL.Path
if handler, ok := e.router[key]; ok {
handler(w, req)
} else {
fmt.Fprintf(w, "404 NOT FOUND :%s\n", req.URL)
}
}
func New() *Engine {
return &Engine{router: make(map[string]HandlerFunc)}
}
func (e *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
key := method + "-" + pattern
e.router[key] = handler
}
func (e *Engine) GET(pattern string, handler HandlerFunc) {
e.addRoute("GET", pattern, handler)
}
func (e *Engine) POST(pattern string, handler HandlerFunc) {
e.addRoute("POST", pattern, handler)
}
func (e *Engine) Run(addr string) (err error) {
return http.ListenAndServe(addr, e)
}
像上文中提到的,要先定义一个Engine
结构体,并让它实现ServeHTTP方法,而与上文不同的是,Engine里定义了一个map路由,key对应着"请求方法-方法名",value对应着要调用的函数;New()没什么可说的,相当于Engine的构造器,初始化了一个空的Engine;addRoute则是对Engine里的router进行赋值,将"请求方法-方法名"赋值给key,要调用的函数赋值给value。GET()和POST()本质上都是addRoute,只是写死了请求的方式。Run()则是调用了http.ListenAndServe,启动了一个web服务。
然后就到了我们的main.go
部分
go
func main() {
r := gee.New()
r.GET("/", func(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "YRL.Path=%q\n", req.URL.Path)
})
r.GET("/hello", func(w http.ResponseWriter, req *http.Request) {
for k, v := range req.Header {
fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
}
})
r.Run(":9999")
}
使用过Gin以后再看这一段代码,确实感到无比的亲切。首先New()初始化路由,接着给路由定义了两个方法,分别对应着"/"和"/hello"路径,用户以GET请求访问这两个路径时,就会调用相应的函数,并将服务运行在9999端口。
二、上下文Context
在前文中,给一个路径绑定对应的方法,采用的是r.GET("/", func(w http.ResponseWriter, req *http.Request){xxxxx}
,可在使用gin
框架的过程中,会发现是这种形式r.GET("/",func (c *gin.Context){xxxxxxxx})
,那如何把w http.ResponseWriter, req *http.Request
这两个无比长的东西封装成gin.Context
呢,这就是我们本次要解决的内容。
请看我们的/gee/Context.go
:
go
type H map[string]interface{}
type Context struct {
Writer http.ResponseWriter
Req *http.Request
Path string
Method string
StatusCode int
}
func newContext(w http.ResponseWriter, req *http.Request) *Context {
return &Context{
Writer: w,
Req: req,
Path: req.URL.Path,
Method: req.Method,
}
}
func (c *Context) PostForm(key string) string {
return c.Req.FormValue(key)
}
func (c *Context) Query(key string) string {
return c.Req.URL.Query().Get(key)
}
func (c *Context) Status(code int) {
c.StatusCode = code
c.Writer.WriteHeader(code)
}
func (c *Context) SetHeader(key string, value string) {
c.Writer.Header().Set(key, value)
}
func (c *Context) String(code int, format string, values ...interface{}) {
c.SetHeader("Content-Type", "text/plain")
c.Status(code)
c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
}
func (c *Context) JSON(code int, obj interface{}) {
c.SetHeader("Content-Type", "application/json")
c.Status(code)
encoder := json.NewEncoder(c.Writer)
if err := encoder.Encode(obj); err != nil {
http.Error(c.Writer, err.Error(), 500)
}
}
func (c *Context) Data(code int, data []byte) {
c.Status(code)
c.Writer.Write(data)
}
func (c *Context) HTML(code int, html string) {
c.SetHeader("Content-Type", "text/html")
c.Status(code)
c.Writer.Write([]byte(html))
}
首先,我们给map[string]interface{}
取名为H,使用时更加简便。而要把http.ResponseWriter
和*http.Request
封装为Context
的思路很简单,无非就是定义个结构体,把它们塞进去!针对于这个Context
结构体,我们又定义了很多个方法,看起来很复杂,但无非就是获取结构体里的值,或者是给结构体里面的东西赋值。
为了方便对路由进行管理,以及后续的扩展功能,我们把它单独拎出来放到gee/route.go
里面:
go
type router struct {
handlers map[string]HandlerFunc
}
func newRouter() *router {
return &router{handlers: make(map[string]HandlerFunc)}
}
func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {
log.Printf("Route %4s - %s", method, pattern)
key := method + "-" + pattern
r.handlers[key] = handler
}
func (r *router) handle(c *Context) {
key := c.Method + "-" + c.Path
if handler, ok := r.handlers[key]; ok {
handler(c)
} else {
c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
}
}
可以发现其中微小的调整,就是handler的参数,变成了 Context。
在剔除router
的部分后,gee/gee.go
就变成了以下样子
go
type HandlerFunc func(*Context)
type Engine struct {
router *router
}
func (e *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := newContext(w, req)
e.router.handle(c)
}
func New() *Engine {
return &Engine{router: newRouter()}
}
func (e *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
e.router.addRoute(method, pattern, handler)
}
func (e *Engine) GET(pattern string, handler HandlerFunc) {
e.addRoute("GET", pattern, handler)
}
func (e *Engine) POST(pattern string, handler HandlerFunc) {
e.addRoute("POST", pattern, handler)
}
func (e *Engine) Run(addr string) (err error) {
return http.ListenAndServe(addr, e)
}
到了这里就会觉得,刚才把router
踢出去封装是值得的!因为这里很多的功能都是直接调router
的接口,并没有多写任何东西。而由于要实现ServeHTTP
接口,所以把接收到的w
和req
统一装到封装好的Context
里。
最后,就来编写项目的入口main.go
吧~
go
func main() {
r := gee.New()
r.GET("/", func(c *gee.Context) {
c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
})
r.GET("/hello", func(c *gee.Context) {
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
})
r.POST("/login", func(c *gee.Context) {
c.JSON(http.StatusOK, gee.H{
"username": c.PostForm("username"),
"password": c.PostForm("password"),
})
})
r.Run(":9999")
}
三、前缀树路由Router
上面的代码看似比较完善,能够做到返回JSON、HTML、query传参等操作,但对于Restful风格来讲,还没有实现动态路由效果。例如"/student/:id"
,目前的代码获取不到这个":id"
参数。so,撸起袖子开干!
1.Trie 树
Trie树,也就是前缀树,是实现动态路由常用的数据结构。假设我们拥有以下路由:
- /:subject/grade 查看某个课程的成绩,课程作为动态参数
- /:subject/teacher 查看某个课程的任课老师,课程作为动态参数
- /about
- /blog/sanjin
- /blog/geektutu
于是就可以得到以下Trie树: 因此通过对树进行查询操作,可以实现路由的匹配。
接下来我们实现的动态路由具备以下两个功能。
- 参数匹配
:
。例如/:subject/grade
,可以匹配/chinese/grade
和/english/grade
。 - 通配
*
。例如/static/*filepath
,可以匹配/static/fav.ico
,也可以匹配/static/js/jQuery.js
,这种模式常用于静态服务器,能够递归地匹配子路径。
为了实现Trie树,编写以下代码gee/trie.go
go
type node struct {
pattern string // 待匹配的路由,例如 /:subject/grade
part string // 路由中的一部分,例如/:subject
children []*node // 子节点
isWild bool // 是否精确匹配,part含有:或*时为true
}
// matchChild 第一个匹配成功的节点,用于插入
func (n *node) matchChild(part string) *node {
for _, child := range n.children {
if child.part == part || child.isWild {
return child
}
}
return nil
}
// matchChildren 所有匹配成功的节点,用于查找
func (n *node) matchChildren(part string) []*node {
nodes := make([]*node, 0)
for _, child := range n.children {
if child.part == part || child.isWild {
nodes = append(nodes, child)
}
}
return nodes
}
func (n *node) insert(pattern string, parts []string, height int) {
if len(parts) == height {
n.pattern = pattern
return
}
part := parts[height]
child := n.matchChild(part)
if child == nil {
child = &node{
part: part,
isWild: part[0] == ':' || part[0] == '*',
}
n.children = append(n.children, child)
}
child.insert(pattern, parts, height+1)
}
func (n *node) search(parts []string, height int) *node {
if len(parts) == height || strings.HasPrefix(n.part, "*") {
if n.pattern == "" {
return nil
}
return n
}
part := parts[height]
children := n.matchChildren(part)
for _, child := range children {
result := child.search(parts, height+1)
if result != nil {
return result
}
}
return nil
}
PS:讲真的,这段代码我是真的没看懂,先放在这,以后慢慢品味。
在此贴一个chatgpt的解答:
该代码定义了一个数据结构node
,用于表示一个路由的部分。每个node
包含了四个属性,分别是待匹配的路由pattern
、路由的一部分part
、子节点children
和一个标志isWild
来表示是否精确匹配。接下来定义了几个方法用于操作这个数据结构:
-
matchChild(part string) *node
:该方法用于在children
中查找第一个能够匹配指定part
的子节点。如果找不到匹配的子节点,返回nil
。 -
matchChildren(part string) []*node
:该方法用于在children
中查找所有能够匹配指定part
的子节点,并返回一个node
类型的切片。 -
insert(pattern string, parts []string, height int)
:该方法用于将一条路由插入到根节点的子树中。pattern
是要插入的路由字符串,parts
是路由字符串按/
分隔后的切片,height
表示目前匹配到的路由字符串的深度。在递归过程中,如果当前节点的子节点列表中存在一个能够匹配parts[height]
的子节点,则在该子节点下继续递归;否则,创建一个新的子节点并插入到当前节点的子节点列表中,然后在该新节点下继续递归,直到匹配到parts
中的最后一个元素时,将pattern
赋值给该叶子节点的pattern
属性。 -
search(parts []string, height int) *node
:该方法用于在根节点的子树中查找与指定路由相匹配的叶子节点。parts
是指定的路由字符串按/
分隔后的切片,height
表示目前匹配到的路由字符串的深度。在递归过程中,如果匹配到了parts
的最后一个元素或者当前节点是以*
开头的子节点,则在当前节点的子节点列表中查找与parts[height]
匹配的子节点,然后在该子节点下继续递归。如果递归结果不为nil
,则返回该结果;否则,返回nil
。
这个数据结构和方法的实现是为了方便路由匹配和查询的操作。通过将一条路由拆分成多个part
,并且每个part
作为node
节点的一部分,通过匹配子节点和查询子节点,可以快速地匹配和查询路由。
2.Router
ok,解决了树的部分,改把它应用到路由当中了!/gee/router.go
代码如下:
go
type router struct {
handlers map[string]HandlerFunc
roots map[string]*node
}
func newRouter() *router {
return &router{
handlers: make(map[string]HandlerFunc),
roots: make(map[string]*node),
}
}
func parsePattern(pattern string) []string {
vs := strings.Split(pattern, "/")
parts := make([]string, 0)
for _, item := range vs {
if item != "" {
parts = append(parts, item)
if item[0] == '*' {
break
}
}
}
return parts
}
func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {
parts := parsePattern(pattern)
key := method + "-" + pattern
_, ok := r.roots[method]
if !ok {
r.roots[method] = &node{}
}
r.roots[method].insert(pattern, parts, 0)
r.handlers[key] = handler
}
func (r *router) getRoute(method string, path string) (*node, map[string]string) {
searchParts := parsePattern(path)
params := make(map[string]string)
root, ok := r.roots[method]
if !ok {
return nil, nil
}
n := root.search(searchParts, 0)
if n != nil {
parts := parsePattern(n.pattern)
for index, part := range parts {
if part[0] == ':' {
params[part[1:]] = searchParts[index]
}
if part[0] == '*' && len(part) > 1 {
params[part[1:]] = strings.Join(searchParts[index:], "/")
break
}
}
return n, params
}
return nil, nil
}
func (r *router) handle(c *Context) {
n, params := r.getRoute(c.Method, c.Path)
if n != nil {
c.Params = params
key := c.Method + "-" + n.pattern
r.handlers[key](c)
} else {
c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
}
}
在router
中定义了roots来存储每种请求方式的Trie树根节点,getRoute 函数中还解析了:
和*
两种匹配符的参数,返回一个 map 。例如/chinese/grade
匹配到/:subject/grade
,解析结果为:{subject: "chinese"}
,/static/css/geektutu.css
匹配到/static/*filepath
,解析结果为{filepath: "css/geektutu.css"}
。
此时,context.go
也需要发生一些变化
go
type Context struct {
// origin objects
Writer http.ResponseWriter
Req *http.Request
// request info
Path string
Method string
Params map[string]string
// response info
StatusCode int
}
func (c *Context) Param(key string) string {
value, _ := c.Params[key]
return value
}
在Context
中增加了Params字段,存储解析后的路径参数。而router.go
中的handle()
实现了在调用匹配到的handler
前,将解析出来的路由参数赋值给了c.Params
。这样就能够在handler
中,通过Context
对象访问到具体的值了。
b话少说,上main.go
:
go
func main() {
r := gee.New()
r.GET("/", func(c *gee.Context) {
c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
})
r.GET("/hello", func(c *gee.Context) {
// expect /hello?name=geektutu
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
})
r.GET("/hello/:name", func(c *gee.Context) {
// expect /hello/geektutu
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
})
r.GET("/assets/*filepath", func(c *gee.Context) {
c.JSON(http.StatusOK, gee.H{"filepath": c.Param("filepath")})
})
r.Run(":9999")
}