Node.js 和 Electron 中 Native 原生 addon 模块的 .node 文件离线加载方案

目录
[隐藏]

1 Node.js 加载 Native 模块 .node 文件的方法

在 Node.js 中加载由 C++、Rust 等其他编程语言编译的 .node 二进制模块文件,可以有两种方式。

1.1 方式一:直接使用 require 加载

示例:

const filepath = path.resolve(`./sqlcipher/${process.platform}-${process.arch}.node`);
return require(filepath);

需要注意的是,在 Electron 中,使用 webpack 编译输出的代码中 require 是经过 webpack 辅助函数包装的,如果遇到因 require 方法调用导致的报错,可尝试改为如下方式:

const filepath = path.resolve(`./sqlcipher/${process.platform}-${process.arch}.node`);
const nodeRequire = typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : globalThis.require;
return nodeRequire(filepath);

1.2 方式二:使用 process.dlopen() 函数

process.dlopen() 函数允许动态加载共享对象。用法示例:

const filepath = path.resolve(`./sqlcipher/${process.platform}-${process.arch}.node`);
const m = { exports: {} };
// @ts-ignore
// eslint-disable-next-line
process.dlopen(m, filepath);
return m.exports as never as T;

一般来说 require 方式应该优先于 process.dlopen,除非有特殊原因,例如需要自定义 dlopen 标志或从 ES 模块加载。

1.3 webpack 构建对 .node 加载的支持

在基于 webpack 构建的项目中,对于依赖其他 Native 语言开发编译的 .node 文件的处理,当前常见的方案是引入 node-loadernative-ext-loader 等模块实现。翻阅其源码可以了解到,其实它们的实现非常简单:根据 require('xxx.node') 依赖分析识别 xxx.node,将该文件以 emitFile 方式产出到输出目录,同时返回基于 process.dlopen 加载的代码进行模块加载适配。

在基于 Electron 的应用中,可能会因为路径处理的问题存在加载 .node 异常的现象,需要具体分析和适配。

2 第三方模块库加载 .node 文件的方式

当前在 npm 仓库中流行的模块中,对于 Native 实现的 .node 文件的加载,基本都会使用 require 的方式,但具体方案则各有差异:

在早期没有 N-API 之前,针对于每一个 Node.js 的版本,都需要编译独立的 .node 二进制文件,不同版本之间不能通用。在这种背景下,大部分流行模块都会将 C++ 等 Native 语言的源码放在 npm 包中一起发布,同时支持基于 node-gyp 在模块安装时触发 install 钩子脚本启动本地编译。但这种方案常常会因为本地编译环境必要工具的缺失而失败,给使用方制造了额外的复杂性。

N-API 出现后,基于该规范开发编译的 .node 文件,一般都可以向后兼容不同的 Node.js 版本,于是一般只需要预编译一次,将产物共享出来即可。

一些项目会将针对所有平台编译的 .node 文件与包一起发布。但由于不同操作系统平台的 .node 文件是不可共用的,这可能会导致 npm 包的大小非常大。

更为常见的做法是将 .node 文件上传到存储服务器上,通过配置 scripts.install 钩子脚本,在模块被安装时实时根据当前系统类型下载对应的 .node 文件。node-pre-gyp工具实现了一种部署原生 Node 预编译二进制模块的方法, 许多流行的模块都是使用它。

对于 Node.js 项目来说,只要模块可以正常下载即可。但当部分网络不可访问的环境下(如屏蔽外网访问的企业内网服务器环境),则问题变的复杂起来。

一些流行模块支持以环境变量的方式自定义下载地址。例如 Electronnode-sass 等著名模块。这种情况下只需要简单的配置内网可访问的资源地址即可。
但是也有一些模块并不支持自定义逻辑,这种情况下方案实现就变得复杂的多。

3 一种离线维护第三方 Native 模块的方案

从上面的介绍内容中,我们了解到对于不同的第三方模块实现的 .node 文件加载方案,可能会面临两个主要的问题:

  • 内部私有网络下,无法下载 .node 文件
  • 简单的使用 node-loader 可能不能正常的实现项目正常加载运行,需要逐一作对应的方案适配

为了避免过于复杂的构建环境配置,在我们的项目中采用了以下方法,实现了一种完全离线加载的方案:

  1. 实现工具方法 getAddonNodePathrequireAddon 方法,用于支持自定义加载 .node 文件
  2. 下载第三方模块源码到 src/addons 目录下维护
  3. 下载第三方模块用到的 .node 文件到 addons 目录下,以 <moduleName>/${process.platform}-${process.arch}.node 方式命名
  4. 修改 .node 文件的加载方式,使用 requireAddon 方法加载,并移除远程下载逻辑
  5. 开发辅助脚本,项目构建时复制 addons 目录到产出目录,同时根据构建产物对应的目标系统平台,将其他系统平台的 .node 文件过滤或移除。

getAddonNodePathrequireAddon 方法实现示例:

/** 获取适用于当前平台的 addon .node 文件路径 */
export function getAddonNodePath(addonName: string) {
    const platform = process.platform;
    const arch = process.arch;
    const nodeName = `${addonName.replaceAll('-', '_')}.node`;
    const list = [`build_win_${arch}/ReleaseDll/${nodeName}`, `napi-v6-${platform}-${arch}/${nodeName}`, `${platform}-${arch}/${nodeName}`];

    for (let nodePath of list) {
        nodePath = getResourcesFilePath(path.join(`addons/${addonName}`, nodePath));
        if (fs.existsSync(nodePath)) return nodePath;
    }

    return '';
}

/** 加载 addon 模块 */
export function requireAddon<T>(addonName: string, dlopen = false): T {
    const nodePath = getAddonNodePath(addonName);

    if (!nodePath) throw Error(`未找到该模块:${addonName}`);
    (globalThis.logger || console).debug('[env][requireAddon]', addonName, dlopen, nodePath);
    if (dlopen) {
        const m = { exports: {} };
        // @ts-ignore
        // eslint-disable-next-line
        process.dlopen(m, nodePath);
        return m.exports as never as T;
    } else {
        const nodeRequire = typeof __non_webpack_require__ === 'function' ? __non_webpack_require__ : globalThis.require;
        return nodeRequire(nodePath);
    }
}

在第三方模块中使用 requireAddon 方法:

const { requireAddon } = require('@/utils');
const sqlite3 = requireAddon('sqlcipher', true);

由于模块源码和 .node 文件均在本地,.node 文件加载方案也完全自定义,不会再为项目构建发布流程增加复杂度。

4 扩展参考

点赞 (0)

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

Captcha Code