智联招聘的大前端Ada提供的Web服务器可以同时运行在服务器端及本机开发环境,其内核是Web框架Koa。Koa以其对异步编程的良好支持而声名在外,而同样让人称道的还有它的中间件机制。本质上,Koa其实是一个中间件运行时,几乎所有实际功能都是通过中间件的形式注册和实现的。
Ada从1.0.0版本开始引入了独立的@zpfe/koa-middleware模块,用于维护Web服务中所需的中间件。该模块单独导出了所有的中间件,Web服务可以按需自行注册(use
)。随着功能不断完善,该模块中逐渐累积了十数个中间件。@zpfe/koa-middleware模块的使用方式大概如下所示:
const app = new Koa() app.use(middleware1) app.use(middleware2) // ... app.use(middlewareN)
中间件之间隐式约定了执行顺序,但却把执行顺序的控制交给了两个使用方(渲染服务和API服务),这就意味着使用方必须知道每个中间件的技术细节,此为“坏味道”之一。
下图展示了使用方与中间件的耦合情况:
Koa中间件体系是一个洋葱形结构,每一个中间件都可以看做洋葱的一层皮。最先注册的位于最外层,最后注册的位于最内层。在执行时,会从最外层依次执行到最内层,再倒序依次执行回最外层。下图展示了Koa中间件的执行方式:
每个中间件都有两次可被执行的机会,而在我们的场景中,大多数中间件实际上只有一段逻辑。随着中间件的数量膨胀,完整的执行轨迹变得过于复杂,增加了调试和理解的成本,此为“坏味道”之二。
基于以上原因,我们决定对@zpfe/koa-middleware模块进行重构,进一步提高其易用性、内聚性和可维护性。
首先逐个分析一下@zpfe/koa-middleware所导出的功能和使用情况,会发现如下模式:
这意味着我们可以收回中间件的注册权,并允许使用方通过参数来控制个别中间件的开启关闭状态、参数、甚至实现。还可以将非中间件功能直接抽离为新的模块。
接下来观察这些中间件的执行顺序,会发现它们可以归属于几种不同的类型:
x-zp-request-id
和日志功能);进一步分析每一个分类所包含的中间件,会发现它们的执行方式在分类内部也是高度一致的。除了预处理器和处理器需要异步执行之外,其余几种类型所包含的中间件全都可以按照同步的方式执行。
上文提到Koa中间件会有两次被执行的机会,@zpfe/koa-middleware也确实包含一些这样的中间件(比如日志功能)。刚才在归类中间件时,这样的中间件被拆成了两部分,归属到了不同的分类中。比如,日志功能会被拆分到初始化器(初始化日志功能)和后处理器(记录请求结束的信息)。对于这样的功能,我们可以换一种思路,将它看成一个完整的功能集,但对外输出了两个不同类型的具体功能。如此,我们就可以在同一个文件中编写日志功能的所有代码,并将其初始化功能和后处理功能定义为不同的函数来导出。
经过分析,我们已经对@zpfe/koa-middleware模块的现状有了清晰的认知。现在来总结一下,形成一些有用的指导原则:
这一步骤比较简单,只需要将这些非中间件功能的文件提取到独立的模块中即可。需要注意的是:
抽离非中间件功能之后,@zpfe/koa-middleware模块现在已经是一个名副其实的中间件模块了。
下图展示了抽离非中间件功能之后的代码结构:
接下来封装一个注册函数,并作为对外的唯一导出项,藉此来简化使用方的代码,并对其隐藏中间件细节。
根据之前的分析,这个注册函数需要通过参数来允许使用方对部分中间件进行配置。下面展示了新的注册函数的主要逻辑:
function registerTo(koaApp, options) { koaApp.use(middleware1) koaApp.use(middleware2) if (options.config3) koaApp.use(middleware3) if (options.config4) koaApp.use(middleware4(options.config4)) // ... koaApp.use(middlewareN) } module.exports = { registerTo }
options
参数不仅可以用来控制特定中间件的启用状态,还可以向中间件提供配置。使用方可以这样来使用新的注册函数:
const middleware = require('@zpfe/koa-middleware') const app = new Koa() middleware.registerTo(app, { config3: true, config4: function () { /* ... */ } })
现在中间件的注册顺序已经封装在@zpfe/koa-middleware模块的内部了,使用方只需要了解注册函数的使用方法即可,假设以后想要增加一个中间件,也不会对使用方造成大的影响。
下图展示了封装注册函数之后的代码结构:
值得注意的是这一步骤中的改动只涉及到@zpfe/koa-middleware模块的主文件和使用方,并没有对任何中间件进行修改,遵循了渐进式重构的原则。 补充和更新单元测试后,就可以进行到下一步了。
根据之前的分析,中间件分属几种类型,初始化器是其中的第一种。初始化器所包含的中间件应该由它自己来注册和管理,下面展示了初始化器的主要逻辑:
function register(koaApp, options) { koaApp.use(middleware1) // ... koaApp.use(middlewareN) } module.exports = register
看起来就是@zpfe/koa-middleware模块主文件的翻版,接下来修改@zpfe/koa-middleware模块主文件,将逐个注册初始化器中间件的代码替换为使用初始化器来统一注册:
const initiators = require('./initiators') function registerTo(koaApp, options) { initiators(koaApp, { configN: options.configN }) if (options.config3) koaApp.use(middleware3) if (options.config4) koaApp.use(middleware4(options.config4)) // ... koaApp.use(middlewareN) }
现在开始,@zpfe/koa-middleware模块的主文件只与初始化器进行交互,不再与后者所包含的多个中间件进行交互。也就是说,我们已经对外隐藏了初始化器中间件的逻辑细节。接下来要进一步重构这些逻辑时,也就不会超出初始化器的范围了。
初始化器所包含的中间件均以同步的方式执行,可以将它们化简为函数,组织成一个函数队列,按顺序执行。下面展示了修改后的初始化器:
const task1 = require('./tasks1') const taskN = require('./tasksn') function register(koaApp, options) { const tasks = [] if (options.config1) tasks.push(task1) // ... if (options.configN) tasks.push(taskN) async function initiate (ctx, next) { tasks.forEach(task => task(ctx)) return next() } koaApp.use(initiate) }
所有初始化器类型的中间件都被化简成了同步函数,并根据注册时传入的参数创建了一个任务列表,接着将自身注册为一个按顺序执行任务列表的中间件。
补充和更新单元测试后,初始化器的重构工作就宣告完成了。在这一步骤中,我们将多个中间件合而为一,并将其逻辑封装在其内部,这会让@zpfe/koa-middleware模块的代码更加结构化,也更容易维护。
下图展示了重构初始化器之后的代码结构:
回顾一下本步骤中的所有重构操作,我们会发现并没有涉及到使用方,这就是在第二步重构过程中对外隐藏内部逻辑所带来的好处。 同样地,我们也没有对非初始化器的中间件进行任何改动,这些中间件不在本步骤的重构范围之内,我们会在后续的步骤中进行重构。
初始化器重构完成之后,就可以按照同样的思路去依次重构其余几种中间件类型:阻断器、预处理器、处理器和后处理器。
这些重构工作完成之后的代码结构如下图所示:
需要注意的依然是要控制重构范围,完成一种类型的重构(包含单元测试)之后,再开始下一个类型。
现在重构工作已经接近尾声。对使用方而言,@zpfe/koa-middleware模块只公开了一个函数,极大地提高了易用性;对@zpfe/koa-middleware模块自身而言,其内部结构更加合理、执行顺序更容易预测、也更容易进行单元测试。
在宣告重构完成之前,我们还需要对@zpfe/koa-middleware模块进行一次整体检查,寻找遗漏的“坏味道”,以及在渐进式重构过程当中逐渐累积出来的“坏味道”。
现在的@zpfe/koa-middleware模块包含五个中间件,每个中间件的注册函数能通过参数来控制自己的内部功能。@zpfe/koa-middleware模块的主文件负责将使用方传入的参数整理成每个中间件所期望的参数格式,如下所示:
function registerTo(koaApp, options) { initiators(koaApp, { configN: options.configN }) blockers(koaApp, { configO: options.configO }) preProcessors(koaApp, { configP: options.configP }) processors(koaApp, { configQ: options.configQ }) postProcessors(koaApp, { configR: options.configR }) }
既然每个中间件都需要从注册函数的options
参数中获取自己所需要的数据,那么完全可以将options
参数的结构按照中间件进行分类,分类之后的注册函数看上去会更加简明:
function registerTo(koaApp, options) { initiators(koaApp, options.initiators) blockers(koaApp, options.blockers) preProcessors(koaApp, options.preProcessors) processors(koaApp, options.processors) postProcessors(koaApp, options.postProcessors) }
在之前的分析中,我们已经知道初始化器会产生一些数据,并且希望这些数据能由它们自己来清理,这就意味着在后处理器有对应的任务来清理数据。将同一个功能的初始化和清理逻辑拆分到两个文件中,也是一种“坏味道”。
处理这种情况的方法很简单,首先找出所有具备这样特征的功能,为它们创建独立的代码文件。然后将其初始化逻辑和清理逻辑移动到该文件中,并分别导出。 如此一来,每个功能都会变得更加内聚。
重构完成之后的代码结构如下图所示:
回顾一下整个重构过程,会发现我们做的第一件事情并不是编码,而是对现状进行深入的剖析。在这个过程中,求同存异,一些模式会自然而然地呈现出来,它们都是重构的“素材”。
在真正进行编码时,我们采取了渐进式的策略,将整个过程分解成多个步骤。争取做到每一个步骤完成后,整个模块都能达到发布标准。这就意味着需要把每一步所涉及的改动都限定到一个可控的范围内,并且每个步骤都需要包含完整的测试。
以上,就是重构与重写的区别。
注:本文最初于2018年8月8日发表于智联大前端内部Wiki。