如何从前端到客户端

我是如何从一个工作全部内容只是 HTML + CSS + JavaScript 的前端,转为一个依然基本靠着前端三板斧技能工作,但支撑起了”横跨三大操作系统 + 各种处理器架构 + 保持 web 同步迭代周期 + 复用了 web 95% 以上功能的桌面端产品“的伪客户端工程师?

首先当仁不让地要祭出 Electron 这件大杀器,现在开发一个桌面端最最行之有效的方式仍然是 web 套壳,让 web 代码几乎不用改就可以直接运行在一个窗口里。如果采用别的技术方案,我曾不止一次地在前同事和当下 leader 等口中听到拿 C++/OC/Swift 之类的写界面要吐血三升之类的话语,理解各种 Window、Dialog、Layout、Signal & Slot 就足够一个前端喝一壶了,而这些技术细节在 Electron 下都可以化繁为简成一个个普通的 Chrome 窗口,以及与之相关联的 Node.js 胶水逻辑。

Electron 更接近于 Hybrid 应用

Electron 更接近于 Hybrid 应用

虽然把页面展示出来只是客户端面临的众多问题之一,但这恰好是客户端做 UI 最复杂的那一部分。

使用 Electron 就是说我可以在不改变主要工作语言的前提下,尽可能快且大地延展自己的作业范围,以较低成本快速搭建起原型,这是前端选择其他语言或者框架进行客户端开发无法企及的优势。

那是不是只要上了 Electron,把 web 页面一封装就万事大吉了呢?倒也不是,客户端开发上接网络通讯、界面渲染,下至系统特性 API,它这个逻辑不是后端来一个 request 返回一个 response(简化模型),也不是前端从一个页面加载到脚本执行后进入等待 UI 交互的过程就结束了。客户端需要同时处理多个页面的展示、管理前后台进程、与服务器做通讯、主动存取数据至文件等。

角色 替身
后端 收银员/会计
前端 导购/客服
客户端 客户经理/维修人员

我更偏向从角色和现实生活中的替身角度认识各个端的分工,这个分工不是绝对的,彼此之间有交集。前端主要负责 UI 的展现,像是一个商店的导购小姐,根据用户喜欢安排不同的商品;客户端则是客户经理,向已经对此感兴趣的用户进一步演示商品的功能,并分析深层次用户需求;后端则是收银员兼会计,这时候用户已经拍板下单了,如何收钱,如何签订协议,如何发货和三保。之后可能还会有进一步交互,比如用户发现商品出了问题,继续通过网页上反馈过来,然后客户端需要更新版本,相当于以旧换新,完成持续交付。

下面我将从四个角度来阐述我对客户端开发的理解:

技术应用

抛开具体使用的语言或者框架,不同的职位或者开发方向在解决用户需求和技术问题上有着不同的纵深,意味着“术业有专攻”。这一点很多时候属于给用户一把锤子,他就看啥都像钉子一样,由不得你,甚至还会倒逼你去一步步提升体验。

剪贴板

既然都上了客户端,想必用户需要频繁操作的了,这就免不了要和键盘及鼠标事件打交道。常见的输入方式还好,就跟浏览器基本一致,只是遇到过 Mac 上无法通过快捷键复制粘贴的问题。说来都离谱,作为一个文本编辑软件,发布出去时竟然无法使用快捷键 Ctrl + C + Ctrl +V!还好我们很快就发布了版本修复了这个问题。

Shortcuts in Electron on Mac

而后我们又发现了右键菜单也得自己实现,不由感叹浏览器真是个复杂且贴心的玩意,给前端实现了如此多的功能。右键菜单部分同样是由 Electron 封装好了大量具体系统的 API,只需 JS 调用即可。

这些可能还是看得见的部分,看不见的部分,比如系统的剪贴板?如果我们想要模仿手机上长按复制文本至 App 内识别链接并打开对应页面的功能该如何操作呢?

因为各系统的剪贴板实现都是不同的,Web 上早期使用 execCommand 来与剪贴板交互,现在则有 Clipboard API。看起来很美好,只是致命的是 clipboardchange 事件尚未被 Chrome 实现 …… 这里陷入了前端的盲区。

