[譯]使用 Rust 編寫快速安全的原生 Node.js 模組

LeopPro發表於2019-03-04

使用 Rust 編寫快速安全的原生 Node.js 模組

內容梗概 - 使用 Rust 代替 C++ 開發原生 Node.js 模組!

RisingStack 去年面臨一件棘手的事:我們已經儘可能讓 Node.js 發揮出最高的效能,然而我們的伺服器開銷還是達到的最高限度。為了提高我們應用的效能(並且降低成本),我們決定徹底重寫它,並將系統遷移到其他的基礎設施上 - 毫無疑問,這個工作量很大,這裡不詳敘了。 後來我發現,我們只要寫一個原生模組就行了!

那時候,我們還沒意識到有更好的方法來解決我們的效能問題。就在幾周前,我發現有另外一個方案可行 採用 Rust 代替 C ++ 來實現原生模組。 我發現這是一個很好的選擇,這要歸功於它提供的安全性和易用性。

在這篇 Rust 教程中,我將手把手教你寫一個先進、快速、安全的原生模組。

Node.js 伺服器的效能問題

我們的問題在 2016 年末的時候暴露出來,當時我們一直在研究 Node.js 的監控產品 Trace,該產品於2017年10月與 Keymetrics 合併。 像當時的其他科技創業公司一樣,我們將服務部署到 Heroku 上以節省一些基礎設施成本和維護費用。我們一直在構建微服務架構應用程式,這意味著我們很多服務都是通過 HTTP(S) 進行通訊的。

棘手的問題來了: 我們想讓各服務之間進行安全的通訊,但是 Heroku 不支援私有網路,所以我們不得不實現一個自己的方案。因此,我們查閱了一些安全認證方案,最終選定了 HTTP 簽名。

簡要地解釋一下:HTTP 簽名基於非對稱密碼體系。要建立一個 HTTP 簽名,你需要獲取一個請求的所有部分:URL、請求頭、請求體,使用你的私鑰對其簽名。然後,你可以將公鑰發給將會收到簽名請求的裝置,以便它們驗證。

隨時間流逝,我們發現在大多數 HTTP 伺服器程式中,CPU 利用率已經達到了極限。顯然,一個原因引起我們懷疑 - 如果你想加密,那就會發生這樣的問題。

然而,在對 v8-profiler 進行了嚴格分析之後,我們發現問題不是由加密引起的!是 URL 解析佔用 CPU 最多的時間。為什麼?因為要進行驗證,就必須解析 URL 來驗證請求籤名。

為了解決這個問題,我們決定放棄 Heroku(這其中也有其他因素),我們建立了一個包含 Kubernetes 和內部網路的 Google 雲基礎設施,而不是優化我們的 URL 解析。

是什麼原因促使我寫這個故事(教程)呢?就在幾周前,我意識到我們可以用另一種方法優化 URL 解析 —— 使用 Rust 寫一個原生庫。

編寫原生模組 - 需要一個 Rust 模組

編寫原生程式碼應該不那麼難,對吧?

在 RisingStack,我們奉行工欲善其事,必先利其器的宗旨。我們經常對更好的軟體構件方式做調查,在必要的時候,也使用 C++ 來編寫原生模組。

恬不知恥地說一句:我也在部落格上寫了我的學習歷程 原生 Node.js 模組之旅。去看一看!

在此之前,我認為在絕大多數業務場景中,C++ 是編寫一個快速有效的軟體的正確選擇。然而現在我們有了現代化的工具(本例中 - Rust),我們可以用它花費比以前都少的人力成本來編寫更有效、更安全、更快速的程式碼。

讓我們回到最初的問題:解析一個 URL 難道很困難麼?它包括協議、主機、查詢引數……

URL-parsing-protocol

