再次理解koa-compose实现
最近常听到 AOP 切面编程这个词儿,回想到 Koa 的洋葱模型中间件,其实也有点切面编程的意思吧。假设 koa 的请求处理流程包括 “收请求”、“处理请求”、“响应给用户”。那么中间请求部分则是 koa 暴露给开发者来切入处理的。
那么这里 koa 采用的是中间件模式来 use 注入处理逻辑,从另外层面讲也可以勉强理解成 “响应用户前”的一个切入点,然后通过 use 让我织入我们的增强能力。
废话不多说,还是去研究研究 koa-compose 的实现吧。之前我已经有博客写过这里的逻辑了,但是这玩意太绕了,很久不学就忘了,现在我们用现在的认知来重新过一遍。
这个图画的真不错:
从掘金上看到了这个图来描述 koa-compose 中的 dispach 高阶函数执行逻辑,确实画的很不错。相当清晰的描述了 dispach 0、1、2 的高阶函数执行过程,且每一列高阶函数中也竖向画出了高阶函数内部实际 fn 函数的执行顺序。
能画出图来说明理解了本质,真是值得我去学习!
最关键的是:
1、他能知道从横向的几列图来描述 compose 的 dispach 函数触发时序
2、他能知道在每个 dispach 中用纵向箭头表示 dispach 内部执行逻辑和实际中间件运行后 console 打印的内容,从而可以用箭头方式把整个 console 打印过程串起来。
3、他能在左侧把 koa 实例化流程也画出来,而且知道从 callback—dispach 这一列开始就算是堆栈的栈底了。于是他从 dispach0 这一列开始,向右每一列都代表一个函数的执行上下文。
给我的启发就是:后续要想一想这种随着时间推移有多个上下文调用的,也可以用横向的多个上下文的这种“类时序图”来表达,再加上箭头表上程序逻辑流转方向,去到右边再回来,挺适合用来描述递归的。
在我没做任何思考时候,先自己手写了一版
基于最近有个面试官跟我讲到,koa-compose 无非就是个 reduce 嘛。于是我就想着用 数组的 reduce 概念来“从最初一个中间件”不断叠加,生成一个最终包含了所有中间件串联的一个最终 state 的中间件。
1 | // 第二步实现真正的 compose函数 |
上述代码,通过依次把中间件进行一个高阶函数包裹,包裹完就作为前一个中间件的 next,交给前一个中间件的高阶函数存起来,依次包裹直到首个中间件的包裹函数也具有了下一个中间件的引用。
然后对外返回第一个中间件的高阶函数即可。但这里问题在于:
1、上述代码尽管在我的测试代码中可用,然而终极的 compose 后的函数是包裹最初 compose 时候的 ctx。我们无法再做到调用最终函数时候实时传递 ctx,这会导致每次过来用户请求都要重新 compose。
2、看起来好像也没有用上面试官所谓的 reduce 思想?
于是我就想:所谓 reduce 就类似于 react 或 redux 里面的 reduce 函数,他就是接受一个上一次状态以及当次循环时候的新 action,你 reduce 函数就负责返回你操作后的数据。也类似于咱们 array 数组的 reduce,他就负责把你每上次处理后的数据作为 state 传入你的 reduce 函数,你就根据本次的情况(例如本次 item 数据)继续处理,并返回本次的最终 state。那我思考一下 koa 的 middlewares 数组要做的事情:就是我最终要形成一个“终极函数 A”,该函数调用时候,其入参的 next 参数永远都是传递 B 函数,同理 B 函数调用时候的 next 就是 C 函数。那我可能就可以把 middleware 数组反过来,然后依次包装每个中间件函数成为高阶函数,给她包裹上下一个中间件入参:
1 | function composeReduce(middlewares, ctx) { |
实测后:
1、确实也能跑。但是依然存在每次请求过来都要 compose 的问题。
2、看起来 koa 的中间件 compose,是不能用 reduce 思路的啊。无法做到只 compose 一次啊。这面试官是不是二把刀。
koa 原版源码
我们还是直接来看大神的源码吧:
1 | // 理论上我们是要 koa app 创建的时候就已经 compose 完了。然后每次请求过来就直接去执行 compose之后的那个函数,并且实时传入 ctx 就行了。 |
现在理论上我们是要 koa app 创建的时候就已经 compose 完了。然后每次请求过来就直接去执行 compose 之后的那个函数,并且实时传入 ctx 就行了。源码其实算是采用的递归思路吧,并不是 reduce 思路,他始终把下一个函数的包裹作为第二个参数传递给当前执行的那个,然后等待当前执行的那个调用。一旦调用,就变成递归调用 dispatch 了。
此时还有个问题: 如果有个人他在 一个中间件里面 await next,然后又 await next,写了两次重复的 next 调用。那么,就会造成逻辑流重复下行。不太符合中间件模型。
于是,我们可以防止他 dispach 同一个数字两次。方法就是:每次 dispath i 的时候,我们就在上层把这个 i 记录一下当前中间件嵌套调用已经到达第几层了
将 dispatch 函数展开来理解 promise 嵌套调用流程
为便于理解,我们通过模拟一个 3 个中间件的 middlewareArr 数组,然后将 3 个中间件的 dispach 调用过程直接平铺展开手写,从而更容易理解 dispach 多层嵌套递归调用的原理。代码如下:
1 | const middlewarsArr = [ |
要想理解 koa-compose,就必须理解上面这段简单的 3 层嵌套函数:
1 | const fnMiddleware = function (ctx) { |
可以认为每个高阶函数,都是负责执行一个具体的 fn 中间件函数,只是高阶函数内部顺带着把“下一个 next 的高阶函数的指针” 放到了具体 fn 中间件函数参数那里。这里虽然有点递归的感觉了,但是并没有直接递归调用,而是通过 fn 各个函数内部手动调用 next 来执行下一个高阶函数。
一旦你的前一个高阶函数一执行,立刻就会触发高阶函数内包裹的具体 fn 函数,而 fn 函数执行过程中如果里面有 await next 这样的代码,就必然就触发去调用“下一个高阶函数”的执行。如此,依次递归执行下去。
1、之所以执行任何 fn1 或 fn2 或 fn2 的时候,都要用 Promise.resolve(fn1(xxx))包裹一下他的返回结果呢。这是因为 koa 担心你 app.use 时候设置的中间件并没有使用 async 语法,那么你里面就可能没有 return 一个 Promise,那么如果这里不对 fn1 的返回结果进行 Promise 包裹,则外层就无法通过 fnMiddleware().then 来等待。 为了确保 fnMiddelware 里面无论是否有异步代码都能返回 promise,所以他要通过 Promise.resolve 进行包裹。
2、之所以调用 fn1 等函数时,fn1(ctx, function next() { fn2() })
, 其 fn1 的第二个参数并没有直接把 fn2 传进去,而是用 HOC 高阶函数包裹来使用,这里的目的也是为了能让 fn1 的函数内部调用 next(即 fn2) 的时候不需要传参,而是自动就能变成 fn2(ctx, next)的调用。
koa-compose 在实务中的自定义用法
有时候你可能在自己某个特定的 url 路由下,需要针对请求做一些特殊的处理。而社区的 “koa 中间件”已经具有这样的功能,你要如何把社区的中间件拿过来当做是自己的功能函数来用呢。
这些社区中间件的特点是他接受 ctx 和 next,但不告诉你处理结果,因为人家内部会根据情况来决定是否调用 next。例如 koa-static。此时就可以用到 koa-compose 了。
设想如下场景,当发现用户的某个请求满足特定的一些参数条件后,你希望走到这样的逻辑中::
1、你希望先看看本地 public 目录下是否有请求的资源。有则返回,无则继续找下一个中间件处理。静态资源检查这里可以采用 koa-static 中间件。
2、若第一步没有匹配的资源,则走到第二步,则走 koa-view 找模板进行服务端渲染(这里咱们假设自己实现一个 koa-view,如果找不到模板则就继续调用 next)。
3、若第二步也没有匹配的模板,则走到第三步。第三步一般是 koa app 初始化时候兜底的一个 404 中间件。给 ctx.body 设置一个 404 文案。
此时,为了能在我的 controller 路由里面让第一步和第二步两个中间件按需调用,则可以这样写:
1 |
|
如上代码中,serveStaticFile 和 serveRenderFile 的执行顺序,就像是 koa 中我们理解的中间件一样,按照洋葱模型顺序执行。且如果能走完 2 个中间件的话,则会调用传入的 next,而那个 next 则是 koa 最顶层的中间件。
refer
https://github.com/ruochuan12/koa-compose-analysis?tab=readme-ov-file