Async Clipboard API - clipboardchange event not fired

从前端角度走不通,可以看一看 Electron。Electron 本身自带了一个 Clipboard API,上面有各种读取文本、富文本数据、图像的方法。同时,发起检查剪贴板的请求倒是容易,再不济也可以用轮询。做得细一点可以用 RxJS 来订阅一个根据鼠标聚焦窗口事件 + 剪贴板文字 + 去重 + 抢占式的调度模型:

使用 RxJS 处理前端数据流

这里有个问题:当用户重复复制同一段文本进入到我们的客户端时如何判断出来呢?文本比较肯定是失效了,系统层面前面也了解过没有一致的剪贴板改变事件,所以只剩一条路,就是标记这段文本。

Untitled

因为绝大部分程序只读 bookmark 里的 url 而不会去读 title,可以用 title 来标记这段文本已经被我们的客户端识别过了,当用户再次从任意地方复制了文本时会清除掉这个 bookmark,也就达到了我们标识已经过 App 识别链接的文本的目的了。

到这里我们发现,客户端的开发与前端正在趋同,两者都会深入系统提供的功能,又期望第三方库或者浏览器能提供标准的接口。

安装与更新

作为前端开发,页面关闭或刷新时,整个页面所有的元素都被销毁了,重新载入就是一张全新的白纸。而客户端不一样,下载下来就在硬盘占据了几百 MB 的空间,那可是战战兢兢,指不定哪一天用户嫌体积大就给卸载了。怎么办?当然是与时俱进,网页的优势在于刷新就是新版本,而客户端就只能老老实实做一键安装与自动更新了。

本着用户价值最大化的原则,现在软件大多抛弃了争奇斗艳的安装界面,反正做得越花哨越像流氓软件,Electron 社区标配 electron-builder 打包时提供的默认安装功能就挺好。只不过有时候要考虑旧版本的卸载问题,因为客户端技术更新换代,总有技术断层的阶段,旧版本无法正常自动更新到新版。像 Mac 上软件都进到了全局唯一的软件目录,安装时可以由系统来提示,玩 Linux 的也都是大神,安装不上也会自己手动 remove,但最广大的 Windows 用户迫切需要一键卸载旧版本的功能。我找到了 electron-builder 所依赖的 NSIS 安装器,定制了其安装脚本,在检测到旧版本的卸载程序存在于注册表时,会直接调用这个卸载程序,并等待它成功返回后进行新版本的安装过程。

作为互联网产品,诱骗,啊不是,引导用户安装上客户端不是终点,需要推陈出新、高效迭代,这就需要自动更新了。自动更新的目的在于及时让用户用上新版本,仿佛是一句废话。但鉴于大部分用户都是既嫌你更新太勤,又嫌你下载耗费流量,还盼着你能给他一天天地带来体验优化的主,这个开发思路还就是和网页前端不一样。

系统 CPU 架构 安装包 自动更新包
Windows x32 / x64 / arm64 exe exe
macOS x64 / arm64 dmg / pkg zip
Linux x64 / arm64 deb / rpm -

首先不同的系统当然是不同的包,而根据 CPU 架构及包的用途,就能打出 10 多个不同的安装包来,总大小甚至超过了 2G …… 这在前端的角度看来真有些不可思议,究其原因还是因为 Electron 打包了 Chromium,这个东西就占据了每个安装包中至少 70% 的空间,而这一切都是为了能保证在不同机型上都有一致的浏览体验。

由于一开始开发时没有意料到 CPU 架构竟会如此迅速地扩展,所以现在只得以新版本逐渐刷量的方式来逐步替换成独立架构的客户端。这样做的好处是,后续更新时只需要下载对应架构的文件即可。

Untitled

关于自动更新还有如何进行灰度配置下发更新信息,通过 CDN 进行版本管理,App 内如何完成更新等,可参见之前的文章:

Electron 客户端自动更新

这还未到终点,更新一整个客户端接近 100M 的体积仍是过重了,还可以将 Electron 内核和不怎么修改的数据库部分都抽离出来,这样只需要与网页一样更新静态资源即可。

Untitled

离线使用

