七天用go实现一个web框架


前言

本文学习自geektutu , 大部分内容摘自 7天用Go从零实现Web框架Gee教程 | 极客兔兔 (geektutu.com),并在此基础上稍加个人的学习历程和理解。

作者仓库地址:geektutu/7days-golang: 7 days golang programs from scratch (web framework Gee, distributed cache GeeCache, object relational mapping ORM framework GeeORM, rpc framework GeeRPC etc) 7天用Go动手写/从零实现系列 (github.com)

day0. 设计一个框架

大部分时候 , 实现一个Web应用 , 第一反应是用哪个框架 , 在Golang中 , 新框架层出不穷 , 例如Beego , Gin , Iris 等 , 那为什么不用标准库 , 而必须使用框架呢 ? 在设计一个框架时 , 我们需要知道核心框架为我们解决了什么问题 , 只有明白这一点 , 才能想明白我们要在框架内实现什么功能。

刚好最近学院开展了一个软件实训课程 , 在五天之内搭好了一个Java游戏框架 , 基本框架搭好之后 , 只需要替换配置文档和游戏元素 , 就可以做出另一款新的游戏 , 当然 , 在理解这个框架的搭建思路的背后是异常痛苦的 (前后花了6天左右去看课程视频 , 各种修bug) , 在做出作品那一刻,虽然运行起来的会让人莫名想笑 , 心情舒畅,不过总算实现了代码和人一个能跑了bushi , 很敬佩游戏框架设计师的奇思妙想 (脑洞)

有点扯远了 , 我们先看看Golang标准库net/http如何处理一个请求。

package main

import (
    "net/http"
    "fmt"
)

func main() {
    http.HandleFunc("/", handler)
    http.HandleFunc("/count", handler)
    log.Fatal(http.ListenAndServe("localhost:8000", nil))
}

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "URL.Path = %q\n", r.URL.Path)
}
基础知识
interface

首先定义一个Animal的接口

type Animal interface {
    Speak() string
}

golang中没有 implements 关键字,那么如何实现接口呢?

type Dog struct {}

func (d Dog) Speak() string {
    return "Woof!"
}

type Cat struct {}

func (c Cat) Speak() string {
    return "Meow!"
}
// 只要实现了Speak(),就算是实现了Animal接口
interface

interface{} 类型,空接口,很容易和interface弄混。interface{} 是没有方法的接口。由于没有 implements 关键字,所以所有类型都至少实现了0个方法,所以 所有类型都实现了空接口。这意味着,如果在写一个函数以 interface{} 为参数,那么可以为该函数提供任何值。例如

func DoSomething(v interface{}) {
    // ...
}

在DoSomething 内部,开始我也认为v时任意类型,但这是错误的,v 不是任意类型,它的静态类型是interface{}类,动态类型由传入的参数的类型决定,当然返回参数时,就不要返回interface{}类了。

interface{}可以承载任意值,但不代表任意类型就可以承接空接口类型的值。当将值传递给DoSomething 函数时,golang将执行类型转换 (if necessary),并将值转换为interface{}类型的值。

题外话,interface{}动态类型慎用,特别是面对需求容易改动的项目,另外,一般不要对动态类型的值进行比较操作

http.ResponseWriter

首先需要了解HandleFunc这个函数的一些信息,其声明如下。

func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
    DefaultServeMux.HandleFunc(pattern, handler)
}

在main函数中,字符串部分容易理解,那handler呢,来看看它的参数的源码。

type ResponseWriter interface {
    Header() Header
    Write([]byte) (int, error)
    WriteHeader(statusCode int)
}

还是不太清楚,再点击Header看看。

type Header map[string][]string

http.Header结构包含请求头信息,常见信息实例如下。

Host: example.com
accept-encoding: gzip, deflate
Accept-Language: en-us
foo: Bar

接下来看看Write([]byte) (int, error),这是一个接口,实现通用的io.Writer

type Writer interface {
    Write(p []byte) (n int, err error)
    // 这个byte其实是个切片,而Write方法在server.go里被重写了,具体先不贴代码,本身含有Fprintf方法,后文会用到
}

最后到WriteHeader(statusCode int)

WriteHeader这个方法名有点误导,并不是来设置响应头的,该方法支持传入一个整形数据表示响应状态码,不调用该方法的话,默认值是200 OK

http.Request

直接看源码的声明。

type Request struct {
    Method string
    URL *url.URL
    Proto string   //eg "HTTP/1.0"
    ProtoMajor int  
    ProtoMinor int
    Header Header
    Body io.ReaderCloser
    GetBody func() (io.ReadCloser, error)
    ContentLength int64
    TransferEncoding []string
    Close bool
    Host string
    Form url.Values
    PostForm url.Values
    MultipartForm *multipart.Form
    Trailer Header
    RemoteAddr string
    RequestURI string
    TLS *tls.ConnectionState
    Cancel <-chan struct{}
    Response *Response
    ctx context.Context
}

常见的Request报文段信息如下:

http.ListenAndServe
func ListenAndServe(addr string, handler Handler) error {
    server := &Server{Addr: addr, Handler: handler}
    return server.ListenAndServe()
}

ListenAndServe中,再查看ServerListenAndServe()的源码

http.Server

type Server struct {
	Addr string   // 服务器的IP地址和端口信息
    Handler Handler  // 请求处理函数的路由复用器
    ReadTimeout time.Duration
    WriteTimeout time.Duration
    MaxHeaderBytes int
    TLSConfig *tls.Config
    TLSNextProto map[string]func(*Server, *tls.Conn, Handler)
    ConnState func(net.Conn, ConnState)
    ErrorLog *log.Logger
    disableKeepAlives int32
}

http.ListenAndServe()

func (srv *Server) ListenAndServe() error {
    if srv.shuttingDown() {
        return ErrServerClosed  // 如果Server已关闭,直接返回ErrServerClosed
    }
    addr := srv.Addr
    if addr == "" {
        addr = ":http"
    }
    ln, err := net.Listen("tcp", addr) 
    if err != nil {
        return err
    }
    return srv.Serve(ln)
}

在本例中,传入了端口号和handler,如果不指定ip就用本机地址 (localhost),如果不指定服务器地址信息,则默认以:http作为地址信息

fmt.Fprintf
func Fprintf(w io.Writer, format string, a ...any) (n int, err error) {
    p := newPrinter()
    p.doPrintf(format, a)
    n, err = w.Write(p.buf)
    p.free()
    return
}
log.Fatal
func Fatal(v ...any) {
    std.Output(2, fmt.Sprint(v...))
    os.Exit(1)
}
func (l *Logger) Fatal(v ...any) {
    l.Output(2, fmt.Sprint(v...))
    os.Exit(1)
}

在函数上面的定义,Fatal等价于Print(),执行完打印直接退出程序,之前通过defer设置延迟的函数不会被运行

框架说明

net/http提供了基础的Web功能 , 即监听端口 , 解析HTTP报文。一些Web开发中简单的需求并不支持,需要手工实现。

  • 动态路由 : 例如hello/:name , hello/*这类的规则
  • 鉴权 (authentication) : 没有分组/统一鉴权的能力 , 需要在每个路由映射的handler中实现
  • 模板 : 没有统一简化的HTML机制
  • ...

可以发现,当我们离开框架,使用基础库时,需要频繁手工处理的地方,就是框架的价值所在。

day1. http.Handler

base1

标准库启动Web服务

Golang内置了net/http库 , 封装了HTTP网络编程的基础的接口 , 本次复刻的Gee Web框架便是基于net/http的 , 下面通过一个例子 , 简单介绍下这个库的使用。

day1/base1/main.go

package main

import (
    "fmt"
    "net/http"
    "log"
)

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] = %q\n", k, v)
    }
}

在上面 , 设置了两个路由 , //hello , 分别绑定indexHandlerhelloHandler , 根据不同的HTTP请求会调用不同的处理函数 , 访问/ , 响应是URL.Path=/ , 而/hello的相应则是请求头 (header)中键值对信息。

用curl工具测试 , 会得到以下结果。

$ curl http://localhost:9999/
URL.Path = "/"
$ curl http://localhost:9999/hello
Header["User-Agent"]=["curl/7.68.0"]
Header["Accept"]=["*/*"]
$ curl http://localhost:9999/helloWorld
URL.Path = "/helloWorld"

