2.9 解析asar文件
默认情况下electron-builder会把开发者编写的HTML、CSS和JavaScript代码以及相关的资源打包成asar文件嵌入到安装包中,再分发给用户。
asar是一种特殊的存档格式,它可以把大批的文件以一种无损、无压缩的方式链接在一起,并提供随机访问支持。
electron-builder是通过Electron官方提供的asar工具制成和提取asar文档的(https://github.com/electron/asar),开发者可以通过如下命令全局安装asar工具(全局安装此工具非常有必要,以便你能随时分析生产环境下Electron应用的源码):
> npm install asar -g
安装好asar工具后,打开你的Electron应用的安装目录,在resources子目录下找到app.asar文件,通过如下命令列出该文件内部包含的文件信息:
> asar list app.asar
如果我们想看app.asar包中的某个文件内容,我们可以通过如下命令把该文件释放出来:
> asar ef app.asar entry.js
这样entry.js就会出现在app.asar同级目录下了,如果释放文件失败,提示如下错误:
internal/fs/utils.js:307 throw err; Error: EPERM: operation not permitted, open 'entry.js' 90m at Object.openSync (fs.js:476:3)39m 90m at Object.writeFileSync (fs.js:1467:35)39m
这往往是你的应用程序正在运行、app.asar文件被占用了导致的,退出应用程序再次尝试,如果还是无法释放目标文件,可以考虑把app.asar拷贝到另一个目录下再释放。
如果你希望一次性把app.asar内的文件全部释放出来,可以使用如下指令:
> asar e app.asar
更多asar工具的指令请参阅https://github.com/electron/asar#usage。
一般情况下Node.js应用加载外部模块或文件都是通过require方法加载用户本地目录下的js文件实现的,然而对于Electron应用来说原生的require方法有如下两个问题:
1)Windows下文件路径的长度是有限制的,然而项目node_modules子目录下各个模块纠缠错杂,嵌套深度难以确定,开发者虽能在开发环境下正确安装和使用node模块,但不能确定最终用户把应用安装在什么目录下,很有可能安装在一个较深层的目录下,这就会导致应用无法安装成功的问题。
2)Node.js提供的require方法是一个非常消耗性能的方法,这对于Node.js开发的Web后台应用来说可能表现得并不明显,因为在应用启动时就完成了这些工作(这也是这些Web后台应用把require方法写在文件头部的原因),运行时就不存在这方面的开销了,但在Electron应用中每打开一个窗口就会执行渲染进程的脚本代码,如果你的脚本中有大量的require方法需要执行,性能损耗就会非常大(有些require操作可能不是你有意执行的,而是隐藏在node模块内的,据分析require加载request这个包大概需要花费500ms的时间,当然,最好的办法还是通过webpack或roolup这样的工具捆扎你的代码)。
基于这两个原因Electron提供了asar工具,这个工具把源码和资源文件拼接成一个大文件,也就是前面所说的app.asar文件,然后把每个文件的文件名、路径信息、开始位置、长度信息等都记录在一个称之为header的结构体内,这个结构体也存储在这个大文件中,再把这个header的大小也记录在这个大文件中,如图2-4所示。
图2-4 asar文件结构示意图
同时Electron自身也内置asar文件的解析能力,并且重写Node.js的require方法,当Electron执行开发者的代码时,遇到require方法加载本地文件时,asar提供的方法就会介入,从header中检索出文件的位置和大小信息,再根据这些信息读取文件的内容(也就是说直接从asar文件的指定位置获取指定长度的数据,而并不会把整个asar文件都加载到内存中再解析)。这就解决了第一个问题、缓解了第二个问题,同时在一定程度上还加密了用户的源码。
在初始化Electron的系统底层模块时,有一个名为InitAsarSupport的方法(shell\common\api\electron_api_asar.cc)完成了Electron对asar文件的支持工作,代码如下所示:
void InitAsarSupport(v8::Isolate* isolate, v8::Local<v8::Value> require) { // Evaluate asar_bundle.js. std::vector<v8::Local<v8::String>> asar_bundle_params = { node::FIXED_ONE_BYTE_STRING(isolate, "require")}; std::vector<v8::Local<v8::Value>> asar_bundle_args = {require}; electron::util::CompileAndCall( isolate->GetCurrentContext(), "electron/js2c/asar_bundle", &asar_bundle_params, &asar_bundle_args, nullptr); }
从上面的代码可知,Node.js的require方法是作为参数传入这个InitAsarSupport方法的,在这个方法中Electron修改了Node.js的require方法,使其支持加载asar文件内部的脚本。那么Electron是如何调用这个InitAsarSupport方法的呢?
Electron又是通过打补丁的方式修改Node.js的源码来完成这项工作的(patches\node\feat_initialize_asar_support.patch),代码如下所示:
@@ -69,6 +69,7 @@ function prepareMainThreadExecution(expandArgv1 = false) { assert(!CJSLoader.hasLoadedAnyUserCJSModule); loadPreloadModules(); initializeFrozenIntrinsics(); + setupAsarSupport(); } +function setupAsarSupport() { + process._linkedBinding('electron_common_asar').initAsarSupport(require); +}
以上这段补丁代码将在Node.js的prepareMainThreadExecution方法中执行,prepare-MainThreadExecution是在Node.js环境初始化成功后的LoadEnvironment方法内执行的,这个方法前面也介绍过。
回到InitAsarSupport方法,在这段代码中执行了Electron的一个CompileAndCall方法,Electron在这个方法内部执行了asar模块的初始化脚本,我们在2.5节介绍过Electron编译了一系列TypeScript脚本,有browser_init.js、renderer_init.js等,asar_bundle.js就是其中之一,此处的CompileAndCall方法就是执行这个脚本文件的内容。
这个脚本的代码是从lib\asar\init.ts和lib\asar\fs-wrapper.ts编译而来的,我们知道Node.js通过require方法加载模块(这里只讨论js模块)时,内部也是用fs模块的readFileSync方法读取模块内容的(以异步著称的Node.js这里居然用的是同步方法,这也是fibjs库的作者反对Node.js的理由之一),所以Electron在init.ts和fs-wrapper.ts两个文件中只是修改了fs模块的一些内部实现,无论用户使用require方法加载asar文件内部的模块,还是使用fs读取asar文件内部的文件时,都会执行Electron提供的内部实现,而不是Node.js原有的实现。
实际上Electron并没有修改fs模块内的所有方法,只是修改了诸如open、openSync、copyFile等方法,因为fs模块内的很多方法最终也是执行了这些方法的逻辑,所以只要修改它们就可以了。
但init.ts和fs-wrapper.ts两个文件内并不是只修改了fs模块内的方法,还修改了child_process和process模块的方法,因为这些模块也会涉及读取asar内部文件的一些API,比如child_process模块的execFile方法和process模块的dlopen方法。
提到execFile方法就不得不讲asar文件内的可执行程序,Electron是无法执行一个位于asar文件内的可执行程序的,Electron会先把这类可执行文件释放到一个临时目录下,再执行execFile方法,所以这类操作会增加一些开销,不建议把可执行程序打包到asar文件中。
另外,读取文件的逻辑并不都是在init.ts和fs-wrapper.ts这两个ts文件中实现的,比如获取文件信息的代码就是在shell\common\api\electron_api_asar.cc实现的,代码如下所示:
v8::Local<v8::Value> Stat(v8::Isolate* isolate, const base::FilePath& path) { asar::Archive::Stats stats; if (!archive_|| !archive_->Stat(path, &stats)) return v8::False(isolate); gin_helper::Dictionary dict(isolate, v8::Object::New(isolate)); dict.Set("size", stats.size); dict.Set("offset", stats.offset); dict.Set("isFile", stats.is_file); dict.Set("isDirectory", stats.is_directory); dict.Set("isLink", stats.is_link); return dict.GetHandle(); }
关于asar文件的序列化和反序列化逻辑,并不是Electron实现的,而是借助chromium内置的Pickle来实现的,这里不再赘述,详情请参见https://www.npmjs.com/package/chromium-pickle。