likes
comments
collection
share

详解 Electron 中的 asar 文件

作者站长头像
站长
· 阅读数 45

Electron 应用中的前端代码最终会打包成 asar 文件,那什么是 asar 文件呢?我们先来看一下 asar 的定义:

Asar is a simple extensive archive format, it works like tar that concatenates all files together without compression, while having random access support.

可以看到,asar 是一种文件归档方式,类似于 tar 包,把多个目录和文件合并在一起,但是并不进行压缩,结构如下:

详解 Electron 中的 asar 文件

为什么要用 asar 文件呢?主要有三点原因:

  • 避免 Windows 系统下文件路径过长(Windows 限制最长路径为 260 个字符)
  • 加快 require 函数的加载文件的速度
  • 避免向用户直接暴露代码文件

asar 的基本用法

首先全局安装 asar :

$ npm i -g asar
# 或者
$ yarn global add asar

查看命令使用方法:

Usage: asar [options] [command]

Manipulate asar archive files

Options:
  -V, --version                         output the version number
  -h, --help                            display help for command

Commands:
  pack|p [options] <dir> <output>       create asar archive
  list|l [options] <archive>            list files of asar archive
  extract-file|ef <archive> <filename>  extract one file from archive
  extract|e <archive> <dest>            extract archive
  *
  help [command]                        display help for command

利用 pack 命令对 dist 目录进行打包,其中 dist 目录的结构如下:

dist
├── main
│   ├── calculatorTemplate.png
│   ├── calculatorTemplate@2x.png
│   └── index.js
├── package.json
└── renderer
    ├── calculator.css
    ├── calculator.html
    └── calculator.js

将 dist 目录中的文件全部打进 dist.asar 包的命令为:

$ asar pack dist dist.asar

查看 dist.asar 中的文件:

$ asar list dist.asar
/main
/main/calculatorTemplate.png
/main/calculatorTemplate@2x.png
/main/index.js
/package.json
/renderer
/renderer/calculator.css
/renderer/calculator.html
/renderer/calculator.js

剖析 asar 的本质

我们已经知道,dist.asar 文件保存了 dist 的目录结构和文件内容,直接用文本编辑器打开查看:

详解 Electron 中的 asar 文件

因为有部分内容是乱码,此时用 hex 格式查看会更清晰一点:

详解 Electron 中的 asar 文件

asar 文件的结构为:

| UInt32: header_size | String: header | Bytes: file1 | Bytes: file2 | ... 

其中:

  • header_size 区域:表示 header 区域占用的字节数
  • header 区域:用 JSON 记录了 asar 内所有文件的信息
  • file 区域:保存了文件内容

撇开乱码部分,我们先把 JSON 部分拷贝出来,格式化后查看其结构:

const header = {
  files: {
    main: {
      files: {
        'calculatorTemplate.png': { size: 507, offset: '0' },
        'calculatorTemplate@2x.png': { size: 1044, offset: '507' },
        'index.js': { size: 1171, offset: '1551' },
      },
    },
    'package.json': { size: 336, offset: '2722' },
    renderer: {
      files: {
        'calculator.css': { size: 1420, offset: '3058' },
        'calculator.html': { size: 1369, offset: '4478' },
        'calculator.js': { size: 3990, offset: '5847' },
      },
    },
  },
}

可以看到 dist.asar 中有 7 个文件,详细的列出了文件名,文件大小,文件位置偏移。

注意:这是 asar 3.0.3 版本打包后的内部结构,在 3.1.0 版本以后,JSON 中存储的数据增加了 integrity 字段来确保文件完整,会跟上述有所区别。

由此可见,dist.asar 文件由九个部分组成:

  • 第一部分:存储第二部分的大小
  • 第二部分:存储 JSON 字符串(用于描述 asar 内部的文件信息)
  • 第三部分:存储 calculatorTemplate.png 文件内容,大小 507,偏移量 0
  • 第四部分:存储 calculatorTemplate@2x.png 文件内容,大小 1044,偏移量 507
  • 第五部分:存储 index.js 文件,大小 1171,偏移量 1551
  • 第六部分:存储 package.json 文件,大小 336,偏移量 2722
  • 第七部分:存储 calculator.css 文件,大小 1420,偏移量 3058
  • 第八部分:存储 calculator.html 文件,大小 1369,偏移量 4478
  • 第九部分:存储 calculator.js 文件,大小 3990,偏移量 5847

详解 Electron 中的 asar 文件

所以本质上讲,asar 是一个虚拟目录,以一个大文件文件形式保存了指定目录下所有文件的方式。

深入解析 asar 的前两部分

asar 文件的第一部分和第二部分使用了 Pickle 对象来存储信息,Pickle 结构是一种非常简单的数据结构,包含两个部分:

  • 前四个字节是 header 区域
  • 后面都是 payload 区域

