初探 Node.js 原生扩展模块

最近因项目需要开始研究 Node.js 原生模块的实现,并尝试接入自研 C++ 模块。Node.js 因其具有良好的跨平台适配性和非阻塞事件循环的特点,受到了服务端开发者的关注,但 JavaScript 毕竟基于 GC 实现的数据结构,在高性能计算上有所不足;而且很多老代码或者扩展库都以 C++ 书写,也给移植编译带来了一定的困难。如何在性能、兼容性和开发效率上取得平衡,为了解决这些个问题,让我们开始书写第一个 Node.js C++ addon 吧!

C++ addons 的原理

不管你看哪个教程(其实中文书也就一本,就是死月这本《Node.js:来一打 C++ 扩展》),提到 Node.js 一上来就是 V8 Isolate, Context, Handle, Scope 讲一堆,看完这两百页头已经晕了。这些都是非常基础的 V8 知识,但离实际运用还隔了很远。为了写出 C++ addons,我们只要抓住一点 ———— “JavaScript 对象就是一个个 V8 C++ 对象的映射”。

V8

String 类型的继承链

上图是 JS 里一个简单 String 的继承关系,如何创建一个简单的字符串呢?String::NewFromUtf8(isolate, "hello").ToLocalChecked(),是不是看了有些头大?如果告诉你这种继承关系随着 V8 的升级经常发生变化,是不是感觉血压都高了?

没错,这就是上古时期 Node.js C++ addons 的开发方式,需要指定 Node.js 的版本进行编译,只有在指定 ABI 的版本下才能运行。

https://nodejs.org/api/addons.html#hello-world ,有兴趣的读者可以阅读下 Node.js 官网对于 C++ addons 的简易劝退教程,里面展示了不少早期 Node.js 开发者与 C++ 对象搏斗的真实记录。

兼容性

在经历了刀耕火种的日子后,盼星星盼月亮终于迎来了 Node.js 的原生抽象 —— N-API。它利用宏封装了不同 V8 版本之间的 API 差异,统一暴露了多种识别、创建、修改 JS 对象的方法。让我们来看看如何创建一个字符串呢?

1
2
3
4
#include <node_api.h>

napi_value js_str;
napi_create_string_utf8(env, "hello", NAPI_AUTO_LENGTH, &js_str);

哎~ 怎么看起来也不是很简单嘛?。。。

别骂了,别骂了,要知道 Node.js 为了兼容不同版本付出了多大的努力吗?相对来说上述的 API 调用算是很简单的了,最重要的是它很稳定,基本不随着 Node.js 和 V8 的版本更迭而变化。env 是执行的上下文,js_str 是创建出来的 JS 字符串,NAPI_AUTO_LENGTH 是自动计算的长度,这里还隐含了一个变量,就是 napi_create_string_utf8 的返回值 napi_status,这个值一般平平无奇,但万一要是出了 bug 就得靠它来甄别各处调用是否成功了。

C++ addons 实战

前情铺垫结束,让我们拥抱改变吧。下面将带大家以一个简单的 Defer 模块的实现为例,走马观花式感受 C++ addons 的开发过程。

初始化项目

首先你得有个 Node.js,版本呢最好能到 14、16 以上,因为 N-API 有些部分在 14 的时候才加入或者稳定下来。

然后你得准备个 C++ 编译环境,下面简单介绍下各个系统下是如何操作的。

  • macOS: xcode-select --install 基本可以解决,后面会用 llvm 进行编译。
  • Windows: 啥都别说了,VS 大法好。推荐装 2019 Community 即可,然后记得把 v142 工具集装了就成。因为 node-gyp 会写死 VS 的版本号,所以如果出了问题就使用 VS installer 继续安装缺失的组件即可。
  • Linux: apt-get install gcc.

这样就准备好正式编译我们的 C++ addons 了。

node-gyp

通常情况下编译并链接 C++ 库是一件非常吃力不讨好的事,cmake 等工具的出现就是为了解决这个问题,而到了 Node.js 这一边,官方提供了同样的工具 node-gyp。只需 npm i node-gyp -g 即可,后续我们都将在 node-gyp 下操作。

VS Code 相关设置

我们可以在 VS Code 中设置 C++ 环境,这会给开发带来不少的体验提升。
https://code.visualstudio.com/docs/languages/cpp

这里是一份可参考的 .vscode/c_cpp_properties.json 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
"configurations": [
{
"name": "Mac",
"includePath": [
"${workspaceFolder}/**",
"/usr/local/include/node"
],
"defines": [],
"macFrameworkPath": [
"/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/System/Library/Frameworks",
"/System/Library/Frameworks",
"/Library/Frameworks"
],
"compilerPath": "/usr/bin/clang",
"cStandard": "c17",
"cppStandard": "c++14",
"intelliSenseMode": "macos-clang-x63"
},
{
"name": "Win32",
"includePath": [
"${workspaceFolder}/**",
"C:\\Users\\kimi\\AppData\\Local\\node-gyp\\Cache\\16.6.0\\include\\node"
],
"defines": [
"_DEBUG",
"UNICODE",
"_UNICODE"
],
"windowsSdkVersion": "10.0.17763.0",
"compilerPath": "C:/Program Files (x86)/Microsoft Visual Studio/2017/Community/VC/Tools/MSVC/14.16.27023/bin/Hostx64/x64/cl.exe",
"cStandard": "c17",
"cppStandard": "c++14",
"intelliSenseMode": "windows-msvc-x64"
}
],
"version": 4
}

这段 JSON 最重要的就是要指向 node 头文件的 includePath,上面分别提供了当前 Node.js 安装版本和 node-gyp 缓存的路径,以供参考。

Hello world

凡事怎么少得了 hello world 呢?这里假设你已经装好了 node-gyp 了。

先创建一个 main.cpp,写入以下内容。

1
2
3
4
5
6
7
8
9
10
#include <node_api.h>

