Bỏ qua để đến nội dung

Rust và WebAssembly

WebAssembly (thường viết tắt là Wasm) cho phép bạn chạy code viết bằng các ngôn ngữ như Rust ngay trong trình duyệt web, bên cạnh JavaScript. Hãy coi nó như một động cơ thứ hai trong trình duyệt: JavaScript vẫn điều khiển trang web, nhưng khi có việc nặng (bộ lọc ảnh, mô phỏng vật lý, phân tích file lớn), bạn có thể giao nó cho code Rust đã biên dịch, chạy rất nhanh.

Trên trang này, bạn sẽ viết một thư viện Rust nhỏ xíu, biên dịch nó sang Wasm và gọi nó từ một trang web. Không có gì ở đây chạy được trên Rust Playground (cần trình duyệt và thêm công cụ), nên phần lớn ví dụ là các khối code thường để bạn chạy trên máy mình. Còn những phần chỉ có Rust thuần thì bạn thử được ngay.

WebAssembly là gì, và vì sao Rust hợp với nó

Phần tiêu đề “WebAssembly là gì, và vì sao Rust hợp với nó”

WebAssembly là một định dạng nhị phân (binary format) gọn nhẹ cho chương trình. Mọi trình duyệt lớn đều chạy được nó, với tốc độ gần bằng code native, bên trong cùng hộp cát (sandbox) bảo mật với JavaScript. Nó không chỉ dành cho trình duyệt: máy chủ và các nền tảng edge cũng chạy Wasm.

Rust là một trong những ngôn ngữ phổ biến nhất cho Wasm, vì vài lý do:

  • Không có bộ thu gom rác hay runtime lớn. Rust không cần mang theo bộ máy phụ trợ nào cùng code của bạn, nên file .wasm có thể nhỏ.
  • Công cụ tuyệt vời. wasm-bindgen viết giúp bạn phần code kết nối (glue code) giữa Rust và JavaScript, còn wasm-pack build mọi thứ chỉ bằng một lệnh.
  • An toàn và nhanh. Bạn có cùng mức an toàn bộ nhớ và hiệu năng như khi chạy trên máy tính để bàn.

Đầu tiên, cho Rust biết nó có thể biên dịch cho target Wasm. Target là “loại máy mà code sẽ chạy trên đó”; wasm32-unknown-unknown nghĩa là “Wasm 32-bit, không gắn với hệ điều hành cụ thể nào”:

Terminal window
rustup target add wasm32-unknown-unknown

Tiếp theo, cài wasm-pack. Nó biên dịch crate của bạn, chạy wasm-bindgen, và gom mọi thứ bạn cần vào một thư mục. Bạn có thể dùng trình cài đặt chính thức, hoặc build nó bằng Cargo:

Terminal window
# Trình cài đặt cho Linux / macOS từ trang web wasm-pack
curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# hoặc, trên bất kỳ hệ thống nào đã cài Rust
cargo install wasm-pack --locked

Một module Wasm là một thư viện, không phải chương trình có main. Hãy tạo một cái:

Terminal window
cargo new --lib hello-wasm
cd hello-wasm

Sau đó sửa Cargo.toml. Dòng crate-type bảo Cargo tạo ra một thư viện phù hợp với .wasm (cdylib), còn wasm-bindgen là crate kết nối Rust với JavaScript:

[package]
name = "hello-wasm"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"

Giờ viết hai hàm trong src/lib.rs. Attribute #[wasm_bindgen] đánh dấu mỗi hàm là “gọi được từ JavaScript”:

use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {name}! This string was built in Rust.")
}

Đó là toàn bộ phần Rust bạn cần. Hãy để ý rằng chúng là những hàm bình thường: wasm-bindgen lo việc biến một chuỗi JavaScript thành &str và biến String được trả về trở lại thành chuỗi JavaScript. (Chuỗi chào được giữ bằng tiếng Anh để khớp với kết quả thật trên trình duyệt ở bên dưới; nó nghĩa là “Xin chào, Ferris! Chuỗi này được tạo trong Rust.”)

Đây là một thói quen giúp tiết kiệm rất nhiều thời gian: giữ phần việc thật trong các hàm Rust thuần, và kiểm tra chúng trên máy tính bình thường của bạn bằng cargo test hoặc một hàm main nhanh. Gỡ lỗi trong trình duyệt chậm hơn gỡ lỗi trong terminal. Cùng logic đó, không có attribute, chạy được ở mọi nơi:

fn add(a: i32, b: i32) -> i32 {
a + b
}
fn greet(name: &str) -> String {
format!("Hello, {name}! This string was built in Rust.")
}
fn main() {
println!("{}", greet("Ferris"));
println!("2 + 3 = {}", add(2, 3));
}

Kết quả:

Hello, Ferris! This string was built in Rust.
2 + 3 = 5
▶ Tự chạy thử

Khi logic đã đúng, lớp bọc #[wasm_bindgen] chỉ là một cánh cửa mỏng dẫn tới trình duyệt. (Các test đánh dấu #[test] cũng chạy được trên máy bằng cargo test; xem Kiểm thử.)

