快应用开发工具之 asar

asar 是一种将多个文件合并成一个文件的类 tar 风格的归档格式。 Electron 可以无需解压整个文件,即可从其中读取任意文件内容。欲了解更多可参考官方解释

注意:asar 只做归档,不做压缩。

asar 的使用

asar 的使用非常简单,更详细的用法可以参考文档

  • 安装 asar
  npm install --engine-strict asar
  • 打包
  asar pack app app.asar
  • 解包
  asar extract app.asar app

asar 的作用

  • 缓解 Windows 下路径名过长的 问题,略微加快一下 require 的速度。

    大多数 Windows 程序不能处理长度超过 260 个字符的文件和文件夹路径。一旦超过此限制,安装脚本便会开始中断,并且无法再使用常规方法删除 node_modules 文件夹。而随着 Node 包变得越来越复杂,依赖层次越来越深,这种情况只会变得更糟。

  • 隐藏代码

    源码归档在 asar 文档内,不直接暴露源码。

  • 减少文件数

    类似压缩包,多个文件归档成一个 asar 文件,利于减少基于 electron 开发的应用程序在 Windows 上的安装速度。

asar 原理

Electron 对 Node API 的 fs 模块进行了重写,这样在 Electron 中使用 fs 的方法便可以将 asar 视之为虚拟文件夹,读取 asar 里面的文件就和从真实的文件系统中读取一样。

这里我们看看 fs.readFile 重写过程,源码如下:

const { readFile } = fs;
// 重写 fs.readFile
fs.readFile = function(pathArgument, options, callback) {
  const { isAsar, asarPath, filePath } = splitPath(pathArgument);
  // 非 asar 路径直接使用原生方法
  if (!isAsar) return readFile.apply(this, arguments);

  if (typeof options === "function") {
    callback = options;
    options = { encoding: null };
  } else if (typeof options === "string") {
    options = { encoding: options };
  } else if (options === null || options === undefined) {
    options = { encoding: null };
  } else if (typeof options !== "object") {
    throw new TypeError("Bad arguments");
  }

  const { encoding } = options;
  // 提取 asar 文档内容,缓存中有则直接读取缓存,缓存没有则提取并缓存
  const archive = getOrCreateArchive(asarPath);
  if (!archive) {
    const error = createError(AsarError.INVALID_ARCHIVE, { asarPath });
    nextTick(callback, [error]);
    return;
  }

  const info = archive.getFileInfo(filePath);
  if (!info) {
    const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
    nextTick(callback, [error]);
    return;
  }

  if (info.size === 0) {
    nextTick(callback, [null, encoding ? "" : Buffer.alloc(0)]);
    return;
  }

  if (info.unpacked) {
    // 将文件复制到临时文件中并返回新路径。
    const realPath = archive.copyFileOut(filePath);
    return fs.readFile(realPath, options, callback);
  }

  const buffer = Buffer.alloc(info.size);
  const fd = archive.getFd();
  if (!(fd >= 0)) {
    const error = createError(AsarError.NOT_FOUND, { asarPath, filePath });
    nextTick(callback, [error]);
    return;
  }

  logASARAccess(asarPath, filePath, info.offset);
  fs.read(fd, buffer, 0, info.size, info.offset, error => {
    callback(error, encoding ? buffer.toString(encoding) : buffer);
  });
};
  • 判断路径是否是 asar 文档,不是则直接调用原生方法读取。
  • 提取 asar 文档内容,缓存中有则直接读取缓存,缓存没有则提取并缓存。
  • 提取 asar 文档内容后判断是不是 unpacked,是则将文件复制到临时文件中并返回新路径。
  • archive 调用的方法都是基于 C++ 实现的

asar 的限制

尽管 Electron 团队已经尽了最大努力使得 asar 包在 Node API 下的应用尽可能的趋向于真实的目录结构,但仍有一些底层 Node API 我们无法保证其正常工作。

档案文件是只读的

档案文件中的内容不可更改,所以 Node APIs 里那些会修改文件的方法在使用 asar 归档文件时都无法正常工作.

工作目录不能设置为档案文件里的目录

