Egg.js + pkg 打包可执行程序踩坑笔记

·  阅读 4433
Egg.js + pkg 打包可执行程序踩坑笔记

需求背景

用 Egg.js 开发了一个 nodeJS 的服务需要部署到客户现场,按照正常的部署操作,需要给服务器上安装 NodeJS 的开发环境,然后配置代理、镜像源,安装依赖,再通过 npm start 命令来启动服务。

实际业务中服务器是由客户方进行运维的,这种复杂操作流程显然不能被接受,用户要的就是一个可执行的 exe 文件,双击就能自动启动服务那种。

得,那就只能将 NodeJs 运行环境,npm 依赖和源码都打包起来了,大致搜了一下可用的 NodeJs 打包工具:

pkg 的口碑比较不错,连 EncloseJS 的作者都强力推荐用户转移到 pkg。

Dear users of EncloseJS. I highly encourage you to switch to github.com/zeit/pkg.

开搞。

初始化 Egg 项目

Egg 的官方文档写的非常详细,照着教程先初始化一个项目。如果对 Egg 项目已经比较熟悉了可以直接跳过该章节。

npm init egg --type=simple复制代码

image.png

如果遇到下载模板超时问题可以尝试在初始化命令行添加代理参数:

image.png

npm init egg --type=simple -r https://registry.npm.taobao.org/复制代码

执行完后项目目录结构如下:

egg-project
├── package.json
├── app
|   ├── router.js
│   ├── controller
│   |   └── home.js
├── config
|   ├── plugin.js
|   ├── config.default.js
└── test
    └── controller
        └── home.test.js复制代码

执行 npm install 安装项目依赖:

npm install复制代码

为确保服务能够正常启动,你还需要全局安装 egg-bin 和 egg-scripts:

npm install -g egg-bin egg-scripts复制代码

此时执行 npm run dev 脚本以开发模式启动服务器:

npm run dev复制代码

image.png

之后打开浏览器 localhost:7001 端口显示 hi, egg,这就算是把 egg 的服务环境调试好了。

image.png

开始踩坑

首先全局安装 pkg 以便可以在命令行中执行 pkg 命令:

npm install -g pkg复制代码

根据 pkg 的 README,我们可以通过三种方式来指定工程入口文件:

The entrypoint of your project is a mandatory CLI argument. It may be:

  • Path to entry file. Suppose it is /path/app.js, then packaged app will work the same way as node /path/app.js
  • Path to package.json. Pkg will follow bin property of the specified package.json and use it as entry file.
  • Path to directory. Pkg will look for package.json in the specified directory. See above.

给翻译翻译:

  • 显式指定入口文件,例如指定入口文件为 /path/app.js,pkg 将会像 node /path/app.js 一样工作;
  • 指定 package.json,pkg 将会以 package.json 的 bin 属性作为入口文件;
  • 指定目录,pkg 将会在该目录下的 package.json 的 bin 属性作为入口文件。

我们知道,一般在 express 的项目根目录下是有一个文件 app.js 作为入口文件的,那么 egg 的入口文件在哪呢?

被 egg 给藏起来了。

参照 使用 pm2 启动 egg 应用的方案,我们可以在根目录添加 server.js 作为入口文件。

// server.js
const egg = require('egg');

const workers = Number(process.argv[2] || require('os').cpus().length);
egg.startCluster({
  workers,
  baseDir: __dirname,
});复制代码

同时在 package.json 中指定入口文件

{
    ...
+ "bin": "server.js"
  ...
}复制代码

pkg 是如何打包的呢?

pkg 从用户指定的入口文件开始遍历整个项目依赖,当引入 javascript 文件的时候,pkg将其编译成 v8 字节码的形式打包到可执行文件中(Scripts);当引入一个静态资源的时候,pkg 会将原始内容直接拷贝到可执行文件 (Assets)。

是不是所有的 js 文件和静态资源 pkg 都能帮你处理好呢?

