7.01 Middleware-AccessLog

PART1. 为什么要有AccessLog?
在日常工作中,我们可能希望能够记录所有进来的请求,以支持Debug.这也就是所谓的AccessLog.
Beego、Iris、Gin都支持了AccessLog.这里我们也提供一个简单的AccessLog Middleware
PART2. 实现
初态目录结构如下:
2.1 创建中间件的过程
创建目录:
/middlewares: 用于存放所有中间件的代码/middlewares/access_log: 用于存储accessLog中间件的代码
这里使用生成器模式来完成中间件的创建.先将生成器模式的结构写出来,不考虑具体的日志记录功能:
middlewares/access_log/middlewareBuilder.go:
2.2 定义中间件结构
这里我们假定需要记录的日志内容如下:
请求的主机地址
命中的路由
请求的HTTP动词
请求的uri(路径)
middlewares/access_log/accessLog.go:
2.3 获取要记录的字段值
2.3.1 基本实现
这里我们开始实现日志记录,完善MiddlewareBuilder.Build()方法
middlewares/access_log/middlewareBuilder.go:
这里选择在defer中记录请求的原因:
next(ctx)可能会出现panic,如果将记录请求的代码直接写在调用next(ctx)后边,那么有可能不会被执行当然你也可以设计为在调用
next(ctx)前记录请求,但如果在记录请求的过程中出现了panic,那么就会因为一些非关键流程的错误导致整个流程跑不通,是一种得不偿失的做法综上所述,写在
next(ctx),或者写在next(ctx)后,都有各自的问题.还是写在defer中最合适
这里我们遇到了第一个问题:命中的路由从哪里来?
2.3.2 记录命中的路由
首先毫无疑问的一点是:命中的路由也得从web.Context中拿.那很明显现在的web.Context没有这个字段,需要加上:
context.go:
第2个问题就是:在哪里给这个字段赋值?
很明显是在查找到命中的路由之后,即HTTPServer.serve()方法中赋值:
httpServer.go:
注:
很明显此处我们只是记录了命中的路由的最后一段,即:假设注册的路由为
/a/b/*,此处我们只是记录了*.这个属于细节问题,后续再调整.我写代码时也应该是这样:先实现整体的结构,再调整细节
2.3.3 获取命中的路由
到这一步就比较顺理成章了:
middlewares/access_log/middlewareBuilder.go:
2.4 记录请求
到了这一步,剩下的就是记录日志了.有的人(包括我)到这一步可能直接就打印了:
middlewares/access_log/middlewareBuilder.go:
那么问题来了:如果框架的使用者,不希望使用GO原生的log包来打印日志(甚至使用者的需求不是打印日志而是将日志写入文件等),该怎么办?
因此应该提供一种方式,让框架的使用者从外部定义打印日志的过程:
2.4.1 对外暴露控制记录方式的接口
这里的"接口",指的不是编码时的interface,而是提供给外部的、让外部控制记录方式的一个"入口"(这里我确实没找到合适的词来形容)
middlewares/access_log/middlewareBuilder.go:
先定义了一个函数类型的字段,该字段用于存储外部提供的记录日志函数
再定义一个方法,该方法对外暴露,用于让外部设置记录日志函数
这是典型的生成器模式的应用.注意MiddlewareBuilder.SetLogFunc()方法的返回值,仍旧是一个MiddlewareBuilder的实例,这样的设计是为了在使用时可以链式调用,更加方便
2.4.2 使用外部提供的函数记录日志
这样再记录日志就很容易了.
middlewares/access_log/middlewareBuilder.go:
这里就是单纯的将accessLog 结构体的实例转为一个JSON,以便后续能够将其承载的数据转换为string类型.
PART3. 传递中间件
先写测试用例,以便发现问题:
middlewares/access_log/middleware_test.go:
现在问题出现了:该怎么将中间件加入到HTTPServer中?
3.1 方案1:实例化HTTPServer时传入中间件链
HTTPServer时传入中间件链3.1.1 实现
httpServer.go:
3.1.2 使用
middlewares/access_log/middleware_test.go:
3.1.3 优缺点
优点:
直观,好理解
缺点:
如果以后的需求变更导致需要在形参
middlewares前后添加其他的参数的话,整个函数的签名就都变了.可想而知受这个函数签名变化的影响,要修改的地方会有很多中间件链的顺序在创建
HTTPServer实例时就固定了,无法灵活调整
3.2 方案2:将HTTPServer.middlewares字段设置为公有属性
HTTPServer.middlewares字段设置为公有属性3.2.1 实现
httpServer.go:
3.2.2 使用
middlewares/access_log/middleware_test.go:
3.2.3 优缺点
优点:
直观,好理解
缺点:
使用者调用了
NewHTTPServer()函数之后,还要单独设置HTTPServer.Middlewares字段.似乎看起来也不方便使用不够灵活.换言之,抽象层级不够高
3.3 Option模式
其实我们要做的就是一件事:把在包(在我的示例代码中是web包)的外部创建的中间件,传入到HTTPServer结构体的实例上去.
我第一反应的想法:
我们把这个思路的抽象层级再提升一层:如果把"为HTTPServer结构体的属性赋值"这件事看做是一个"职责"(这里的职责是指设计模式原则中的"单一职责"),会怎么样?
很明显能得到一个答案:需要一个类来完成这个职责.至此,Option模式的第一步出现了:创建Option类.
3.3.1 创建HTTPServerOption结构体
option.go:
注意这个结构体的类型为func(server *web.HTTPServer),我们在这个函数的内部修改web.HTTPServer实例的属性值即可
第2个问题随之而来:怎么修改web.HTTPServer实例的属性值?
3.3.2 实现修改web.HTTPServer实例的属性值的函数
web.HTTPServer实例的属性值的函数这个问题比较容易回答:直接在函数体内部修改就行了,反正结构体实例的指针都已经拿到了
httpServer.go:
第3个问题又来了:什么时候修改属性值?
3.3.3 调用函数修改web.HTTPServer实例的属性值
web.HTTPServer实例的属性值答案也比较简单:当然是创建出HTTPServer实例之后修改了
httpServer.go:

至此我们完成了使用Option模式创建HTTPServer实例的过程,继续回到测试代码上
PART4. 测试并调试
4.1 以创建http.Request的方式编写测试用例
http.Request的方式编写测试用例这里我们先手动创建一个请求,因为我们现在写的这个测试用例,是为了测试主逻辑是否正确的
middlewares/access_log/middleware_test.go:
运行结果:
4.2 测试发现的问题
accessLog.Route字段记录的值不正确.这里日志应该记录的是/a/b/*,而非*accessLog.Host值没记录上
这里第2个问题是好解决的,只要正常通过HTTP访问的请求,都能拿到这个Host.我们的测试用例由于是通过调用http.NewRequest()函数拿到的一个http.Request实例,因此http.Request.Host字段没有值
重点是第1个问题.
4.3 修Bug:命中的路由在日志中记录不正确
这个Bug比较好修.整体思路分为4步:
step1. 为
node结构体添加一个承载该节点对应的全路由的字段.注意:这里不需要为路由树上的每个节点都赋值该字段例如:注册路由
/a/b/c,其实只需为最深层的c节点赋值全路由即可.因为我们添加这个字段的目的是为了在记录日志时能够取到,而实际上用户如果访问路由/a或/a/b,根本就命中不了任何路由,也就不用记录日志了
step2. 注册路由时将全路由写入到节点(确切的说是绑定了
HandleFunc的节点)中step3. 匹配节点时从命中的节点中读取该字段并赋值给
Contextstep4. 记录日志时从
Context中读取全路由(其实现在就是从Context中读取的全路由,所以这一步不用改任何代码)
4.3.1 为node结构体添加用于承载全路径的字段
node结构体添加用于承载全路径的字段node.go:
4.3.2 将全路由写入到节点中
router.go:
注:此处为了便于观看,只保留了记录全路由相关代码的注释,其他注释都删掉了
4.3.3 匹配节点时从命中的节点中读取该字段并赋值给Context
ContexthttpServer.go:
注:此处为了便于观看,只保留了传递节点全路由给Context相关代码的注释,其他注释都删掉了
这里应该传递的是node.route而非node.path
4.3.4 重新执行测试用例
测试用例的代码没有任何变更,重新测试的结果如下:
可以看到,此时结果符合预期了
4.4 以启动服务器的方式编写测试用例
这里我们就只剩下测试Host字段是否正常工作了:
middlewares/access_log/middleware_test.go:
测试访问:

运行结果:
PART5. 潜在的问题
我们写的MiddlewareBuilder.Build()方法,相当于是一个样板,用户完全可以照猫画虎自己实现一个符合他需求的AccessLog中间件
这是老师和别人在设计理念上的巨大差异.在这种地方,用户完全可以自己提供扩展实现,且我们也设计了良好的接口让用户接入(回想func NewHTTPServer(opts ...Option) *HTTPServer的函数签名).老师也不愿意花费很多时间去设计精巧的结构和机制,去支持各种千奇百怪的用法.原则只有一个:用户有特殊需求,用户就自己写.
默认提供的实现是给大多数普通用户使用的,也相当于一个例子,有需要的用户可以参考这个实现写自己的实现.
对于大多数普通用户而言,简单记录一下accessLogMiddleware结构体中的字段(主机、命中的路由、请求的uri、请求的HTTP动词)就已经足够用了.更加复杂的、针对特定业务特性的AccessLog,应该自行实现.
在开源领域,也分为两类人:保姆型研发和简洁型研发
保姆型研发:使用者要什么,他们就去设计和实现什么,且实现时会考虑到方方面面的问题.尽可能的为用户着想,兼容用户的各种输入
简洁型研发:只提供主流的实现,但同时提供了扩展接口和例子给使用者.如果使用者有需求,让他们自己去实现.当然,使用者需要自己去学这个扩展接口怎么适配的
Last updated