main函数的最后一行 , 是来启动服务的 , 第一个是参数地址 , :9999表示在9999端口监听 , 而第二个参数则代表处理所有的HTTP请求的实例 , nil代表使用标准库中的实例处理 , 也是我们基于net/http标准库实现Web框架的入口。

这里第三个命令 , 访问了/helloWorld , 而在文件中未定义/helloWorld这个路由 , 用curl工具仅从测试 , 得到的却是URL.Path , 关于这个bug , 后文会进行修改。

实现http.Handler接口
package http

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

func ListenAndServe(address string, h Handler) error

查阅net/http源码发现 , Handler是一个接口 , 需要实现方法 ServeHTTP , 也就是说 , 只要传入任何实现了 ServeHTTP接口的实例 , 所有的HTTP请求 , 就都交给了该实例处理。

base2

day1/base2/main.go

package main

import (
    "fmt"
    "log"
    "net/http"
)

type Engine struct{}
// 定义一个空的结构体,并命名为Engine,后期可以直接用Engine加'.'加成员名的方式调用

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // 这里engine作为Engine类型的对象,拥有Engine的所有方法
    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))
}

后面复盘时,有一点一直搞不懂,究竟ServeHTTP是怎么被调用的?在这个go文件里面,感觉还是得从ListenAndServe下手,ListenAndServe的定义是这样的

func ListenAndServe(addr string, handler Handler) error {
    serve := &Server{Addr: addr, Handler: handler} // 创建一个Server结构体
    return server.ListenAndServe()
    // 这里开始也还是没怎么看懂,后面也去查了下资料,这里返回的ListenAndServe,传入到Fatal里,如果不报错的话,是正常打印switch中的内容,有错误就打印错误信息
}

继续追溯server.ListenAndServe()

func (srv *Server) ListenAndServe() error {
    addr := srv.Addr
    if addr == "" {
        addr = ":http"
    }
    ln, err := net.Listen("tcp", addr)
    if err != nil {
        return err
    }
    return srv.Serve(ln)
}

此方法只是开始侦听给定的地址,并用新创建的侦听器调用Server方法

func (srv *Server) Serve(l net.Listener) error {
    defer l.close()
    ...
    for{
        rw, e := l.Accept()
        ...
        c := srv.newConn(rw)
        c.setState(c.rwc, StateNew)
        go c.serve(ctx)
    }
}

Serve方法我们可以看到 , 这是我们接受新连接并开始在它自己的goroutine中处理它的地方

func (c *Conn) serve(ctx context.Context) {
    ...
    for {
        w, err := c.readRequest(ctx)
        ...
        serverHandler{c.server}.ServeHTTP(w, w.req)
        ...
    }
}

在这里 , 才调用了ServeHTTP方法 , 正如我们在上面看到的 , 我们对这个ServeHTTP方法进行重写 , 打印输出HTTP请求的信息


回到main.go , 这里定义了一个空的结构体Engine , 实现了方法ServrHTTP , 这个方法有两个参数 , 第二个参数是Request , 该对象包含了该HTTP请求的所有信息 , 比如请求地址 , Header和Body等信息 ; 第一个参数是ResponseWriter , 利用ResponseWriter可以构造指针对该请求的相应。

在main函数里 , 我们给ListenAndServe方法的第二个参数传入了刚才创建的engine实例 , 至此已经踏出了实现Web框架的第一步 , 即将所有的HTTP请求转向了我们自己的处理逻辑 。

在实现Engine之前 , 我们调用http.HandleFunc实现了路由和Handler的映射 , 也就是只能针对具体的路由写处理逻辑 , 比如\hello , 但在实现engine后 , 我们拦截了所有的HTTP请求 , 拥有了统一的控制入口 , 在这里我们可以自由定义路由映射的规则 , 也可以统一添加一些处理逻辑 , 例如日志 , 异常处理等。

代码运行结果前两行代码的结果一致 , 第三行代码结果如下。

$ curl http://localhost:9999/helloWorld
404 NOT FOUND: /helloWorld

base3

Gee框架的雏形

下面重新来组织上面的代码 , 搭建整个框架的雏形。

gee/
  |--gee.go
  |--go.mod
main.go
go.mod

day1/base3/go.mod

module example

go 1.18

require gee v1.0.0

replace gee => ./gee

day1/base3/main.go

package main

import (
	"fmt"
    "net/http"
    "gee"
)

func main() {
    r := gee.New()
    r.GET("/", func(w http.ResponseWriter, req *http.Request) {
        fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
    })
    
    r.GET("/hello", func(w http.RequestWriter, req *http.Request) {
        for k, v := range req.Header {
            fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
        }
    })
    
    r.Run(":9999")
}

在这里 , 使用GET()方法添加路由 , 最后使用Run()启动Web服务 , 这里的路由 , 只是静态路由 , 不支持/hello/:name这样的动态路由 , 动态路由将在下一次实现。

day1/base3/gee/go.mod

module gee

go 1.18

因为我是用vscode进行代码编辑,工作区选择gee的根目录,而不是src,这里的go.mod管理需要做一点额外工作,按照教程写好mod后,会提示无法导入gee模块,查了各种帖子,对Go111Module进行设置也没效果,后面查到了一篇帖子,要在设置里搜 go.useLanguageServer,并将其关闭

day1/base3/gee/gee.go

package gee

import (
    "fmt"
    "net/http"
)

type HandlerFunc func(http.ResponseWriter, *http.Request) 
// 定义了一个HandlerFunc的函数类型,其签名必须符合输入为 http.ResponseWriter和*http.Request

type Engine struct {
    router map[string]HandlerFunc
}

func New() *Engine {
    return &Engine{router: make(map[string]HandlerFunc)}
}

func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
    key := method + "-" +pattern
    engine.router[key] = router
}

func (engine *Engine) GET(pattern string, handler HandlerFunc) {
    engine.addRoute("GET", pattern, handler)
}

func (engine *Engine) POST(pattern string, handler HandlerFunc) {
    engine.addRoute("POST", pattern, handler)
}

func (engine *Engine) Run(addr string) (err error) {
    return http.ListenAndServe(addr, engine)
}

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    key := req.Method + "-" + req.URL.Path
    if handlerm ok := engine.router[key]; ok {
        hanlder(w, req)
    } else {
        fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
    }
}

gee.go , 介绍下这几部分的实现。

  • 定义了类型HandlerFunc , 这是提供给框架用户的 , 用来定义路由映射的基本方法。我们在Engine中,添加了一张路由映射表router,key由请求方法和静态路由地址构成,例如GET-/GET-/helloPOST-/hello,这样针对相同的路由,如果请求方式不同,可以映射不同的处理方法(Handler),value是用户映射的处理方法。
  • 当用户调用(*Engine).Run()方法,会将路由和处理方法注册到映射表router中,(*Engine).Run(),是 ListenAndServe的包装。
  • Engine实现的ServeHTTP方法的作用就是,解析请求的路径,查找路由映射表,如果查到,就执行注册的处理方法,如果查不到,就返回404 NOT FOUND。