napi_value Init(napi_env env, napi_value exports){
napi_value hello_str;
napi_create_string_utf8(env, "hello", NAPI_AUTO_LENGTH, &hello_str);
napi_set_property(env, exports, hello_str, hello_str);
return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

创建一个 binding.gyp,写入以下内容。

1
2
3
4
5
6
7
8
{
"targets": [
{
"target_name": "native",
"sources": ["./main.cpp"],
}
]
}

然后执行 npx node-gyp configure build,不出意外的话会生成一个 build 目录,build/Release/native.node 就是我们所要的货了。

生成的原生模块

如何使用呢?很简单,打开 Node.js REPL,直接 require 就行。

1
2
3
4
Welcome to Node.js v16.6.0.
Type ".help" for more information.
> require('./build/Release/native').hello
< 'hello'

大功告成!

小试牛刀:开发一个简单的 Defer 模块

在学习新知识点时,以熟悉的概念切入会更有学下去的动力。我们就小试牛刀,先实现一个非常非常简陋只支持一个 Promise 调用的 Defer 模块吧!

先让我们看一下最终需要的调用方式,从 JS 侧看就是加载一个 *.node 的原生模块,然后 new 了一个对象出来,最后调用一下它的 run 方法。可能 JS 写起来 10 行都不到,但这次的目标是将 C++ 与 JS 联动,这中间的过程就有点让人摸不着头脑了。

简单的 Defer 模块

别慌,遇事不决先确定接口类型,类型就是编程中的量纲,分析量纲就能得出解题思路。

JS 接口类型定义

抛开语言的差异,来分析一下这个 Deferred 类,它的构造函数接受一个字符串进行初始化,然后有个 public 的 run 方法接受一个数字并返回一个 Promise,以这个数字所代表的毫秒数来延迟 resolve 所返回的 Promise。

1
2
3
4
class Deferred {
constructor(private name: string)
public run(delay: number): Promise<string>
}

咦,这么简单吗?是的,JS 本就为了开发效率而生,但事情整到 C++ 层面可就不那么简单了 …… 但天下大事,必作于细,良好的职责划分有利于用不同的工具切准要害,逐个突破,我们接着往下看。

划分 C++ 与 JavaScript 职责

JavaScript 与 C++ 各自的职责

为了 OOP,我们将数据和行为都存放在一起,这会带来一些问题,就是数据该由谁持有?如果 JS 持有数据,将 C++ 作为一个无状态的服务,每次都将数据从 JS 传过来,计算完了传回去,但这样会造成序列化的开销。如果 C++ 持有数据,JS 侧就相当于一个代理,只是把用户请求代理到 C++ 这一边,计算完再转发给用户侧。

实际情况是,一旦涉及到原生调用,C++ 持有的数据很有可能是 JS 处理不了的不可序列化数据,比如二进制的文件,线程 / IO 信息等等,所以还是 C++ 做主导,JS 只做接口比较好。但这样就不可避免地要从 C++ CRUD 一些 JS 对象了,接着往下走。

创建 C++ 类

激动的心,颤抖的手,终于开始写 C++ 代码了 …… 老规矩,还是先定义一个 class 吧。

1
2
3
4
5
6
7
8
9
10
11
#include <string>
#include <functional>
#include <node_api.h>

class NativeDeferred {
public:
NativeDeferred(char *str);
void run(int milliseconds, std::function<void(char *str)> complete);
private:
char *_str;
};

看起来和 JS 侧的代码也很像嘛,只不过换成了 callback 的方式。如何使它能在 JS 侧使用呢?

创建 JS class

napi_define_class

N-API 提供了各种直接创建 JS 对象的方法,包括字符串、数字、undefined 等基本量,也有函数和对象等等。擒贼先擒王,一上来就找到了用于创建 class 的 napi_define_class。读了一遍定义后,发现需要提供 napi_callback constructorconst napi_property_descriptor* properties 作为参数。又马不停蹄地找到了 napi_callback,这个函数是我们后面会经常遇到的。

napi_callback

napi_callback 接受一个 napi_envnapi_callback_info,前者是创建 JS 对象所必须的环境信息,而后者是 JS 传入的信息。

如何解读这些信息呢?有 napi_get_cb_info 这个方法。通过它可以读出包括 this 和各种 ArrayLike 的参数。

napi_get_cb_info

我们在讨论如何创建一个 JS 的 class 啊,这是不是绕太远了?等等,你提到了 this?有的面试题里会考如何手写一个 Object.create,难道这就是那里面默认的 this?你猜对了,这个 this 在通过 Function 创建时,在构造器里是用 v8 的 ObjectTemplate 来实例化一个 instance 的。(PS: 如果 napi_callback 是从 JS 侧调用,那它就是 JS 的那个 this。)

从 JS class 创建对象的话,这个 napi_callback 就是 JS 定义的 constructor,执行完返回 this 就行了,但既然是深度融合 C++ 的功能,我们当然还有别的事要做。

将 C++ 对象封装到 JS instance 上

前面声明了一个非常简易的 C++ 对象 NativeDeferred,我们要将它封装到刚创建的 this 上,返回给 JS 侧。为啥要这样做?因为前面提到了,我们要用 C++ 对象持有一些数据和状态,这些不便于在 JS 和 C++ 来回传递的数据需要一个可追溯的容器来承载(即 NativeDeferred),我们可以假设这个容器有两种存储方式:

  1. 全局对象,也就是 V8 里的 global,然后生成一个 key 给 JS instance。
  2. 挂到 JS instance 上(N-API 支持这种操作)。

很明显第一种方法不仅污染了全局对象,也避免不了 JS instance 需要持有一个值,那还不如直接把 C++ 对象绑到它上面。

从 napi_callback 中读出 C++ 对象

取出 C++ 对象的过程形成了 napi_callback -> JS Deferred(this) -> unwrap C++ NativeDeferred 这样一个线路,需要用到 napi_wrapnapi_unwrap 方法。

napi_wrap

这里又有个坑,finalize_cb 是必须要赋值的,而且它应该去调用 NativeDeferred 的析构函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static void Destructor(napi_env env, void *instance_ptr,
void * /*finalize_hint*/)
{
reinterpret_cast<NativeDeferred *>(instance_ptr)->~NativeDeferred();
}

napi_value js_constructor(napi_env env, napi_callback_info info)
{
// 中间省略了获取 js_this 和 name 的步骤
NativeDeferred *deferred = new NativeDeferred(name);

napi_wrap(env, js_this, reinterpret_cast<void *>(deferred),
Destructor, nullptr, nullptr);
return js_this;
}

这样,我们就设置好了一个在 constructor 里会生成并自动绑定 C++ 对象的 JS class。

设置 JS class 上的调用方法

数据只有实际被使用才能发挥其价值,对应到 JS Deferred 上面,就是要让 JS 侧 run 方法顺利地调用到 C++ 侧的 run,这里面又要经历前面所说的从 napi_callback 一直到拿到原生 NativeDeferred 的过程,但如何让这个 napi_callback 可以被 Deferred 实例后的对象应用呢?

聪明的读者已经猜到了,就是将它设到 Deferred 这个类的原型链上,具体来说就是前面 napi_define_class 时的 const napi_property_descriptor* properties,我们来看一下它的定义。

napi_property_descriptor

napi_property_descriptor 上其他属性都比较常见,似乎跟 Object.defineProperty 有些相似,但 enumerableconfigurable 这些值呢?。我们注意到了 napi_property_attributes 这个参数,

napi_property_attributes

找到了找到了,这就是我们需要的属性了。

1
2
3
4
5
6
7
8
9
#include<node_api.h>

napi_property_descriptor runDesc = {"run", 0, js_run, 0,
0, 0, napi_default_method, 0};
napi_value js_class;
napi_property_descriptor descs[1] = {runDesc};
napi_define_class(env, "Deferrered", NAPI_AUTO_LENGTH, js_constructor,
nullptr, 1, descs,
&js_class);

*js_run 会在下一节实现。

上面的 js_class 就是我们一开始定义的 JS Deferred 了,将他 napi_set_property 到 hello world 中的 exports 上就能被 Node.js 访问啦。

这里还有个坑,napi_default_method 有些版本下是被定义在 if 里的,需要我们预先 define NAPI_VERSION 或者 NAPI_EXPERIMENTAL
NAPI_VERSION 需要 8 以上

让我们打开 binding.gyp,在 target 里加入以下内容,就可以啦。

1
2
3
4
5
6
{
"defines": [
"NAPI_EXPERIMENTAL",
"NAPI_VERSION=8",
],
}

C++ 回调 JS callback

到现在我们已经实现了一个 class 所需要的一切能力,但有个小问题:这些方法都是单向的从 JS 侧传递给 C++ 侧,或者反之,没有双向交互的部分。可以想一想怎样算是“双向交互”呢?就是 Node.js 常见的 callback 啊,我们还没有涉及到如何从 C++ 调用 JS 函数。napi_call_function这个函数就是 napi_get_cb_info 的逆操作了,把参数按个数和数组传递给函数指针。

napi_call_function

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 将这个函数 export 出去,使用时会以传入的第一个参数 args[0],判定其为函数传入 42 作为唯一参数进行调用
napi_value fire_js_callback(napi_env env, napi_callback_info info) {
napi_value js_this;
napi_value args[1];
size_t argc = 1;
napi_get_cb_info(env, info, &argc, args, &js_this, nullptr);

napi_value num;
napi_create_int32(env, 42, &num);
napi_value res[1] = { num };
napi_call_function(env, js_this, args[0], 1, res, nullptr);
return num;
}

总结一下,我们目前总共实现了以下的 C++ addon 能力。

功能 实现
创建 JS class
给 JS class 添加 method
将 C++ 对象封装到 JS 对象上
调用 JS 函数

高级技巧

读到这里的朋友可能发现了,前面提到的 Deferred 还有一环没有实现,就是延时调用。来想一下 C++ 里如何能延时呢?可以另外启动一个线程,将它 sleep,可以简单写下代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <node_api.h>
#include <thread>
#include <functional>

static void thread_run(std::function<void()> complete) {
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
complete();
}

napi_value fire_js_callback(napi_env env, napi_callback_info info) {
napi_value js_this;
napi_value args[1];
size_t argc = 1;
napi_get_cb_info(env, info, &argc, args, &js_this, nullptr);

napi_value num;
napi_create_int32(env, 42, &num);
napi_value res[1] = { num };

std::function<void()> complete = [=]() {
napi_call_function(env, js_this, args[0], 1, res, nullptr);
};
std::thread runner(thread_run, complete);
runner.detach();
return num;
}

但实际调用时,等了很久也没有触发,这是为什么呢?

JavaScript functions can normally only be called from a native addon’s main thread. If an addon creates additional threads, then Node-API functions that require a napi_env, napi_value, or napi_ref must not be called from those threads.

When an addon has additional threads and JavaScript functions need to be invoked based on the processing completed by those threads, those threads must communicate with the addon’s main thread so that the main thread can invoke the JavaScript function on their behalf. The thread-safe function APIs provide an easy way to do this.

Asynchronous thread-safe function calls

原来跨线程之后 napi_env 就不是原来的那个它了,我们需要按照 N-API 的方式来包装一下异步调用的函数。

线程安全调用

写到这里,笔者发现自己的功力已经不足以解释我所看到的文档了,直接上代码吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <node_api.h>
#include <thread>
#include <functional>

static void thread_run(std::function<void()> complete) {
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
complete();
}

static void thread_callback(napi_env env, napi_value js_callback, void* context, void* data) {
napi_value js_this = reinterpret_cast<napi_value>(context);

napi_value num;
napi_create_int32(env, 42, &num);
napi_value res[1] = { num };

napi_call_function(env, js_this, js_callback, 1, res, nullptr);
}

napi_value fire_js_callback(napi_env env, napi_callback_info info) {
napi_value js_this;
napi_value args[1];
size_t argc = 1;
napi_get_cb_info(env, info, &argc, args, &js_this, nullptr);

napi_value async_resource_name;
napi_create_string_utf8(env, "foobar", NAPI_AUTO_LENGTH,
&async_resource_name);
napi_threadsafe_function thread_complete;
// 将 js 传来的 callback 调谐函数 thread_callback 一起传入生成线程安全的回调
napi_create_threadsafe_function(
env, args[0], nullptr, async_resource_name, 0, 1, nullptr, nullptr,
js_this, thread_callback, &thread_complete);

// 将线程安全的回调再包装成闭包
std::function<void()> complete = [=]() {
napi_call_threadsafe_function(thread_complete, nullptr, napi_tsfn_blocking);
};
// 真正放到另一个线程去执行
std::thread runner(thread_run, complete);
runner.detach();

return js_this;
}

napi_value Init(napi_env env, napi_value exports){

napi_value fire_str;
napi_create_string_utf8(env, "fire", NAPI_AUTO_LENGTH, &fire_str);
napi_value fire;
napi_create_function(env, "fire", NAPI_AUTO_LENGTH, fire_js_callback, nullptr, &fire);
napi_set_property(env, exports, fire_str, fire);
return exports;
}

NAPI_MODULE(NODE_GYP_MODULE_NAME, Init)

经过一番眼花缭乱的操作后,终于成功触发了 args[0] 处的 JS callback 函数,这就是简化版本的 js_run 了。

Promise 的实现

既然实现了异步回调,我们再努力一把,实现 Promise 的返回值,这就比较简单了,N-API 将 napi_create_promise 设计为生成 napi_deferred* deferrednapi_value* promise,一式两份,一份直接返回给 JS,一份则留着在异步调用中将其 resolve。
我们只需稍微改写一下前面的代码即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static void thread_callback(napi_env env, napi_value js_callback, void* context, void* data) {
napi_deferred deferred = reinterpret_cast<napi_deferred>(data);

napi_value num;
napi_create_int32(env, 42, &num);

napi_resolve_deferred(env, deferred, num);
}

napi_value fire_js_callback(napi_env env, napi_callback_info info) {
napi_value js_this;
size_t argc = 0;
napi_get_cb_info(env, info, &argc, nullptr, &js_this, nullptr);

napi_value async_resource_name;
napi_create_string_utf8(env, "foobar", NAPI_AUTO_LENGTH,
&async_resource_name);
napi_threadsafe_function thread_complete;
// 将 js 传来的 callback 调谐函数 thread_callback 一起传入生成线程安全的回调
napi_create_threadsafe_function(
env, nullptr, nullptr, async_resource_name, 0, 1, nullptr, nullptr,
nullptr, thread_callback, &thread_complete);

napi_value promise;
napi_deferred deferred;
napi_create_promise(env, &deferred, &promise);

// 将线程安全的回调再包装成闭包
std::function<void()> complete = [=]() {
napi_call_threadsafe_function(thread_complete, deferred, napi_tsfn_blocking);
};
// 真正放到另一个线程去执行
std::thread runner(thread_run, complete);
runner.detach();

return promise;
}

篇幅起见,只贴出关键的两个函数了。

完工

事已至此,与 Deferred 这个类相关的代码已经基本介绍完了,完整的代码可以参见这个仓库:
https://github.com/msyfls123/basin

启动工程应该只需要:

1
2
3
4
npm i
npm run configure
npx node-gyp rebuild --debug
npm run basin

C++ addons 调试与构建

别看前面洋洋洒洒一堆操作,只写出了百来行代码,基本每行代码都踩过坑。这时候强有效的调试工具就显得非常重要了。

VSCode CodeLLDB 调试

推荐大杀器 CodeLLDB,配合 launch.json 食用,可在 VSCode 中左侧 Run and Debug 里对 C++ 代码断点并显示变量信息。

CodeLLDB

简易 launch.json

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"version": "0.2.0",
"configurations": [
{
"name": "debug with build",
"type": "lldb",
"request": "launch",
"preLaunchTask": "npm: build",
"program": "node",
"args": ["${workspaceFolder}/index.js"]
},
]
}

