在上一篇文章中,我们完成了简单的路由逻辑,实现从请求的方法和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
的基础上扩展后缀,这就是combineHandlers
和calculateAbsolutePath
所做的事情。
RouterGroup
对外提供POST
、GET
等方法,用于在组中增加新的路由,这些路由基于路由组的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
,可以看到彩色的输出效果:
完整代码仓库
「Yogin」系列全部代码可在我的GitHub代码仓库中查看:Yogin is Your Own Gin
欢迎提出各类宝贵的修改意见和issues,指出其中的错误和不足!
最后,感谢你读到这里,希望我们都有所收获!