执行go run main.go,再用curl访问,结果和base2的结果一致

$ curl http://localhost:9999/
URL.Path = "/"
$ curl http://localhost:9999/hello
Header["Accept"] = ["*/*"]
Header["User-Agent"] = ["curl/7.68.0"]
$ curl http://localhost:9999/helloWorld
404 NOT FOUND: /helloWorld

至此,整个Gee框架的原型就已经出来了。有了基本路由映射表,提供了用户注册静态路由的方法,包装了启动服务的函数。当然到目前为止,我们还没有实现比net/http标准库更强大的能力,这些会在后面将动态路由、中间件等功能添加上去。

day2. 上下文 Context

  • 将 路由(router) 独立出来 , 方便之后改进。
  • 设计 上下文 (Context),封装 Request和 Response,提供对JSON、HTML等返回类型的支持。
  • 第二天的框架内容,代码约140行,新增约90行。
  • 后面每一天贴出的代码基本为原文件基础上新增的内容或修改后的内容

设计Context

必要性
  1. 对Web服务来说,无非是根据请求*http.Request,构造响应http.ResponseWriter。但是这两个对象提供的接口粒度太细,比如我们要构造一个完整的相应,需要考虑消息头(Header)和消息体(Body),而Header包含了状态码(StatusCode),消息类型(ContentType)等几乎每次都需要设置的信息。因此,如果不进行有效的封装,那么框架的用户将需要写大量重复、繁冗的代码,且容易出错。针对常用场景,能够高效地构造出HTTP相应是一个好的框架必须考虑的点。

用返回JSON数据作比较,对比封装前后的差异:

封装前

obj = map[string]interface{} {
    "name": "abc",
    "password": "1234",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
if err := encoder.Encode(obj); err != nil {
    http.Error(w, err.Error(), 500)
}

封装后

c.JSON(http.StatusOK, gee.H{
    "username": c.PostForm("username"),
    "password": c.PostForm("password"),
})
  1. 针对使用场景,封装*http.Requesthttp.ResponseWriter的方法,简化相关接口的调用,只是设计Context的原因之一。对于框架来说,还需要支撑额外的功能。例如,将来解析动态路由/hello/:name,参数name的值放在哪?再比如,框架需要支持中间件,那中间件产生的信息放在哪?Context随着每一个请求的出现而产生,请求的结束而销毁,和当前请求强相关的信息都应由Context承载。因此,设计Context结构,扩展性和复杂性留在了内部,而对外简化了接口。路由的处理函数,以及将要实现的中间件,参数都统一使用Context实例
具体实现

day2/gee/context.go

type H map[string]interface{}

type Context struct {
    // origin objects
    Writer http.ResponseWriter
    Req *http.Request
    // request info
    Path string
    Method string
    // response info
    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)
    }
    // 这里的obj,在后文的测试中,就是一个map,输出为["passowrd":"xxx","username":"xxx"]
}

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{}起了一个别名gee.H,构建JSON数据时,显得更简洁。
  • Context目前只包含了http.ResponseWriter*http.Request,另外提供了对Method和Path这两个常用的属性的直接访问。
  • 提供了访问Query和PostForm参数的方法。
  • 提供了快速构造String/Data/JSON/HTML相应的方法。

路由(Router)

我们将和路由相关的方法和结构提取了出来,放到了一个新的文件router.go,方便下一次对router的功能进行增强,例如提供动态路由的支持。router的handle方法作了一个细微的调整,即handler的参数,变成了Context。

day2/gee/router.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 {
        // 这里根据输入的方法和路径查找handlers中值,返回HandlerFunc,赋给handler,此时handler就是一个带有HandlerFunc签名的函数(http.ResponseWriter和*http.Request),输入的参数类型为*Context,handler再根据输入的Context
        handler(c)
    } else {
        c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
    }
}

框架入口

day2/gee/gee.go

package gee

import "net/http"

// HandlerFunc defines the request handler used by gee
// HandlerFunc包含Context所有属性和方法
type HandlerFunc func(*Context)

// Engine implement the interface of ServeHTTP
type Engine struct {
    router *router
}

// New is the constructor of gee.Engine
func New() *Engine {
    return &Engine{router: newRouter()}
}

func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
    engine.router.addRoute(method, pattern, handler) // 调用router的addRoute方法
    // router的addRoute方法类似于一个私有函数,通过engine的addRoute传入参数再传递给router
}
// 把第一天的addRoute修改为这样,

router相关的代码独立后,gee.go简单了不少。最重要的还是通过实现了ServeHTTP接口,接管了所有的HTTP请求。相比第一天的diamond,这个方法也有细微的调整,在调用router.handle之前,构造了一个Context对象。这个对象目前还非常简单,仅仅是包装了原来的两个参数,之后我们会慢慢地给Context加上更多内容。

day2/main.go

package main

import(
    "net/http"
    "gee"
)

func main() {
    r := gee.New()
    r.GET("/", func(c *gee.Context) {
        c.HTML(http.StatusOK, "<h1>hello gee</h1>\n")
    }) 
    // 思路: 向GET传入一个"/"的路由和一个匿名的HandlerFunc函数,该函数内部含有相关HTTP请求信息(HTML函数),然后GET把这个路由和handler传给engine的addRoute,经过套娃,再到达router的addRoute
    // 下面的GET也同理,只不过String和HTML传入的切片不同
    r.GET("/hello", func(c *gee.Context) {
        // expect /hello?name=abc
        c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
    })
    r.POST("/login", func(c *gee.Context) {
        // 在服务器端输出的是 "POST - /login"
        c.JSON(http.StatusOK, gee.H{
            "username": c.PostForm("username"),
            "password": c.PostForm("password"),
        })
    })
    r.Run(":9999")
}

运行main.go,看看day2的成果:

$ curl -i http://localhost:9999/
HTTP/1.1 200 OK
Content-Type: text/html
Date: Fri, 08 Jul 2022 08:29:52 GMT
Content-Length: 19

<h1>Hello Gee</h1>

$ curl http://localhost:9999/hello?name=abc
hello abc, you're at/hello

$ curl http://localhost:9999/login -X POST -d "username=abc&password=1234"
{"password":"1234","username":"abc"}

$ curl http://localhost:9999/xxx
404 NOT FOUND: /xxx

服务器端输出

2022/07/09 11:38:15 Route  GET - /
2022/07/09 11:38:15 Route  GET - /hello
2022/07/09 11:38:15 Route POST - /login

day3 前缀树路由Router

  • 使用Trie树实现动态路由(dynamic route)解析。
  • 支持两种模式:name*filepath,代码约150行。

Trie树简介

之前,用了一个非常简单的map结构存储了路由表,用map存储键值对,索引非常高效,但是有一个弊端,键值对的存储方式,只能来索引静态路由。如果我们想支持类似于/hello/:name这样的动态路由怎么办呢?所谓动态路由,即一条路由规则可以匹配某一类型而并非某一条固定的路由,例如/hello/:name,可以匹配/hello/abc/hello/jayden等。

动态路由有很多种实现方式,支持的规则,性能等有很大的差异。例如开源的路由实现gorouter支持在路由规则种嵌入正则表达式,例如/p/[0-9A-Za-z]+,即路径种的参数仅匹配数字和字母;另一个开源实现httprouter就不支持正则表达式。Web开源框架gin在早期的版本没有实现自己的路由,而是直接用了httprouter,后来又放弃了httprouter,自己实现了一个版本。

实现动态路由最常用的数据结构,被称为前缀树(Trie树)。每个节点的所有子节点都有相同的前缀。这种结构非常适用于路由匹配,例如我们定义了如下路由规则:

  • /:lang/doc
  • /:lang/tutorial
  • /:lang/intro
  • /about
  • /p/blog
  • /p/related