断点信息

prebuildify 预构建包

前面都是开发模式,如果是服务端使用的话,加上入口 js 文件后已经可以作为 npm 包发布了,安装时会自动执行 node-gyp rebuild 重新构建的。
但如果是嵌入到某个 App,比如腾讯文档桌面端,或是 QQ 之类的客户端应用里,那就需要根据不同的系统和架构进行跨平台编译了。

常见架构有:

  • Linux: x64, armv6, armv7, arm64
  • Windows: x32, x64, arm64
  • macOS: x64, arm64

竟然有这么多 …… 还好社区提供了跨平台编译的解决方案 ———— prebuild,但它需要在安装时下载对应的包,所以还需要将这些构建产物发布到服务器上,不与 npm 包放在一起。虽然在包体积很大的情况下的确有必要,这显然不是我们所追求的一键下载。

然后我就找到了 prebuildify。它是这么说的:

With prebuildify, all prebuilt binaries are shipped inside the package that is published to npm, which means there’s no need for a separate download step like you find in prebuild. The irony of this approach is that it is faster to download all prebuilt binaries for every platform when they are bundled than it is to download a single prebuilt binary as an install script.

Always use prebuildify --@mafintosh

有没有成功案例呢?有,那就是 Google 出品的 LevelDB 的 js 封装就是它做的,

