JavaScript 開發者用 napi-rs 初學 Rust

C.T. Lin
14 min readNov 8, 2021

為什麼學習 Rust

最近 JavaScript/TypeScript 的生態系有不少專案都底層都轉往使用 Rust 開發,包括像是 Deno、Next.js 最近用來取代 Babel 的 swc 或是 Babel 原作者的新專案 Rome

這現象並不是說 Rust 要來取代 JavaScript/TypeScript 了,實際上更像是取代原本必須使用 C/C++ 來完成的部分,我實際上試了一下,如果是要做這樣的事情我的確更願意使用 Rust 而不是 C/C++。

而 compiler、linter 或是 formatter 等等 JavaScript/TypeScript 的常用工具確實在過去一段時間遭遇到了很大的效能問題,畢竟現代 JS monorepo 動輒幾千、幾萬個檔案,有時候跑個 Babel、ESLint、TypeScript 幾分鐘就過去了,程式碼的解析(parse)、轉換(transform)、生成(generate)也不算是 JavaScript 特別擅長的部分,確實是有必要使用更快的方式實作來釋放開發者大量的時間。

我自己是一個很喜歡讀程式碼的人,跟 Babel、ESLint、Webpack 的 source 都還算熟,算是有接觸過 C、C++、Objective-C、PHP、JavaScript、Java、Ruby、Swift、Python、Haskell、TypeScript、Go,其中有些只是碰過短暫的一陣子,而且隨著時間有些也是漸漸淡忘了,但這讓我在看某些沒學過的語言也能直接看懂個大概。

但是初次探索 swc 的 source 的過程非常不順暢,許多符號 !?#[]::<T> 在一開始眼花撩亂,如果看不懂自己在用的工具的原始碼我認為是很討厭的事,所以就開始正式地踏上學習 Rust 之路了,這種時候最好還是看個教學或是花點時間做個小練習來搞清楚語言的運作方式跟習慣。

為什麼用 napi-rs 學習 Rust

最適合我的學習方式就是簡單地看完 Hello World 教學後,直接開始一邊實作練習一邊 Google,因為目的是未來可以輕鬆的搞懂 swc,直個搞個原理跟它類似的專案最合適,而 swc 現在就是使用 napi-rs

Node API(N-API)提供一個穩定的 Application Binary Interface(ABI)來開發 Native Addons 讓開發者不需要為了每個 Node 版本重新編譯,雖然跟它不太熟,不過剛好多年前因緣際會有用過 C++ 來寫過 Node Addons,那時候還沒有 N-API,是超級久以前 Node v0.10 的 NAN (Native Abstractions for Node.js)時代,napi-rs 有一頁文件就在講這個歷史故事,看了這篇很有共鳴。

剛好又想到 Jamie Kyle 以前用來介紹 Babel 運作原理用的 The Super Tiny Compiler,把這個專案改寫成 Rust 再讓 JavaScript 來 call 感覺是個很好的練習(改完的程式放在這裡)。

Rust 初學需要知道的一些特性

這邊列出了一些我開始學習這個語言後馬上想要釐清的部分:

可變(mutable) — mut

在 Rust 變數宣告(用 let),預設是不可變的(immutable),可變的(mutable)的變數需要多加上 mut 來宣告:

let mut current = 0;

這部分是知道即可,用錯 compiler 都會直接跟你說。

在建立 reference(引用)的時候也是,mutable reference 必須寫 &mut

fn main() {     
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}

同一時間對特定資料只能有一個 mutable reference。

巨集(Macro)

如果看到 ! 結尾的呼叫,這是在呼叫 macro,是用程式碼產生程式碼的方式,例如在 Hello World 的範例中可能就會看到 println!()

println!("Hello World!");

這個是有定義在 std 裡面的 macro。

如果要引用一些其他 crate (binary 或 library)定義的 macro,必須要加上這段:

#[macro_use]
extern crate napi_derive;

表達式(Expression)vs. 陳述式(Statement)

Rust 是以 expression 為基礎的語言,最後一行可以寫 expression 直接回傳值,但在這個狀況下不能寫分號,寫分號會變成 statement 就會回傳 ()(unit type)

fn add_one(x: i32) -> i32 {     
x + 1
}

if 跟後面會講到的 match 都是可以回傳值的:

let x = 5;  
let y = if x == 5 { 10 } else { 15 }; // y: i32