Từ thư mục crate, chạy:

Terminal window
wasm-pack build --target web

--target web nghĩa là “tạo ra các file mà trình duyệt có thể tải trực tiếp bằng thẻ <script type="module">”, không cần bundler như webpack. Khi chúng tôi chạy lệnh này, thư mục pkg/ mới chứa:

pkg/
├── hello_wasm_bg.wasm the compiled Rust code
├── hello_wasm.js JavaScript glue that loads it and wraps each function
├── hello_wasm.d.ts TypeScript types for your functions
├── hello_wasm_bg.wasm.d.ts
└── package.json

Trong đó hello_wasm_bg.wasm là code Rust đã biên dịch, hello_wasm.js là glue code JavaScript tải nó và bọc từng hàm, còn hello_wasm.d.ts chứa kiểu TypeScript cho các hàm của bạn. Dấu gạch ngang trong hello-wasm đã thành dấu gạch dưới trong tên file. Mặc định wasm-pack build tạo build release (đã tối ưu); thêm --dev để có build nhanh hơn, chưa tối ưu, trong lúc bạn thử nghiệm.

Tạo index.html cạnh Cargo.toml (để pkg/ nằm ngay bên cạnh):

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hello from Rust</title>
</head>
<body>
<p id="message">Loading…</p>
<script type="module">
import init, { add, greet } from "./pkg/hello_wasm.js";
await init(); // download and start the .wasm file
const text = greet("Ferris");
const sum = add(2, 3);
document.getElementById("message").textContent = `${text} 2 + 3 = ${sum}`;
</script>
</body>
</html>

Export mặc định init() tải file .wasm về và khởi động nó. Sau đó, add và greet là những hàm JavaScript bình thường.

Trình duyệt không tải module từ địa chỉ file://, nên bạn cần một web server nhỏ chạy trên máy. Nếu đã cài Python, đây là cách nhanh nhất:

Terminal window
python3 -m http.server 8080

Mở http://localhost:8080 trong trình duyệt. Khi chúng tôi làm vậy, trang hiển thị:

Hello, Ferris! This string was built in Rust. 2 + 3 = 5

Câu đó được tạo bởi Rust, chạy ngay bên trong trình duyệt. 🦀

Thứ gì đi qua ranh giới rẻ (và thứ gì thì không)

Phần tiêu đề “Thứ gì đi qua ranh giới rẻ (và thứ gì thì không)”

Rust và JavaScript không dùng chung bộ nhớ theo cùng một cách. Module Wasm có khối bộ nhớ riêng, và các giá trị phải di chuyển qua lại giữa hai bên. Có chuyến đi miễn phí, có chuyến tốn một lần sao chép:

Kiểu trong Rust Trong JavaScript Chi phí
i32, u32, f64, bool number, boolean Rẻ: truyền trực tiếp
&str, String string Một lần sao chép, cộng với chuyển đổi UTF-16 sang UTF-8 (hoặc ngược lại)
&[u8], Vec<u8> Uint8Array Một lần sao chép vào (hoặc ra khỏi) bộ nhớ Wasm
&mut [u8] Uint8Array Sao chép vào, rồi sao chép trở lại sau lời gọi

Vì vậy quy tắc vàng là: gọi ít lần, mỗi lần làm thật nhiều việc. Gọi Rust một lần để xử lý cả bức ảnh thì nhanh. Gọi Rust một triệu lần, mỗi lần một pixel, thì phần lớn thời gian bị tiêu vào việc đi qua ranh giới.

Đây là hình dung cụ thể. Hàm này làm sáng cả một buffer giá trị pixel chỉ trong một lời gọi:

use wasm_bindgen::prelude::*;
/// Làm sáng mọi pixel. JS truyền vào một Uint8Array.
#[wasm_bindgen]
pub fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
*p = p.saturating_add(amount);
}
}

Từ JavaScript, sau await init(), bạn gọi brighten(pixels, 20) với một Uint8Array, và mảng được cập nhật tại chỗ. Bản thân vòng lặp là Rust thuần, nên bạn kiểm tra được nó ngay trên máy:

fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
// saturating_add dừng ở 255 thay vì bị tràn số
*p = p.saturating_add(amount);
}
}
fn main() {
let mut pixels = [10, 100, 250];
brighten(&mut pixels, 20);
println!("{pixels:?}");
}

Kết quả:

[30, 120, 255]
▶ Tự chạy thử

Để ý rằng 250 + 20 thành 255, không crash cũng không quay vòng: saturating_add dừng ở giá trị tối đa. Đó chính xác là điều bạn muốn với màu sắc.

Mọi byte của file .wasm đều phải được tải về trước khi trang web dùng được nó. Một vài mẹo:

  • Luôn phát hành bản build release. Với ví dụ nhỏ của chúng ta, wasm-pack build --dev tạo ra file .wasm 58.591 byte; bản build release mặc định là 21.530 byte. (Số liệu từ máy của chúng tôi khi tắt wasm-opt; số của bạn sẽ khác.)
  • Để wasm-pack chạy wasm-opt. Ở chế độ release, wasm-pack mặc định chạy trình tối ưu wasm-opt của Binaryen trên kết quả, và thường làm file nhỏ thêm.
  • Bật nén trên server của bạn. File 21.530 byte của chúng tôi còn 8.464 byte sau khi nén gzip.
  • Thử các thiết lập tối ưu kích thước, rồi đo:
[profile.release]
opt-level = "z" # tối ưu cho kích thước ("s" là phiên bản nhẹ tay hơn)
lto = true
codegen-units = 1

Hãy thành thật với bản thân và đo đạc. Với ví dụ hai hàm của chúng ta, các thiết lập này không giúp gì: file ra 22.817 byte, còn lớn hơn một chút. Chúng thường có lợi với các crate lớn hơn. Những thứ như format! và thông báo panic cũng kéo thêm code vào, nên một crate tránh được chúng có thể nhỏ hơn nhiều.

Vài ví dụ, mỗi cái kèm bài viết của chính công ty đó:

  • Figma biên dịch trình soạn thảo thiết kế viết bằng C++ của họ sang WebAssembly và cho biết nó giảm thời gian tải xuống 3 lần (tiếng Anh). (Không phải Rust, nhưng cho thấy Wasm làm được gì trong một ứng dụng lớn.)
  • Cloudflare Workers hỗ trợ Rust (tiếng Anh) qua crate workers-rs: code Rust của bạn được biên dịch sang Wasm và chạy trên các máy chủ của Cloudflare khắp thế giới.
  • Shopify Functions là các module WebAssembly (tiếng Anh) dùng để tùy biến cửa hàng, và Shopify khuyên dùng Rust như ngôn ngữ cho hiệu năng tốt nhất.
  • Tiện ích trình duyệt của 1Password đã chuyển phần điền trang sang WebAssembly (tiếng Anh) và cho biết nó chạy nhanh hơn ít nhất hai lần.

Quên crate-type = ["cdylib"]. Không có nó, wasm-pack dừng ngay lập tức. May là thông báo lỗi nói chính xác bạn cần thêm gì:

Error: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]
crate-type = ["cdylib", "rlib"]

Lỗi này nói rằng crate-type phải là cdylib để biên dịch sang WebAssembly, và đưa luôn đoạn cần thêm vào Cargo.toml.

Mở index.html bằng cách nhấp đúp. Trang cứ đứng ở “Loading…”, và console của trình duyệt (trong Chrome) báo:

Access to script at 'file:///…/pkg/hello_wasm.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: chrome, chrome-extension, chrome-untrusted, data, http, https, isolated-app.

Thông báo này nói rằng chính sách CORS đã chặn việc tải script từ một địa chỉ file://. Cách sửa: phục vụ thư mục bằng một web server cục bộ, như python3 -m http.server, và mở địa chỉ http://localhost thay vào đó.

Gọi một hàm trước await init(). Module Wasm chưa được tải, nên glue code không có gì để gọi. Khi chúng tôi xóa dòng init(), Chrome báo Cannot read properties of undefined (reading '__wbindgen_free'). Hãy luôn await init() trước.

Bước tiếp theo: ứng dụng web hoàn chỉnh bằng Rust

Phần tiêu đề “Bước tiếp theo: ứng dụng web hoàn chỉnh bằng Rust”

Gọi vài hàm Rust từ JavaScript là một khởi đầu tuyệt vời. Nếu bạn muốn xây cả ứng dụng web bằng Rust, hãy xem các framework này. Cả hai đều biên dịch sang Wasm và cho phép bạn viết component bằng Rust, hơi giống React:

  • Leptos (tiếng Anh): một framework hiện đại, nhanh, có thể render trang ở phía server.
  • Yew (tiếng Anh): một framework lâu đời, dựa trên component.
  • WebAssembly là một định dạng nhị phân nhanh, an toàn, mà trình duyệt (và máy chủ) chạy được; Rust biên dịch sang nó rất tốt vì không có bộ thu gom rác.
  • Thêm target bằng rustup target add wasm32-unknown-unknown, và cài wasm-pack.
  • Đánh dấu hàm bằng #[wasm_bindgen], đặt crate-type = ["cdylib", "rlib"], và build bằng wasm-pack build --target web.
  • Trong JavaScript, await init() trước, rồi mới gọi các hàm của bạn; phục vụ trang qua http://, không phải file://.
  • Số đi qua ranh giới rất rẻ; chuỗi và slice bị sao chép, nên hãy gọi ít lần, mỗi lần làm nhiều việc.
  • Phát hành bản build release có nén, và đo trước khi tin vào các tinh chỉnh kích thước.

Kiểm tra nhanh

1. Lệnh nào thêm target Rust cho WebAssembly trên trình duyệt?

2. Attribute #[wasm_bindgen] làm gì?

3. Trang của bạn cứ đứng ở "Loading…" và console báo lỗi CORS cho một địa chỉ file:///. Cách sửa là gì?

4. Thứ nào rẻ nhất khi truyền từ JavaScript sang Rust?

Điểm: 0 / 4