本身 Electron 就是可以离线使用的,只是加上了在线网页之后就需要网络才能工作。一种方案是和移动端一样拦截 HTTP 请求并转发到本地离线资源,但这样带来的问题是需要定义非常繁复的拦截规则,以及如何更新离线资源等。

我们可以将离线的数据和资源分离,数据可以同移动端走一套数据库接口,只不过需要将数据库编译成不同的平台的动态链接库。

初探 Node.js 原生扩展模块

然后是资源离线使用,或许很多人已经猜到了,那就是被称为 Electron 杀手的 PWA。

Untitled

在看过了 PWA 所列出的种种好处后,我们发现其不可避免地仍是一个 web 应用。所以,一个大胆的假设,用 PWA 来加载客户端所需要的页面!这样既拥有了网页的便利,又拥有了可触达系统本身 API 的能力,可谓一石二鸟之计。

Untitled

目前我们的腾讯文档桌面端离线功能正在紧锣密鼓地攻坚中,很快将于大家正式见面。

浏览器体验

作为一个名为客户端,实际上是定制版 Chrome 的软件,向 Chrome 看齐永远是对的。

用户说页面字体太小,看不清,那就给他加缩放快捷键。

用户又说缩放了看不到当前比例很慌,那就给他加缩放比例的 tips。

Untitled

这个用户满意了,那个用户说,哎呀我导出来了文档放在电脑上记不住了呀。

给他抄个 Chrome 的下载记录页面!

Untitled

如何存取页面缩放比例?如何在不同窗口间切换时共享一个 tips?鼠标悬浮移入移出 tips 显隐规则是怎样的?

导出记录该如何跟文档一一对应?有很多天记录时该怎么设计数据结构?本地文档被删除了怎么办?

不做不知道,一做吓一跳。原来一个浏览器不止看到的网页部分,周围的配套功能也有很多门道。

作为追赶者,有个好处是毕竟前方一直有领军者,永远有追赶的目标,但也总得想着如何积攒自己的优势,在属于自己的赛道上滑出自己的风采。

角色扮演

前面提到的都是用户需要什么功能,但作为开发不仅仅是写出代码交给用户就完事了,同样重要的是进行多方合作,齐心协力将产品做好。这时就需要发挥主观能动性,在不同的情景下扮演不同的角色了。

测试者

前面提到过,前端页面往往是单个页面完成单个任务,随用随走,所以其测试也主要针对在线数据。但客户端与之不同的在于有本地数据和系统 API 的差异,这也就决定了如果按照传统的人工测试,其成本是很高的。从效能上讲,如果只是编写的代码虽然可以在不同系统运行,但有多少系统就需要测多少遍,是不够经济的,同时测试人员也不一定完全理解设计意图。答案是得用自动化的测试进行覆盖,这就要求开发人员同时扮演测试者的角色。

如何改进呢?首先从技术上讲,应该把测试行为左移。

Untitled

从设计阶段就应该埋入测试所需的常量或者数据,比如可供 UI 测试获取元素用的 CSS 选择器,一些数据的 mock 可以直接放在类型定义旁边,方便测试时直接引入。

而在编写代码时,则应注意拆分可测试的单元。如果一个功能很复杂,那可以分成若干个子模块来进行编写,同样的,如果一个模块无法便捷地被测试,那它也可以被拆成若干个可测试单元,这样可以在一旦出现问题时通过一系列测试用例,准确定位到具体的单元,而不是一遍遍运行完整的模块测试以查找蛛丝马迹。

在编写测试用例时,区分平台特性是很重要的,例如快捷键或是菜单,这是 mac 和 Windows 存在显著差异的部分,又或者说只支持某个平台的用例,这时就得将不同系统的用例集用不同的机器运行。

到了运行测试时,因为客户端测试往往需要漫长的启动初始化,运行测试,处理异常情况、退出销毁测试环境等过程,在将测试自动化的过程中仍需要缓存一些数据,以供多次运行测试,降低边际成本。

合作方

作为客户端,前端和后端都是你的爸爸。为啥这么说呢?因为页面出了问题,得找前端修,接口出了问题,得找后端修,仿佛变成了 bug 路由器。

