找回密码
 会员注册
查看: 17|回复: 0

用Addon增强Node.js和Electron应用的原生能力

[复制链接]

2万

主题

0

回帖

7万

积分

超级版主

积分
73174
发表于 2024-10-2 10:38:30 | 显示全部楼层 |阅读模式
前言Node.js Addon 是 Node.js 中为 JavaScript 环境提供 C/C++ 交互能力的机制。其形态十分类似 Java 的 JNI,都是通过提供一套 C/C++ SDK,用于在 C/C++ 中创建函数方法、进行数据转换,以便 JavaScript / Java 等语言进行调用。这样编写的代码通常叫做 Bindings。此外还有基于 C ABI Calling Convention (例如 stdcall / System-V 等标准) 直接进行跨语言调用的方案,例如 Rust FFI、Python 的 ctypes、Node.js 的 ffi 包等。这两者的差别在于 Rust 等原生语言是直接针对平台来将函数调用编译为机器码,而 ctypes 和 ffi 包则是基于 libffi 动态生成机器码来完成函数调用的。和 Node.js Addon 的差别则在于调用和类型转换的开销上。本文将围绕 Node.js Addon 进行介绍,即创建一个 Bindings 来增强 Node.js 或 Electron 应用的原生能力,使其可以和系统进行交互,或者使用一些基于 C/C++ 编写的第三方库。Node.js 和 Electron 的关系Electron 在主进程和渲染进程中都包含了完整的 Node.js 环境,因此本文既适用于 Node.js 程序,也适用于 Electron 程序。Node.js Addon 的类型在 Node.js 的 Addon,有三种类型:本文主要介绍 Node-API 的原理,以及以 node-addon-api 作为例子。Node-API 基本原理Node.js 本质上是一个动态链接库(即 Windows 下的 .dll 文件、MacOS 下的 .dylib 文件、Linux 下的 .so 文件),只不过在分发时会将文件的扩展名改为 .node加载Node.js Addon 通常通过 CommonJS 的 require 函数进行导入和初始化。require 在被 .node 扩展名路径作为参数进行调用的情况下,最终会利用 dlopen(Windows 下是 LoadLibrary)方法来动态加载这个以 .node 扩展名的动态链接库:初始化以 https://github.com/nodejs/node-addon-examples/blob/main/1_hello_world/napi/hello.c 作为参考:staticnapi_valueInit(napi_envenv,napi_valueexports){napi_statusstatus;napi_property_descriptordesc=DECLARE_NAPI_METHOD("hello",Method);status=napi_define_properties(env,exports,1,&desc);assert(status==napi_ok);returnexports;}NAPI_MODULE(NODE_GYP_MODULE_NAME,Init)NAPI_MODULE 宏用来绑定一个 C 函数作为初始化函数。这个函数中可以用来给模块的 exports 对象添加所需要的功能。例如上述的代码中,给 exports 添加了一个叫做 hello 的函数。这样一来,我们在 Node.js 中 require 这个模块之后,就能获得到一个包含 hello 函数的 exports 对象:调用以 https://github.com/nodejs/node-addon-examples/blob/main/1_hello_world/napi/hello.c 作为参考:staticnapi_valueMethod(napi_envenv,napi_callback_infoinfo){napi_statusstatus;napi_valueworld;status=napi_create_string_utf8(env,"world",5,&world);assert(status==napi_ok);returnworld;}Method 本身是一个 C 函数,接受 napi_env 作为 JavaScript 的上下文信息。napi_callback_info 作为当前函数调用的信息,例如函数参数等。返回一个 napi_value 作为函数的返回结果。从这个函数的例子中可以看到,在 C 中是可以获取到函数的调用参数,并且产生一个值作为函数的返回结果。稍后我们会以 node-addon-api 作为例子来具体介绍其编写方式。模块编写指南本节介绍使用 C++ 配合 node-addon-api 开发模块时常见的一些模式和样板代码,仅供参考。更多用法详见官方文档:https://github.com/nodejs/node-addon-api/blob/main/doc/hierarchy.md模块初始化使用 NODE_API_MODULE 宏绑定一个 C++ 函数进行模块初始化:Napi::ObjectInit(Napi::Envenv,Napi::Objectexports){exports.Set(Napi::String::New(env,"hello"),Napi::Function::New(env,Method));returnexports;}NODE_API_MODULE(hello,Init)其中 Napi::Env 是对 napi_env 的封装,代表一个 JavaScript 上下文,大部分和 JavaScript 交互的场景都需要这个上下文,可以保存起来以供下次使用(但是不要跨线程使用)。Napi::Object exports 则是这个模块的 exports 对象,可以把想要给 JavaScript 暴露的值和函数都设置到这个上面。创建 JavaScript 函数首先需要创建一个如下函数签名的 C++ 函数:Napi::ValueAdd(constNapi::CallbackInfo&info){Napi::Envenv=info.Env();doublearg0=info[0].As().DoubleValue();doublearg1=info[1].As().DoubleValue();Napi::Numbernum=Napi::Number::New(env,arg0+arg1);returnnum;}其中函数的返回值可以是任何派生自 Napi::Value 的类型,也可以是 Napi::Value 本身。获取函数参数通过 Napi::CallbackInfo& 来获取函数参数,例如 info[0] 代表第一个参数。info[n] 会获取一个 Napi::Value 值,我们需要调用它的 As 方法来转换为具体的值,我们才能将它继续转换为 C/C++ 可用的数据类型。例如,我们希望将函数的第一个参数转换为字符串,我们需要经过两个步骤:将 Napi::Value 转换为 Napi::String:Napi::Stringjs_str=info[0].As();将 Napi::String 转换为 std::stringstd::stringcpp_str=js_str.Utf8Value();其他数据类型例如 Napi::Number、Napi::Buffer 均有类似的方法。返回函数结果我们可以直接创建一个 JavaScript 值并在 C++ 函数中返回。具体创建值的方法详见下一小节。创建 JavaScript 值我们可以利用各种实例化方法,来从 C/C++ 的数据类型中创建 JavaScript 的值,下面举几个常见的例子。创建字符串Napi::String::New(env,"字符串内容")创建数字Napi::Number::New(env,123)创建 Buffer创建 Buffer 是一个有风险的操作。Node-API 提供了两种创建方式:提供一个指针和数据长度,创建一个数据的拷贝 安全,首选这种方法 v8 会负责这个 Buffer 的垃圾回收Napi::Buffer::Copy(napi_envenv,constT*data,size_tlength)直接基于指针和数据长度创建一个 External Buffer 同一个指针(相同的内存地址)只能创建一个 Buffer,重复创建会引起错误 v8 / Node.js 不负责这个 Buffer 的内存管理Napi::Buffer::New(napi_envenv,constT*data,size_tlength)异步代码异步函数异步函数通常用于实现一些异步 IO 任务、事件,例如实现一个异步网络请求库的绑定。异步函数通常有两种实现方式:回调 和 Promise。同线程回调同线程回调的使用场景比较少:使用了 libuv 来运行了一些异步任务,并且这个异步任务会在 libuv 主线程唤醒事件循环来返回结果,这时候可以比较安全地直接进行同线程回调。但是要求事先把 Napi::Env 保存在一个地方。实现一个函数的时候,在实现中直接同步调用一个 Napi::Function。获取函数通常我们会从函数调用的参数中获取到 Napi::Function,一般来说我们需要在当次调用就把这个函数给使用掉,避免后续被 v8 GC 回收。持久化函数如果我们确实需要在之后的其他时机去使用函数,我们需要将它通过 Napi:ersistent 持久化:Napi::FunctionReferencefunc_persist=Napi:ersistent(func);使用时,可以作为一个正常的函数去使用。调用函数无论是 Napi::Function 还是 Napi::FunctionReference,我们都可以通过 Call 方法来调用:Napi::Valueret_val=func_persist.Call({Napi::String::New(env,"Arg0")});跨线程回调跨线程回调是比较常见使用场景,因为我们通常会想在另外一个线程调用 JavaScript 函数。使用线程安全函数 (ThreadSafeFunction)为了在其他线程中调用 JavaScript 函数,我们需要基于 Napi::Function 去创建一个 Napi::ThreadSafeFunction。Napi::ThreadSafeFunctiontsfn=Napi::ThreadSafeFunction::New(env,//Napi::Envinfo[0].As(),//JavaScript函数"handler",//异步函数的名称,用于调试的识别0,//队列最大大小,通常指定为0代表没有限制。如果队列已满则可能会导致调用时阻塞。1//初始线程数量,通常指定为1。实际上是作为内存管理使用。可参考这篇文档。);接着就可以把 tsfn 保存在任何位置,并且并不需要同时保存一份 Napi::Env。调用线程安全函数调用线程函数有两种形式,一种是同步调用,另一种是异步调用。同步调用同步调用指的是如果我们限制了 ThreadSafeFunction 的队列大小,并对其进行了多次调用,从而创建了许多调用任务,则会导致队列已满,调用就会被阻塞,直到成功插入队列后返回结果。这是进行一次同步调用的例子:constchar*value="helloworld";napi_statusstatus=tsfn.BlockingCall(value,[](Napi::Envenv,Napi::Functioncallback,constchar*value){Napi::Stringarg0=Napi::String::New(env,value);callback.Call({arg0});});这样一来就能顺利地在任意线程去调用 JavaScript 函数。但是我们发现,实际上我们并不能同步地获取函数调用的返回结果。并且 Node-API 或者 node-addon-api 都没有提供这么一种机制。但是我们可以借助 libuv 的信号量来达到这个目的。uv_sem_tsem;uv_sem_init(&sem,0);constchar*value="helloworld";Napi::Valueret_val;napi_statusstatus=tsfn.BlockingCall(value,[&ret_val](Napi::Envenv,Napi::Functioncallback,constchar*value){Napi::Stringarg0=Napi::String::New(env,value);*ret_val=callback.Call({arg0});uv_sem_post(&sem);});uv_sem_wait(&sem);//直至JavaScript运行结束并返回结果,才会走到这里//这里就可以直接使用ret_val了异步调用异步调用则会在队列已满时直接返回错误状态而不进行函数调用。除此之外的使用方法同 “同步调用” 完全一致:constchar*value="helloworld";napi_statusstatus=tsfn.NonBlockingCall(value,[](Napi::Envenv,Napi::Functioncallback,constchar*value){Napi::Stringarg0=Napi::String::New(env,value);callback.Call({arg0});})romiseC++ 中创建 Promise 给 JavaScript 使用我们通常会需要在 C++ 中实现异步函数。除了直接用上面已经介绍的基于回调的方法之外,我们还可以直接在 C++ 中创建一个 Promise。Promise 只支持同 线程 调用由于 Promise 并未提供跨线程 Resolve 的方式,因此如果希望在其他线程对 Promise 进行 Resolve 操作,则需要结合 libuv 来实现。此方法比较繁琐,建议转而使用跨线程回调函数。如果读者感兴趣,后续本文可以补充相关内容。我们可以直接创建一个 Promise,并在函数中返回:Napi::ValueYourFunction(constNapi::CallbackInfo&info){Napi:romise:eferreddeferred=Napi:romise:eferred::New(info.Env());//我们可以把env和Napi:romise:eferred保存在任何地方。//deferred_会在Resolve或者Reject之后释放。env_=info.Env();deferred_=deferred;returndeferred.Promise();}接着我们可以在其他地方调用 Napi:romise:eferred 来完成 Promise。注意,这里一定需要在主线程中调用://返回成功结果deferred_.Resolve(Napi::String::New(info.Env(),"OK"));//返回错误deferred_.Reject(Napi::String::New(info.Env(),"Error"));C++ 中使用来自 JavaScript 的 Promise由于 Node-API 或者 node-addon-api 均没有提供使用 Promise 的封装,因此我们需要像在 JavaScript 中通过 .then 手动使用 Promise 的方式,在 C++ 中使用 Promise。//首先需要定义两个函数,用来接受Promise成功和失败Napi::ValueThenCallback(constNapi::CallbackInfo&info){Napi::Valueresult=info[0];//result是Promise的返回结果returninfo.Env().Undefined();}Napi::ValueCatchCallback(constNapi::CallbackInfo&info){Napi::Valueerror=info[0];//error是Promise的错误信息returninfo.Env().Undefined();}Napi:romisepromise=async_function.Call({}).As()Napi::Valuethen_func=promise.Get("then").As();then_func.Call(promise,{Napi::Function::New(env,ThenCallback,"then_callback")});Napi::Valuecatch_func=promise.Get("catch").As();catch_func.Call(promise,{Napi::Function::New(env,CatchCallback,"catch_callback")});显然这种使用方式是比较繁琐的,我们也可以通过一些办法使其可以将 C++ Lambda 作为回调函数来使用,但是本文暂时不涉及这部分内容。异步任务异步任务通常是利用 libuv 提供的线程池来运行一些 CPU 密集型的工作。而对于一些跨线程异步回调的 Bindings 实现则直接使用 ThreadSafeFunction 即可。具体使用可以参考:https://github.com/nodejs/node-addon-api/blob/main/doc/async_worker.mdNode-API 的构建基本构建配置Node.js Addon 通常使用 node-gyp 构建,这是一个基于 Google 的 gyp 构建系统实现的构建工具。至于为何是 gyp,因为 Node.js 是基于 gyp 构建的。我们来看一个 node-addon-api 项目的构建配置,以 bindings.gyp 命名:{"targets":[{"target_name":"hello","cflags!":["-fno-exceptions"],"cflags_cc!":["-fno-exceptions"],"sources":["hello.cc"],"include_dirs":[" 参数来指定构建的目标架构,例如希望构建一个 32 位的产物,则可以使用 --arch ia32 来构建。node-gyp clean 清理构建缓存。如果希望使用 node-gyp build 来进行构建的话,需要善用 clean 功能。实用构建配置添加头文件目录'include_dirs':['win32/x64/include']在 Windows 下进行动态链接 / 静态链接'libraries':['some_library.lib']对于动态链接,需要指定 .dll 对应的 .lib 文件,并在分发的时候将 .dll 放在 .node 相同的目录下。对于静态链接,则直接指定 .lib 文件即可。但是在 Node.js Addon 中进行静态链接是一个比较费劲的事情,因为通常涉及到对其他静态依赖的管理,需要谨慎选择此方案。在 Windows 下设置 C++ 版本'msvs_settings':{'VCCLCompilerTool':{'AdditionalOptions':['/std:c++20']}}在 Windows MSVC 下构建支持代码文件中的 UTF-8 字符(中文注释等)本质上是给 MSVC 的编译器添加一个 /utf-8 参数'msvs_settings':{'VCCLCompilerTool':{"AdditionalOptions":['/utf-8']}}在 MacOS 下进行动态链接 / 静态链接'link_settings':{'libraries':['-L','-l']}在 MacOS 下引入系统 Framework 依赖'libraries':['-frameworkMediaPlayer','-frameworkCocoa',]在 MacOS 下设置 C++ 版本"cflags_cc":["-std=c++20"]在 MacOS Xcode 的 Release 构建下生成 .dSYM 调试文件'xcode_settings':{'DEBUG_INFORMATION_FORMAT':'dwarf-with-dsym'}使 MacOS 下的 addon 能够使用同目录下的动态库 / Framework'link_settings':{'libraries':['-Wl,-rpath,@loader_path',##此外,还可以设置到任何相对于.node文件的其他目录下'-Wl,-rpath,@loader_path/../../darwin/arm64',]},但是这也要求 .dylib 文件支持该功能,可以通过 otool -D .dylib 的返回结果来检查:.dylibrpath/.dylib如果文件名往前的开头是 @rpath,则意味着支持该功能。如果不是,则可以使用 install_name_tool 来修改动态链接库使其支持:install_name_tool-id"@rpath/.dylib".dylib在 MacOS 下支持 Objective-C 和 C++ 混编'xcode_settings':{'OTHER_CFLAGS':['-ObjC++']}开发&分发&使用项目文件组织通常来说,我们可以用下面的文件夹结构来扁平地组织我们的 addon 文件:.├──node_modules##npm依赖├──build##构建路径│├──Release##Release产物路径│├──myaddon.node##addon产物│├──myaddon.node.dSYM##addon的符号文件├──binding.gyp##构建配置├──addon.cc##Addon的C++源码├──index.js##Addon的JavaScript源码├──index.d.ts##Addon的TypeScript类型(下方会介绍)└──package.json##Addon的package.json文件当然我们也可以把 JavaScript 源码和 C++ 源码分别放入不同的文件夹,只需要修改对应的构建配置和 package.json 即可。编写 index.js - 使用 bindings 包一般来说我们会直接在 C++ 中实现大部分逻辑,JavaScript 文件只用来引入 .node 文件。由于 Node.js Addon 存在各种不同的方案、构建配置,因此 .node 文件产物的位置可能也会因此不同,所以我们需要借助一个第三方 npm 包来自动为我们寻找 .node 文件的位置:https://github.com/TooTallNate/node-bindings通过 bindings,我们的 index.js 仅需一行代码就能自动获取并导出 .node 模块:module.exports=require('bindings')('binding.node')同时保证 package.json 的 main 配置为我们的 index.js:{//..."main":"index.js"//...}为 Addon 添加 TypeScript 类型添加 TypeScript 类型,最简单的方式只需要创建一个 index.d.ts 文件,并在其中声明在 C++ 代码中创建的函数们即可:exportinterfaceFooOptions{bar:string}exportfunctionfoo(options:FooOptions)并在 package.json 添加一行参数用于指向类型文件:{//..."types":"index.d.ts"//...}大部分情况下,这个方法就可以给你的 Node.js Addon 声明类型。分发形式安装时编译一种方式是在使用者进行 npm install 时,使用用户设备进行 Addon 的编译。这时候我们可以使用 install 钩子来实现,我们仅需在 package.json 文件中添加如下内容:{//..."scripts":{//..."install":"prebuild-install||node-gyprebuild--release"//...}//...}保险起见,确保 node-gyp 在你的 devDependencies 之中,这样就能在用户通过 npm 安装你的 Addon 时,自动编译当前系统架构所对应的产物。预编译如果希望更近一步,节约用户安装 Addon 的时间,或者是为了让用户无需具备编译环境即可安装 Addon,可以使用预编译方案。即在集成环境中提前编译常见的操作系统、架构对应的 .node 文件,并随着 npm 包进行分发,再通过 bindings 或者其他一些库来自动匹配寻找系统所需要的对应 .node 文件。由于预编译方案涉及到更多的细节,本文不再做介绍,大家可以参考该项目:https://github.com/mapbox/node-pre-gyp
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 会员注册

本版积分规则

QQ|手机版|心飞设计-版权所有:微度网络信息技术服务中心 ( 鲁ICP备17032091号-12 )|网站地图

GMT+8, 2025-1-12 17:59 , Processed in 0.544107 second(s), 26 queries .

Powered by Discuz! X3.5

© 2001-2025 Discuz! Team.

快速回复 返回顶部 返回列表