happypack 原理解析

tb15zejofxxxxcyxvxxxxxxxxxx-900-500

说起 happypack 可能很多同学还比较陌生,其实 happypack 是 webpack 的一个插件,目的是通过多进程模型,来加速代码构建,目前我们的线上服务器已经上线这个插件功能,并做了一定适配,效果显著。这里有一些大致参考:

tb1apiaofxxxxcnxvxxxxxxxxxx-549-451

这张图是 happypack 九月逐步全量上线后构建时间的的参考数据,线上构建服务器 16 核环境。

在上这个插件的过程中,我们也发现了这个单人维护的社区插件有一些问题,我们在解决这些问题的同时,也去修改了内部的代码,发布了自己维护的版本 @ali/happypack,那么内部是怎么跑起来的,这里做一个总结记录。

webpack 加载配置

这个示例只单独抽取了配置 happypack 的部分。可以看到,类似 extract-text-webpack-plugin 插件,happypack 也是通过 webpack 中 loaderplugin 的相互调用协作的方式来运作。

loader 配置直接指向 happypack 提供的 loader, 对于文件实际匹配的处理 loader ,则是通过配置在 plugin 属性来传递说明,这里 happypack 提供的 loader 与 plugin 的衔接匹配,则是通过 id=less 来完成。

happypack 文件解析

HappyPlugin.js

tb1svtkofxxxxcaxpxxxxxxxxxx-767-269

对于 webpack 来讲,plugin 是贯穿在整个构建流程,同样对于 happypack 配置的构建流程,首先进入逻辑的是 plugin 的部分,从初始化的部分查看 happypack 中与 plugin 关联的文件。

1. 基础参数设置

对于基础参数的初始化,对应上文提到的配置,可以看到插件设置了两个标识

  • id: 在配置文件中设置的与 loader 关联的 id 首先会设置到实例上,为了后续 loader 与 plugin 能进行一对一匹配
  • name: 标识插件类型为 HappyPack,方便快速在 loader 中定位对应 plugin,同时也可以避免其他插件中存在 id 属性引起错误的风险

对于这两个属性的应用,可以看到 loader 文件中有这样一段代码

其次声明 state 对象标识插件的运行状态之后,开始配置信息的处理。

调用 OptionParser 函数来进行插件过程中使用到的参数合并,在合并函数的参数对象中,提供了作为数据合并依据的一些属性,例如合并类型 type、默认值 default 以及还有设置校验函数的校验属性 validate 完成属性检查。

这里对一些运行过车中的重要属性进行解释:

  • tmpDir: 存放打包缓存文件的位置
  • cache: 是否开启缓存,目前缓存如果开启,(注: 会以数量级的差异来缩短构建时间,很方便日常开发)
  • cachePath: 存放缓存文件映射配置的位置
  • verbose: 是否输出过程日志
  • loaders: 因为配置中文件的处理 loader 都指向了 happypack 提供的 loadr ,这里配置的对应文件实际需要运行的 loader

2. 线程池初始化

这里的 thread 其实严格意义说是 process,应该是进程,猜测只是套用的传统软件的一个主进程多个线程的模型。这里不管是在配置中,配置的是 threads 属性还是 threadPool 属性,都会生成一个 HappyThreadPool 对象来管理生成的子进程对象。

2.1. HappyThreadPool.js

在返回 HappyThreadPool 对象之前,会有两个操作:

2.1.1. HappyRPCHandler.js

对于 HappyRPCHandler 实例,可以从构造函数看到,会绑定当前运行的 loader 与 compiler ,同时在文件中,针对 loader 与 compiler 定义调用接口:

  • 对应 compiler 会绑定查找解析路径的 reolve 方法:
  • 对应 loader 其中一些绑定:

通过定义调用 webpack 流程过程中的 loader、compiler 的能力来完成功能,类似传统服务中的 RPC 过程。

2.1.2. 创建子进程 (HappyThread.js)

传递子进程数参数 config.size 以及之前生成的 HappyRPCHandler 对象,调用createThreads 方法生成实际的子进程。

fullThreadId 生成之后,传入 HappyThread 方法,生成对应的子进程,然后放在 set 集合中返回。调用 HappyThread 返回的对象就是 Happypack 的编译 worker 的上层控制。

对象中包含了对应的进程状态控制 openclose,以及通过子进程来实现编译的流程控制configurecompile

2.1.2.1 子进程执行文件 HappyWorkerChannel.js

上面还可以看到一个信息是,fd 子进程的运行文件路径变量 WORKER_BIN,这里对应的是相同目录下的 HappyWorkerChannel.js

精简之后的代码可以看到 fork 子进程之后,最终执行的是 HappyWorkerChannel 函数,这里的 stream 参数对应的是子进程的 process 对象,用来与主进程进行通信。

函数的逻辑是通过 stream.on('messgae') 订阅消息,控制层 HappyThread 对象来传递消息进入子进程,通过 accept() 方法来路由消息进行对应编译操作。

对于不同的上层消息进行不通的子进程处理。

2.1.2.1.1 子进程编译逻辑文件 HappyWorker.js