所有權(Ownership)和借用(Borrowing

之前大多時間在接觸的語言大多都是仰賴垃圾回收機制(garbage collection,GC),Rust 這邊倚賴 ownership 來保障記憶體安全,對我來說是一個很新穎的概念。

  • Rust 中每個數值都會有一個變數作為它的擁有者(owner)
  • 同時間只能有一個擁有者。
  • 當擁有者離開作用域時,數值就會被丟棄。

最為經典的範例就是下面這個,把 s1 assign 到 s2 這樣的操作在其他語言看起來在平凡不過:

let s1 = String::from("hello");     
let s2 = s1;
println!("{}, world!", s1);

在 Rust 卻是會噴出錯誤:

$ cargo run    
Compiling ownership v0.1.0 (file:///projects/ownership) error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:28
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{}, world!", s1);
| ^^ value borrowed here after move

這稱為 ownership 的轉移(move),可以避免之後重複釋放(double free)記憶體,所以 s1 會被無效化。

但是在該 type 有實作 Copy trait 的時候是沒問題的,這邊的整數型別 i32 有實作 Copy,所以遇到這個狀況會複製一份:

let x = 5;     
let y = x;
println!("x = {}, y = {}", x, y);

因為 ownership 轉移的行為會導致變數失效,可以利用 & 建立 reference 來借用(borrowing)一下變數,因為 ownership 沒有轉移,變數還有效,而且不會重複釋放記憶體:

let s1 = String::from("hello");      
let len = calculate_length(&s1);
println!("'{}' 的長度為 {}。", s1, len);

&str vs String

雖然知道這層級的語言在面對 string 跟 array 處理,一定不會像 JavaScript 一樣只用一個可變長度的 type,但一開始還沒搞清楚直接開始寫的話,還是很容易就會噴出這種 &strString 誤用的錯誤:

error[E0308]: mismatched types
--> src/main.rs:10:11
|
3 | hello(name);
| ^^^^^^^
| |
| expected struct `std::string::String`, found `&str`
| help: try using a conversion method: `name.to_string()`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.

&str又稱為 string slice,是對 UTF-8 bytes 的引用(reference),沒有包含 capacity 資訊:

let str = "Hello, World!";

String 是動態存在 heap 的字串型別,想要擁有所有權或是想要修改,必須使用 String:

let mut hello = String::from("Hello, ");

hello.push('w');
hello.push_str("orld!");

中間的轉換並不困難,把 &strString

let str = "Hello, World!";
let string = str.to_string();

String&str

let str = String::from("Hello, World!");
let string = str.as_str();

模式匹配(Pattern Matching)

這個在其他語言看過的人可能很熟悉,JavaScript 也有一個相關的 Proposal

let x = 1;  
match x {
1 => println!("one"),
2 => println!("two"),
3 => println!("three"),
_ => println!("anything"),
}

一開始看到這個的人,可能常會把它跟 switch/case 搞混,不過其中有蠻大的區別,而且 Rust 也沒有 switch/case。Pattern Matching 是用 pattern 而不是 value 做匹配,而且可以把 pattern 裡的值綁定到變數上,例如下面這個例子就絕對不是 switch/case 能做得到的了:

match message {     
Message::Quit => println!("Quit"),
Message::WriteString(write) => println!("{}", &write),
Message::Move{ x, y: 0 } => println!("move {} horizontally", x),
Message::Move{ .. } => println!("other move"),
Message::ChangeColor { 0: red, 1: green, 2: _ } => {
println!("color change, red: {}, green: {}", red, green);
}
};

因為必須徹底涵蓋所有的 pattern 或要有一個 _ (catch-all) pattern,這個語法也是相當安全,不會漏掉該處理的狀況。

列舉(Enum)

在 Rust 使用 enum 再搭配 Pattern Matching 是一種非常安全的用法,也體現在錯誤處理以及空值處理上面,必須非常確實。這種方式對 JavaScript 的開發者來說應該很不熟悉,不過剛好我已經有在其他語言看過了這個模式,乍看之下還蠻直覺的。

在寫 napi-rs 的時候,我首先也是看到一大堆的 Result 回傳值,它的定義非常簡單:

enum Result<T, E> {
Ok(T),
Err(E),
}

收到這個值的人必須針對 Ok 或者 Err 的狀況去個別處理:

let version = parse_version(&[1, 2, 3, 4]);
match version {
Ok(v) => println!("working with version: {:?}", v),
Err(e) => println!("error parsing header: {:?}", e),
}

有一個常用的取巧手段 — The question mark operator(也就是我一開始看不太懂的 ?):

let v = parse_version(&[1, 2, 3, 4])?;

可以直接把 Ok 的值取出來,遇到 Err 則是直接往呼叫的外層回傳。另外,如果遇到不預期或不可修正的錯誤,也可以考慮用 panic!() macro 處理錯誤。

另一個重要的 enum 是 Option:

pub enum Option<T> {
None,
Some(T),
}

這個 enum 可以確保空值的 None 一定會被處理起來:

match a {     
None => (),
Some(value) => (),
}

屬性(Attribute)

attribute 是一種 metadata 標記,提供給 compiler、linter、test、tool 額外的資訊,例如前面有提到的 macro:

#[macro_use]

之後我們在 N-API 的部分也會看到:

#[js_function(1)]

以及

#[module_exports]

這樣的 attribute。

特徵(Trait)

Trait 是類似 interface 的概念,只要在 type 上實作特定 trait,他們即共享特定的行為,例如前面所提到的 Copy trait

napi-rs 的一些概念

建立專案

建立 napi-rs 的專案非常容易,可以使用它的 cli:

npx @napi-rs/cli new

它會產生一個 monorepo,包括你需要支援的各種平台的 package,並可以一併把 GitHub action 等 release 需要的東西一次產生好,它用的是一種特殊的結構來支援不同的 os,有興趣的人可以直接看這頁文件

主程式會放在 src/lib.rs 裡面。

程式架構

產生出來的 src/lib.rs 裡面的重點簡化版大概是這樣:

#[module_exports]
fn init(mut exports: JsObject) -> Result<()> {
exports.create_named_method("sync", sync_fn)?;
Ok(())
}
#[js_function(1)]
fn sync_fn(ctx: CallContext) -> Result<JsNumber> {
let argument: u32 = ctx.get::<JsNumber>(0)?.try_into()?;
ctx.env.create_uint32(argument + 100)
}

上面的 init 有標記 #[module_exports]attribute,再加上裡面的 exports.create_named_method("sync", sync_fn),應該很容易能猜到它就是要定義 Node 裡面的module.exports.sync

下面的部分則是要定義 sync_fn#[js_function(1)] 這邊代表它是接收一個參數, ctx.get::<JsNumber>(0) 代表要取得呼叫的第 1 個參數型別是一個 JsNumber.try_into() 是要試著把 JsNumber 轉成 Rust 的 u32

最後再利用 ctx.env.create_uint32 把計算好的結果轉成 Result<JsNumber> 傳出去。

這邊要熟悉的就是在 Rust 操作 JavaScript 的型別,例如你可能會接收多個不同型別的參數:

ctx.get::<JsNumber>(0)
ctx.get::<JsString>(1)
ctx.get::<JsObject>(2)

或是需要回傳各種不同的型別

ctx.env.get_undefined()
ctx.env.get_null()
ctx.env.create_uint32(100)
ctx.env.get_boolean(false)
ctx.env.create_string("Hello, World!")
let mut obj = ctx.env.create_object()?;
obj.set_named_property("type", ctx.env.create_string("my_type")?)?;

這個部分有可能會在 napi-rs 2.0 變得更加簡潔:https://github.com/napi-rs/napi-rs/discussions/757

編譯測試

相關的腳本也都已經寫好,只要執行

npm run build:debug

即可編譯。就可以直接進去 REPL 測試了:

-> node
Welcome to Node.js v16.11.1.
Type ".help" for more information.
> require('.').sync(100);

結語

這就是我學習一個禮拜的一些心得,希望能幫助到初學 Rust 的 JavaScript 開發者,如果中間有什麼疏漏或錯誤可能難免還請留言指正。我練習的 Project 放在這裡:https://github.com/chentsulin/the-super-tiny-compiler-in-rust/blob/master/src/lib.rs,畢竟才剛學很有可能漏洞百出,如果有任何能改進的建議歡迎 PR 或留言跟我說。

--

--

C.T. Lin

Architect @ Dcard. Author of Electron React Boilerplate and Bottender. JavaScript Developer.