首页 「Yogin」实现分组路由和日志中间件
文章
取消

「Yogin」实现分组路由和日志中间件

上一篇文章中,我们完成了简单的路由逻辑,实现从请求的方法和URL到相应业务处理逻辑的映射。随着网站接口规模越来越庞大,我们期望对路由进行分组归类,对不同组别的路由区分管理。例如,/pubilc开头的API可公开调用,而/admin开头的API需要进行鉴权,调用前要先登录。

分组路由使得多个API按照语义归类到不同的API组下,根据业务场景,同一组中的API中,公共的处理逻辑可以被单独抽取出来统一执行,减少代码冗余。具体而言,我们可以为不同的路由分组有各自的中间件。在上篇文章中,我们已经实现了一个较为完备的Context上下文,为本文具体中间件的实现提供了良好基础。

分组管理

我们首先来实现路由组RouterGroup。想象一下,要建立一个路由组,我们期望它保存什么信息。API按照语义归类到不同的路由组下,同一个路由组中,API具有相同的前缀。因此,我们需要它保存这个组下API的前缀basePath。此外,前面说到期望提取路由组下API的公共逻辑,例如鉴权,因此路由组要保存这些处理逻辑。与路由实现时,Trie树的叶节点类似,我们在路由组中也要保存Handlers。因此,我们的RouterGroup结构体设计如下:

1
2
3
4
5
6
type RouterGroup struct {
    Handlers    HandlersChain
    basePath    string
    engine      *Engine
    root        bool
}

现在我们来考虑RouterGroup应该提供哪些方法。最迫切的需求显然是新建属于该组的API,向其中增加HTTP的GET、POST等方法,并在框架的路由树中注册,就像之前我们用框架的addRoute方法新建路由一样。此外,我们希望一个路由组可以进一步划分,例如/v1组下进一步分为/v1/public/v1/admin。因此,我们希望从某个路由组产生新的子路由组。最后,不要忘记,路由组本身也要能够增加中间件,并且在增加中间件后,这些中间件最终应能反映在该路由组新创建的路由上,即能保存在Trie树的叶节点Handlers中。

到此,我们可以实现RouterGroup的核心方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func (group *RouterGroup) Use(middleware ...HandlerFunc) {
    group.Handlers = append(group.Handlers, middleware...)
}

func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup {
    return &RouterGroup{
        Handlers: group.combineHandlers(handlers),
        basePath: group.calculateAbsolutePath(relativePath),
        engine:   group.engine,
    }
}

func (group *RouterGroup) handle(method, relativePath string, handlers HandlersChain) {
    absolutePath := group.calculateAbsolutePath(relativePath)
    handlers = group.combineHandlers(handlers)
    group.engine.addRoute(method, absolutePath, handlers)
}

func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) {
    group.handle(http.MethodPost, relativePath, handlers)
}

func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) {
    group.handle(http.MethodGet, relativePath, handlers)
}

我们用Use方法向路由组中新增中间件,用Group方法创建子路由组。可以看到,子路由组继承了父路由组的中间件,并在父路由组basePath的基础上扩展后缀,这就是combineHandlerscalculateAbsolutePath所做的事情。

RouterGroup对外提供POSTGET等方法,用于在组中增加新的路由,这些路由基于路由组的basePath前缀以及路由组的中间件,这和创建子路由组是类似的。这些方法是handle方法的简单封装,handle最终调用框架提供的addRoute方法注册路由,这和我们上一篇文章中直接用addRoute新建路由的效果是一样的。然而,addRoute是包级私有方法,目前我们的框架还没有对外提供公开的新建路由的方法。在实现RouterGroup后,我们直接将其嵌入框架的结构体中,作为一个匿名的成员,用组合的方式实现“继承”的效果。

完成这些改动后,我们修改一下框架的初始化函数,加入对路由组的初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Engine struct {
    RouterGroup
    methodTrees map[string]methodTree
    contextPool        sync.Pool
}

func New() *Engine {
    engine := &Engine{
        RouterGroup: RouterGroup{
            Handlers: nil,
            basePath: "/",
            root:     true,
        },
        methodTrees: make(map[string]methodTree),
    }
    engine.RouterGroup.engine = engine
    engine.contextPool.New = func() interface{} {
        return engine.allocateContext()
    }
    return engine
}

日志中间件

中间件往往用于处理与业务逻辑无关的,各个模块间都需要的公共逻辑,例如日志记录、性能测量等等。现在,让我们实现一个简单的日志中间件,用于服务器处理请求时在控制台打印输出。

中间件的本质就是处理请求上下文的流水线中的一环,先于业务逻辑执行,在业务逻辑执行返回后可以再次返回中间件完成收尾工作。中间件的实现也遵循HandlerFunc的设计。因此,一个简单的日志中间件的结构大概是这样的:

1
2
3
4
5
6
func Logger(c *Context) {
    // start timer
    c.Next() // process request
    // stop timer
    // print log
}    

在进入Logger中间件时,记录当前时间,然后调用c.Next()进入流水线中的下一个环节。得益于Next函数的设计,中间件最终可以在后面的工序完成后再次获取控制权,此时可以计算出请求处理的时间,并且打印日志。

在设计上下文时,我们设计了一个Error方法,该方法记录各中间件在执行时产生的错误,并附加到上下文的Errors切片中。我们的日志中间件可以获取里面的内容并打印。

LogFormatterParams

我们可以将需要日志打印的信息记录在一个结构体中,无论输出的格式怎么变化,输出的内容都基于这些基本信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type LogFormatterParams struct {
    Request *http.Request

    // TimeStamp shows the time after the server returns a response.
    TimeStamp time.Time
    // StatusCode is HTTP response code.
    StatusCode int
    // Latency is how much time the server cost to process a certain request.
    Latency time.Duration
    // ClientIP equals Context's ClientIP method.
    ClientIP string
    // Method is the HTTP method given to the request.
    Method string
    // Path is a path the client requests.
    Path string
    // ErrorMessage is set if error has occurred in processing the request.
    ErrorMessage string
    // BodySize is the size of the Response Body
    BodySize int
}

LogFormatter

接下来,我们实现一个日志格式化的接口,将我们的日志数据转换为字符串输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type LogFormatter func(params LogFormatterParams) string

var defaultLogFormatter = func(param LogFormatterParams) string {
    statusColor := param.StatusCodeColor()
    methodColor := param.MethodColor()
    resetColor := param.ResetColor()

    if param.Latency > time.Minute {
        param.Latency = param.Latency.Truncate(time.Second)
    }
    return fmt.Sprintf("[YOGIN] %v |%s %3d %s| %13v | %15s |%s %-7s %s %#v %d\n%s",
        param.TimeStamp.Format("2006/01/02 - 15:04:05"),
        statusColor, param.StatusCode, resetColor,
        param.Latency,
        param.ClientIP,
        methodColor, param.Method, resetColor,
        param.Path,
        param.BodySize,
        param.ErrorMessage,
    )
}

状态值和方法值采用了控制台的彩色输出。一个类型只要实现了Writer接口,就可以负责日志字符串的输出,因此我们可以选择输出到控制台、文件或是网络等。

完整的Logger实现如下,其中,errorMessage函数将上下文Context中保存的错误信息格式化为字符串。此外,我们之前在上下文中保存的响应状态值statusCode和响应体大小bodySize在这派上了用场。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func Logger() HandlerFunc {
    formatter := defaultLogFormatter
    out := DefaultWriter

    return func(c *Context) {
        start := time.Now() // Start timer

        c.Next() // Process request

        param := LogFormatterParams{
            Request: c.Request,
            Path: c.Path,
        }

        param.TimeStamp = time.Now() // Stop timer
        param.Latency = param.TimeStamp.Sub(start)

        param.ClientIP = c.ClientIP
        param.Method = c.Request.Method
        param.StatusCode = c.statusCode
        param.ErrorMessage = errorMessage(c.Errors)

        param.BodySize = c.bodySize

        fmt.Fprint(out, formatter(param))
    }
}

由于有接口的设计,格式化和输出的方式都可以轻松配置。

示例

编写测试用例,看一看本文实现的分组管理和日志中间件是如何使用的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func TestRouterGroupBasic(t *testing.T) {
    r := New()
    g0 := r.Group("/hola", func(c *Context) {})
    g0.Use(func(c *Context) {})

    assert.Len(t, g0.Handlers, 2)
    assert.Equal(t, "/hola", g0.BasePath())
    assert.Equal(t, r, g0.engine)

    g1 := g0.Group("manu")
    g1.Use(Logger(), func (c *Context) {}, func(c *Context) {})

    assert.Len(t, g1.Handlers, 5)
    assert.Equal(t, "/hola/manu", g1.BasePath())
    assert.Equal(t, r, g1.engine)
}

运行go test,可以看到彩色的输出效果:

-16365447077031

完整代码仓库

yogin

「Yogin」系列全部代码可在我的GitHub代码仓库中查看:Yogin is Your Own Gin

欢迎提出各类宝贵的修改意见和issues,指出其中的错误和不足!

最后,感谢你读到这里,希望我们都有所收获!

本文由作者按照 CC BY 4.0 进行授权

「Yogin」从零实现一个gin-like框架

「Yogin」实现错误恢复和基本鉴权