缘起
最近,笔者在开发一个命令行模式的工具类软件,由于很多操作都是涉及网络、文件和密码学相关的内容,考虑到nodejs是一个功能比较全面和合适的平台,并且笔者比较熟悉,所以主程序就主要使用nodejs和JS语言实现。
软件开发基本完成之后,笔者就发现。这个软件的分发、部署和使用,就成了一个问题。这个程序,对于有经验的开发者或者开发环境而言来说,问题不大,git pull, node . 就可以了,但很难想象对于普通用户如何来进行操作。这就涉及到软件如何进行分发和安装的问题了。
传统的软件分发,一般是在开发测试完成之后,将软件封装成为一个安装程序,然后放在网站或者软件市场当中。使用者下载后执行安装程序,将软件安装到系统当中。一般情况下这个安装过程,会将程序文件复制到其指定的安装位置,并做一些系统的初始化和配置的工作。对于这个过程我们已经很熟悉,很多大型的图形界面商业软件(典型的如M$ Office系列)就是这样做的,那确实有一种"仪式感"。但对于一个功能单一的命令行工具程序,这个过程又过于复杂了,最好的方式就是下载后就可以直接使用,最多修改一下可执行权限,即所谓的"便携(免安装)"软件。
基于便携的需求,当然就希望软件的结构越简单越好,最好就是一个文件,并且是可以执行的。所以就催生出这种新的软件类型,即所谓的单一可执行文件(Single Execuable Application, SEA)。这种软件虽然只是单一的文件,但通常已经内置了相关的执行机制和环境,无需操作系统的外部支持,有非常强的可移植性和适应性。以前的技术条件下,也不是没有考虑过这种方式,但由于当时的计算机硬件能力比较弱,软件和支持系统打包后对于当时的软硬件而言过于庞大,所以需要进行拆分并且共享相同的内部组件。好在现在的计算机内存和存储能力都十分强大,一般认为单一文件不超过100M,都是可以接受的。
除了便携方便使用之外,随着云计算技术的发展,人们还意外的为这种模式找到了新的应用场景,就是微服务。单一可执行文件,非常方便自动化的分发、部署和管理,所以很多新型的应用系统,也使用这种方式提供,比如minio、caddy等等。这里顺便提一下,很多go语言编写的程序,都是SEA的方式。
在一些特定甚至通用的场景下,SEA有很多优势,这里借助pkg(一个Nodejs SEA技术方案)总结的一些内容,例举的相关优势和应用场景如下:
- 不需要提供源代码的情况下,提供应用程序的商用可执行版本
- 无需源代码,提供演示、评估和使用程序
- 方便的为不同的平台,提供对应的可执行应用程序
- 可用于自解压和安装器程序
- 不需要nodejs和npm即可执行打包的可执行程序
- 无需下载和配置复杂的npm依赖
- 可选附带如文档、图片等相关支持资产
- 保持针对特定的nodejs版本的执行程序
笔者这里再补充几点:
- SEA很适合cli工具类的应用
- SEA很适合无状态的云原生应用,可以快速部署或者docker化
- 跨平台运行,因为nodejs是跨平台的,所以一份代码,可以方便的编译成为在不同平台上运行的应用程序
下面我们现从相关的理论和方法论出发,并结合Nodejs的相关技术,以Nodejs为例,初步了解一下SEA的基本原理和需要处理的问题。
NODEJS SEA和方法论
按照一般的解决方案思路和方法论,一个NODEJS SEA技术方案,需要解决以下问题:
- 封装
既然是单一可执行文件,首先就需要有一个文件封装的方式。对于一个完整的应用系统,它可能不只是包括可执行的代码或者文件,还可以包括一些辅助性的文件,比如帮助文本、图片、界面模板,甚至视频、音频等,因为一个完整的应用程序,需要为用户提供完整的应用体验,很多附加性的UI内容和元素,是不可或缺的。
这些内容,在原来的普通应用程序时代,可能会以文件夹和文件的形式组织并复制到操作系统当中(比如Windows 的 Program Files文件夹),但在SEA中,都需要使用某种封装和打包方式,以一个单一文件的形式呈现。
- 执行环境
SEA的设计目标是无需环境和上下文支持,自己就可以独立运行。就是说,以nodejs为例,其SEA呈现的运行,是不需要操作系统环境中安装有nodejs环境的。这个很好理解,因为它在编译和打包的时候,会先封装一套目标平台的nodejs二进制程序,然后用这个程序来执行,看起来就是独立运行了。
所以,这也很让人容易理解,一般的SEA编译过程,需要指定编译的目标环境和需要支持的平台系统,而且需要区分操作系统、CPU架构和nodejs版本,来适应不同的执行环境。
- 执行参数
对应一般的nodejs程序,它的命令行执行启动方式的一般形式是: nodejs index.js ....
所以,其参数数组,前两个元素固定的就是nodejs 和 入口js文件,后续才是真正的参数。而如果是SEA程序,真正的参数是从第二个就开始的,有一点差异。但以笔者使用pkg的经验来看,由于这个问题比较简单清晰,都可以很好的处理,它们会在内部进行一个映射和转换,开发者不需要关心这个问题,可以使用原来的方式。
- 模块化
几乎所有的nodejs程序都是模块化的多js文件的形式。而且它们之间,可能有比较复杂的依赖关系。最简单的方式,就是将整个项目的文件夹结构,都保持下来,在封装和运行的时候保证其完整性。所以有些技术方案会使用一个"虚拟"的文件系统来处理这个问题。
虚拟的文件系统还有一个好处,可以兼容非程序的资产文件,所以在项目架构内部,都可以很好的处理。但需要注意一些外部文件系统关联的问题,我们在后续的内容有相关讨论。
- 外部依赖
大多数nodejs程序都不会只是简单的只使用系统标准库和模块,而是多多少少要引用一些外部功能库。方便和丰富的外部库,这本身也是nodejs生态系统的特点和优势,所以对这个特性的支持是非常重要的。
从技术上来看,一般的nodejs应用项目,是通过在项目文件夹中,创建和维护一个"node_modules"文件夹,并且将所有运行时需要的外部库和依赖关系,都放在这个文件夹中,来进行处理的。逻辑上来说,一般nodejs程序的部署,并不需要复制这个文件夹,只需要部署主程序文件,然后在部署环境中,运行 npm install命令,它会在部署环境中按需下载和安装相关的依赖,从而完成整个完整的部署。
但问题的复杂性在于,有些依赖模块本身就是可执行文件,并不是纯JS程序。它在安装的时候,可能会编译一下,来适应当前的执行环境,这个模块,显然可能就不能直接预编译打包,来适应不同的平台了。所以笔者觉得,要开发SEA程序,要尽量少用外部依赖,特别是底层或者系统基本的依赖模块。
- 外部关系
SEA程序在操作系统当中的运行场景,也可能是比较复杂的。典型的问题就是SEA和文件系统的关系。在很多情况下,需要SEA来操作所在操作系统中的文件。这样就需要SEA程序明确的知晓自己所在的位置和外部文件系统的情况,如用户环境等等。
这个问题通常也不会很严重,但是会影响到开发和测试的工作。例如一些原来使用的文件处理相关的方法,就可能无法使用了,需要其他额外的处理。比如常用的__dirname,在SEA环境中,可能会出问题,这就是由于对于SEA程序而言,内部的文件结构,和所在真实的操作系统环境,其实是不一样的。可能原生程序没有问题,但SEA会出问题,这些都需要开发者知晓并重视。这个问题的解决方式,我们后面会结合pkg技术的实践过程具体讨论。
- 兼容开发
作为开发者,当然希望自己的应用程序,可以运行在不同的场景当中,并且需要的改造越少越好。最理想的情况就是当前的nodejs应用程序,可以完全不用修改,就可以直接编程成为一个SEA程序,并且后续的使用方式和原来一样。现在好像不能完全做到这一点,但只需要开发者在开发过程中,预先知晓相关的情况,有意识的避免可能出现的问题,这些问题也不是非常严重。
对于一般的应用程序,以pkg方案为例,它的解决方案是,为一些常见的主流环境,分别进行编译和打包,生成适应性的结果内容。
- 便于分发和部署
作为工具类的应用,在合适的环境中,可以做到下载即用,无需复杂的下载-安装-配置-运行流程。这个主要需要在开发时考虑各种操作系统和环境的差异,避免在不同运行环境中出现适应性问题。
- 适合于云计算环境
在云计算环境中部署SEA,是要求SEA本身是无状态的,就是配置信息不能写在项目内部,而是可以使用外部方法进行配置,如启动环境和参数等等。这个都需要在开发时就进行规划和实现。
- 版权和版本保护
将原来作为脚本语言和代码的nodejs应用程序,封装成为单一的可可执行应用程序,可以有效的保护代码被复制,并且方便进行程序的签名和验证,有助于保护数字版权。
另外,由于可以固定封装nodejs的二进制版本,而不受操作系统nodejs环境的影响,可以固定nodejs执行环境,包括相关的依赖程序的版本,这些有助于减少由于版本差异导致的兼容和不稳定的问题。
可选实现技术
上一章节中,我们已经了解了SEA技术的基本原理和方法论,这一章节我们将分享一些相关的实践和操作。
笔者觉得比较奇怪的情况是,SEA这个技术,虽然显然有比较强的优势和市场需求,但真正的可选的技术体系,却比较少,笔者尝试了几个实现方案,都不是特别理想,最后觉得可以接受的,就只有pkg。笔者当然希望,市场上可以出现更多更好的技术方案,让这一生态可以蓬勃发展,并且改善nodejs系统在SEA这方面比较薄弱的局面。
Nodejs原生SEA
其实,可能是感觉到了某种必要性,SEA这个技术,已经被正式纳入了nodejs的发展规划当中。就是在nodejs20和以后的版本中,已经明确提出和实现了实验性的功能,也是名为Single executable applications。相关的技术文档页面地址为:
但笔者感觉,这个官方的实现似乎比较薄弱,比如它只支持单一的JS文件,并且好像对扩展模块特别是编译模块的支持还没有实现。确实也就是一个"实验性"的尝试,但不管怎么说,这个特性应该已经被官方纳入了发展计划,希望在两三个版本的迭代后,可以成熟并且用于生产环境。
我们这里简单的了解一下它的实现和操作过程,主要的目的还是了解一下在nodejs中它的技术原理和思路,这里简单起见,只讨论linux系统的情况(Mac和Windows有一些特别的设置和步骤,详见原文):
shell
1 创建用于SEA的js文件
echo 'console.log(`Hello, ${process.argv[2]}!`);' > hello.js
2 创建SEA配置文件
echo '{ "main": "hello.js", "output": "sea-prep.blob" }' > sea-config.json
3 使用配置信息,生成blob文件
node --experimental-sea-config sea-config.json
4 创建nodejs副本
cp $(command -v node) hello
5 移除签名
linux系统略过
6 注入blob内容
npx postject hello NODE_SEA_BLOB sea-prep.blob \
--sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2
7 程序签名
linux系统可略过
8 运行程序
$ ./hello world
笔者对这一过程的简单分析如下:
在编写完成主js文件之后,可以结合使用使用一个配置文件(sea-config.json),将这个js文件(可选asset资产文件)编译成为一个blob文件,就是其二进制版本;然后获取并复制当前使用的nodejs程序,本身就是一个二进制版本;如果是windows或者Mac,Node程序是内置签名信息的,需要去除这些信息;然后使用postject命令,将这两个二进制文件合成为一个可执行文件;最后可以通过运行这个二进制文件来进行测试。
从这个过程,我们可以发现,起码在现阶段,nodejs SEA还远没有达到实用的程度,主要问题包括:
- 操作过程略显繁琐
- 只支持单一的js文件,所以应该也不支持库和node_modules
- 它使用当前的node环境,如果是不同的环境,可能需要在对应的环境中编译,很不方便
- 从文档上看,需要特别的方式,通过代码和函数的方法,才能访问打包内的资产文件
PKG
PKG是一个npm和工具,它可以用于将指定的nodejs程序,编译成为目标环境的单一可执行文件。pkg在github上的项目其实应该也是其官方地址是:
经过一段实际的实践和研究,笔者觉得虽然不是让人特别满意,特别操作过程比较繁琐,系统可用性和鲁棒性也不足,但pkg确实是现有这个技术阶段,一个可以接受的解决方案。笔者现在的开发和实践,主要还是基于pkg。所以我们后面有专门的章节详细探讨相关的内容。
其他选项和技术
除了nodejs SEA和pkg之外,在这个页面探讨了已有的SEA技术方案:
这里将SEA相关技术分为两种类型,SEA技术和相关的虚拟文件系统技术。前者包括pkg、boxednode(来自mongoDB)、nexe和node-sea等;后者包括ASAR(来自Electron)和ZipFS(来自yarn)。笔者没有时间和经历对这些技术进行研究和实践。但大致了解了一些,除了pkg之外,都多多少少有一些缺陷,比如nexe已经很久没有更新了,显然无法随着nodejs的发展和改进。所以市场其实非常期待有生产系统级别的技术和解决方案出现。
PKG应用和实践
本章节的主要内容就是关于PKG这个技术的详细的使用过程和一些相关的问题。
安装
pkg作为标准的npm,安装是非常简单的,一般作为全局npm工具来进行安装(因为后续需要执行pkg命令):
sudo npm i pkg -g
配置
pkg的配置信息,就使用package.json文件选项的方式。例如,笔者项目的参考package.json配置文件的内容如下:
package.json
{
"name": "jhsend",
"version": "0.5.5",
"description": "",
"main": "index.js",
"bin": "index.js",
"scripts": {
"pkgbuild" : "pkg . ",
"test": "echo \"Error: no test specified\" && exit 1"
},
"pkg": {
"targets": [ "node18-linux-x64", "node18-win-x64" ],
"outputPath": "dist"
},
"keywords": [],
"author": "",
"license": "ISC"
}
这里有关于pkg的设置,要点如下:
- bin
pkg设置需要的SEA程序的入口文件,没有特别的原因的话,可以保持和原生程序的相同,这里都是index.js。
- scripts
这个主要是可以支持使用npm script来简化操作,就是有了这个项目,开发者或者系统运维人员,可以一致的使用 " npm run pkgbuild" 指令来执行编译任务。这个指令的配置内容,在后续的pkg配置项目当中。
- pkg
这是针对pkg编写的配置信息。此处包括了定义编译结果输出文件夹为"dist"(项目路径中,需要预先创建);和编译目标平台,这里使用了node18,支撑的操作系统为linux和windows,都是x64架构,如果考虑要支撑其他的平台和版本,就可以加入相关的项目,如arm等等。
pkg配置还有一些的可选的功能和项目,因为笔者也没有实际使用,这里就不深入讨论,只是做简单列举了解:
- pkg.assets
按照pkg处理nodejs项目的原理,它会分析js代码和它们之间的依赖关系。但对于一些支持性的项目,它是无法处理的,需要用户手动指定。这些项目主要包括一些资产文件,如图片、HTML、CSS等等。
- pkg.scripts
有一些特别的js文件,和一些特殊处理的js文件等等。
- pkg.output
这个比较简单,就是指定打包结果的输出路径。一般情况下,打包后,会在这个文件夹中生成几个针对不同环境的可执行文件。文件的名称都是以应用名称为开头。比如本例中,会生成jhsend-linux和jhsend-win.exe两个文件。开发者可以直接将它们复制到对应的操作系统中进行执行。
- pkg.options
这里可以设置一些和打包、编译或运行选项,如指定gc方式,gc空间等等。
其他有一些命令行选项,可能有机会用到包括:
- debug
调试选项。可以用于输入打包的调试信息。
- bytecode
可选是否使用字节码。这个特性好像主要是用于避免在打包时内容的变化,导致应用程序的签名信息变化。因为字节码处理可能会生成一些随机信息,最终使编译的结果不同(即使原始内容完全一致),就是幂等的问题。
- compress
可选打包时压缩。
执行构建
在package.json中的项目正确配置完成之后,执行构建是非常简单的。进入项目文件夹,执行 npm run pkgbuild 就可用了(对于pgkbuild script)。 这里实际执行的就是 pkg . 命令,然后,它会在dist目录中,生成对应配置项目的可执行文件,如 -linux-x64, -win-x64.exe等等。这些文件是可以直接复制到对应操作系统中执行的。
下面是在笔者的系统上,执行构建的相关操作结果和信息:
shell
anjh@10-40-60-56:~/jhsend$ npm run pkgbuild
> jhsend@0.5.5 pkgbuild
> pkg .
> pkg@5.8.1
Fetching base Node.js binaries to PKG_CACHE_PATH
fetched-v18.5.0-linux-x64 [====================] 100%
fetched-v18.5.0-win-x64 [====================] 100%
yanjh@10-40-60-56:~/jhsend$ ls dist -l
total 82180
-rwxr-xr-x 1 yanjh yanjh 46390242 Mar 27 17:19 jhsend-linux
-rw-r--r-- 1 yanjh yanjh 37759781 Mar 27 17:19 jhsend-win.exe
yanjh@10-40-60-56:~/jhsend$
如果是全新开始构建,我们可以从中了解一下这一过程的简单原理。就是它将从pkg的网络服务中,按照配置信息,分别下载Linux-X64和Windows X64的nodejs二进制程序,并保持在本地缓存文件夹中(PKG-CACHE-PATH,默认为 .pkg-cache)。然后基于这些包进行构建工作。这个过程其实是设计的有问题的,我们后面有相关的分析。
测试和运行
构建完成之后,一切正常的化,就会在输入文件夹中看到构建完成的文件了。开发者可以直接将这些文件复制到对应架构的操作系统中,理论上是可以直接执行的。
执行过程中,笔者也遇到了一些小的问题。在充分理解SEA程序的原理情况下,都很快得到了解决,这里简单的例举一下:
- __dirname
nodejs中有一些全局变量,可以直接方便使用。比如__dirname就可以方便的获取当前脚本文件的路径。但由于封装的问题,在SEA程序中,这个路径并不是SEA程序的路径,而是一个虚拟的路径,这显然也业务设计不符。修改的方式,就是不使用这些全局变量,而是使用process.cwd()方法,来获取当前进程的启动位置,基本可以获得相同的效果。
- ~路径
在nodejs程序中,是可以使用~作为当前用户的主目录的位置的。但在SEA中应该是没有这个概念。但可以使用os.homedir()方法,获取当前执行用户的主文件夹。
- 执行参数
原来笔者以为这个问题也需要进行调整。后面的实践证明,pkg可以处理这个参数数组的问题,完全不需要修改程序。
- 配置信息
原来笔者喜欢使用一个配置信息文件来保持常用的配置信息,以适应不同的执行环境。但封装后,已经不能通过简单的编辑配置文件的方式解决这个问题了。笔者后面是配合使用默认配置信息和用户文件夹中的动态生成配置文件的方式。当然也可以考虑外部的配置文件访问的方式。这里只是说明,可能需要为SEA程序进行相关的调整。
问题和不足
之所以笔者觉得pkg不是特别理想,是因为笔者在使用时,觉得它还是有一些问题和不足。其实有些内容,它也很坦诚的写在了它的技术文档当中,但确实需要用户用心去阅读和体会。
- 安装过程
实际上,在笔者主力开发使用的Windows电脑上,安装pkg是失败了的,笔者只能使用远程的linux电脑来完成这些操作。起码说明,这个pkg的兼容性和鲁棒性不佳(或者水土不服?)。
- 项目的分析能力
据其技术文档显示,pkg可以处理js文件和依赖关系,但无法处理其他类型的文件,所以需要在配置信息中进行手动的设定。这个需求在一些Web应用中非常常见,就是很多HTML的文件模板,包括其附加的CSS和图片文件,都需要进行封装和处理,可能也需要相关的规划和规范。
但笔者认为,如果将这些文件放置在项目文件夹中,它们和主程序可以达成一个相对固定的引用关系。pkg以项目文件夹作为整体来进行处理,理论上也是可行的。
- 网络服务和版本
由于网络的问题,直接使用pkg构建,来下载nodejs的二进制包的过程体验并不好。因为看不到它是如何提供服务的,只感觉下载速度比较慢。此外由于所有的构建,都会针对特定的nodejs版本和操作系统平台,好处当然就是在一个地方就可以完成多个平台版本的构建。它也有缺点,就是nodejs版本的支持,不是特别及时。比如当前的nodejs LTS长期版本已经是20了,但它还没有提供对这一版本的支持,而且看起来用户无法自己来完成这一版本适配工作。笔者也尝试过不使用pkg来下载Nodejs,而是预先下载后,复制到.pkg-cache文件夹中,但操作没有成功,打包时仍然提示无法下载或者很容易中断下载。
小结
本文通过笔者编写的一个CLI工具的SEA化,探讨了SEA技术的相关优势和应用常见,原理和方法论,以及使用PKG技术进行操作的实践过程。