我们用前缀树表示,是这样的:

HTTP请求的路径恰好是由/分割的多端构成的,因此,每一段可以作为前缀树的一个节点。我们通过树结构查询,如果中间某一层的节点都不满足条件,那么就说明没有匹配到的路由,查询结束。

接下来我们实现的动态路由具备以下俩功能:

  • 参数匹配:例如/p/:lang/doc,可以匹配/p/c/docp/go/doc
  • 通配*,例如/static/*filepath,可以匹配/static/fav.ico,也可以匹配/static/js/jQuery.js,这种模式常用于静态服务器,能够递归地匹配子路径。

Trie树实现

首先需要设计树节点上应存储哪些信息。

day3/gee/trie.go

type node struct {
    pattern string // 待匹配路由,例如 /p/:lang
    part string // 路由中一部分,例如 :lang
    children []*node // 子节点,例如 [doc, tutorial, intro]
    isWild bool // 是否精确匹配,part含有 : 或 * 时为true
}

// 这里重写String函数,便于后期查看相关参数的值(直接输出n.children是打印地址)
func (n node) String() string {
    return fmt.Sprintf("pattern:%s, part:%s, children:%s, isWild:%t", n.pattern, n.part, n.children, n.isWild)
}

与普通的树不同,为了实现动态路由匹配,加上了isWild这个参数。即当我们匹配/p/go/doc这个路由时 (/算0个节点) ,第一层节点,p精确匹配到了p,第二层节点,go模糊匹配到:lang,那么会将lang这个参数赋值为go,继续下一层匹配。我们将匹配的逻辑,包装为一个辅助函数。

// 第一个匹配成功的节点,用于插入
func (n *node) matchChild(part string) *node {
    for _, child := range n.children {
        // 这里之前一直没搞懂为什么要忽略range的第一个参数,查阅资料后发现,第一个参数是索引值index,第二个参数是value,这里第二个参数,就是n的子节点
        if child.part == part || child.isWild {
            return child
        }
    }
    return nil
}
// 所有匹配成功的节点,用于查找
func (n *node) matchChildren(part string) []*node {
    nodes := make([]*node, 0) // new一个长度为0的切片
    for _, child := range n.children {
        if child.part == part || child.isWild {
            nodes = append(nodes, child)
            // 将匹配成功的节点加入到nodes中
        }
    }
    return nodes
}

对于路由来说,最重要的当然是注册于匹配了。开发服务时,注册路由规则,映射handler;访问时,匹配路由规则,查找到对应的handler。因此,Trie树需要支持节点的插入与查询。插入功能很简单,递归查找每一层的节点,如果没有匹配到当前part的节点,则新建一个,有一点需要注意,/p/:lang/doc只有在第三层节点,即doc节点,pattern才会设置为/p/:lang/docp:lang节点的pattern属性皆为空。因此,当匹配结束时,我们可以用n.pattern == ""来判断路由规则是否匹配成功。例如,/p/python虽能成功匹配到:lang,但:langpattern值为空,因此匹配失败。查询功能,同样也是递归查询每一层的节点,退出规则是,匹配到 * ,匹配失败,或匹配到第len(parts)层节点。

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]
    // (height+1)的值是当前搜索的层数
    children := n.matchChildren(part)
    // children则含有搜索到每一层的part
    
    for _, child := range children {
        result := child.search(parts, height + 1)
        if result != nil {
            return result
        }
    }
    
    return nil
}

func (n *node) travel(list *([]*node)) {
    if n.pattern != "" {
        *list = append(*list, n)
    }
    for _, child := range n.children {
        child.travel(list)
    }
}

Router

Trie树的插入与查找都成功实现了,接下来我们将Trie树应用到路由中。我们用roots来存储每种请求方式的Trie树根节点。使用handlers存储每种请求方式的HandlerFunc。getRoute函数中,还解析了:*两种匹配符的参数,返回一个 map。例如/p/go/doc匹配到/p/:lang/doc,解析结果为{lang: "go"}/static/css/gee.css匹配到/static/*filepath,解析结果为{filepath: "css/gee.css"}

day3/gee/router.go

type router struct {
    roots map[string]*node
    handlers map[string]HandlerFunc
}

// roots key eg, roots['GET'] roots['POST']
// handlers key eg, handlers['GET-/p/:lang/doc'], handlers['POST-/p/book']

func newRouter() *router {
    return &router {
        roots: make(map[string]*node),
        handlers: make(map[string]HandlerFunc),
    }
}

// Only one * is allowed
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, path string) (*node, map[string]string) {
    searchParts := parsePattern(pattern)
    
    key := method + "-" + pattern
    _, ok := r.roots[method]
	// 这里卡的比较久,第一个返回值是获取的值,第二个是判断值是否获取成功
    if !ok {
        r.roots[method] = &node{}
        // 一般来说,对于一个新路由,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 := roots.search(searchParts, 0)
    
    if n != nil {
        parts := parsePattern(n.pattern)
        for index, part := range parts {
            // getRoute的参数匹配,这里匹配:和*两种字符
            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) getRoutes(method string) []*node {
    root, ok := r.roots[method]
    if !ok {
        return nil
    }
    nodes := make([]*node, 0)
    root.travel(&nodes)
    return nodes
}

Context与handle的变化

在HandleFunc中,希望能够访问到解析的参数,因此,需要对Context对象增加一个属性和方法,来提供对路由参数的访问。我们将解析后的参数储存到Params中,通过c.Param("lang")的方式获取到对应的值。

day3/gee/context.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
}

day3/gee/router.go

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.go的变化比较小,比较重要的一点是,在调用匹配到的handler前,将解析出来的路由参数赋值给了c.Params。这样就能够在handler中,通过Context对象访问到具体的值了

单元测试

router_test.go

package gee

import (
    "fmt"
    "reflect"
    "testing"
)

func newTestRouter() *router {
    r := newRouter()
    r.addRoute("GET", "/", nil)
    r.addRoute("GET", "/hello/:name", nil)
    r.addRoute("GET", "/hello/b/c", nil)
    r.addRoute("GET", "/hi/:name", nil)
    r.addRoute("GET", "/assets/*filepath", nil)
    return r
}

func TestParsePattern (t *testing.T) {
    r := newTestRouter()
    ok := reflect.DeepEqual(parsePattern("/p/:name"), []string{"p", ":name"})
    ok = ok && reflect.DeepEqual(parsePattern("/p/*"), []sring{"p", "*"})
    ok = ok && reflect.DeepEqual(parsePattern("/p/*name/*"), []string{"p", "*name"})
    if !ok {
        t.Fatal("test parsePattern failed")
    }    
}

func TestGetRoute(t *testing.T) {
    r := newTestRouter()
    n, ps := r.getRoute("GET", "/hello/gee")
    
    if n == nil {
        t.Fatal("nil shouldn't be returned")
    }
    
    if n.pattern != "/hello/:name" {
        t.Fatal("should match /hello/:name")
    }
    
    if ps["name"] != "gee" {
        t.Fatal("should match be equal to gee")
    }
    
    fmt.Printf("matched path: %s, params['name']: %s\n", n.pattern, ps["name"])
}

func TestGetRoute2(t *testing.T) {
    r := newTestRouter()
    n1, ps1 := r.getRoute("GET", "/assets/file1.txt")
    ok1 := n1.pattern == "/assets/*filepath" && ps1["filepath"] == "file1.txt"
    if !ok1 {
        t.Fatal("pattern should be /assets/*filepath & filepath should be file1.txt")
    }
    
    n2, ps2 := r.getRoute("GET", "/assets/css/test.css")
    ok2 := n2.pattern == "/assets/*filepath" && ps2["filepath"] == "css/test.css"
    if !ok2 {
        t.Fatal("pattern should be /assets/*filepath &filepath should be css/test.css")
    }
}

func TestGetRoutes(t *testing.T) {
    r := newTestRouter()
    nodes := r.getRoutes("GET")
    for i, n := range nodes {
        fmt.Println(i+1, n)
    }
    
    if len(nodes) != 5 {
        t.Fatal("the number of routes should be 4")
    }
}

使用DEMO

day3/main.go

package main

import (
	"gee"
    "net/http"
)

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=xxx
        c.String(http.StatucOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
    })
    
    r.GET("/assets/*filepath", func(c *gee.Context) {
        c.JSON(http.StatusOK, gee.H{"filepath": c.Param("filepath")})
    })
    
    r.Run(":9999")
}

使用curl测试

$ curl http://localhost:9999/hello/abc
hello abd, you're at /hello/abc

$ curl "http://localhost:9999/assets/css/abc.css"
{"filepath":"css/abc.css"}

day3小结

之前因为一项暑期实践活动的工作 , 我没有很认真地过一遍 , 也只是草草地敲一遍代码 , 简单地挖几个函数的源码来看 , 后面因为实训项目就先搁置了这个gee框架的学习 , 等做完实训项目后 , 花了半天把代码敲了一遍 , 也运行了一遍 , 但还是觉得心里心里很没底,于是想着,从第一天的内容开始认真看一遍,然后就开始对着前两天的源码一顿操作,开始不断查看源码中函数引用的内容,在关键函数print相关变量,前两天内容并不算很难,到了第三天,可能是跳跃性太大,加上前两天基础不牢,我在这里卡了4天,加上这几天状态不太好,就学的比较慢,这几天意识到事情的严重性,稍微加快了脚步,对第三天的路由部分进行了更多的测试,也对这个路由部分有了更深的认识

day4. 分组控制Group

  • 本次实现路由分组控制(Route Group Control),代码约50行。

分组的意义

分组控制(Route Group Control) 是Web框架应提供的基础功能之一。所谓分组,是指的路由的分组。如果没有路由分组,我们需要针对每一个路由进行控制。但是真实的业务场景中,往往某一组路由需要相似的处理。例如:

  • /post开头的路由匿名可访问
  • /admin开头的路由需要鉴权
  • /api开头的路由时RESTful接口 , 可以对接第三方平台 , 需要三方平台鉴权

大部分情况下的路由分组 , 是以相同的前缀来区分的。因此,我们今天实现的分组控制也是以前缀来区分,并支持分组的嵌套。例如/post是一个分组,/post/a/post/b可以是该分组下的子分组。作用在/post分组上 中间件(middleware),也都会作用在子分组,子分组还可以应用自己特有的中间件。

中间件可以给框架提供无限的扩展能力,应用在分组上,可以使得分组控制的收益更为明显,而不是共享相同的路由前缀这么简单。例如/admin的分组,可以应用鉴权中间件;/分组应用日志中间件,/是默认的最顶层的分组,也就意味着给所有的路由,即整个框架增加了日志的能力。

分组嵌套

一个Group对象需要具备哪些属性呢?首先是前缀(prefix),比如/,或者/api;要支持分组嵌套,那么需要知道当前分组的父亲(parent)是谁;当然了,按照我们一开始的分析,中间件是应用在分组上的,那还需要储存应用在该分组上的中间件(middlewares)。还记得,我们之前调用函数*(Engine).addRoute()来映射所有的路由规则和 Handler。如果Group对象需要直接映射路由规则的画,比如我们想在使用框架时,这么调用

r := gee.New()
v1 := r.Group("/v1")
v1.GET("/", func(c *gee.Context) {
    c.HTML(http.StatusK, "<h1>hello gee</h1>")
})

那么Group对象,还需要有访问Router的能力,为了方便,我们可以在Group中,保存一个指针,指向Engine,整个框架的所有资源都是由Engine统一协调的,那么就可以通过Engine间接地访问各种接口了。

所以,最后的Group做出以下改动:

day4/gee/gee.go

type HandlerFunc func(*Context)

type (
    RouterGroup struct {
        prefix string
        middlewares []HandlerFunc
        parent *RouterGroup
        engine *Engine
    }
    // 进一步抽象,将Engine作为最顶层的分组,也就是说Engine拥有RouterGroup的所有能力
    Engine struct {
        *RouterGroup
        router *router
        groups []*RouterGroup
    }
)

// 下面是实现和路由有关的函数
func New() *Engine {
    engine := &Engine{router: newRouter()}
    engine.RouterGroup = &RouterGroup{engine: engine}
    engine.groups = []*RouterGroup{engine.RouterGroup}
    return engine
}

func (group *RouterGroup) Group(prefix string) *RouterGroup {
    engine := group.engine
    newGroup := &RouterGroup {
        prefix: group.prefix + prefix,
        // parent: group,
        engine: engine,
        // 查阅评论区后,作者用gruop.prefix+prefix的方式初始化已经拼接了完整的prefix,不需要parent,于是可以删除
    }
    engine.groups = append(engine.groups, newGroup)
    return newGroup
}

func (group *RouterGroup) addRoute(method string, comp string, handler HandlerFunc) {
    pattern := group.prefix + comp
    log.Printf("Route %4s - %s", method, pattern)
    group.engine.router.addRoute(method, pattern, handler)
}

func (group *RouterGroup) GET(pattern string, handler HandlerFunc) {
    group.addRoute("GET", pattern, handler)
}

func (group *RouteGroup) POST(pattern string, handler HandlerFunc) {
    gourp.addRoute("POST", pattern, handler)
}

在这里可以观察到addRoute函数,调用了group.engine.router.addRoute来实现了路由的映射。由于Engine从某种意义上继承了RouterGroup的所有属性和方法,因为(*Engine).engine是指向自己的。这样实现,我们既可以像原来一样添加路由,也可以通过分组添加路由。

使用Demo

day4/main.go

func main() {
    r := gee.New()
    r.GET("/index", func(c *gee.Context) {
        c.HTML(http.StatusOK, "<h1>Index Page</h1>")
    })
    v1 := r.Group("/v1")
    {
        v1.GET("/", func(c *gee.Context) {
            c.HTML(http.StatusOK, "<h1>hello gee</h1>")
        })
        
        v1.GET("/hello", func(c *gee.Context) {
            // expect /hello?name=abc
            c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
        })
    }
    v2 := r.Group("/v2")
    {
        v2.GET("/hello/:name", func(c *gee.Context) {
            // expect /hello/abc
            c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
        })
        v2.POST("/login", func(c *gee.Context) {
            c.JSON(http.StatusOK, gee.H{
                "username": c.PostForm("username"),
                "password": c.PostForm("password"),
            })
        })
    }
    
    r.Run(":9999")
}

通过curl的简单测试:

$ curl "http://localhost:9999/v1/hello?name=abc"
hello abc, you're at /v1/abc
$ curl "http://localhost:9999/v2/hello/abc"
hello abc, you're at /hello/abc

day5. 中间件 Middleware

  • 设计并实现Web 框架的中间件 (Middlewares)机制
  • 实现通用的 Logger 中间件,能够记录请求到响应所花费的时间,代码约50行

中间件是什么

中间件(middlewares),简单说,就是非业务的技术类组件。Web框架本身不可能去理解所有的业务,因而不可能实现所有的功能。因此,框架需要有一个插口,允许用户自定义功能,嵌入到框架中,仿佛这个功能是框架原生支持的一样。因此,对中间件而言,需要考虑2个比较关键的点:

  • 插入点在哪?使用框架的人并不关心底层逻辑的具体实现,如果插入点太底层,中间件逻辑就会非常复杂
  • 中间件的输入是什么?中间件的输入,决定了扩展能力。暴露的参数太少,用户发挥空间有限。

那对于一个Web框架而言,中间件应该设计成什么样呢?接下来的实现,基本参考了Gin框架。

中间件设计

Gee的中间件的定义与路由映射的Handler一致,处理的是输入的Context对象。插入点是框架接收到请求初始化Context对象后,允许用户使用自己定义的中间件做一些额外的处理,例如记录日志等,以及对Context进行二次加工。另外通过调用(*Context).Next()函数,中间件可等待用户自己定义的Handler处理结束后,做一些额外的操作,例如计算本次处理所用时间等。即Gee的中间件支持用户在请求被处理的前后,做一些额外的操作。举个例子,我们希望最终能够支持如下定义的中间件,c.Next()表示等待执行其他的中间件或用户的Handler

day4/gee/logger.go

func Logger() HandlerFunc {
    return func(c *Context) {
        // start timer
        t := time.Now()
        // process request
        c.Next()
        // calculate resolution time
        log.Printf("[%d] %s in %v", c.StatusCode, c.Req.RequestURI, time.Since(t))
    }
}

另外,支持设置多个中间件,依次进行调用。

在第四天的 "分组控制 Group Control"讲到,中间件是应用在RouterGroup上的,应用在最顶层的Group,相当于作用域全局,所有的请求都会被中间件处理。那为什么不作用在每一条路由规则上呢?作用在某条路由规则,那还不如用户直接在Handler中调用。只作用在某条路由规则的功能通透性太差,不适合定义为中间件。

我们之前的框架设计是这样的,当接收到请求后,匹配路由,该请求的所有信息都保存在Context中。中间件也不例外,接收到请求后,应查找所有应作用于该路由的中间件,保存在Context中,依次进行调用。为什么依次调用后,还需要在Context中保存呢?因为在设计中,中间件不仅作用在处理流程前,也可以作用在处理流程后,即在用户定义的Handler处理完毕后,还可以执行剩下的操作。

day4/gee/context.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
    // middleware
    handlers []HandlerFunc
    index int
}

func newContext(w http.RequestWriter, req *http.Request) *Context {
    return &Context {
        Path: req.URL.Path,
        Method: req.Method,
        Req: req,
        Writer: w,
        index: -1,
    }
}

func (c *Context) Next() {
    c.index++
    s := len(c.handlers)
    for ; c.index < s; c.index++ {
        c.handlers[c.index](c)
    }
}

index是记录当前执行到第几个中间件,当在中间件调用Next方法时,控制权交给了下一个中间件,直到调用到最后一个中间件,然后再从后往前,调用每个中间件在Next方法之后定义的部分。如果我们将用户在映射路由时定义的Handler添加到c.handlers列表中,结果会怎么样呢?

func A(c *Context) {
    part1
    c.Next()
    part2
}

func B(c *Context) {
    part3
    c.Next()
    part4
}

假设我们应用了中间件A和B,和路由映射的Handler。c.handlers是这样的 [A, B, Handler]c.index初始化为-1。调用c.Next(),接下来的流程是这样的:

  • c.index++ , c.index=0
  • 0 < 3 , 调用c.handlers[0],即A
  • 执行part1,调用c.Next()
  • c.index++,c.index=1
  • 1 < 3 , 调用c.handlers[1],即B
  • 执行part3,调用c.Next()
  • c.index++ , c.index=2
  • 2 < 3 , 调用c.handlers[2],即Handler
  • Handler调用完毕,返回到B中的part4,执行part4
  • part4执行完毕,返回到A中的part2,执行part2
  • part2执行完毕,结束

说重点,执行顺序是part1 -> part3 -> Handler -> part4 ->part2。恰恰满足了我们对中间件的要求,接下来看调用部分的代码,就能全部串起来了。

代码实现

定义Use函数,将中间件应用到某个Group

day4/gee/gee.go

// Use is defined to add middlewares to the group
func (group *RouterGroup) Use(middlewares ...HandlerFunc) {
    group.middlewares = append(group.middlewares, middlewares...)
}

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    var middlewares []HandlerFunc
    for _, group := range engine.groups {
        if strings.HasPrefix(req.URL.Path, group.prefix) {
            middlewares = append(middlewares, group.middlewares...)
        }
    }
    c := newContext(w, req)
    c.handlers = middlewares
    engine.router.handle(c)
}

ServeHTTP函数也有变化,当我们接收到一个具体请求时,要判断该请求适用于哪些中间件,在这里我们简单通过URL的前缀来判断。得到中间件列表,赋值给c.handlers

handle函数中,将从路由匹配得到的Handler添加到c.handlers列表中,执行c.Next()

day4/gee/router.go

func (r *router) handle(c *Context) {
    n, params := r.getRoute(c.Method, c.Path)
    
    if n != nil {
        key := c.Method + "-" +n.pattern
        c.Params = params
        c.handlers = append(c.handlers, r.handlers[key])
    } else {
        c.handlers = append(c.handlers, func(c *Context) {
            c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
        })
    }
    c.Next()
}

使用demo

func onlyForV2() gee.HandlerFunc {
    return func(c *gee.Context) {
        // start timer
        t := time.Now()
        // if a server error occurred
        c.Fail(500, "Internal Server Error")
        // Calculate resolution time
        log.Printf("[%d] %s in %v for group v2", c.StatusCode, c.Req.RequestURI, time.Since(t))
    }
}

func main() {
    r := gee.New()
    r.Use(gee.Logger()) // global middleware
    r.GET("/", func(c *gee.Context) {
        c.HTML(http.StatusOK, "<h1>hello gee</h1>")
    })
    
    v2 := r.Group("/v2")
    v2.Use(onlyForV2())
    {
        v2.GET("/hello/:name", func(c *gee.Context) {
            // expect /hello/gee
            c.String(http.StatisOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
        })
    }
    
    c.Run(":9999")
}

gee.Logger()即我们一开始就介绍的中间件,我们将这个中间件和框架代码放在了一起,作为框架默认提供的中间件。在这个例子中,我们将gee.Logger()应用在了全局,所有的路由都会应用该中间件。onlyForV2()是用来测试功能的,尽在v2对应的Group中应用了。

接下来使用curl测试,可以看到,v2 Group 2个中间件都生效了。

$ curl http://localhost:9999/
<h1>Hello Gee</h1>

$ curl http://localhost:9999/v2/hello/abc
{"message":"Internal Server Error"}

服务器端

2022/07/27 16:00:01 [200] / in 300ns
2022/07/27 16:00:28 [500] /v2/hello/abc in 0s for group v2
2022/07/27 16:00:28 [500] /v2/hello/abc in 1.6176ms

这里的测试v2中间件,一开始,测试了很多次,返回的都是500错误码,比对了源码很久,没发现问题,再次运行curl测试,还是返回500错误码。后面查阅了第五天的评论区,发现,day5的中间件仅仅用来演示,发送500错误码表示中间件起作用了。

day6. 模板 (HTML Template)

  • 实现静态资源服务 (Static Resource)
  • 支持HTML模板渲染

服务器渲染

现在越来越流行前后端分离的开发模式,即 Web 后端提供RESTful接口,返回结构化的数据 (通常为JSON或XML)。前端使用AJAX 技术请求到所需的数据,利用 JavaScript 进行渲染。Vue/React 等前端框架持续火热,这种开发模式前后端解耦,优势很突出。后端打工人专心解决资源利用,并发,数据库等问题,只需要考虑数据如何生成;前端打工人专注于界面设计实现,只需要考虑拿到数据后如何渲染即可。后端只关注于数据,接口返回值是结构化的,于前端解耦。同一套后端服务能够同时支撑小程序,移动app,pc端 Web界面,以及对外提供的接口。随着前端工程化的不断发展,Webpack,gulp等工具层出不穷,前端技术越来越自成体系了。

但是前后端分离的一大问题在于,页面是在客户端渲染的,比如浏览器,这对爬虫并不友好。

今天的内容便是介绍 Web框架如何支持服务端渲染的场景。

静态文件 (Serve Static Files)

网页三剑客,js,css,html。要做到服务端渲染,第一步便是要支持js,css等静态文件。之前设计动态路由的时候,支持通配符*匹配多级子路径。比如路由规则/assets/*filepath,可以匹配/assets/开头的所有地址。例如/assets/js/geek.js,匹配后,参数filepath旧赋值为js/geek.js

那么如果我们将所有静态文件放在/usr/web目录下,那么filepath的值既是该目录下文件的相对地址。映射到真实的文件后,将文件返回,静态服务器就实现了。

找到文件后,如何返回这一文件,net/http库已经实现了。因此,gee框架要做的,仅仅是解析请求的地址,映射到服务器上文件的真实地址,交给http.FileServer处理就好了。

day6/gee/gee.go

// create static handler
func (group *RouterGroup) createStaticHandler(relativePath string, fs http.FileSystem) HandlerFunc {
    absolutePath := path.Join(group.prefix, relativePath)
    fileServer := http.StripPrefix(absolutePath, http.FileServer(fs))
    return func(c *Context) {
        file := c.Param("filepath")
        // check if file exists and/or if we have permission to access it
        if _, err := fs.Open(file); err != nil {
            c.Status(http.StatusNotFound)
            return
        }
        
        fileServer.ServeHTTP(c.Writer, c.Req)
    }
}

// serve static files
func (group *RouterGroup) Static(relativePath string, root string) {
    handler := group.createStaticHandler(relativePath, http.Dir(root))
    urlPattern := path.Join(relativePath, "/*filepath")
    // Register GET handlers
    group.GET(urlPattern, handler)
}

我们给RouterGroup添加了两个方法,Static这个方法是暴露给用户的。用户可以将磁盘上的某个文件夹root映射到路由relativePath。例如:

r := gee.New()
r.Static("/assets", "/usr/Jayden/blog/static")
// 或者相对路径 r.Static("/assets", "./static")
r.Run(":9999")

用户访问localhost:9999/assets/js/geek.js

最终返回/usr/geek/blog/static/js/geek.js

HTML 模板渲染

golang内置了text/templatehtml/template2个模板标准库,其中 html/template 为 HTML提供了较为完整的支持。包括普通变量渲染、列表渲染、对象渲染等。gee框架的模板渲染直接使用了html/template提供的能力。

day6/gee/gee.go

Engine struct {
    *RouterGroup
    router *router
    groups []*RouterGroup // store all groups
    htmlTemplate *template.Template // for html render
    funcMap template.FuncMap // for html render
}

func (engine *Engine) SetFuncMap(funcMap template.FuncMap) {
    engine.funcMap = funcMap
}

func (engine *Engine) LoadHTMLGlob(pattern string) {
    engine.htmlTemplates = template.Must(template.New("").Funcs(engine.funcMap).ParseGlob(pattern))
}

首先为 Engine 实例添加了*template.Templatetemplate.FuncMap对象,前者将所有的模板加载进内存,后者是所有的自定义模板渲染函数。

另外,给用户分别提供了设置自定义渲染函数funcMap和加载模板的方法。

接下来,对原来的(*Context).HTML()方法做了些小修改,使之支持根据模板文件名选择模板进行渲染。

day6/gee/context.go

type Context struct {
    // ..
    // engine pointer
    engine *Engine
}

func (c *Context) HTML(code int, name string, data interface{}) {
    c.SetHeader("Content-Type", "text/html")
    c.Status(code)
    if err := c.engine.htmlTemplates.ExecuteTemplate(c.Writer, name, data); err != nil {
        c.Fail(500, err.Error())
    }
}

我们在Context中添加了成员变量engine *Engine,这样就能够通过Context访问 Engine 中的HTML模板。实例化Context时,还需要给c.engine赋值。

day6/gee/gee.go

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // ...
    c := newContext(w, req)
    c.handlers = middlewares
    c.engine = engine
    engine.router.handle(c)
}

使用Demo

最终目录结构

---gee/
---static/
   |---css/
       |---geek.css
   |---file1.txt
---template
   |---arr.tmpl
   |---css.tmpl
   |---custom_func.tmpl
---main.go

day6/templates/arr.tmpl

<html>
    <body>
        <p>hello, {{.title}}</p>
        {{range $index, $ele :=.stuArr}}
        <p>{{ $index}}: {{$ele.Name}} is {{ $ele.Age}} years old</p>
        {{end}}
    </body>
</html>

day6/template/css.tmpl

<html>
    <link rel="stylesheet" href="/assets/css/geek.css">
    <p>geek.css is loaded</p>
</html>

day6/template/custom_func.tmpl

<html>
    <body>
        <p>hello, {{.title}}</p>
        <p>Date: {{.now | FormatDate}}</p>
    </body>
</html>

day6/main.go

type student struct {
    Name string
    Age int8
}

func FormatAsDate(t time.Time) string {
    year, month, day := t.Date()
    return fmt.Sprintf("%d-%02d-%02d", year, month, day)
}

func main() {
    r := gee.New()
    r.Use(gee.Logger())
    r.SetFuncMap(template.FuncMap{
        "FormatAsDate": FormatAsDate,
    })
    r.LoadHTMLGlob("templates/*")
    r.Static("/assets", "./static")
    
    stu1 := &student{Name: "gee", Age: 20}
    stu2 := &student{Name: "Jay", Age: 22}
    r.GET("/", func(c *gee.Context) {
        c.HTML(http.StatusOK, "css.tmpl", nil)
    })
    r.GET("/students", func(c *gee.Context) {
        c.HTML(http.StatusOK), "arr.tmpl", gee.H{
            "title": "gee",
            "stuArr": [2]*student{stu1, stu2},
        })
    })
    
    r.GET("/students", func(c *gee.Context) {
        c.HTML(http.StatusOK, "custom_func.tmpl", gee.H{
            "title": "gee",
            "now": time.Date(2019,8,17,0,0,0,0,time.UTC)
        })
    })
    
    r.Run(":9999")
}

在浏览器访问主页,模板正常渲染,css静态文件加载成功

day7. 错误恢复 (Panic Recover)

实现错误处理机制

panic

golang中,比较常见的错误处理方法是返回error,由调用者决定后续如何处理。但是如果是无法恢复的错误,可以手动触发panic,当然如果在程序运行过程中出现了类似于数组越界的错误,panic也会被触发。panic会中止当前执行的程序,退出。

下面是主动触发的例子:

package main
// hello.go
func main() {
    fmt.Println("before panic")
    panic("crash")
    fmt.Println("after panic")
}
$ go run hello.go

before panic 
panic: crash

goroutine 1 [running]:
main.main()
        ~/*your path*/hello.go:5 +0x95