prebuildify 直接应用在 npm scripts

我们项目里也应用了这个方案,参见 https://github.com/msyfls123/basin/blob/main/package.json#L16-L18。

与 CI 集成

可是这虽然可以只在三个系统各执行一遍进行编译,但每次发布都得登录三台机器来执行吗?no, no, no, 我们当然可以将这一切集成到 CI 中自动运行。

这里展示一下业界标杆 ———— GitHub Actions 的配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
name: Build

on: push

jobs:
build:
runs-on: ${{ matrix.platform.runner }}
env:
CXX: g++
strategy:
matrix:
platform:
[
{ runner: "windows-latest", command: "build:windows" },
{ runner: "macos-latest", command: "build:mac" },
{ runner: "ubuntu-latest", command: "build:linux" },
]
fail-fast: false
steps:
- name: Check out Git repository
uses: actions/checkout@v2

- name: Set up GCC
uses: egor-tensin/setup-gcc@v1
with:
version: latest
platform: x64
if: ${{ matrix.platform.runner == 'ubuntu-latest' }}

- name: Install Node.js, NPM and Yarn
uses: actions/setup-node@v2
with:
node-version: "16.6.1"

- name: Install Dependencies
run: |
npm i --ignore-scripts
- name: Compile
run: |
npm run configure
npm run ${{ matrix.platform.command }}

