背景
对于一个开发工具,管理多个组件的构建,组件构建本身是一个独立进程,上层命令需要管理这些构建过程。需要构建过程(产物)与上层管理实现强耦合。
例如一个服务形态如下:
以上结构造成的开发构建组件的问题:
- 直接使用 webpack-dev-server,导致构建的产物无法通过其他进程实例进行管理。生成的文件也是通过自持服务托管,当启动多组件时,需要绑定多端口,无法使用上层服务作统一封装
- 选择构建出文件到统一文件夹下,从而使上层服务可以获取到文件内容进行操作。这就带来的一个问题,构建效率的问题
尽管在大部分场景构建出文件对于开发端的影响并不明显,但是文件的IO相比内存处理,在文件大/多的情况下,差距还是比较明显的
要解决的问题
- 内存文件系统
- webpack 构建至内存
- 上层服务托管内存中的文件
- 主/子进程如何进行内存文件的同步
- 兼容文件构建/内存构建两种方式
- 组件本身(开发者)如何尽可能弱感知
内存文件系统
关于内存文件系统,其实原理很简单——无非就是对标 fs 模拟一个对象,实现内容的增删改查等。
这里直接使用webpack官方的 memory-fs,而且 webpack 构建需要依赖一个join方法,这个memfs是没有实现的
注意的是memory-fs 并没有单独打包,大概就是因为比较简单,可以直接源码引入,方便后续的更改
引入之后,需要做一下更改
// 自定义合并方法 function merge(o) { if (!isPlainObject(o)) { return {}; } var args = arguments; var len = args.length - 1; for (var i = 0; i < len; i++) { var obj = args[i + 1]; if (isPlainObject(obj)) { for (var key in obj) { if (hasOwn(obj, key)) { if (isPlainObject(obj[key])) { if (isPlainObject(o[key])) { o[key] = merge(o[key], obj[key]); } else { o[key] = obj[key] || o[key]; } } else { o[key] = obj[key] || o[key]; } } } } } return o; } class MemoryFileSystem { constructor(data) { this.data = data || {}; this.join = join; this.pathToArray = pathToArray; this.normalize = normalize; this.mergeData = this.mergeData.bind(this); } mergeData(sourceData) { const newObj = merge(this.data, sourceData); this.data = newObj; return newObj; } }
这里在实例上增加了一个mergeData的方法。方便数据同步,这个下文再说。
webapck 构建至内存
内存文件模型选好了,接下来需要改造webpack构建至内存。强大的webpack提供了node构建接口,只需要修改 outputFileSystem
即可
const compiler = webpack(config); compiler = memoryfs // memofs 就是memory-fs构建出的实例
服务托管内存中的文件
构建出的内存在内存中,如何将内存托管出去呢?
技术选型:koa + koa-static / koa-send
静态文件的托管直接通过koa-static,使用很方便。但这里我们涉及到内容出处的改造,修改是少不了。观察下源码我们发现koa-static是koa-send的封装,这里直接拿出koa-send进行源码修改。
koa-send 主要就是实现了文件查找以及创建文件读句柄,提供给ctx.body; 而且整个操作依赖的是 fs 这个node module。yep!直接把 fs 替换成memoryf-fs是不是就可以了!
组件本身可能是内存构建也可能是文件构建,这里的独文件可以同时兼容两种读取方式,先查内存,没有再去文件中查找,则实现了两种文件系统的兼容
在使用的过程中发现,文件托管到内存中,输出的文件在服务端的读取时间大幅延长。达到5s+,最终排查发现,因为memory-fs中没有对输出的字节数做映射
// memory-fs MemoryFileSystem.js
statSync(_path) { let current = this.meta(_path); if(_path === "/" || isDir(current)) { return { isFile: falseFn, isDirectory: trueFn, isBlockDevice: falseFn, isCharacterDevice: falseFn, isSymbolicLink: falseFn, isFIFO: falseFn, isSocket: falseFn, size: Buffer.byteLength(current, 'utf8'), //增加此处 }; } else if(isFile(current)) { return { isFile: trueFn, isDirectory: falseFn, isBlockDevice: falseFn, isCharacterDevice: falseFn, isSymbolicLink: falseFn, isFIFO: falseFn, isSocket: falseFn, size: Buffer.byteLength(current, 'utf8'), //增加此处 }; } else { throw new MemoryFileSystemError(errors.code.ENOENT, _path, "stat"); } }
即对stats对象增加 size 属性(内容buffer的字节数)即可。
主/子进程如何进行内存文件的同步
如果你仔细看了上文,会注意到一个问题,就是组件本身的构建以及koa服务是两个进程,内存不一致,这里的内存文件如何进行共享呢。node 多进程中进行内存共享的方式,这里直接跳过(因为我没找到很合适的内存共享策略,比如我研究了下shm-typed-array,最终一知半解,放弃了)。
这里选择了使用 socket 做数据同步。即子进程每次构建结束后,将内容同步到主服务进程。主服务进程可以再将内容广播出去,通知每个子进程进行数据同步,如果子进程直接的内容互不影响,广播这一步也可以省略。
子进程的构建是不同步的,每次更新需要对于内容进行文件替换,文件夹需要合并,体现在内存文件操作上,即上文提到的memory-fs中追加的mergeData方法。
从而在内存层面上实现了一个一致性的文件系统。
组件开发者弱感知
以上,整体功能已经完成,最后需要考虑的一点是,如何让开发者感知不到这种变化呢。好在开发过程中,组件的启动是通过主进程控制的,及主进程创建一个子进程并执行npm script。这里可以通过在启动的时候,创建一个方法,替代执行脚本。
由于组件的 webpack 以及其他 node_module 是组件内持有的(各个组件互不影响),则模块的引用需要依赖组件内部,实现思路:
- 通过组件内组件一个配置项,导出webpack及webpack.config.js
- cli中通过导出的webpack实例,以及webpack配置文件进行自定义执行。这里要注意的是webpack配置文件需要通过绝对地址引入,以用来使配置中的插件可以正确的查找
最后
做出来回看似乎很简单,但是从一开始往下想,似乎并没有很清晰。
解决实际问题总会依赖各种各样的思路想法,只要明确最终的目的,一切以使用者的角度出发,最终的结果一般不会太差。