(出自 Node.js documentation

這看起來真複雜。當我通讀 the URL standard 之後,我發現我不想自己實現它,所以我開始尋找替代品。

我確信我不是唯一一個想要解析 URL 的人。瀏覽器可能已經解決了這個問題,所以我搜尋了 Chromium 的解決方案:谷歌連結。儘管使用 N-API 可以很容易地從 Node.js 呼叫這個實現,但是有幾個原因讓我不這樣做:

  • 更新: 當我只是從網上覆制貼上程式碼的時候,我立即感到了不安。長久以來,人們一直這樣做,而且總有許多原因使它們不能很好地工作……沒有什麼好的方法去更新程式碼庫中的大段程式碼。
  • 安全性: 一個沒有豐富 C++ 程式設計經驗的人是無法驗證程式碼是否正確的,但是我們又不得不將它執行在我們伺服器上。C++ 學習曲線過於陡峭,人們需要花費很長時間掌握它。
  • 私密性: 我們都聽說過可用的 C++ 程式碼是存在的,然而我寧願避免複用 C++ 程式碼,因為我沒辦法獨自審計它。使用維護良好的開源模組給了我足夠的信心,我不必擔心它的私密性。

所以我更傾向於一門更易於使用的,具有簡易更新機制和現代化的語言:Rust!

關於 Rust 簡單說兩句

Rust 允許我們編寫快速有效的程式碼。

所有的 Rust 工程由 cargo 管理 —— 就是 Rust 界的 npmcargo 可以安裝工程依賴,並且有一個登錄檔包含了所有你需要使用的包。

我發現了一個可以在我們例子中使用的庫 - rust-url,非常感謝 Servo 團隊所做的工作。

我們也要使用 Rust FFI!兩年前我已經寫過一個相關的部落格 using Rust FFI with Node.js。從那時到現在,Rust 生態系統已經發生了很多改變。

我們有了一個可以工作的庫(rust-url),讓我們試著去編譯它吧!

如何編譯一個 Rust 應用?

根據 rustup.rs 指南,我們可以用 rustc 編譯器,但是我們現在更應該關心的是 cargo。我不想深入描述它是如何工作的,如果你感興趣,請移步至我們以前的 Rust 博文

建立新的 Rust 工程

建立一個新的 Rust 工程就這麼簡單:cargo new --lib <工程名>

你可以在我的倉庫中檢視完整程式碼 github.com/peteyy/rust…

想要引用 Rust 庫,我們只要將它作為一個依賴列在 Cargo.toml 中就可以了。

[package]
name = "ffi"
version = "1.0.0"
authors = ["Peter Czibik <p.czibik@gmail.com>"]

[dependencies]
url = "1.6"
複製程式碼

Rust 沒有類似 npm install 一樣安裝依賴的命令 - 你必須自己手動新增它。然而有一個叫做 cargo edit 的 crate 可以實現類似功能。

譯者注:crate 是 Rust 中一個類似包(package)的概念,上文中的 rust-url 也屬於一個 crate。crates.io 允許全世界的 Rust 開發者搜尋或者釋出 crate。

Rust FFI

為了從 Node.js 中呼叫 Rust,我們可以使用 Rust 提供的 FFI。FFI 是外部函式介面(Foreign Function Interface)的縮寫。外部函式介面(FFI)是由一種程式語言編寫的,能夠呼叫另一種語言編寫的例程或使用服務的機制。

為了連結我們的庫,我們還需要向 Cargo.toml 中新增兩個東西

[lib]
crate-type = ["dylib"]

[dependencies]
libc = "0.2"
url = "1.6"
複製程式碼

在這裡需要說明:我們的庫是動態連結庫,副檔名為 .dylib,這個庫在執行期被載入而不是編譯期。

我們還要為工程新增 libc依賴,libc 是遵從 ANSI C 標準的 C 語言標準庫。

libc crate 是 Rust 的一個庫,它具有與各種系統(包括libc)中常見型別和函式的本地繫結。這允許我們在 Rust 程式碼中使用 C 語言型別,我們想在 Rust 函式中接收或返回任何 C 型別資料,我們都必須使用它。

我們的程式碼相當簡單 —— 我使用 extern crate 關鍵字來引用 urllibc crate。我們要把函式標記為 pub extern 使得這些函式可以通過 FFI 被暴漏給外部。我們的函式持有一個代表 Node.js 中 String 型別的 c_char 指標。

我們需要把型別轉換標記為 unsafe。被標記了 unsafe 關鍵字的程式碼塊可以訪問非安全的函式或者取消引用在安全函式中的裸指標(raw pointer)。

Rust 使用 Option<T> 型別來表示一個可為空的值。就像 JavaScript 中一個值可以為 null 或者 undefined 一樣。每次嘗試訪問可能為空的值時,都可以(也應該)明確地檢查。在 Rust 中,有幾種方式可以訪問它,但是在這裡,我將使用最簡單的方式:如果值為空,則將會丟擲一個錯誤(panic in Rust terms)unwrap

當我們搞定了 URL 解析,我們要將結果轉化為 CString 才能傳回 JavaScript。

extern crate libc;
extern crate url;

use std::ffi::{CStr,CString};
use url::{Url};

#[no_mangle]
pub extern "C" fn get_query (arg1: *const libc::c_char) -> *const libc::c_char {

    let s1 = unsafe { CStr::from_ptr(arg1) };

    let str1 = s1.to_str().unwrap();

    let parsed_url = Url::parse(
        str1
    ).unwrap();

    CString::new(parsed_url.query().unwrap().as_bytes()).unwrap().into_raw()
}
複製程式碼

要編譯這些 Rust 程式碼,你可以使用 cargo build --release 命令。在編譯之前,確認你在 Cargo.toml 的依賴中新增 url 庫了!

現在我們可以使用 Node.js 的 ffi 包建立一個用於呼叫 Rust 程式碼的模組。

const path = require('path');
const ffi = require('ffi');

const library_name = path.resolve(__dirname, './target/release/libffi');
const api = ffi.Library(library_name, {
  get_query: ['string', ['string']]
});

module.exports = {
  getQuery: api.get_query
};
複製程式碼

cargo build --release 命令編譯出的 .dylib 命名規則是 lib*,其中的 * 是你的庫名。

美滋滋:我們已經有了一個可以從 Node.js 呼叫的 Rust 程式碼!雖說能拔膿的就是好膏藥,但是你應該已經發現了,我們不得不做一大堆型別轉換,這將增加我們函式呼叫的開銷。一定有更好的辦法將我們的程式碼與 JavaScript 做整合。

初遇 Neon

用於編寫安全、快速的原生 Node.js 模組的 Rust 繫結。

Neon 讓我們可以在 Rust 程式碼中使用 JavaScript 型別。要建立一個新的 Neon 工程,我們可以使用它自帶的命令列工具。執行 npm install neon-cli --global 來安裝它。

執行 neon new <projectname> 將會建立一個新的沒有任何配置 Neon 工程。

建立好 Neon 工程後,我們重寫上面的程式碼如下:

#[macro_use]
extern crate neon;

extern crate url;

use url::{Url};
use neon::vm::{Call, JsResult};
use neon::js::{JsString, JsObject};

fn get_query(call: Call) -> JsResult<JsString> {
    let scope = call.scope;
    let url = call.arguments.require(scope, 0)?.check::<JsString>()?.value();

    let parsed_url = Url::parse(
        &url
    ).unwrap();

    Ok(JsString::new(scope, parsed_url.query().unwrap()).unwrap())
}

    register_module!(m, {
        m.export("getQuery", get_query)
    });
複製程式碼

上述程式碼中,新型別 JsStringCallJsResult 是對 JavaScript 型別的封裝,這樣我們就可以接入 JavaScript VM ,執行上面的程式碼。Scope 將我們的新變數繫結到當前的 JavaScript 域中,這讓我們的變數就可以被垃圾收集器回收。

這和我之前寫的博文中 使用 C++ 編寫原生 Node.js 模組 解釋地非常類似。

值得注意的是,#[macro_use] 屬性允許我們使用 register_module! 巨集,這可以讓我們像 Node.js 中的 module.exports 一樣建立模組。

唯一棘手的地方是對引數的訪問:

let url = call.arguments.require(scope, 0)?.check::<JsString>()?.value();
複製程式碼

我們得接受所有型別的引數(如同任何 JavaScript 函式一樣),所以我們沒辦法確定引數的數量,這就是我們必須要檢查第一個元素是否存在的原因。

除此之外,我們可以擺脫大多數的序列化工作,直接使用 Js 型別就好了。

現在,我們嘗試執行它!

如果你事先下載了我的示例程式碼,你需要進入 ffi 資料夾執行 cargo build --release ,然後進入 neon 資料夾執行 neon build(事先要裝好 neon-cli)。

如果你都準備好了,你可以使用 Node.js 的 faker library 生成一個新的 URL 列表。

執行 node generateUrls.js 命令,這將會在你的資料夾中建立一個 urls.json 檔案,我們的測試程式一會兒會嘗試解析它。搞定了這些後,你可以執行 node urlParser.js 來執行基準測試,如果全部成功了,你將會看到下圖:

Rust-Node-js-success-screen

測試程式解析了100個URL(隨機產生),我們的應用只需要一次執行就可以解析出結果。如果你想做基準測試,請增加 URL 數量(urlParser.js 中的 tryCount)或次數(urlGenerator.js 中的 urlLength)。

顯而易見,在基準測試中表現最好的是 Rust neon 版本,但是隨之陣列長度的增加,V8 有越來越多的優化空間,他們之間的成績會接近。最終它將超過 Rust neon 實現。

Rust-node-js-benchmark

這只是一個簡單的例子,當然,在這個領域我們還有很多東西要學習,

後續,我們可以進一步優化計算,儘可能的利用併發計算提高效能,一些類似 rayon 的 crates 提供給我們類似的功能。

在 Node.js 中實現 Rust 模組

希望你今天跟我學到了在 Node.js 中實現 Rust 模組的方法,從此你可以從(工具鏈中的)新工具中受益。我想說的是,雖然這是能解決問題的(而且很有趣),但它並不是解決所有效能問題的銀彈。

請記住,在某些場景下,Rust 可能是很便利的解決方案

如果你想看看我在 Rust 匈牙利研討會上關於本話題的發言,點這裡

如果你有任何問題或評論,請在下面留言,我將在這回復你們!


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章