- name: Archive debug artifacts
uses: actions/upload-artifact@v2
with:
name: build
path: |
index.js
index.d.ts
package.json
prebuilds/

GitHub Actions

C++ addons 的展望

至此,本文也要进入尾声了,期望能对想要提升 Node.js 程序性能或是拓展应用场景的你带来一些帮助!最后提两点展望吧:

无痛集成第三方库

笔者看到项目里大部分第三方 C++ 库都是以源码形式引入的 …… 对于习惯 npm i 的人来说这肯定是像狗皮膏药一样贴在心上。听说 bazel 挺香,但其语法令人望而却步,似乎也不是一个依赖管理工具,这时有个叫 Conan 的货映入眼帘。

这里有篇文章讲述如何将 Conan 和你的 Node.js addons 结合,笔者试了一下确实可行,甚至都不需要 python 的 virtualenv,只是 libraries 需要小小的调整下:

1
2
3
'libraries': [
"-Wl,-rpath,@loader_path/"
]

编译目标:WebAssembly Interface?

居安思危,笔者也思考了下 Node.js addons 的局限性,需要每个平台都编译一遍还是有点麻烦的,有没有什么办法可以 compile once, run everywhere 呢?

有!那就是 WebAssembly,“那你为啥不用呢?”,这是个好问题。LevelDB 仓库内也有过类似的讨论,最后问题落到了性能和文件系统上,如果涉及到异步线程问题的话,会更复杂一点,因为 emccpthread 是基于 Web Worker 提供的,不清楚 Node.js 侧是否有 polyfill,以及在不同 Worker 运行,各种同步原语、Arc、Mutex 等是否都得到了支持,这些都是未知的。所以遇到一坨祖传下来打满了补丁的 C++ 代码,我们选择的稳妥方式依然是悄悄关上门,然后建座桥,把路直接修到它门口就跑,真刺激啊……

Electron 客户端自动更新

随着科技的进步,啊不,是网络通信技术的提升,客户端用户不再受限于拨号上网那样的小水管,百兆宽带触手可达,随时随地自动更新版本成为了标配。作为跨端框架的翘楚,Electron 自然也是内置了自动更新功能,但查阅其官网发现其提供的 autoUpdater 并无明确的操作路径可言,读完仍是一头雾水,尤其是还需要私有 CDN 部署时更是两眼一抹黑。

漫漫更新路

让我们从零开始,更新逻辑其实很简单,每次发版时将更新文件分发到 CDN 上,客户端通过检查 CDN 上有无更新文件继而下载文件并安装即可完成更新。抛开上传下载这种技术问题不谈,要解决两点:

  • 什么版本可以更新
  • 可以更新到什么版本

说人话就是从哪儿来,要到哪儿去。本文将要为你解答的就是如何通过一系列配置服务及本地设置,完成包含灰度更新、强制更新、静默更新以及 GUI 更新过程展示在内的可操纵动态更新策略。

发布与更新

首先确定一点,我们依然用的是 Electron 提供的更新功能,但主要用了 electron-builder 封装后的 electron-updater。这里的文档和 Electron 官方文档比较类似,有点啰嗦,下面就使用自定义 CDN 这条路提纲挈领地给大家梳理关键步骤。

生成制品信息

这里假定你一定是通过 electron-builder 进行打包了,需要在 electron-builder-config 中加入如下字段(详细字段配置

1
2
3
4
5
6
7
8
const config = {
...others,
publish: {
provider: 'generic',
channel: 'latest-win32',
url: 'https://your.cdn/update-path',
},
}

这里假设你的更新文件会被放在 https://your.cdn/update-path 目录下,通过这个配置打出来的安装文件就会多出一个 latest-win32.yml 文件,这个文件长下面这样子。

制品信息

这里面主要包含了版本号、更新资源文件的文件名及校验 hash 及发布日期等关键信息,对于后续步骤最重要的就是资源的文件名了。

将安装包与 yml 文件一起上传到 CDN 的 https://your.cdn/update-path 目录下就完成了生成制品信息的这一步。

配置自动更新信息

来到这一步需要保证 https://your.cdn/update-path/latest-win32.yml 已经是可以访问到的了,后面就是如何在端内把 electron-updater 支棱起来。

首先安装 electron-updater: npm i electron-updater
这里作者实操中有个问题,electron-updater 打包后失效了,暂未明确原因,故在 webpack 中将其设为 externals 并在最终由 electron-builder 打包的目录 projectDir 里安装了 electron-updater。

接下来,为了开发调试我们需要做一点骚操作,在下图所示目录中有 app-update.yml 文件。
mac app-update.yml
windows app-update.yml
这个文件里面内容是这样的,将它复制到项目根目录下并改名叫 dev-app-update.yml,后面就能调试更新了。

需要说明的是,macOS 上自动更新只有在签名后的 App 上才能进行,在后续步骤的退出并安装前会校验签名,校验失败时会报错。

自动更新

进入激动人心的代码环节!

1
2
3
4
5
6
7
8
9
10
11
12
13
import { autoUpdater } from 'electron-updater';

// 设置为指定发布版本,以防错读为 Electron 版本
autoUpdater.currentVersion = APP_VERSION;
autoUpdater.setFeedURL({
provider: 'generic',
channel: os.platform() === 'darwin' ? 'latest' : 'latest-win32',
url: `https://your.cdn/update-path`,
});
autoUpdater.checkForUpdates();
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall();
});

