深入浅出Electron:原理、工程与实践
上QQ阅读APP看书,第一时间看更新

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所示。

048-1

图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