智联招聘的大前端Ada提供的Web服务器能够同时运行在服务器端及本机开发环境,其内核是Web框架Koa。Koa以其对异步编程的良好支持而声名在外,而一样让人称道的还有它的中间件机制。本质上,Koa实际上是一个中间件运行时,几乎全部实际功能都是经过中间件的形式注册和实现的。javascript
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服务),这就意味着使用方必须知道每一个中间件的技术细节,此为“坏味道”之一。java
下图展现了使用方与中间件的耦合状况:编程
Koa中间件体系是一个洋葱形结构,每个中间件均可以看作洋葱的一层皮。最早注册的位于最外层,最后注册的位于最内层。在执行时,会从最外层依次执行到最内层,再倒序依次执行回最外层。下图展现了Koa中间件的执行方式:segmentfault
每一个中间件都有两次可被执行的机会,而在咱们的场景中,大多数中间件实际上只有一段逻辑。随着中间件的数量膨胀,完整的执行轨迹变得过于复杂,增长了调试和理解的成本,此为“坏味道”之二。服务器
基于以上缘由,咱们决定对@zpfe/koa-middleware模块进行重构,进一步提升其易用性、内聚性和可维护性。app
首先逐个分析一下@zpfe/koa-middleware所导出的功能和使用状况,会发现以下模式:框架
这意味着咱们能够收回中间件的注册权,并容许使用方经过参数来控制个别中间件的开启关闭状态、参数、甚至实现。还能够将非中间件功能直接抽离为新的模块。koa
接下来观察这些中间件的执行顺序,会发现它们能够归属于几种不一样的类型:异步
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。