对,就是这么简单,一旦下载更新完成立即以迅雷不及掩耳之势退出 App 进行更新并重启。这是不是太快了点?都没留给用户反应的时间了。别着急,可以通过 autoUpdater 上的各种事件,参考这篇文章做一个漂亮的更新界面出来。
https://blog.csdn.net/Wonder233/article/details/80563236

autoUpdater 事件:

  • error
  • checking-for-update
  • update-available
  • update-not-available
  • download-progress
  • update-downloaded

精细化更新

静默更新

如果把上面的退出更新步骤去掉,离静默更新就只差一步了。

1
2
3
4
+ autoUpdater.autoInstallOnAppQuit = true;
- autoUpdater.on('update-downloaded', () => {
- autoUpdater.quitAndInstall();
- });

至这一步为止,你已经做完了一个不断更新到最新版本的 Electron App 所需要的一切了。

强制更新

即使你设置了每次都会自动更新,依然免不了有用户不肯买账,或者说会在各种网络差的情况下没法及时更新到最新版本,我们可以通过下发一个配置文件,来控制一些有废弃 API 或者有严重 bug 的版本被继续使用。

例如在配置系统上生成一个如下的配置,其中 force_update_version_list 就是一串 semver 规范的版本范围。
配置字段

在使用时只需要判断一下 APP_VERSION 是否在这些个区间内即可。

1
2
3
4
5
6
import * as semver from 'semver';

const config = await fetchUpdateConfig(key);
const forceUpdateVersions = config.force_update_version_list;
const shouldForceUpdate = forceUpdateVersions.length &&
semver.satisfies(APP_VERSION, forceUpdateVersions.join('||'));

这里在发出拉取更新配置请求时出现了一个 key,这个 key 提供了本地去决定使用哪个配置组的能力,比如测试就填 Test,线上默认为 Production,方便测试。

灰度更新

强制更新解决了哪些版本必须更新的问题,如果我们只想让某些版本或是用户更新到指定版本呢?这也就是通常所说的金丝雀发布、A/B 测试之类的了。同样可以用从网络拉取一个配置文件来解决,正好内网配置平台也满足我们的这种需要。

配置下发

首先配置系统支持根据 uin、ip 等进行灰度发布,我们选择了将 uid 后两位截取为 uin 上传到灰度名单,配置系统拿到上传的 uin 后根据灰度规则(上图配的是 30% 的比例)下发最新更改的配置项。

逐天放开灰度比例

直至 100% 比例后,可以进一步替换官网链接,完成全量发布。

更新路径划分

聪明的读者已经发现了,在发布与更新中,我们设置了统一的更新目录 https://your.cdn/update-path,如果有不同的更新版本,我们就需要设置不同的文件或是目录来控制。该用哪一种呢?

版本排布方式 优势 劣势
按目录 一个目录一个版本 无统一更新地址
按文件 目录层级扁平 文件混杂难分清

综合考虑后,我们选择了按目录划分版本的方式。
版本
文件

在上面的自动更新代码中替换如下内容即可享受精细控制的灰度更新功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { autoUpdater } from 'electron-updater';

+ const config = await fetchUpdateConfig(key);

// 设置为指定发布版本,以防错读为 Electron 版本
autoUpdater.currentVersion = APP_VERSION;
autoUpdater.setFeedURL({
provider: 'generic',
channel: os.platform() === 'darwin' ? 'latest' : 'latest-win32',
- url: `https://your.cdn/update-path`,
+ url: `https://your.cdn/update-path/${config.version}`,
});
autoUpdater.checkForUpdates();
autoUpdater.on('update-downloaded', () => {
autoUpdater.quitAndInstall();
});

尾声

按渠道分发

更新和下载一样是为了分发,当我们有了更多渠道时也可能需要考虑渠道间的差异性。渠道包可以通过配置文件进行区分,更新时只更新资源而不更新配置文件,这样就可以做到不同的安装渠道在同一更新下保持自身渠道特殊性。

永远递增你的版本号

The lastest, the best.

非主流 App GUI 框架 - Druid

不会吧,不会吧,都 2021 年了还有人在写桌面端应用吗?不都是 Electron 一统江湖了吗?对,也不对,Electron 的确大大降低了 web 页面直接生成单个可执行桌面程序的难度,但因其依赖于 Chromium 内核,糟糕的启动速度和海量内存占用一直是广大网友所诟病之处。
市面上还是有很多跨端 GUI 解决方案的,比如 QtGTK 等等,但既然是玩票嘛,就搞点新鲜的,本文就介绍一下当红炸子鸡语言 Rust 上的非主流 GUI 框架 - Druid。

demo

先简单介绍下 Druid,它同样是一个数据驱动的原生框架,背后是同作者开发的 Piet 绘图库。在不同的系统上有着不同的实现,这里就不多提了,感兴趣的可以深入研究一下。目前 Druid 还处在较为早期的开发阶段(除此之外的 GUI 库也都差不多……),所以文档和示例都很不全。本文将基于 0.7.0 版本进行阐述,如后续有不兼容升级,以官方文档为准。

启动

安装

万事开头难,中间更难,最后最难。说实话,Rust 的包管理系统已经算是不错的了,在你装好了 Rust 环境之后,随便创建个 cargo bin 项目即可,把 druid 加到依赖里面,这里推荐装个 cargo-edit 的包,这样你就能得到以下几个 cargo 命令,后续就不需要手动改 Cargo.toml 文件了。

1
2
3
4
cargo-edit v0.7.0:
cargo-add
cargo-rm
cargo-upgrade

第一个界面

官网第一个 case 就让我们栽了跟头,这里的 log_to_consoleui_builder 都不太对劲,改成如下代码就可以跑出首个界面啦。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use druid::widget::{Button, Flex, Label};
use druid::{AppLauncher, LocalizedString, PlatformError, Widget, WidgetExt, WindowDesc};

fn main() -> Result<(), PlatformError> {
let main_window = WindowDesc::new(ui_builder);
let data = 0_u32;
AppLauncher::with_window(main_window)
.use_simple_logger()
.launch(data)
}

