从我的工作经历就能够看出我注定是一个不正经非主流前端工程师,从一开始的
c语言写网络应用,用Java开发Swing,好不容易赶上移动浪潮去搞了1年android却通过了ios开发工程师认证但是每天在写js开发后端接口。然后就一直挂着前端工程师的title写java,写node,写shell。现在公司,发现大家都在写golang,于是乎,这条筋又不对了。开始研究了golang,俗话说,知己知彼百战百胜,只有了解了RD的武器,才能更好地(撕)合作。
Koa.js是Nodejs技术一个非常注明的框架,由JS大神TJ创建,其中洋葱模型和co在nodejs4.0 - nodejs10解决了大家非常多的困扰。虽然我不喜欢koa.js。但是也非常喜欢洋葱模型,co以及它的中间件机制。虽然在nodejs10+以后co已经没什么卵用了。
什么是洋葱模型呢?
说白了就是请求进来,一层一层的通过中间件执行next函数进入到你设置的下一个中间件中,并且可以通过context对象一直向下传递下去,当到达最后一个中间件的时候,又向上返回到最初的地方。是不是有点像dom事件的捕获冒泡?确实是这个样子。
有人说,不就是递归吗,这有什么?确实,从原理角度来看确实是递归但是又有很多不同:
- 由于我们需要通过第三参数
next进行下一个回调的执行,并且在next调用的下一个回调执行后,还能继续执行剩余代码,并且可以在任意位置执行下一次调用的函数。所以这个实现就不仅仅是递归那么简单了。因为执行的不是同一个函数,且位置也不同。 - 传参和引用,我们需要保障整个执行过程,各个中间执行的函数中
context指向的是同一个上下文对象。
为了实现以上2个功能,首先语言必须具备1个基本能力 - 闭包,golang因为具备闭包,因此从理论上来讲是可以实现洋葱模型的。实际上是否可行呢?确实也是可行的。
koa一个实现了洋葱模型和中间件机制的golang框架
我就把它叫做koa吧。目前大多数golang的框架都是支持中间件的,但是中间件都是只支持顺序执行,并不支持洋葱模型。所以。这个也算是一个挑战吧。
话不多说先看看执行效果
package main
import (
"fmt"
"github.com/ryouaki/koa"
"github.com/ryouaki/koa/example/plugin"
)
func main() {
app := koa.New()
app.Use(plugin.Duration)
app.Use("/", func(err error, ctx *koa.Context, next koa.NextCb) {
fmt.Println("test1")
next(err)
fmt.Println("test1")
})
app.Get("/test/:var/p", func(err error, ctx *koa.Context, next koa.NextCb) {
fmt.Println("test", ctx.Params)
ctx.SetData("test", ctx.Query["c"][0])
next(nil)
}, func(err error, ctx *koa.Context, next koa.NextCb) {
ctx.Write([]byte(ctx.GetData("test").(string)))
})
err := app.Run(8080)
if err != nil {
fmt.Println(err)
}
}
这段代码包含一个中间件Duration用于输出请求耗时,test1中间件是观察洋葱模型的执行顺序,test/:var/p是我们的测试接口
$ curl localhost:8080/test/test/p?c=Hello World
test1
test map[var:test]
test1
2020-12-17 15:04:28 +0800 CST /test/test/p?c=Hello%20World Request cost: 0.040756 ms
通过日志我们可以看到,我们的test/:var/p接口函数像嵌套在test1中间件next的位置一样被执行了。那么是如何实现的呢?是不是很好玩?
洋葱模型的核心代码
整个koa核心代码不足300行,其中最核心的代码只有20行,也就是实现洋葱模型的核心代码:
func compose(ctx *Context, handlers []Handler) func(error) {
currentPoint := int32(0) // 记录当前执行到第几个函数
var next func(err error) // 缓存退栈函数,
var cbMax = len(handlers) // 记录最大执行次数
// 整个框架最核心的代码,退栈执行函数
next = func(err error) {
_ctx := ctx // 闭包缓存当前执行上下文指针
_router := handlers // 缓存需要执行的函数
bFound := false // 当前轮训是否匹配到已执行函数。保证每次只执行一个函数
for int(currentPoint) < cbMax && bFound == false {
bFound = true
// 取出当前需要执行的函数
currRouterHandler := _router[currentPoint]
atomic.AddInt32(¤tPoint, 1)
// 执行,并将退栈执行函数传入。
currRouterHandler(err, _ctx, next)
}
}
// 返回退栈函数
return next
}
解读如何使用compose实现洋葱模型
为了保障各个中间件的执行顺序,并且保障执行上下文的一致,我们需要将所有符合请求路由规则的Handler进行压栈。
// ServeHTTP interface func
func (app *Application) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var err error = nil
var body []uint8 = nil
method := strings.ToLower(req.Method)
if req.Body != nil {
body, err = ioutil.ReadAll(req.Body)
}
// 创建一个新的执行上下文
ctx := &Context{
Header: req.Header,
Res: w,
Req: req,
URL: req.RequestURI,
Path: req.URL.Path,
Query: formatQuery(req.URL.Query()),
Body: body,
Method: method,
Status: 200,
IsFinish: false,
data: make(map[string]interface{}),
}
var routerHandler []Handler
// 将符合规则的中间件进行压栈
for _, middleware := range app.middlewares {
if ok := compare(middleware.path, ctx.Path, false); ok {
routerHandler = append(routerHandler, middleware.handler)
}
}
// 将符合规则的路由函数进行压栈
for _, router := range app.route[ctx.Method] {
if ok := compare(router.path, ctx.Path, true); ok {
ctx.MatchURL = router.path
ctx.Params = formatParams(router.path, ctx.Path)
routerHandler = append(routerHandler, router.handler...)
}
}
// 开始执行因为需要保障每一个中间件和路由函数访问同一个执行上下文,所以传入的是指针。
fb := compose(ctx, routerHandler)
fb(err)
}
压栈后的函数,统一推入compose中,进行退栈执行。就是这么简单。
总结
通过洋葱模型,使我们可以更容易的监控整个路由请求的全链路,并且可以在中间件做更多的事情。比如统计响应耗时:
package plugin
import (
"fmt"
"time"
"github.com/ryouaki/koa"
)
// Duration func
func Duration(err error, ctx *koa.Context, next koa.NextCb) {
startTime := time.Now()
next(nil)
d := time.Now().Sub(startTime)
fmt.Println(time.Date(startTime.Year(),
startTime.Month(),
startTime.Day(),
startTime.Hour(),
startTime.Minute(),
startTime.Second(), 0, time.Local),
ctx.URL,
"Request cost: ",
float64(d)/float64(time.Millisecond), "ms")
}
可以看到,在洋葱模型的支持下,我们可以将整个中间件逻辑写在一起即可。而next就是具体的路由逻辑,我们并不需要关心它具体做了什么,只需要关心我们自己需要做什么。这样可以将同一个业务逻辑的代码聚合到一起,更容易维护,不是么?