exit status 2

下面是数组越界触发的panic

package main
// hello.go
func main() {
    arr := []int{1, 2, 3}
    fmt.Println(arr[4])
}
$ go run hello.go
panic: runtime error: index out of range [4] with legnth 3

defer

panic会导致程序被中止,但是在退出前,会先处理完当前携程上已经defer的任务,执行完成后再退出。效果类似于Java语言的try...catch

package main
// hello.go
func main() {
    defer func() {
        fmt.Println("defer func")
    }()
    
    arr := []int {1, 2, 3}
    fmt.Println(arr[4])
}
$ go run hello.go
defer func
panic: runtime error: index out of range [4] with length 3

可以defer多个任务,在同一个函数中defer多个任务,会逆序执行。即先执行最后的defer的任务 (类似于栈)。

在这里,defer的任务执行完成之后,panic还会继续被抛出,导致程序非正常结束。

recover

golang还提供了recover函数,可以避免因为panic发生而导致整个程序终止,recover函数只在defer中生效

// recover.go
func test_recover() {
    defer func() {
        fmt.Println("defer func")
        if err := recover(); err != nil {
            fmt.Println("recover success")
        }
    }()
    
    arr := []int{1, 2, 3}
    fmt.Println(arr[4])
    fmt.Println("after panic")
}
$ go run recover.go
defer func
recover success
after recover