想要克服这些问题,需要做到两点:

  1. 做好日志
    当用户发现问题,找到你这边,如何优雅地甩锅 …… 哦不是 …… 定位问题呢?那就是在各种用户行为及接口返回时都做好日志以及进行参数校验,遵循宽入严出的准则。所谓害人之心不可有,防人之心不可无,把任何第三方业务都当成是不可信来源进行防范。同时日志也可以还原出用户操作轨迹,有时候出错的点并不是问题的根源,前几页日志里一行不起眼的 warning 才是真正的问题所在。
  2. 明确职责
    虽然仍然是直面用户的那一端,但团队协作讲究的是分工明确,不逾矩不代劳。客户端本来就是中枢站,如果把前后端的功能都挪到端上实现,必将牵一发而动全身,这就是重构原则里的“发散式变化”。笔者之前接受登录模块,模块里甚至直接存了用户的 token,这是非常敏感的数据,如果不经良好的加密手段,可能造成用户权限被盗用。如何解决这一点呢?答案是不要重复发明轮子,而要善于利用标准轮子。登录相关的标准存储轮子就是 cookie 了,而 cookie 是由 Chrome 直接管辖的,我们只需交给 Chrome 来鉴权,并且自己维护一个非敏感的用户登录状态即可。
    如果一段程序不知道其作用范围,那就不要写。

客服

记得我刚毕业实习时问当时 leader,我们会有直面用户的机会吗?leader 微微一笑,肯定会有的。后来我发现,原来不用和用户打交道、安心写代码的日子,才是非常弥足珍贵的……

有人说客服是性子最好的,因为需要每天应对用户各种刁难职责而不变色。当用户找上门来,通常都是丢失了数据、或者打不开应用闪退之类的问题,仿佛落水之人看到了救命稻草。这种情况下,如果不能给用户解决问题,是有很大心理压力的,而人有压力时就容易犯错。

如何降低犯错的概率和风险呢?一种方式是尽量减少操作的步骤。俗话说,less is more,能让用户一键完成的操作就不要让他点两次。

Untitled

上图是一个打开崩溃日志目录的按钮,用户需要手动把该目录里的 log 文件提供给开发(因为没找到司内相关 native 日志服务)。在没有这个按钮之前,用户需要手动打开终端,输入一长串地址,然后才能到这个目录下,而这中间任何一步都可能阻塞住用户。只有最简单的操作流程,才能高效地解决问题。

但很多时候,用户的问题并不那么简单。看似是 A 除了问题,对比日志后发现 B 也有问题,在 debug B 的过程中发现其依赖于 C。甚至到最后发现这些统统都不是问题所在,用户使用的根本不是你这个软件!这里就陷入了用户给你制造的黑盒。怎么办呢?在现有工具无法保证筛选出正常的反馈时,就得通过作业流程来保证各种类型的 bug 在每个阶段就被精准定位并消灭了。

中医有望闻问切四种基本诊察疾病方法,在针对用户反馈时也可以这么做:

  1. 望:根据用户描述,大致判断问题是否属于所属产品,是网络问题还是应用问题。
  2. 闻:故障截图、上报日志,分析可能出错的模块,如若遇到误报情形,在这一步即可排除。
  3. 问:让用户参与调试,通过改变设置项、清理缓存等步骤以期快速恢复。
  4. 切:直接给用户换一个版本(客户端就这点好,不同用户可以用着不同版本),相当于做移植手术了。可以植入更多自定义日志上报功能,更全面地分析用户使用状态,以便根治问题。

知识储备

前面讲到了技术应用方向和自我角色扮演,它们都属于外功,也就是技能部分,下面要讲的可说是内功心法。读过武侠小说的人都知道,内外兼修,德才兼备,方可成为一代大师。技巧永远是层出不穷的,只有透过日复一日,年复一年的基础积累,感受技术背后的脉搏,才能融会贯通,成为优秀的开发者。

IPC

Node.js addons

CI 流水线:蓝盾 Stream CI

WebAssembly

模式:图、pub/sub

Windows 注册表

心理建设

采用最通用的技术,延长更迭周期

控制技术的复杂度

学习新技术的恐慌

初探 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++ 代码,我们选择的稳妥方式依然是悄悄关上门,然后建座桥,把路直接修到它门口就跑,真刺激啊……