用Rust 和N-API 開發高性能NodeJS 擴展
Node native addon,過去和現狀
N-API也發布一段時間了,社區中有很多Native addon也慢慢遷移到了N-API,比如bcrypt , sse4_crc32等。 N-API彌補了之前nan
最痛的跨V8版本ABI不兼容的問題。
想了解NodeJS native addon相關接口的同學可以看@死月絲卡蕾特大佬的部落格從暴力到NAN再到NAPI——Node.js原生模塊開發方式變遷。
但即使是遷移到了N-API,編寫native addon 也有一些編寫代碼之外的痛點。
分發困難
目前主流的native addon 有以下幾種分發方式:
1. 分發源碼
需要使用的用戶自行安裝node-gyp
, cmake
g++
等構建工具,在開發階段這些都不是什麼問題,但隨著Docker
的普及,在特定的Docker
環境中安裝一堆編譯工具鏈實在是很多團隊的噩夢。而且這個問題如果處理不好的話,還會白白增加Docker image
的體積(其實這個問題是可以通過構建Docker image之前就在一個專門的Builder image裡面編譯完來解決,但是我在各種公司聊下來鮮有團隊會這樣做)。
2. 只分發JavaScript 代碼,postinstall 下載對應產物
有些native addon 的構建依賴實在是太複雜了,讓普通的Node 開發者在開發階段安裝全套的編譯工具不太現實。還有一種情況是,native addon 本身太複雜了,可能編譯一次需要非常多的時間,庫的作者肯定不希望大家在使用他的庫的時候僅安裝就要花掉半小時吧。
所以還有一種比較流行的方式是通過CI
工具,在各個平台(win32/darwin/linux)的CI
任務中預編譯native addon,進行分發的時候只分發對應的JavaScript代碼,而預編譯的addon文件通過postinstall
腳本從CDN下載下來。比如社區中有一個比較流行的用來幹這個事情的工具: node-pre-gyp 。這個工具會根據使用者的配置自動將CI
中編譯出來的native addon上傳到特定的地方,然後在安裝的時候從上傳的地方下載下來。
這種分發方式看起來無懈可擊,但其實有幾個沒辦法繞過去的問題:
- 諸如
node-pre-gyp
這種工具會在項目裡增加很多運行時無關的依賴。 - 無論上傳到哪個CDN ,都很難兼顧國內/海外用戶。回想起了你卡在
postinstall
從某個Github release下載文件等1小時最後失敗了的慘痛回憶嗎?誠然在國內搭建對應的binary mirror 可以部分緩解這個問題,但是mirror 不同步/缺失的情況也時有發生。 - 私有網絡不友好。很多公司的CI/CD 機器可能都沒法訪問外網(他們會有配套的私有NPM,沒有的話也沒有討論的意義),更別說從某些CDN 下載native addon。
3. 不同平台的native addon 通過不同的npm package 分發
最近前端很火的新一代構建工具esbuild就採用了這種方式。每一個native addon 對應一個npm package。然後通過postinstall
腳本去安裝當前系統對應的native addon package。
還有一種方式是暴露給用戶安裝使用的package將所有的native package作為optionalDependencies
,然後通過package.json
中的os
與cpu
字段,讓npm/yarn/pnpm
在安裝的時候自動選擇(其實是不符合系統要求的就安裝失敗了)安裝哪一個native package,比如:
{"name":"@node-rs/bcrypt","version":"0.5.0","os":["linux","win32","darwin"],"cpu":["x64"],"optionalDependencies":{"@node-rs/bcrypt-darwin":"^0.5.0","@node-rs/bcrypt-linux":"^0.5.0","@node-rs/bcrypt-win32":"^0.5.0"}}{"name":"@node-rs/bcrypt-darwin","version":"0.5.0","os":["darwin"],"cpu":["x64"]}{"name":"@node-rs/bcrypt-linux","version":"0.5.0","os":["linux"],"cpu":["x64"]}{"name":"@node-rs/bcrypt-win32","version":"0.5.0","os":["win32"],"cpu":["x64"]}
這種方式是對使用native addon的用戶侵擾最小的分發方式, @ffmpeg-installer/ffmpeg就採用了這種方式。
但是這種方式會對native addon 的作者帶來額外的工作量,包括需要編寫一些管理Release binary 和一堆package 的工具,這些工具一般都非常難以調試(一般會跨好幾個系統與好幾個CPU 架構)。
這些工具需要管理整個addon 在開發-> 本地release version -> CI -> artifacts -> deploy 整個階段的流轉過程。除此之外,還要編寫/調試大量的CI/CD 配置,這些都十分費時費力。
生態和工具鏈
目前大部分的NodeJS addon 基本都使用C/C++ 開發。 C/C++ 生態非常的繁榮,基本上你想做任何事情都能找到對應的C/C++ 庫。但C/C++ 的生態因為缺乏統一的構建工具鏈以及包管理工具,導致這些第三方庫在實際封裝和使用上會遇到一些其它的問題:
- 使用多個不一樣構建工具鏈的庫的時候可能會很難搞定編譯,比如這幾年以來我一直都在嘗試封裝skia 到nodejs binding, 但是skia 的編譯。 。實在是一言難盡的複雜,所以一直都在遇到這樣或者那樣的問題。
- 由於沒有好用的包管理器,很多優質的C/C++ 代碼都是作為一個大型項目的一部分存在的,而不是獨立成一個庫。這種情況下想要使用可能只能以Copy代碼的形式: bcrypt.cc ,這樣對項目後期的升級和維護都帶來了一些問題。
When Rust meets N-API
Rust
相信不用我過多介紹(能點進來這篇文章的應該對Rust都有一定的了解了吧)。
用Rust
替代C/C++
看起來是一個很美好的選擇,Rust有現代化的包管理器: Cargo
,經過這麼多年的發展在生態上尤其是與NodeJS
重疊的服務端開發、跨平台CLI工具、跨平台GUI (electron)等領域有了非常多的沉澱。比起C/C++
生態, Rust
生態的包屬於只要有,都可以直接用的狀態,而C/C++
生態中的第三方代碼則屬於肯定有,但不一定能直接用的狀態。這種狀態下,用Rust
開發Node addon少了很多選擇,也少了很多選擇的煩惱。
在正式決定開始使用Rust
+ N-API
開發NodeJS
addon之前,還有一些問題需要討論:
N-API 的Rust binding
NodeJS 官方為N-API 提供了相應的頭文件,作為開發Node addon 時所需。而Rust
沒有辦法直接使用C的頭文件,所以我們需要將node.h
暴露的API先封裝成Rust
可以使用的Rust binding
.
在Rust
生態中,有官方維護的bindgen來自動生成頭文件對應的Rust binding
,這個工具非常適合node.h
這樣非常純粹的C API頭文件,如果是C++ API則會復雜很多。
但是這樣生成出來的Rust binding
一般都是unsafe
的並且會充滿底層的指針操作,這顯然不利於我們進一步封裝native addon,也享受不到Safe Rust
帶來的種種好處。
在早夭的xray項目中,最早的編輯器架構並非後來的類似LSP
的Client/Server
架構,而是NodeJS
直接調用Rust
編寫的addon。所以在早期,xray有一個非常粗糙的Rust N-API
實現。
幾年前我將這些代碼從xray項目的Git
的歷史中找回來了,並且加以封裝和改進: napi-rs ,將大部分常用的N-API接口封裝成了Safe Rust
接口,並為它們編寫了全方位的單元測試,它可以作為使用Rust
編寫native addon的重要基石。
選擇分發方式
Rust
作為出了名的編譯極緩慢的語言,分發源碼顯然是不現實的,而且也不可能要求使用的開發者全部都安裝Rust
全家桶。
通過postinstall
下載作為一種比較簡單但是對使用者極不友好的方式,我覺得也不應該繼續提倡使用。
那最終對於使用Rust
編寫的NodeJS native addon
,我們最好的選擇就是使用不同平台分別分發addon的形式。
在napi-rs項目中,我封裝了簡單的cli
工具,用來幫助使用napi-rs
的開發者管理從本地開發到CI
發布的全流程。下面我們來用一個簡單而實際的例子介紹一下如何使用Rust
和napi-rs
開發、測試、發布一個NodeJS native addon。
用Rust
能做哪些事情
我們編寫一個native addon,肯定是想要加速一些計算的過程,然而這種加速並不是沒有代價的。
Native code在一些純計算的場景比js快非常多,但是一旦使用N-API
與node的js引擎打交道,就會有非常大的開銷(相對計算而言)。
比如在js裡面設置一個對象的屬性會比在native裡面使用N-API
設置一個對象的屬性快好幾倍
constobj={}obj.a=1letmutresult=env.create_object()?;result.set_named_property("code",env.create_uint32(1)?)?;Ok(result)
所以在封裝native addon的時候,我們應該盡量避免N-API
的調用,不然native邏輯為你減少的運行時間又全部被N-API
調用給加回去了。
使用
N-API
中需要注意的性能點實在是太多了,這裡就不展開來講了,後面有時間了或許會寫一系列文章介紹各種使用場景下如何選擇最優的方式調用N-API
來達到更好的性能。
但是有一些N-API
的調用其實是必不可少的,比如從參數中的Js值裡面獲取對應的native值,或者把native值轉換成Js值返回:
#[js_function(1)] // -> arguments length fn add_one ( ctx : CallContext ) -> Result < JsNumber > { let input : JsNumber = ctx . get ( 0 ) ? ; // get first argument let input_number : u32 = input . try_into () ? ; // get u32 value from JsNumber, call `napi_get_value_uint32` under the hood ctx . env . create_u32 ( input_number + 1 ) // convert u32 to JsNumber, call `napi_create_uint32` under the hood }
所以一個典型的native addon至少需要兩次N-API
調用(即使返回undefined
也要調用napi_get_undefined
)。在你打算開始編寫一個native addon的時候,要時刻計算native帶來的加速是否能抵消其中的N-API
調用的開銷。像上面例子中的add_one
方法,肯定是比Js版本要慢非常多的。 Github上有一個項目對比了不同封裝方式中典型的N-API
的調用開銷: rust-node-perf 。
這裡近似認為a + b
這個操作對於純Js
和native
代碼來說執行時間近似相等:
| Framework | Relative Exec Time | Effort | | ----------------------------- | ------------------ | -------- | | node.js | 1 | n/a | | wasm-pack (nodejs target) | 1.5386953312994696 | low | | rust-addon | 2.563630295032209 | high | | napi-rs | 3.1991337066589773 | mid | | neon | 13.342197321199631 | mid | | node-bindgen | 13.606728128895583 | low |
可以看到napi-rs
比直接使用JavaScript
慢了3倍多。
這也是為什麼大家都知道native addon 比純JavaScript 快很多,但很少有人在項目中大規模使用的原因。在N-API
的調用開銷和v8
引擎已經非常快的前提下,大部分的純計算的場景也不適合使用native addon來替換Js,甚至是你還能看到一些地方提到用JavaScript替換了native模塊之後,性能有了質的提升: https:// github.com/capnproto/no de-capnp#this-implementation-is-slow
再比如我最早用N-API
封裝addon的時候有一個失敗的嘗試: @node-rs/simd-json 。我將simd-json封裝成了native addon,希望得到一個比Node
自帶的JSON.parse
更快的API,但實際測下來native parse的部分快的突破了天際,而將native struct
轉變成Js Object
中間的N-API
call所需要的時間在數量級上遠超parse一個JSON字符串的時間。
已有的一個SIMD json port也有這個問題: simdjson_nodejs#5
那麼到底哪些功能適合用native addon 來完成呢?
- 簡單的輸入輸出但是中間邏輯複雜的計算邏輯,比如直接用到CPU simd指令的@node-rs/crc32 ,或者加密算法@node-rs/bcrypt ,中文分詞@node-rs/jieba 。這些庫的邏輯都有一個共同點:輸入輸出都非常簡單(避免額外的
N-API
調用),中間計算邏輯非常複雜。 - 一些需要調用系統級API能力的庫,比如之前提到的
SIMD
指令,還有類似GPU
調用等。
所以下面讓我們到crates.io中找一個簡單的支持SIMD功能庫,將它封裝成node native addon,來演示一下如何快樂的使用Rust
+ N-API
做一些高性能並且實用的工具庫。
@napi-rs/fast-escape
先上鍊接:
在crates.io
裡面搜索SIMD
,按全時期下載量排序,找到比較流行的使用了SIMD
技術的庫,然後逐個查看。前面2頁的庫要么已經被我封裝過了(逃要么輸入輸出太複雜不適合封裝,要么已經有現成的Node stdlib可以用了,翻到第三頁看到第一個v_htmlescape
,一看就適合用來封裝成addon:
- 用到了SIMD 技術,計算過程可以加速很多
- 輸入輸出簡單,進一個字符串出一個字符串,不會有太多的
N-API
call來消耗性能
我們從package-template模版項目new一個新項目出來,package-template中已經設置好了各種依賴/CI配置和命令,直接在src/lib.rs
中開始寫代碼就行了:
#[macro_use]externcratenapi;#[macro_use]externcratenapi_derive;usenapi::{CallContext,Env,JsString,Module,Result};usev_htmlescape::escape;register_module!(escape,init);fninit(module:&mutModule)->Result<()>{module.create_named_method("escapeHTML",escape_html)?;Ok(())}#[js_function(1)]fnescape_html(ctx:CallContext)->Result<JsString>{letinput=ctx.get::<JsString>(0)?;ctx.env.create_string_from_std(escape(input.as_str()?).to_string())}
這就是一個最小可以使用的的native addon 代碼了,代碼分2 個部分:
register_module
宏接受2個參數,第一個是module的名字,可以任意命名,這裡命名為escape
,第二個參數接受一個rust function
,它有唯一一個參數:Module
,這個參數代表了NodeJS中的module
對象,我們可以通過設置module.exports
對象設置需要導出的東西,而如果要導出函數則有一個helper
方法:module.create_named_method
來直接導出。-
#[js_function(1)]
宏用來定義一個JsFunction
,被定義的Rust function
有唯一一個CallContext
參數,我們從JavaScript
代碼中傳入的參數可以通過ctx::get(n)
方法獲取。#[js_function()]
宏裡面的參數定義了這個函數有幾個參數,當ctx.get
傳入的值大於實際傳入的參數個數的時候會拋一個Js異常。
在運行yarn build
之後,我們可以在js
裡面這樣調用這裡的escape_html
函數:
const{escapeHTML}=require('./index')console.log(escapeHTML('<div>1</div>'))// <div>1</div>
這裡的yarn build
其實做了很多事情:
- 運行
cargo build
,將lib.rs
編譯成了動態鏈接庫,放在了./target/release/escape.[dll|so|dylib]
- 運行
napi build --release --platform
,這個命令將上一步的(lib)escape.[dll|so|dylib]
從target/release
目錄拷貝到當前目錄下,並重命名為escape.[darwin|win32|linux].node
然後index.js
中調用@node-rs/helper
裡面的loadBinding
方法,自動從正確的地方加載native addon:
const { loadBinding } = require ( '@node-rs/helper' ) /** * __dirname means load native addon from current dir * 'escape' means native addon name is `escape` * the first arguments was decided by `napi.name` field in `package.json` * the second arguments was decided by `name` field in `package.json` * loadBinding helper will load `escape.[PLATFORM].node` from `__dirname` first * If failed to load addon, it will fallback to load from `@napi-rs/escape-[PLATFORM]` */ module . exports = loadBinding ( __dirname , 'escape' , '@napi-rs/escape' )
這樣我們就能愉快的通過index.js
使用封裝好的escapeHTML
函數了。
那麼這個封裝能比純JavaScript
版本快多少呢?寫一個簡單的benchmark測試一下: bench 。這裡選取了大神sindresorhus的escape-goat作為性能比較的基準:
napi x 799 ops/sec ±0.38% ( 93 runs sampled ) javascript x 586 ops/sec ±1.40% ( 81 runs sampled ) Escape html benchmark # Large input bench suite: Fastest is napi napi x 2,158,169 ops/sec ±0.59% ( 93 runs sampled ) javascript x 1,951,484 ops/sec ±0.31% ( 92 runs sampled ) Escape html benchmark # Small input bench suite: Fastest is napi
我們測試兩個場景的性能: 小規模輸入和大規模輸入。小規模輸入是一行字符串: <div>{props.getNumber()}</div>
大規模輸入是一個1610
行的HTML,可以看到不同輸入規模下我們的native addon性能都要優於純JavaScript版本。
經驗告訴我,輸入如果是Buffer
,性能會更優一些(N-API的JsBuffer相關API調用開銷要明顯小於字符串API),所以我們再加一個接受Buffer
的API:
...module.create_named_method("escapeHTMLBuf",escape_html_buf)?;#[js_function(1)]fnescape_html_buf(ctx:CallContext)->Result<JsString>{letinput=ctx.get::<JsBuffer>(0)?;letinput_buf:&[u8]=&input;ctx.env.create_string_from_std(escape(unsafe{str::from_utf8_unchecked(input_buf)}).to_string())}
再來測試一下性能,運行yarn bench
:
napi x 799 ops/sec ±0.38% ( 93 runs sampled ) napi#buff x 980 ops/sec ±1.39% ( 92 runs sampled ) javascript x 586 ops/sec ±1.40% ( 81 runs sampled ) Escape html benchmark # Large input bench suite: Fastest is napi#buff napi x 2,158,169 ops/sec ±0.59% ( 93 runs sampled ) napi#buff x 2,990,077 ops/sec ±0.73% ( 93 runs sampled ) javascript x 1,951,484 ops/sec ±0.31% ( 92 runs sampled ) Escape html benchmark # Small input bench suite: Fastest is napi#buff
果然, Buffer
風格的API性能更好一些。
而一般高強度的計算任務,我們一般都希望把它spawn
到另一個線程,以免阻塞主線程的執行。 ( escape
這個例子可能不太合適,因為這裡的計算開銷還是挺小的)。使用napi-rs
也可以比較輕鬆的完成這個任務:
module.create_named_method("asyncEscapeHTMLBuf",async_escape_html_buf)?;structEscapeTask<'env>(&'env[u8]);impl<'env>TaskforEscapeTask<'env>{typeOutput=String;typeJsValue=JsString;fncompute(&mutself)->Result<Self::Output>{Ok(escape(unsafe{str::from_utf8_unchecked(self.0)}).to_string())}fnresolve(&self,env:&mutEnv,output:Self::Output)->Result<Self::JsValue>{env.create_string_from_std(output)}}#[js_function(1)]fnasync_escape_html_buf(ctx:CallContext)->Result<JsObject>{letinput=ctx.get::<JsBuffer>(0)?;lettask=EscapeTask(input.data);ctx.env.spawn(task)}
在上面的代碼中,我們定義了一個EscapeTask
結構,然後實現napi
中的Task trait
, Task trait
需要實現4個部分:
type Output
在libuv
線程池中計算返回的值,一般是Rust
值type JsValue
計算完成後Promise resolve
的值compute
方法,定義了在libuv
線程池中的計算邏輯resolve
方法,將計算完畢的Output
轉化成Js值,最後被Promise resolve
而在新定義的js_function
async_escape_html_buf
中,我們只需要構造剛才的EscapeTask
,然後使用spawn
方法就能得到一個Promise
對象:
lettask=EscapeTask(input.data);ctx.env.spawn(task)
在js
中,我們可以這樣使用:
const{asyncEscapeHTMLBuf}=require('./index')asyncEscapeHTMLBuf(Buffer.from('<div>1</div>')).then((escaped)=>console.log(escaped))// <div>1</div>
到這里為止,我們的一個簡單的native addon 就編寫完畢了,而發布這個包,只需要以下幾步:
- commit 剛才變更的代碼
- 運行
npm version [patch | minor | major | ...]
命令 git push --follow-tags
倉庫中配置好的Github actions
會自動幫你將native
模塊分別通過不同的npm包發布Build log
當然如果你真的要做這些事情,有幾個前置修改需要做:
- 全局替換
pacakge-tempalte
到你的包名(後面會提供CLI來幫你做這件事情) - 修改
.github/workflows/CI.yml
中Upload artifact
步驟中的package-template
,新的值需要和package.json
中的napi.name
字段保持一致(後面也會提供CLI來幫你做這件事) - 如果你的包名不在
@scope
下,需要保證package-name-darwin
,package-name-win32
,package-name-linux
,package-name-linux-musl
這幾個包你都有發布權限
至此,一個簡單的native addon就封裝完成了,大家可以使用yarn add @napi-rs/escape
來試玩一下剛才封裝的這個native addon。
END
napi-rs
從誕生到現在,已經形成了一定規模的生態了, node-rs倉庫集中封裝了一些常見的native addon (deno_lint目前還在非常初始的階段), swc-node已經有很多項目用起來了,而由於swc-node
的成功, swc
的作者最近也從neon
遷移到了napi-rs
上: https:// github.com/swc-project/ swc/pull/1009
這次migrate
讓swc
的API性能快了2倍swc#852 (這也是目前napi-rs對比neon的優勢之一),並且在CI和發布管理上節省了很多代碼量。
最後歡迎大家試用napi-rs ,包括strapi在內的很多大型NodeJS項目(包括字節跳動內部的NodeJS基礎庫,支撐的總QPS可能超過10w)已經用上napi-rs
封裝的庫了,所以它在代碼上已經production ready 了。
後面我會持續建設它的文檔和周邊工具鏈,讓它更好用更易用,所以大家也不要忘了給個Star或者Sponsor !
招聘
我所在的團隊是字節跳動IES 前端架構,基礎體驗方向。 IES 中文名稱是互娛研發,也就是抖音、tiktok 等超大體量產品所在的部門。我們團隊計劃在前端、hybrid 、flutter、自研引擎、小程序、NodeJS 等多個方面做很多關於性能、體驗、監控相關的事情。
最近我們有計劃通過swc
和N-API
做一些源碼掃描的工具,預計可以比基於acorn的掃描邏輯快10~100倍。後面的規劃中,也會有大量涉及到Rust
與前端/NodeJS結合的領域可以去開拓,歡迎大家踴躍聯繫我給我投簡歷! ! !
我的個人微信在: https:// github.com/Brooooooklyn
你也可以直接通過我的內推鏈接投遞: https:// job.toutiao.com/s/JSea1 oG
也可以發郵件到我的郵箱投遞: [email protected]