Pickle 类提供基本的二进制打包、解包的功能,以下代码是从 asar 源码摘录出来的,我们可以用其生成 asar 文件(这里用上面的 header 变量生成 test.asar 文件):

const pickle = require('chromium-pickle-js')
const fs = require('fs')

async function createAsar() {
  const headerPickle = pickle.createEmpty()
  headerPickle.writeString(JSON.stringify(header))
  const headerBuf = headerPickle.toBuffer()

  const sizePickle = pickle.createEmpty()
  sizePickle.writeUInt32(headerBuf.length)
  const sizeBuf = sizePickle.toBuffer()

  const out = fs.createWriteStream('test.asar')
  await new Promise((resolve, reject) => {
    out.on('error', reject)
    out.write(sizeBuf)
    return out.write(headerBuf, () => resolve())
  })
}

createAsar()

用 hex 格式打开 test.asar 比对一下,果不其然,发现跟 dist.asar 前面的内容一模一样。

详解 Electron 中的 asar 文件

上面生成 asar 的代码中,都先调用了 pickle.createEmpty() 方法初始化空的 Pickle 对象,其结构为:

Pickle {
  header: <Buffer 00 00 00 00 00 00 ... bytes>,
  headerSize: 4,
  capacityAfterHeader: 64,
  writeOffset: 0
}

Pickle 对象封装了三类方法用于数据写入:

  • 写入布尔类型:writeBool
  • 写入数字类型
    • 整数:writeInt
    • 无符号整数:writeUInt32
    • 单精度浮点数:writeFloat
    • 双精度浮点数:writeDouble
  • 写入字符串类型:writeString

对于数字类型和布尔类型,直接从第五位开始写入,例如:

// 写入整数 1
const p1 = pickle.createEmpty()
p1.writeInt(1)
console.log(p1.toBuffer()) // <Buffer 04 00 00 00 01 00 00 00>

// 写入无符号整数 1
const p2 = pickle.createEmpty()
p2.writeInt(1)
console.log(p2.toBuffer()) // <Buffer 04 00 00 00 01 00 00 00>

// 写入布尔类型true
const p3 = pickle.createEmpty()
p3.writeBool(true) // true 会被转成 1,false 转成 0,然后调用 writeInt 方法
console.log(p3.toBuffer()) // <Buffer 04 00 00 00 01 00 00 00>

写入之后,buffer 的二进制如下:

详解 Electron 中的 asar 文件

你可能会问,既然有符号整数1、无符号整数1和布尔类型 true 在 Pickle 对象中存储的值一模一样,那怎么区分呢?实际上 Pickle 不关心值的数据类型,由消费者自己解析:

When reading from a Pickle object, it is important for the consumer to know what value types to read and in what order to read them as the Pickle does not keep track of the type of data written to it.

而 Pickle 存储字符串类型数据的方式跟数字和布尔类型有一些区别,以下面的代码为例:

const p4 = pickle.createEmpty()
p4.writeString('hello')
console.log(p4.toBuffer()) // <Buffer 0c 00 00 00 05 00 00 00 68 65 6c 6c 6f 00 00 00>

从第五位开始,先以 int 类型写入字符串的长度,然后再将字符串的内容拼接到后面,源码如下:

Pickle.prototype.writeString = function (value) {
  var length = Buffer.byteLength(value, 'utf8')
  if (!this.writeInt(length)) return false
  return this.writeBytes(value, length)
}

由于先调用了 writeInt 写入字符串长度,所以整个的二进制如下:

详解 Electron 中的 asar 文件

其实 asar 只是用 Pickle 来保存第一部分和第二部分信息,资源文件的具体内容则是直接读取二进制拼接到后面的。所以当 Electron 解析 asar 包的时候,会先从 JSON 中读取文件的大小和偏移量,然后截取 buffer,这部分内容就是文件内容啦!

以上面的 dist.asar 文件为例,我们从 JSON 中得知第六部分存储了 package.json 文件,大小是 336 字节,偏移量 2722 字节,就可以用下面的代码获取内容:

const asarFile = path.join(__dirname, `dist.asar`)
const asarBuffer = fs.readFileSync(asarFile)
const part1size = 8 // 第一部分大小(固定占用 8 个字节)
const part2size = asarBuffer.readInt32LE(4) // 第二部分大小(从第一部分中获取)
const part6offset = 2722 // 第六部分的偏移量(从第二部分的 JSON 数据中获取)
const part6size = 336 // 第六部分的大小(从第二部分的 JSON 数据中获取)
const part5start = part1size + part2size + part6offset // 第六部分在 asar 中的开始位置
const part6end = part5start + part6size // 第六部分在 asar 中的结束位置
const part6 = asarBuffer.subarray(part5start, part6end) // 第六部分的内容
console.log(part6.toString())

