智联招聘大前端Ada提供的web服务器,可以同时运行在服务端和本地开发环境,其核心是web框架Koa。Koa以对异步编程的良好支持着称,其中间件机制同样值得称道。从本质上讲,Koa实际上是一个中间件运行时,几乎所有的实际功能都是以中间件的形式注册和实现的。StatusAda从1.0.0版本开始引入了独立的@zpfe/koa-middleware模块,用于维护Web服务中需要的中间件。该模块单独导出所有中间件,Web服务可以按需注册(使用)。随着功能的不断完善,该模块逐渐积累了十多个中间件。@zpfe/koa-middleware模块的用法大致如下:constapp=newKoa()app.use(middleware1)app.use(middleware2)//...app.use(middlewareN)implicitbetweenmiddleware的formula约定了执行顺序,但是执行顺序的控制权交给了两个用户(渲染服务和API服务),这意味着用户必须知道每个中间件的技术细节,这是“坏品味”。一。下图展示了用户和中间件之间的耦合:Koa中间件系统是一个洋葱形的结构,每个中间件都可以看成是一层洋葱。最先注册的在最外面,最后注册的在最里面。执行的时候会从最外层到最内层依次执行,然后再逆序执行回最外层。下图展示了Koa中间件是如何执行的:每个中间件有两次机会被执行,而在我们的场景中,大多数中间件实际上只有一段逻辑。随着中间件数量的增加,完整的执行轨迹变得过于复杂,增加了调试和理解的成本。这是第二个“坏味道”。基于以上原因,我们决定对@zpfe/koa-middleware模块进行重构,进一步提升其易用性、内聚性和可维护性。分析首先对@zpfe/koa-middleware导出的功能和使用情况一一进行分析,会发现有如下规律:中间件的注册顺序在两个用户之间是一致的;某些中间件仅注册用于API服务(例如CORS和CSRF);一些中间件在两个消费者(如解析器和入口处理器)中有不同的参数或实现;有些功能实际上不是中间件(例如请求上下文和熔断器)。这意味着我们可以收回注册中间件的权利,让用户可以通过参数来控制各个中间件的开关状态、参数,甚至实现。也可以直接将非中间件功能提取到新模块中。接下来观察这些中间件的执行顺序,你会发现它们可以归结为几种不同的类型:initializers:负责初始化数据或函数(比如初始化x-zp-request-id和log函数);blockers:负责Interrupt执行过程(如CORS和CSRF);预处理器:负责准备处理请求所需的环境(如parser);处理器:负责处理请求(如诊断和入口处理程序);postprocessor:负责请求处理后的Cleanup(比如清理临时文件和数据)。进一步分析每个类别中包含的中间件,其执行方式在类别内也是高度一致的。除了预处理器和处理器需要异步执行外,其他类型包含的中间件都可以同步执行。上面说了Koa中间件会有两次执行的机会,@zpfe/koa-middleware确实包含了一些这样的中间件(比如日志功能)。刚才对中间件进行分类的时候,把这样的中间件分成了两部分,属于不同的类别。例如,日志功能将被拆分为一个初始化器(初始化日志功能)和一个后处理器(记录有关请求结束的信息)。对于这样一个功能,我们可以换个思路,把它看成是一个完整的功能集,而是对外输出两种不同类型的具体功能。这样我们就可以把log函数的所有代码都写在同一个文件中,将其初始化函数和后处理函数定义为不同的函数导出。分析完原理后,我们对@zpfe/koa-middleware模块的现状有了一个清晰的认识。现在让我们总结并形成一些有用的指导原则:单一职责原则(SRP):提取非中间件功能;依赖倒置原则(DIP):不把中间件的功能细节暴露给用户;自清理:请求处理完成后,中间件必须清理自己产生的数据;易于测试:每个组件都可以单独测试;增量重构:分阶段重构,每个阶段不破坏已有功能,并具备单独发布的能力。第一阶段:抽取非中间件功能这一步比较简单,只需要将这些非中间件功能的文件抽取成独立的模块即可。需要注意的是,独立模块必须满足高内聚低耦合的标准;单元测试也应该被提取成独立的模块,并适当修改以满足测试标准;所有用户都应一一切换到独立模块并修改其单元测试;控制重构的范围并限制对非中间件及其消费者的更改。@zpfe/koa-middleware模块在抽取非中间件功能后,就是名副其实的中间件模块了。下图为非中间件功能提取后的代码结构:第二步:封装注册功能接下来封装一个注册功能,作为唯一对外导出项,简化用户代码,隐藏中间件细节。根据前面的分析,这个注册函数需要传递参数让用户配置一些中间件。新注册函数的主要逻辑如下所示:koaApp.use(middleware4(options.config4))//...koaApp.use(middlewareN)}module.exports={registerTo}options参数不仅可以用来控制特定中间件的启用状态,还可以向中间件提供配置。用户可以像这样使用新的注册函数:constmiddleware=require('@zpfe/koa-middleware')constapp=newKoa()middleware.registerTo(app,{config3:true,config4:function(){/*...*/}})现在@zpfe/koa-middleware模块内部已经封装了中间件注册序列,用户只需要知道如何使用注册功能,假设他想在未来,不会对用户产生重大影响。下图是注册函数封装后的代码结构:值得注意的是,这一步的改动只涉及主文件和@zpfe/koa-middleware模块的user,并没有修改中间件。遵循增量重构的原则。一旦补充和更新了单元测试,就可以进行下一步了。第三步:重构初始化器根据前面的分析,中间件有几种类型,初始化器是第一种。初始化器中包含的中间件应自行注册和管理。初始化器的主要逻辑如下所示:@zpfe/koa-middleware模块,然后修改@zpfe/koa-middleware模块的主文件,将初始化中间件的代码一一替换为使用初始化器统一注册:constinitiators=require('./initiators')functionregisterTo(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模块的主文件只与初始化器交互,不再与后者包含的多个中间件进行交互。换句话说,我们对外部隐藏了初始化中间件的逻辑细节。当你想进一步重构这些逻辑时,你不会超出初始化器的范围。初始化器中包含的中间件以同步方式执行。它们可以简化为函数,组织成一个函数队列,并按顺序执行。修改后的初始化器如下所示:push(task1)//...if(options.configN)tasks.push(taskN)异步函数启动(ctx,next){tasks.forEach(task=>task(ctx))returnnext()}koaApp.use(initiate)}所有initializer类型的中间件都简化为同步函数,在注册时根据传入的参数创建一个任务列表,然后将自己注册为按顺序执行任务列表的中间件。在补充和更新单元测试后,初始化器的重构被宣布完成。在这一步中,我们将多个中间件合二为一,并将其??逻辑封装在里面,这样会使得@zpfe/koa-middleware模块的代码更加结构化,更易于维护。下图是构造器重构后的代码结构:回顾这一步的所有重构操作,我们会发现用户并没有参与其中,这是第二步重构隐藏了内部逻辑的结果。福利来了同样,对于非初始化中间件,我们也没有做任何改动,不在这一步的重构范围内,我们会在后续的步骤中进行重构。第四步:依次重构其余的中间件类型初始化器重构完成后,可以按照同样的思路依次重构其余的中间件类型:blocker、preprocessor、processor、postprocessor。这些重构完成后的代码结构如下图所示:需要注意的是,重构的范围还是要控制的。完成一种类型的重构(包括单元测试)后,开始下一种类型的重构。第五步:整体检查现在重构工作已经接近尾声。对于用户,@zpfe/koa-middleware模块只对外暴露了一个功能,大大提高了易用性;@zpfe/koa-middleware模块本身,内部结构更合理,执行顺序更容易预测,更容易单元测试。在宣告重构完成之前,我们还需要对@zpfe/koa-middleware模块进行一次全面的检查,寻找缺失的“臭味”和在增量重构过程中逐渐积累的“臭味”。目前@zpfe/koa-middleware模块包含五个中间件,每个中间件的注册函数可以通过参数控制其内部功能。@zpfe/koa-middleware模块的主文件负责将用户传入的参数组织成各个中间件期望的参数格式,如下:functionregisterTo(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参数的结构进行分类,分类后的注册函数看起来会更简洁:functionregisterTo(koaApp,options){initiators(koaApp,options.initiators)blockers(koaApp,options.blockers)preProcessors(koaApp,options.preProcessors)processors(koaApp,options.processors)postProcessors(koaApp,options.postProcessors)}在之前的分析,我们已经知道initializers会产生一些数据,希望这些数据能够自己清理,也就是说在post-processor中有相应的任务来清理数据。将同一个函数的初始化和清理逻辑拆分到两个文件中,也是一种“恶臭”。处理这种情况的方法很简单,首先找到所有具有此类特征的函数,并为它们创建单独的代码文件。然后把它的初始化逻辑和清理逻辑移到这个文件中,分别导出。这样每个函数就变得更有凝聚力。重构完成后的代码结构如下图所示:总结和回顾整个重构过程,我们会发现我们首先做的不是编码,而是对现状的深入分析。在这个过程中,求同存异,自然会出现一些规律,它们都是重构的“素材”。当涉及到实际编码时,我们采用了渐进式方法,将整个过程分解为多个步骤。力求做到每一步完成后,整个模块都能达到发布标准。这意味着每一步涉及的变更需要限制在一个可控的范围内,每一步都需要包含完整的测试。以上就是重构和重写的区别。注:本文首发于2018年8月8日智联前端内部维基。