这里的核心方法 compile ,对应了一层 worker 抽象,包含 Happypack 的实际编译逻辑,这个对象的构造函数对应 HappyWorker.js 的代码。

applyLoaders 的参数看到,这里会把 webpack 编辑过程中的 loadersloaderContext通过最上层的 HappyPlugin 进行传递,来模拟实现 loader 的编译操作。

从回调函数中看到当编译完成时, fs.writeFileSync(compiledPath, source); 会将编译结果写入 compilePath 这个编译路径,并通过 done 回调返回编译结果给主进程。

3. 编译缓存初始化

happypack 会将每一个文件的编译进行缓存,这里通过

这里的 cachePath 默认会将 plugin 的 tmpDir 的目录作为生成缓存映射配置文件的目录路径。同时创建好 config.tempDir 目录。

3.1 happypack 缓存控制 HappyFSCache.js
HappyFSCache 函数这里返回对应的 cache 对象,在编译的开始和 worker 编译完成时进行缓存加载、设置等操作。

对于编译过程中的单个文件,会通过 getCompiledSourceCodePath 函数来获取对应的缓存内容的文件物理路径,同时在新文件编译完整之后,会通过 updateMTimeFor 来进行缓存设置的更新。

HappyLoader.js

在 happypack 流程中,配置的对应 loader 都指向了 happypack/loader.js ,文件对应导出的是 HappyLoader.js 导出的对象 ,对应的 bundle 文件处理都通过 happypack 提供的 loader 来进行编译流程。

省略了部分代码,HappyLoader 首先拿到配置 id ,然后对所有的 webpack plugin 进行遍历

找到 id 匹配的 happypackPlugin。传递原有 webpack 编译提供的 loaderContext (loader 处理函数中的 this 对象)中的参数,调用 happypackPlugincompile 进行编译。

上面是 happypack 的主要文件,作者在项目介绍中也提供了一张图来进行结构化描述:

tb12ji-opxxxxclaxxxxxxxxxxx-916-556

实际运行

从前面的文件解析,已经把 happypack 的工程文件关联结构大致说明了一下,这下结合日常在构建工程的一个例子,将整个流程串起来说明。

启动入口

tb1px8jofxxxxbdaxxxxxxxxxxx-1022-487

在 webpack 编译流程中,在完成了基础的配置之后,就开始进行编译流程,这里 webpack 中的 compiler 对象会去触发 run 事件,这边 HappypackPlugin 以这个事件作为流程入口,进行初始化。

run 事件触发时,开始进行 start 整个流程

start函数通过 async.series 将整个过程串联起来。

1. registerCompilerForRPCs: RPCHandler 绑定 compiler

通过调用 plugin 初始化时生成的 handler 上的方法,完成对 compiler 对象的调用绑定。

2. normalizeLoaders: loader 解析

对应中的 webpack 中的 happypackPlugin 的 loaders 配置的处理:

对应配置的 loaders ,经过 normalizeLoader 的处理后,例如 [css!less] 会返回成一个loader 数组 [{path: 'css'},{path: 'less'}],复制到 plugin 的 this.state 属性上。

3.resolveLoaders: loader 对应文件路径查询

为了实际执行 loader 过程,这里将上一步 loader 解析 处理过后的 loaders 数组传递到resolveLoaders 方法中,进行解析

resolveLoaders 方法采用的是借用原有 webpack 的 compiler 对象上的对应resolvers.loader 这个 Resolver 实例的 resolve 方法进行解析,构造好解析参数后,通过async.parallel 并行解析 loader 的路径

4.loadCache: cache 加载

cache 加载通过调用 cache.load 方法来加载上一次构建的缓存,快速提高构建速度。

load 方法会去读取 cachePath 这个路径的缓存配置文件,然后将内容设置到当前 cache对象上的 mtimes 上。

在 happypack 设计的构建缓存中,存在一个上述的一个缓存映射文件,里面的配置会映射到一份编译生成的缓存文件。

5.launchAndConfigureThreads: 线程池启动

上面有提到,在加载完 HappyPlugin 时,会创建对应的 HappyThreadPool 对象以及设置数量的 HappyThread。但实际上一直没有创建真正的子进程实例,这里通过调用threadPool.start 来进行子进程创建。

start 方法通过 sendnotget 这三个方法来进行过滤、启动的串联。

传递 'isOpen'send 返回函数中,receiver 对象绑定调用 isOpen 方法;再传递给 not返回函数中,返回前面函数结构取反。传递给 threadsfilter 方法进行筛选;最后通过 get 传递返回的 open 属性。

HappyThread 对象中 isOpen 通过判断 fd 变量来判断是否创建子进程。

HappyThread 对象的 open 方法首先将 async.parallel 传递过来的 callback 钩子通过Once 方法封装,避免多次触发,返回成 emitReady 函数。

然后调用 childProcess.fork 传递 HappyWorkerChannel.js 作为子进程执行文件来创建一个子进程,绑定对应的 errorexit 异常情况的处理,同时绑定最为重要的 message 事件,来接受子进程发来的处理消息。而这里 COMPILED 消息就是对应的子进程完成编译之后会发出的消息。