并不是,这取决于资源加载方式。pkg 会将通过 __dirname 和 __filename 指定路径的资源自动打包进可执行程序,如 require 方法(内部使用 __direname 来定位资源)和通过 path.join(__dirname, "../path/to/asset") 方法引入的静态文件:

require('./router.js')
path.join(__dirname, './app/public/index.html')复制代码

但是,对于非字面量参数的资源引入,pkg 是无法识别并自动引入的,如:

require('./build/' + cmd + '.js')
path.join(__dirname, 'views/' + viewName)复制代码

这个时候就需要我们主动的在 package.json 的 pkg 属性中显示声明需要打包的资源了。在 egg 的项目中,我们先按照如下配置,待会遇到坑我们再调整:

// package.json
{
  ...
  "pkg": {
    "scripts": [
        "./app/**/*.js",
      "./config/*.js"
    ],
    "assets": [
      "./app/public/**/*"
    ]
  }
  ...
}
复制代码

这个时候,我们来考虑一下这个问题,在运行时,原本指向本地文件系统中的资源不存在了,程序是怎么运行的呢,举个例子,代码里写了 require("./server.js"),但是当前文件目录只有一个可执行文件,没有 server.js 这个文件,程序是怎么跑起来的呢。

这里就要说起 pkg 的文件资源加载机制了。

pkg 魔改了 node 的 fs API,拦截了文件操作并代理到自己虚拟的文件快照系统 (snapshot filesystem) 中。比如,运行时,对于 require("./server.js") 中 server.js 的加载,pkg 将会代理到 /snapshot/server.js 路径(windows系统中是 c:\snapshot ),并从虚拟文件快照系统中返回之前打包的文件内容。

哪些形式的文件读写会命中 pkg 的虚拟文件快照系统呢?作者给出了下面这张表格:

valuewith nodepackaged
__filename/project/app.js/snapshot/project/app.js
__dirname/project/snapshot/project
process.cwd()/project/deploy
process.execPath/usr/bin/nodejs/deploy/app-x64
process.argv[0]/usr/bin/nodejs/deploy/app-x64
process.argv[1]/project/app.js/snapshot/project/app.js
process.pkg.entrypointundefined/snapshot/project/app.js
process.pkg.defaultEntrypointundefined/snapshot/project/app.js
require.main.filename/project/app.js/snapshot/project/app.js

也就是说,如果想要资源被自动打包进可执行文件,就使用 __filename 和 __direname 等路径定位符。另一方面,如果你想访问真实的文件系统,如加载环境变量文件,就应该使用 process.cwd() 等路径定位符,这使得动态修改服务的启动或运行参数成为可能,如修改服务端口号等。

这个时候我们来思考一个问题,像本地记录运行日志这种情况应该如何处理呢?

没错,我们需要在 egg 配置文件中通过 process.cwd() 来指定日志路径,以便执行对真实文件系统的写入操作。同理,但凡是对真实文件系统的读写操作,都要在配置文件中通过 process.cwd() 进行声明:

// config/config.default.js
const path = require("path");

module.exports = appInfo => {
  ...
  // 配置运行时相关文件存储路径
    config.rundir = process.cwd() + '/run';
  
  // 配置日志目录
  config.logger = {
     dir: path.join(process.cwd(), 'logs'),
  };
  
  // 配置静态资源路径
  config.static = { 
    prefix: '/',
    dir: process.cwd() + '/public',
  };
  ...
}复制代码

在执行打包命令的时候,我们需要添加 --targets 选项来指定打包的目标格式:[nodeRange]-[platform]-[arch],其中:

  • nodeRange:指定 node 版本,如 node${n} 或 latest
  • platform:指定操作系统,如 freebsd, linux, alpine, macos, win
  • arch:指定 CPU 架构,如 x64, x86, armv6, armv7

如:node12-win-x64,多个打包目标格式可以采用逗号分割。

也可以将 targets 选项指定在 package.json 的 pkg 属性中:

// package.json
{
  "pkg": {
    "scripts": [
        "./app/**/*.js",
      "./config/*.js"
    ],
    "assets": [
      "./app/public/**/*"
    ],
    "targets": [
        "node12-win-x64"
    ]
  }
}
复制代码

同时,为了方便打包,我们新建一个 npm 打包脚本:

// package.json
{
  "scripts": {
    "build": "pkg . --out-path D:/ --debug"
  }
}复制代码

注意,这里特地通过 --out-path 参数指定了和当前项目路径不一样的输出目录,是为了确保打包出来的可执行文件脱离当前项目环境也是可用的,避免出现部分资源虽然没打包进来,但是可执行文件依然可以从真实文件系统中访问的情况,方便尽早暴露出问题。

此时,我们执行以下命令来进行初步打包尝试:

npm run build复制代码

image.png

这时出现了第一个问题:因为内网代理或其他网络原因,导致 node 二进制文件下载失败。参照 Issues #419,可以在 pkg-fetch Release 页面手动下载 node 二进制文件,并放到 C:\Users\用户名\.pkg-cache\v2.6\ 目录下,继续执行 npm run build 命令,最后打包完成:

image.png

这个时候启动可执行文件,会发现以下报错:

image.png

来看一下报错提示:

Error: [egg-core] load file: D:\snapshot\egg-pkg-template\node_modules\egg-security\app\extend\ context.js, error: Cannot find module 'nanoid/non-secure'

实际上也就是说 egg-security 这个包里的一个文件没有打包到我们的可执行文件中,导致运行时找不到文件。

我们可以在 pkg 的 scripts 中显式的指定打包缺失的 js 资源:

// package.json
{
    "pkg": {
    "scripts": [
      "./app/**/*.js",
      "./config/*.js",
+     "./node_modules/nanoid/**/*.js"
    ],
    "assets": [
      "./app/public/**/*"
    ],
    "targets": [
      "node12-win-x64"
    ]
  }
}复制代码

再执行一次打包命令,然后运行可执行文件:

image.png这个时候虽然服务启动成功了,但是依然有报错,我们接着分析报错日志:

2020-07-28 23:11:43,493 ERROR 37628 nodejs.ENOENTError: ENOENT: no such file or directory, watch 'D:\snapshot\egg-pkg-template\app'
  at FSWatcher.start (internal/fs/watchers.js:169:26)
  at Object.watch (fs.js:1415:11)
  at Walk.<anonymous> (D:\snapshot\egg-pkg-template\node_modules\wt\index.js:183:20)
  at Walk.emit (events.js:315:20)
  at D:\snapshot\egg-pkg-template\node_modules\ndir\lib\ndir.js:107:16
  at zalgoSafe (pkg/prelude/bootstrap.js:260:10)
  at pkg/prelude/bootstrap.js:962:9
  at pkg/prelude/bootstrap.js:336:5
  at pkg/prelude/bootstrap.js:314:14
  at FSReqCallback.wrapper [as oncomplete] (fs.js:516:5)

看样子是本地开发运行环境下 watcher 插件功能异常,实时上我们打包生成可执行文件的时候应该默认是应用于生产环境的,所以这个时候我们需要在 config 目录下新增 env 文件来指定当前环境为生产环境。同时,记得修改 pkg 配置来确保这个文件会被打包进去。

// package.json
{
    "pkg": {
    "scripts": [
      "./app/**/*.js",
-     "./config/*.js",
+     "./config/*",
      "./node_modules/nanoid/**/*.js"
    ],
    "assets": [
      "./app/public/**/*"
    ],
    "targets": [
      "node12-win-x64"
    ]
  }
}复制代码

再次执行打包命令并运行可执行程序,启动顺利:

image.png

此时打开 localhost:7001 端口,egg 服务正常运行,同时当前目录下也新增了 log、run、public 文件夹,印证了我们之前真实文件系统写入的配置。

image.png

这里仅仅是抛砖引玉,后续的业务代码开发就交由大家各自发挥了。本文项目代码已上传 github Coodool/egg-pkg-template, 喜欢请 star 一下。

参考资料



分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改