把 asar 当做虚拟目录使用

站在操作系统视角来看,asar 是一个文件而并非目录,因此 node.js 自带的 fs 模块是无法把 asar 当做虚拟目录来使用的,例如下面的代码:

const fs = require('fs')
const path = require('path')

fs.readFileSync(path.join(process.cwd(), 'dist.asar/package.json'))

会直接报错:

node:internal/fs/utils:345
    throw err;
    ^

Error: ENOTDIR: not a directory, open '/Users/keliq/electron-desktop/test/dist.asar/package.json'
    at Object.openSync (node:fs:585:3)
    at Object.readFileSync (node:fs:453:35)

不过 Electron 重写了 fsrequireprocesschild_process 等模块的方法,支持识别 asar 内部的文件,所以上面的代码在 Electron 主进程中是可以正确运行的,仿佛用户磁盘上真的存在一个叫做 dist.asar 的文件夹一样,因此下面的方法都可以正确执行:

const fs = require('fs')
const utils = require('./path/to/dist.asar/utils.js')
fs.readdirSync('/path/to/dist.asar')
fs.readFileSync('/path/to/example.asar/file.txt')

想知道 Electron 具体重写了哪些方法,可以参考 Electron 源码中的 lib/asar/fs-wrapper.ts 文件:

详解 Electron 中的 asar 文件

既然通过 require 和 fs 引用 asar 路径,都会执行 Electron 提供的内部实现,而不是 Node.js 原有的实现,如果想获取 asar 文件自身的相关信息,例如计算 checksum 的话,就必须要引用 original-fs 模块,这个才是 node.js 原生的 fs 模块:

const originalFs = require('original-fs')
originalFs.readFileSync('/path/to/example.asar')

asar 的局限性

由于 asar 本质上是虚拟目录,虽然 Electron 尝试让其在使用起来跟真实目录一样,但跟真实目录还是有区别的,主要表现在:

  • asar 中的文件是只读的
  • asar 中的目录不能被设置成工作目录(working directory)
  • 需要将 asar 中的文件解压出来才能运行 node.js 的部分 API
  • fs.stat 拿到的信息是假的

这里需要特别强调一下第三点,把 js 文件、图片文件等打入 asar 包问题不大,但是如果把可执行文件打进去的话,就会有点麻烦了,因为 node.js 的部分 API 不支持执行 asar 中的虚拟文件,必须是真实存在的文件才行,这些 API 有:

  • child_process.execFile
  • child_process.execFileSync
  • fs.open
  • fs.openSync
  • process.dlopen

因此当调用这些 API 操作 asar 中文件的时候,会先把文件拷贝到临时目录中,然后再操作,下图中的 .com.github.Electon 开头的隐藏文件就是 Electron 从 asar 中提取出来的临时文件:

详解 Electron 中的 asar 文件

每次执行 Electron 应用的时候,都会创建这些临时文件,且不被删除,所以临时目录中可能存在大量的此类文件。因此,不建议把可执行文件打入 asar 内,不仅仅是因为会重复创建临时文件,更重要的是很多 node.js 的 API 不能用,例如 spawn 方法,这对程序的影响是非常大的。asar 也提供了命令行选项,让用户指定不打包哪些文件:

# 不打包 .node 文件
$ asar pack app app.asar --unpack *.node
# 不打包 .node 和 .out 文件
$ asar pack test test.asar --unpack "{*.node,*.out}"

这个时候就会将匹配的文件放入同级的 test.asar.unpacked 目录中,但是如果用 asar list 命令查看 test.asar 中包含的文件时,会发现被移到 unpacked 目录中的文件也存在,不用担心,asar 中只是记录了该文件的信息,并没有拼接文件内容:

{
  "files": {
    "add.node": { "size": 38848, "unpacked": true },
    "asar.js": { "size": 1023, "offset": "0" },
    "c.out": { "size": 52792, "unpacked": true },
    "del.js": { "size": 21, "offset": "1023", "executable": true },
    "pickle.js": { "size": 4626, "offset": "1044" },
    "tmp.js": { "size": 593, "offset": "5670" }
  }
}

可以看到,凡是未打包进去的文件,都标识为 unpacked: true。很多基于 Electron 开发的应用,都是采用这种方案来存放可执行文件的,例如 vscode 中,把 node_modules 里面的可执行文件放到了 node_modules.asar.unpacked 里面:

详解 Electron 中的 asar 文件

然后就可以用 spawn 方法来创建子进程了,不过引用文件的时候需要把路径换成 unpacked 目录,下面是 vscode 源码中引用 node_modules.asar.unpacked 目录下 @vscode/ripgrep 可执行文件的示例:

详解 Electron 中的 asar 文件

转载自:https://juejin.cn/post/7213171235577036860
评论
请登录