fn ui_builder() -> impl Widget<u32> {
// The label text will be computed dynamically based on the current locale and count
let text =
LocalizedString::new("hello-counter").with_arg("count", |data: &u32, _env| (*data).into());
let label = Label::new(text).padding(5.0).center();
let button = Button::new("increment")
.on_click(|_ctx, data, _env| *data += 1)
.padding(5.0);

Flex::column().with_child(label).with_child(button)
}

麻雀虽小,五脏俱全,我们可以看到 ui_builder 就是界面相关的部分了,其中有文本和按钮,以 flex 布局,而这个函数被整个传递给了一个 WindowDesc 的构造函数,这就创建了一个窗口,然后这个窗口又被传递给了 AppLauncher,接着用 data 作为初始数据启动。整体逻辑还是比较清晰的。

数据

虽然前面传入的数据只是一个 u32,但实际应用的数据状态肯定不止如此。Druid 提供了一套简单但够用的数据定义和处理模型,其核心是通过内部获取数据的可变引用直接修改数据本身,而外部通过消息传递给代理器统一更改数据,实现了灵活多样的数据操作。

类型定义

首先是类型定义,Druid 提供了 DataLens 两个重要的 trait,它们分别提供了如何判断数据相等和如何从一大块数据中提取所需要数据的方式。

1
2
3
4
5
6
7
8
9
use druid::{Data, Lens};
use tokio::sync::mpsc::{UnboundedSender}

[derive(Debug, Clone, Data, Lens)]
pub struct State {
pub day: u32,
#[data(ignore)]
pub dispatch: UnboundedSender<u32>,
}

通常情形下,DateLens 可以被 derive 自动实现,但有些时候则需要一点小小的帮助,比如上图就需要忽略掉不可比较的 dispatch 字段,它只是一个消息发送器,无所谓变更,也不会变更。
Lens 的用途就更大了,比如组合属性等,详情可见 LenExt

事件与代理

前面讲了事件处理的方式:

  • 直接获取数据可变引用并修改
  • 通过消息代理

先讲讲消息代理这种形式吧,毕竟从 React 过来的人都偏好单向数据流。

AppLauncher 真正 launch 之前可以通过 get_external_handle 获取一个 ExtEventSink,通过它可以向 Druid App 内发送消息,这玩意甚至可以跨线程传递。而接受消息同样在 AppLauncher 上,通过传入一个实现了 AppDelegate trait 的 struct 给 delegate 方法即可。

需要注意的是,发送的消息和接受的消息都需要以唯一的 Selector 识别,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use druid::{Selector, AppDelegate};

const NEW_DAY: Selector<String> = Selector::new("new-day");
impl AppDelegate<State> for AppDelegater {
fn command(
&mut self,
ctx: &mut DelegateCtx,
_target: Target,
cmd: &Command,
data: &mut State,
_env: &Env,
) -> Handled {
if let Some(day) = cmd.get(NEW_DAY) {
data.days.push_back(day.to_string());
Handled::Yes
}
}
}

控制器

控制器即 Controller,它和 App 上的消息代理类似,但不同之处在于它往往是局部的,能提供针对某种 Event,组件生命周期和内外数据变化的精细控制。

例如,我们想要在窗口中实现一个右键菜单:每当用户操纵鼠标在窗口内右键单击时调用 make_demo_menu 创建一个菜单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
use druid::widget::{Controller};
use druid::{Widget, Event, ContextMenu};
use crate::components::menu::make_demo_menu;
use crate::types::{State};

pub struct WindowController;

impl <W: Widget<State>> Controller<State, W> for WindowController {
fn event(
&mut self,
child: &mut W,
ctx: &mut druid::EventCtx<'_, '_>,
event: &druid::Event,
data: &mut State,
env: &druid::Env
) {
match event {
Event::MouseDown(ref mouse) if mouse.button.is_right() => {
let context_menu = ContextMenu::new(make_demo_menu(), mouse.pos);
ctx.show_context_menu(context_menu);
},
_ => child.event(ctx, event, data, env),
}
}
}

需要注意的是没有处理的 Event 需要显式交给 child 继续处理,这与浏览器的 DOM 事件不同,是向下“冒泡”的。

环境变量

这里的环境变量不是指系统的环境变量,而是 Druid App 组件相关的整体设定,例如窗口颜色和按钮尺寸等等。
环境变量分两种:一种全局,一种局部。

全局的环境变量通过 launcher 的 configure_env 设置。

1
2
3
4
5
6
7
8
9
launcher.use_simple_logger()
.configure_env(|env, _| {
env.set(theme::WINDOW_BACKGROUND_COLOR, Color::WHITE);
env.set(theme::LABEL_COLOR, Color::AQUA);
env.set(theme::BUTTON_LIGHT, Color::WHITE);
env.set(theme::BUTTON_DARK, Color::WHITE);
env.set(theme::BACKGROUND_DARK, Color::GRAY);
env.set(theme::BACKGROUND_LIGHT, Color::WHITE);
})

而局部的环境变量则可以通过 EnvScope 来设置,就可以做到组件样式隔离。

1
2
3
4
5
6
EnvScope::new(
|env, data| {
env.set(theme::LABEL_COLOR, Color::WHITE);
},
Label::new("White text!")
)

界面

所谓界面,就是窗口中显示的那部分东西,通常来说是布局和组件的有机结合,当然也可以自定义组件的展示和行为,只需定义好所需的更新方式、事件处理、生命周期等即可,这样就带来了更多的可扩展性。

组件

最常见的组件莫过于文本块 Label 和按钮 Button 了。剩下的比如 Tab 栏、进度条、单选多选项、输入框等也是基本都有。
展示一下基本的按钮和文本块的创建方法。

1
2
3
4
5
6
let button = Button::new(button_text)
.on_click(|_ctx, data: &mut State, _env| {
data.count += 1;
})
.padding(5.0);
let label = Label::new("hello world");

布局

1
2
3
4
5
6
7
 -------non-flex----- -flex-----
