在上一章節中,我們有提到WebAssembly比JavaScript的效能還要快很多,但始終沒有舉出一個例子來比較它們實際的效能差異。在這篇文章中,會介紹如何把任意現有的Rust函式庫編譯成WebAssembly,然後移植到網頁瀏覽器上執行,並且比較Rust函式庫做出來的WebAssembly模組以及原生JS模組在網頁瀏覽器上執行的效能差異。
ShortCrypt
ShortCrypt是筆者開發的跨程式語言的資料加解密函式庫,其介紹文章的網址如下:
在本篇文章中,將會嘗試把用Rust程式語言實作的ShortCrypt函式庫編譯成WebAssembly模組,並與用原生JS程式實作的ShortCrypt一同用Webpack打包,直接在網頁瀏覽器上實際測試Rust-WebAssembly實作和JS實作的效能差異。
ShortCrypt WebAssembly
建立專案目錄
要將現有的Rust函式庫編譯成WebAssembly模組,不一定需要去修改原本Rust函式庫程式專案的程式碼。我們同樣可以依照上一章介紹的方式,利用wasm-pack-template
模板建立出一個新的Cargo程式專案,名為short-crypt-webassembly
。
指令如下:
引用short-crypt
套件(crate)
編輯Cargo.toml
,在[dependencies]
區塊下加上:
引用serde
框架
為了方便實作,我們會引用serde
框架來協助處理序列化和反序列化的動作。
編輯Cargo.toml
,在[dependencies]
區塊下加上:
另外,為了讓產生出來的JS的膠水代碼也能夠支援serde
框架的序列化和反序列化,我們還需要啟用wasm-bindgen
這個crate的serde-serialize
特色,使它能夠提供JsValue
結構體。
Rust程式實作
由於#[wasm_bindgen]
屬性無法用在非目前這個crate底下的結構體、列舉、或是函數、方法,因此我們要另外建立一個Wrapper結構體,將short-crypt
這個crate下的ShortCrypt
結構體包裹起來。
程式如下:
...
#[wasm_bindgen]
pub struct ShortCrypt(short_crypt::ShortCrypt);
接著參考ShortCrypt的API文件,將想要暴露(export)給JavaScript使用的函數、方法寫進有套用#[wasm_bindgen]
屬性的impl
區塊中。程式如下:
...
use serde::{Deserialize, Serialize};
...
#[derive(Serialize, Deserialize)]
struct Cipher {
pub base: u8,
pub body: Vec<u8>,
}
#[wasm_bindgen]
impl ShortCrypt {
#[wasm_bindgen(constructor)]
pub fn new(key: &str) -> ShortCrypt {
ShortCrypt(short_crypt::ShortCrypt::new(key))
}
pub fn encrypt_string(&self, plaintext: &str) -> JsValue {
let (base, body) = self.0.encrypt(plaintext);
JsValue::from_serde(&Cipher {
base,
body,
}).unwrap()
}
pub fn encrypt_binary(&self, plaintext: &[u8]) -> JsValue {
let (base, body) = self.0.encrypt(plaintext);
JsValue::from_serde(&Cipher {
base,
body,
}).unwrap()
}
pub fn decrypt_to_binary(&self, cipher: JsValue) -> Option<Vec<u8>> {
let cipher: Cipher = cipher.into_serde().ok()?;
self.0.decrypt(&(cipher.base, cipher.body)).ok()
}
pub fn decrypt_to_string(&self, cipher: JsValue) -> Option<String> {
let cipher: Cipher = cipher.into_serde().ok()?;
match self.0.decrypt(&(cipher.base, cipher.body)) {
Ok(v) => {
String::from_utf8(v).ok()
}
Err(_) => {
None
}
}
}
pub fn encrypt_string_to_url_component(&self, data: &str) -> String {
self.0.encrypt_to_url_component(data)
}
pub fn encrypt_binary_to_url_component(&self, data: &[u8]) -> String {
self.0.encrypt_to_url_component(data)
}
pub fn decrypt_url_component_to_binary(&self, url_component: &str) -> Option<Vec<u8>> {
self.0.decrypt_url_component(url_component).ok()
}
pub fn decrypt_url_component_to_string(&self, url_component: &str) -> Option<String> {
match self.0.decrypt_url_component(url_component) {
Ok(v) => {
String::from_utf8(v).ok()
}
Err(_) => {
None
}
}
}
pub fn encrypt_string_to_qr_code_alphanumeric(&self, data: &str) -> String {
self.0.encrypt_to_qr_code_alphanumeric(data)
}
pub fn encrypt_binary_to_qr_code_alphanumeric(&self, data: &[u8]) -> String {
self.0.encrypt_to_qr_code_alphanumeric(data)
}
pub fn decrypt_qr_code_alphanumeric_to_binary(&self, url_component: &str) -> Option<Vec<u8>> {
self.0.decrypt_qr_code_alphanumeric(url_component).ok()
}
pub fn decrypt_qr_code_alphanumeric_to_string(&self, url_component: &str) -> Option<String> {
match self.0.decrypt_qr_code_alphanumeric(url_component) {
Ok(v) => {
String::from_utf8(v).ok()
}
Err(_) => {
None
}
}
}
}
以上程式中,比較需要特別注意的部份有以下幾點:
- Rust程式語言提供的
i8
、u32
、f64
等基本資料型別(不包括128位元的整數)可以直接與JavaScript的Number型別的值做對應。而String
字串結構體和&str
字串切片則可以直接與JavaScript的字串型別的值做對應。Vec<T>
可以直接與JavaScript的陣列型別的值做對應。 wasm_bindgen
提供的JsValue
可以與JavaScript的任意值做對應。#[wasm_bindgen(constructor)]
屬性可以用來指定要把哪個關聯函數當作是JavaScript物件的建構子(constructor)。也就是在JS中使用new關鍵字時會呼叫的函數。- 因為
wasm_bindgen
不支援有使用泛型的函數,所以這邊把原先有用到泛型的函數拆開來寫了(將輸入和輸出的值分為binary
和string
版本)。 Option
列舉的變體None
,對應著JavaScript的undefined
。
然後您可能還會有一個疑問:為什麼這邊要使用JsValue
結構體,怎麼不要像以下這樣也把Cipher
結構體加上#[wasm_bindgen]
屬性,使它能夠直接被函數回傳,或是直接作為函數的參數來傳入?
#[wasm_bindgen]
pub struct Cipher {
pub base: u8,
pub body: Vec<u8>,
}
是的,這樣的確是非常直覺的作法,但這實際上會讓程式編譯失敗,因為Vec<T>
並沒有實作Copy
特性,它無法作為JavaScript物件的公開(public)欄位。
建置專案
寫完Rust程式後,就可以執行以下指令將它編譯成.wasm
檔案,以及產生出相對應的膠水代碼!
把WebAssembly套用在HTML網頁
我們可以在Cargo程式專案目錄中,新增一個www
目錄,然後在這個目錄中再新增以下這些檔案,來將pkg
目錄中的檔案加進Webpack中。
{
"name": "short-crypt-dev",
"version": "0.0.0",
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"start": "webpack serve --mode=production",
"start:dev": "webpack serve --mode=development"
},
"devDependencies": {
"@babel/core": "^7.18.2",
"@babel/preset-env": "^7.18.2",
"@babel/register": "^7.17.7",
"babel-loader": "^8.2.5",
"buffer": "^6.0.3",
"html-webpack-plugin": "^5.5.0",
"terser-webpack-plugin": "^5.3.1",
"webpack": "^5.72.1",
"webpack-cli": "^4.9.2",
"webpack-dev-server": "^4.9.0"
},
"dependencies": {
"short-crypt": "git+https://github.com/magiclen/js-short-crypt.git"
}
}
注意這邊我們還把用原生JavaScript實作的ShortCrypt套件加進這個專案中。buffer
套件是個polyfill。
import * as module from "../../pkg/short_crypt_webassembly";
import ShortCrypt from "short-crypt";
const times = 100;
let jsDuration = 0;
let jsResult1;
let jsResult2;
let wasmDuration = 0;
let wasmResult1;
let wasmResult2;
for (let i = 0;i < times;i++) {
const start = performance.now();
const sc = new ShortCrypt("magickey");
jsResult1 = sc.encryptToURLComponent("articles");
jsResult2 = sc.encryptToQRCodeAlphanumeric("articles");
jsDuration += performance.now() - start;
}
for (let i = 0;i < times;i++) {
const start = performance.now();
const sc = new module.ShortCrypt("magickey");
wasmResult1 = sc.encrypt_string_to_url_component("articles");
wasmResult2 = sc.encrypt_string_to_qr_code_alphanumeric("articles");
wasmDuration += performance.now() - start;
}
console.log((jsDuration / times * 1000) + "us", jsResult1, jsResult2);
console.log((wasmDuration / times * 1000) + "us", wasmResult1, wasmResult2);
在Webpack的進入點中,我們分別測量了原生JavaScript版本的ShortCrypt和WebAssembly版本的ShortCrypt的執行時間。會將結果輸出到網頁瀏覽器的主控台(console)中。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>ShortCrypt</title>
</head>
<body>
Please see the console.
<script src="./js/bundle.min.js"></script>
</body>
</html>
const TerserPlugin = require("terser-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
output: {
clean: true,
filename: "./js/bundle.min.js",
},
plugins: [
new HtmlWebpackPlugin({
template: "./views/index.html",
filename: "./index.html",
minify: {
collapseBooleanAttributes: true,
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
removeEmptyAttributes: true,
removeRedundantAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
minifyCSS: true,
minifyJS: true,
sortAttributes: true,
useShortDoctype: true,
},
inject: false,
}),
],
module: {
rules: [
{
test: /\.js$/i,
use: {
loader: "babel-loader",
options: { presets: ["@babel/preset-env"] },
},
},
],
},
optimization: {
minimizer: [
new TerserPlugin({
extractComments: false,
terserOptions: {
format: {
comments: false,
},
},
}),
]
},
experiments: { asyncWebAssembly: true },
};
將以上檔案添加好後,就可以執行npm install
指令來安裝套件。接著再執行npm run start
指令來瀏覽結果。
在Firefox 60.7.2esr上的執行結果如下:
40us 2E87Wx52-Tvo 3BHNNR45XZH8PU
在Chromium 75.0.3770.90上的執行結果如下:
24.650000013934914us 2E87Wx52-Tvo 3BHNNR45XZH8PU
由此可知WebAssembly版本的ShortCrypt的效能大概是原生JavaScript版本的20倍!
適合或不適合編譯成WebAssembly的Rust程式
WebAssembly固然強大,但我們並沒有辦法將所有的Rust程式都移植成WebAssembly程式。若Rust程式有用到C/C++函式庫,或者有用到檔案I/O,又或者有用到thread::spawn
函數來產生新執行緒(未來可能會支援),那它就無法被成功編譯為WebAssembly程式。
在crates.io上有很多Rust的套件可以取用,其中很適合用於WebAssembly的分類有:不使用標準函式庫的no-std
、解析資料的parser-implementations
和text-processing
、讓Rust語法更方便的rust-patterns
。而有些套件則是原本就專門針對WebAssembly來設計,會被分在wasm
。
總結
在這個章節中,我們學習了把現有Rust函式庫編譯為WebAssembly程式的方式,並且對於WebAssembly的程式的效能有所認識。從下一章節開始,要開始實作大一點的專案,利用WebAssembly來高效地操作HTML的Canvas,並使它能與使用者互動。
下一章:實作康威生命遊戲。