我们可以看到,recover捕获了panic,程序正常结束。test_recover()中的after panic没有打印,这是正确的,当panic被触发时,控制权就被交给了defer。就像在Java中,try代码块中发生了异常,控制权交给了catch,接下来执行catch代码块中的代码。而在main()中打印了after recover,说明程序已经恢复正常,继续往下执行到结束。

Gee的错误处理机制

对一个Web框架而言,错误处理机制是非常必要的。可能是框架本身没有完备的测试,导致在某些情况下出现空指针异常等情况。也有可能用户不正确的参数,触发了某些异常,例如数组越界,空指针等。如果因为这些原因导致系统宕机,必然是不可接受的。

我们在第六天实现的框架并没有加入异常处理机制,如果代码中存在会触发panic的bug,就很容易宕机。

看下面示例代码

// hello.go
func main() {
    r := gee.New()
    r.GET("/panic", func(c *gee.Context) {
        names := []string{"gee"}
        c.String(http.StatusOK, names[100])
    })
    r.Run(":9999")
}

在上面的代码中,我们为gee注册了路由/panic,而这个路由的处理函数内部存在数组越界names[100],如果访问localhost:9999/panic,web服务器就会宕掉。

今天,我们将在gee中添加一个添加一个非常简单的错误处理机制,即在此类错误发生时,向用户返回 Internal Server Error,并且在日志中打印必要的错误信息,方便进行错误定位。