在子进程完成创建之后,会向主进程发送一个 READY 消息,表明已经完成创建,在主进程接受到 READY 消息后,会调用前面封装的 emitReady ,来反馈给 async.parallel 表示完成open 流程。

6.markStarted: 标记启动

最后一步,在完成之前的步骤后,修改状态属性 startedtrue,完成整个插件的启动过程。

编译运行

tb1qd5uofxxxxxqxxxxxxxxxxxx-1141-720

1. loader 传递
在 webpack 流程中,在源码文件完成内容读取之后,开始进入到 loader 的编译执行阶段,这时 HappyLoader 作为编译逻辑入口,开始进行编译流程。

loader 中将 webpack 原本的 loaderContext(this指向) 对象的一些参数例如this.resourcethis.resourcePath等透传到 HappyPlugin.compile 方法进行编译。

2. plugin 编译逻辑运行

HappyPlugin 中的 compile 方法对应 build 过程,通过调用 compileInBackground 方法来完成调用。

2.1 构建缓存判断

compileInBackground 中,首先会代用 cache 的 hasChangedhasErrored 方法来判断是否可以从缓存中读取构建文件。

hasError 判断的是更新缓存的时候的 error 属性是否存在。

hasChanged 中会去比较 nowMTimelastMTime 两个是否相等。实际上这里 nowMTime 通过调用 generateSignature(默认是 getMTime 函数) 返回的是文件目前的最后修改时间,lastMTime 返回的是编译完成时的修改时间。

如果 nowMTimelastMTime 两个的最后修改时间相同且不存在错误,那么说明构建可以利用缓存

2.1.1 缓存生效

如果缓存判断生效,那么开始调用 readFromCache 方法,从缓存中读取构建对应文件内容。

函数的意图是通过 cache 对象的 getCompiledSourceCodePathgetCompiledSourceMapPath方法获取缓存的编译文件及 sourcemap 文件的存储路径,然后读取出来,完成从缓存中获取构建内容。

获取的路径是通过在完成编译时调用的 updateMTimeFor 进行存储的对象中的 compiledPath编译路径属性。

2.1.2 缓存失效

在缓存判断失效的情况下,进入 _performCompilationRequest ,进行下一步 happypack 编译流程。

在调用 _performCompilationRequest 前, 还有一步是从 ThreadPool 获取对应的子进程封装对象。

这里按照递增返回的 round-robin,这种在服务器进程控制中经常使用的简洁算法返回子进程封装对象。

3. 编译开始

首先对编译的文件,调用 cache.invalidateEntryFor 设置该文件路径的构建缓存失效。然后调用子进程封装对象的 compile 方法,触发子进程进行编译。

同时会生成衔接主进程、子进程、缓存的 compiledPath,当子进程完成编译后,会将编译后的代码写入 compiledPath,之后发送完成编译的消息回主进程,主进程也是通过compiledPath 获取构建后的代码,同时传递 compiledPath 以及对应的编译前文件路径filePath,更新缓存设置。

这里的 messageId 是个从 0 开始的递增数字,完成回调方法的存储注册,方便完成编译之后找到回调方法传递信息回主进程。同时在 thread 这一层,也是将参数透传给子进程执行编译。

 

子进程接到消息后,调用 worker.compile 方法 ,同时进一步传递构建参数。

在 HappyWorker.js 中的 compile 方法中,调用 applyLoaders 进行 loader 方法执行。applyLoadershappypack 中对 webpack 中 loader 执行过程进行模拟,对应 NormalModuleMixin.js 中的 doBuild 方法。完成对文件的字符串处理编译。

根据 err 判断是否成功。如果判断成功,则将对应文件的编译后内容写入之前传递进来的compiledPath,反之,则会把错误内容写入。

在子进程完成编译流程后,会调用传递进来的回调方法,在回调方法中将编译信息返回到主进程,主进程根据 compiledPath 来获取子进程的编译内容。

获取子进程的编译内容 contents 后,根据 result.success 属性来判断是否编译成功,如果失败的话,会将 contents 作为错误传递进去。

在完成调用 updateMTimeFor 缓存更新后,最后将内容返回到 HappyLoader.js 中的回调中,返回到 webpack 的原本流程。

4. 编译结束

当 webpack 整体编译流程结束后, happypack 开始进行一些善后工作

4.1. 存储缓存配置

首先调用 cache.save() 存储下这个缓存的映射设置。

cache 对象的处理是会将这个文件直接写入 cachePath ,这样就能供下一次 cache.load 方法装载配置,利用缓存。

4.2. 终止子进程

其次调用 threadPool.stop 来终止掉进程

类似前面提到的 start 方法,这里是筛选出来正在运行的 HappyThread 对象,调用 close方法。

HappyThread 中,则是调用 kill 方法,完成子进程的释放。

汇总

happypack 的处理思路是将原有的 webpack 对 loader 的执行过程从单一进程的形式扩展多进程模式,原本的流程保持不变。整个流程代码结构上还是比较清晰,在使用过程中,也确实有明显提升,有兴趣的同学可以一起下来交流~

1 收藏 评论

相关文章

可能感兴趣的话题



直接登录
跳到底部
返回顶部