尽管 asar 档案是虚拟文件夹,但其实并没有真实的目录架构对应在文件系统里,所以你不可能将 working Directory 设置成 asar 包里的一个文件夹。 将 asar 中的文件夹以 cwd 形式作为参数传入一些 API 中也会报错。

某些 API 需要额外解压档案包

大部分 fs API 可以无需解压即从 asar 档案中读取文件或者文件的信息,但是在处理一些依赖真实文件路径的底层系统方法时,Electron 会将所需文件解压到临时目录下,然后将临时目录下的真实文件路径传给底层系统方法使其正常工作。 对于这类 API,会增加一些开销。

以下是一些需要额外解压的 API:

  • child_process.execFile
  • child_process.execFileSync
  • fs.open
  • fs.openSync
  • process.dlopen - 用在 require 原生模块时

fs.stat 统计信息不真实

对 asar 档案中的文件取 fs.stat,返回的 Stats 对象不是精确值,因为这些文件不是真实存在于文件系统里。 所以除了文件大小和文件类型以外,你不应该依赖 Stats 对象的值。

执行 asar 档案内的二进制文件

Node 中有一些可以执行程序的 API,如 child_process.exec,child_process.spawn 和 child_process.execFile 等, 但只有 execFile 可以执行 asar 包中的程序。

因为 exec 和 spawn 允许 command 替代 file 作为输入,而 command 是需要在 shell 下执行的. 目前没有 可靠的方法来判断 command 中是否在操作一个 asar 包中的文件,而且即便可以判断,我们依旧无法保证可以在无任何 副作用的情况下替换 command 中的文件路径。

快应用开发工具使用 asar 的背景

  1. 快应用开发工具中有一个内置插件的 node_modules 有 1w+ 的文件数,直接导致安装时间超长,用户体验很糟糕,所以我们使用 asar 的核心需求是减少文件数,提升安装包的安装速度。

  2. 同时做到 Windows 下 对 node_modules 全覆盖,避免第三方模块的文件删除或重命名后引用错误问题。

node_modules.asar

按照 Node require 加载第三方模块的原理,只读取 xxx/node_modules 下的文件。我们把插件工程的 node_modules 归档成 asar 文档,模块路径变成了 xxx/node_modules.asar ,这就导致代码中的 require 方法找不到模块。因此需要对 node 加载模快进行改造,在原有的 paths 上增加 asar 文档的路径,这样在 node 加载模块时就能找到对应的模块。

node require 流程

实施

exports.enableASARSupport = function(nodeModulesPath) {
  const Module = require("module");
  const path = require("path");

  // 原生 node_modules 路径
  let NODE_MODULES_PATH = nodeModulesPath;
  if (!NODE_MODULES_PATH) {
    NODE_MODULES_PATH = path.join(__dirname, "../node_modules");
  }

  // node_modules.asar 文档路径
  const NODE_MODULES_ASAR_PATH = NODE_MODULES_PATH + ".asar";

  // 缓存原生 _resolveLookupPaths 方法
  const originalResolveLookupPaths = Module._resolveLookupPaths;

  // 重写 _resolveLookupPaths
  Module._resolveLookupPaths = function(request, parent) {
    // 首先调用原生方法得到加载模块的路径数组
    const paths = originalResolveLookupPaths(request, parent);
    if (Array.isArray(paths)) {
      // 遍历 paths ,如果其中有原生 node_modules 路径,则在这个路径前面插入 node_modules.asar 文档路径
      for (let i = 0, len = paths.length; i < len; i++) {
        if (paths[i] === NODE_MODULES_PATH) {
          paths.splice(i, 0, NODE_MODULES_ASAR_PATH);
          break;
        }
      }
    }

    return paths;
  };
};
  • 重写 Module 的 _resolveLookupPaths 方法,这个方法的功能就是获取模块可能存在的目录集合,如下:

/a/b/c/node_modules
/a/b/node_modules
/a/node_modules
/node_modules

  • 调用原生方法得到加载模块的路径数组

  • 遍历 paths ,如果其中有指定的 node_modules 路径,则在这个路径前面插入 node_modules.asar 文档路径

  • 以上修改后,require 函数就能读取到被归档到 node_modules.asar 文档中的模块文件了。