我们之前实现了中间件机制,错误处理也可以作为一个中间件,增强gee框架的能力。

day7/gee/recovery.go

package gee

import (
    "fmt"
    "log"
    "net/http"
    "runtime"
    "strings"
)

// print stack trace for debug
func trace(message string) string {
    var pcs [32]uintptr
    n := runtime.Callers(3, pcs[:]) // skip first 3 caller
    
    var str strings.Builder
    str.WriteString(message + "\nTraceback: ")
    for _, pc := range pcs[:n] {
        fn := runtime.FuncForPC(pc)
        file, line := fn.FileLine(pc)
        str.WriteString(fmt.Sprintf("\n\t%s:%d", file, line))
    }
    return str.String()
}

func Recovery() HandlerFunc {
    return func(c *Context) {
        defer func() {
            if err := recover(); err != nil {
                message := fmt.Sprintf("%s", err)
                log.Printf("%s\n\n", trace(message))
                c.Fail(http.StatusInternalServerError, "Internal Server Error")
            }
        }()
        c.Next()
    }
}

Recovery()的实现很简单,使用defer挂载上错误恢复的函数,在这个函数中调用recover(),捕获panic,并且将堆栈信息打印在日志里,向用户返回Internal Server Error。

trace()中,调用了runtime.Callers(3, pcs[:]),Callers用来返回调用栈的程序计数器,第0个Caller是Callers本身,第一个是上一层trace,第二个是再上一层的defer func。因此,为了日志简洁一点,我们跳过前三个Caller。