| child #1 | child #2 |


----flex------- ----flex-------
| child #1 | child #2 |

Druid 提供了 Flex 布局,熟悉 CSS 的同学一定很快就能理解,但类似以下这种命令式的创建方式还是让人皱眉头且怀念 CSS。

1
2
3
4
5
6
7
8
use druid::widget::{Flex, FlexParams, Label, Slider, CrossAxisAlignment};

let my_row = Flex::row()
.cross_axis_alignment(CrossAxisAlignment::Center)
.must_fill_main_axis(true)
.with_child(Label::new("hello"))
.with_default_spacer()
.with_flex_child(Slider::new(), 1.0);

新建窗口

光靠组件和布局,仅仅是在窗口之内操作肯定是不足以创建出足够具有动态的应用的,我们还需要动态创建窗口的能力!Druid 也提供了在 EventCtxDelegateCtx 上创建窗口的能力。

比如我们可以在全局 AppDelegate 上注册新窗口的 Command

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub struct AppDelegater;

impl AppDelegate<State> for AppDelegater {
fn command(
&mut self,
ctx: &mut DelegateCtx,
_target: Target,
cmd: &Command,
data: &mut State,
_env: &Env,
) -> Handled {
if let Some(_) = cmd.get(NEW_WINDOW) {
ctx.new_window(WindowDesc::new(new_window_builder)
.window_size((400.0, 300.0)));
Handled::Yes
} else {
Handled::No
}
}
}

图片

图片是个复杂的东东,目前看到 Druid 的处理方式是直接将图片的二进制数据编译进去,在运行时转变成像素进行渲染,需要安装 image 这个 crate 进行处理。后续 Druid 的版本会简化这一流程,但当前还是得这么写 …… 且图像会被变成黑白照片,不知道为啥,有知道的同学请不吝赐教。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
use druid::widget::{Image, SizedBox};
use druid::{Widget, ImageBuf, WidgetExt, Color};
use druid::piet::{ImageFormat};

use crate::types::State;

pub fn make_image() -> impl Widget<State> {
let raw_image = include_bytes!("../../resources/image/example.jpg");
let image_data = image::load_from_memory(raw_image).map_err(|e| e).unwrap();
let rgb_image = image_data.to_rgb8();
let size_of_image = rgb_image.dimensions();
let image_buf = ImageBuf::from_raw(
rgb_image.to_vec(),
ImageFormat::Rgb,
size_of_image.0 as usize,
size_of_image.1 as usize,
);
SizedBox::new(Image::new(image_buf))
.fix_width(size_of_image.0 as f64 / 8.0)
.fix_height(size_of_image.1 as f64 / 8.0)
.border(Color::grey(0.6), 2.0).center().boxed()
}

其他

通常来说 GUI 程序拥有数据和界面就够了,这也就是典型的 MVC 架构,但实际上作为跨平台框架还需要考虑系统原生接口和国际化等问题,甚至包括富文本的处理。只有这些都面面俱到了,才能做到开发者无痛接入,一发入魂。

菜单与快捷键

Windows 和 macOS 的菜单不太一样,Windows 是挂在每个窗口标题栏下,而 macOS 则是挂在屏幕边缘,实际上它们都是作为窗口的一部分存在的,所以在设计时也是统一在窗口初始化时传入。

1
2
3
4
let menu = MenuDesc::new(LocalizedString::new("start"))
.append(make_file_menu());
.append(make_window_menu());
let main_window = WindowDesc::new(ui_builder).menu(menu);

国际化 i18n

Druid 的国际化是通过 LocalizedString 来实现的,例如在界面中有如下一段文本。

1
2
let text = LocalizedString::new("hello-counter")
.with_arg("count", |data: &State, _env| data.count.into());

则可以通过创建一个 resources/i18n/en-CN/builtin.ftl 的文件(具体以 Druid 启动时的输出语言为准),在其中写入对应 hello-counter,其中的 count 就会被替换成实际的数据。
DEBUG 启动时输出了 en-CN

1
2
# resources/i18n/en-CN/builtin.ftl
hello-counter = 现在的值是 { $count }

展示结果

路径示意

富文本渲染、编辑

我们知道,经典物理学只是茫茫科学中限于低速宏观之中极小的一块研究区域,同理,一个简单的输入框也是富文本编辑的一个缩影。


完整的 text 模块包含了很多东西,但简单一点考虑,我们实现一个富文本编辑器只需要一个 Editor 和一个 RichText 的展示容器即可。

而 RichText 本质上是一串字符串与数个按索引设置的属性的数据集合。

1
2
3
4
5
pub fn generate_example_rich_data(text: &str) -> RichText {
let attr = Attribute::TextColor(KeyOrValue::Concrete(Color::PURPLE));
RichText::new(text.into())
.with_attribute(6..=10, attr)
}

显示效果

Druid 并不支持 WYSIWYG 所见即所得的编辑模式,所以编辑器和富文本内容是分离的,在数据中实际存储的应该是一段 raw 文本和数个属性的集合,在渲染时组成 RichText 传递给 RawLabel 进行渲染。

详情可见官方示例 - markdown_preview

调试

在万能的 VS Code 里调试 Rust 程序是比较方便的。在创建 Rust 项目后,VS Code 就会提示按照 llvm 相关组件以便启用 DEBUG 模式。

.vscode/launch.json 中添加 lldb 为目标的配置后,即可在调试侧边栏一键开启调试模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug executable 'gui'",
"cargo": {
"args": [
"build",
"--bin=gui",
"--package=gui"
],
"filter": {
"name": "gui",
"kind": "bin"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

断点调试

如图所示,断点处的变量、调用栈、上下文等信息一览无余。

结语

本文简单介绍了 Rust GUI 框架 Druid 的基本架构和使用,通过笔者自行摸索解决了 Druid 实际运行版本和 Demo 及文档脱钩的问题,希望能对读者有所裨益。

后附笔者调试测试用的 Demo 仓库地址:https://github.com/msyfls123/rust-gui