接下来,通过runtime.FuncForPC(pc)获取对应的函数,再通过fn.FileLine(pc)获取到调用该函数的文件名和行号,打印在日志里。

至此,gee框架的错误处理机制就完成了。

day7/gee/gee.go

func Default() *Engine {
    engine := New()
    engine.Use(Logger(), Recovery())
    return engine
}

使用Demo

day7/main.go

package main

import (
    "net/http"
    "gee"
)

func main() {
    r := gee.Default()
    r.GET("/", func(c *gee.Context) {
        c.String(http.StatusOK, "hello gee\n")
    })
    // index out of range for testing Recovery()
    r.GET("/panic", func(c *gee.Context) {
        names := []string{"gee"}\
        c.String(http.StatusOK, names[100])
    })
    r.Run(":9999")
}

下面来进行测试,先访问一个主页,访问一个有bug的/panic,服务正常返回。接下来我们再一次成功访问了主页,说明服务完全运转正常。

Client

$ curl http://localhost:9999
hello gee

$ curl http://localhost:9999/panic
{"message":"Internal Server Error"}

$ curl http://localhost:9999
hello gee

Server

我们可以在后台日志中看到如下内容,引发错误的原因和堆栈信息都被打印了出来,通过日志,我们可以很容易知道,在day7/main.go:47的地方出现了index out of range的错误。

2022/07/29 22:15:43 Route  GET - /
2022/07/29 22:15:43 Route  GET - /panic
2022/07/29 22:15:45 runtime error: index out of range [100] with length 1
Traceback:
    /usr/local/go/src/runtime/panic.go:838
        /usr/local/go/src/runtime/panic.go:89
        /root/code/go/src/gee_web/dev/main.go:17
        /root/code/go/src/gee_web/dev/gee/context.go:41
        /root/code/go/src/gee_web/dev/gee/recovery.go:56
        /root/code/go/src/gee_web/dev/gee/context.go:41
        /root/code/go/src/gee_web/dev/gee/logger.go:15
        /root/code/go/src/gee_web/dev/gee/context.go:41
        /root/code/go/src/gee_web/dev/gee/router.go:101
        /root/code/go/src/gee_web/dev/gee/gee.go:121
        /usr/local/go/src/net/http/server.go:2917
        /usr/local/go/src/net/http/server.go:1967
        /usr/local/go/src/runtime/asm_amd64.s:1572
2022/07/29 22:15:45 [500] /panic in 103.5μs

一些想法

其实整篇做下来吧,其实到现在对整个框架只能够说是大概了解,自己也跟着博客敲了一遍,也大概能看懂作者的设计思路,先做一个简单的http相应,后面再添加Context、前缀树等等。

在做的过程中,也会遇到很多bug,不同于c,Java,golang这门语言,个人感觉抽象程度比Java这些高,有时候出现panic,找到了出错的行数,还得去翻阅源码,不过吧,这个也算是在锻炼自己的动手能力和解决问题的能力,也算是有些收获吧。

参考链接

7天用Go从零实现Web框架Gee教程 | 极客兔兔 (geektutu.com)

(79条消息) 解决vscode和go mod 导包冲突的问题_sora!的博客-CSDN博客_gomod vscode

(80条消息) vscode使用go get 之后无法import_Restart丶的博客-CSDN博客

Golang fmt.Fprintf()用法及代码示例 - 纯净天空 (vimsky.com)

理解Golang中的interface和interface{} - maji233 - 博客园 (cnblogs.com)

Go 语言通过 Request 对象读取 HTTP 请求报文 | 请求处理 | Go Web 编程 (laravelacademy.org)

ResponseWriter.Write 和 io.WriteString 有什么区别?_慕课猿问 (imooc.com)

(79条消息) Go net.http包下的ListenAndServe函数的参数问题_qwe1765667234的博客-CSDN博客

(79条消息) Go使用net/http标准库(二)源码学习之- http.ListenAndServe()_这个名字想了很久的博客-CSDN博客

ServeHTTP是如何工作的? - 问答 - 腾讯云开发者社区-腾讯云 (tencent.com)