Salta ai contenuti

Rust e WebAssembly

WebAssembly (spesso abbreviato in Wasm) ti permette di eseguire codice scritto in linguaggi come Rust dentro un browser web, accanto a JavaScript. Pensalo come un secondo motore nel browser: JavaScript continua a guidare la pagina, ma quando c’è un lavoro pesante (filtri per immagini, fisica, parsing di file grandi) puoi passarlo a codice Rust compilato e veloce.

In questa pagina scriverai una piccola libreria Rust, la compilerai in Wasm e la chiamerai da una pagina web. Niente di tutto questo gira nel Rust Playground (servono un browser e strumenti in più), quindi la maggior parte degli esempi sono semplici blocchi di codice per il tuo computer. Le parti in puro Rust, però, puoi provarle subito.

WebAssembly è un formato binario compatto per i programmi. Tutti i browser principali possono eseguirlo, a una velocità vicina a quella nativa, dentro la stessa sandbox di sicurezza di JavaScript. Non è solo per i browser: anche server e piattaforme edge eseguono Wasm.

Rust è uno dei linguaggi più usati per Wasm, per alcuni motivi:

  • Niente garbage collector né grossi runtime. Rust non ha bisogno di portarsi dietro macchinari extra insieme al tuo codice, quindi i file .wasm possono essere piccoli.
  • Ottimi strumenti. wasm-bindgen scrive per te il codice di collegamento (glue code) tra Rust e JavaScript, e wasm-pack costruisce tutto con un solo comando.
  • Sicurezza e velocità. Ottieni la stessa sicurezza della memoria e le stesse prestazioni che avresti su un desktop.

Per prima cosa, di’ a Rust che può compilare per il target Wasm. Un target è «il tipo di macchina su cui girerà il codice»; wasm32-unknown-unknown significa «Wasm a 32 bit, nessun sistema operativo in particolare»:

Terminal window
rustup target add wasm32-unknown-unknown

Poi installa wasm-pack. Compila il tuo crate, esegue wasm-bindgen e mette tutto ciò che ti serve in un’unica cartella. Usa l’installer ufficiale, oppure compilalo con Cargo:

Terminal window
# Installer per Linux / macOS dal sito di wasm-pack
curl https://wasm-bindgen.github.io/wasm-pack/installer/init.sh -sSf | sh
# oppure, su qualsiasi sistema con Rust installato
cargo install wasm-pack --locked

Un modulo Wasm è una libreria, non un programma con main. Creane una:

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

Poi modifica Cargo.toml. La riga crate-type dice a Cargo di produrre una libreria adatta a .wasm (cdylib), e wasm-bindgen è il crate che collega Rust e JavaScript:

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

Ora scrivi due funzioni in src/lib.rs. L’attributo #[wasm_bindgen] marca ciascuna come «chiamabile da 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.")
}

Non ti serve altro codice Rust. Nota che sono funzioni normalissime: wasm-bindgen si occupa di trasformare una stringa JavaScript in una &str e la String restituita di nuovo in una stringa JavaScript. (Il saluto resta in inglese perché più avanti vedrai l’output reale che ha prodotto nel browser.)

Ecco un’abitudine che fa risparmiare un sacco di tempo: tieni il lavoro vero in normali funzioni Rust e testale sul tuo computer con cargo test o con un main veloce. Fare debug in un browser è più lento che farlo in un terminale. La stessa logica, senza l’attributo, gira ovunque:

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));
}

Output:

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

Una volta che la logica è giusta, il wrapper #[wasm_bindgen] è solo una porta sottile verso il browser. (Anche i test marcati #[test] girano in modo nativo con cargo test: vedi Test.)

Dalla cartella del crate, esegui:

Terminal window
wasm-pack build --target web

--target web significa «crea file che un browser possa caricare direttamente con un tag <script type="module">», senza bisogno di un bundler come webpack. Quando l’abbiamo eseguito, la nuova cartella pkg/ conteneva:

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

In ordine: il codice Rust compilato, il codice JavaScript di collegamento che lo carica e avvolge ogni funzione, i tipi TypeScript delle tue funzioni. Il trattino di hello-wasm è diventato un trattino basso nei nomi dei file. Di default wasm-pack build fa una build release (ottimizzata); aggiungi --dev per una build più veloce e non ottimizzata mentre sperimenti.

Crea index.html accanto a Cargo.toml (così pkg/ si trova lì vicino):

<!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(); // scarica e avvia il file .wasm
const text = greet("Ferris");
const sum = add(2, 3);
document.getElementById("message").textContent = `${text} 2 + 3 = ${sum}`;
</script>
</body>
</html>

L’export predefinito, init(), scarica il file .wasm e lo avvia. Dopo di che, add e greet sono normali funzioni JavaScript.

I browser non caricano moduli da un indirizzo file://, quindi ti serve un piccolo server web locale. Se hai Python installato, questo è il modo più rapido:

Terminal window
python3 -m http.server 8080

Apri http://localhost:8080 nel browser. Quando l’abbiamo fatto noi, la pagina mostrava:

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

Quella frase («Ciao, Ferris! Questa stringa è stata costruita in Rust») l’ha costruita Rust, girando dentro il browser. 🦀

Cosa attraversa il confine a basso costo (e cosa no)

Sezione intitolata “Cosa attraversa il confine a basso costo (e cosa no)”

Rust e JavaScript non condividono la memoria allo stesso modo. Il modulo Wasm ha un suo blocco di memoria, e i valori devono viaggiare tra i due lati. Alcuni viaggi sono gratis; altri costano una copia:

Tipo in Rust In JavaScript Costo
i32, u32, f64, bool number, boolean Basso: passati direttamente
&str, String string Una copia, più la conversione da UTF-16 a UTF-8 (o viceversa)
&[u8], Vec<u8> Uint8Array Una copia dentro (o fuori da) la memoria Wasm
&mut [u8] Uint8Array Copiato dentro, poi ricopiato fuori dopo la chiamata

Quindi la regola d’oro è: fai poche chiamate che fanno ognuna tanto lavoro. Chiamare Rust una volta per elaborare un’intera immagine è veloce. Chiamarlo un milione di volte, una per pixel, fa passare la maggior parte del tempo ad attraversare il confine.

Ecco come appare. Questa funzione schiarisce un intero buffer di valori dei pixel con una sola chiamata:

use wasm_bindgen::prelude::*;
/// Rende ogni pixel più chiaro. JS passa un Uint8Array.
#[wasm_bindgen]
pub fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
*p = p.saturating_add(amount);
}
}

Da JavaScript, dopo await init(), chiameresti brighten(pixels, 20) con un Uint8Array, e l’array verrebbe aggiornato sul posto. Il ciclo in sé è puro Rust, quindi puoi testarlo in modo nativo:

fn brighten(pixels: &mut [u8], amount: u8) {
for p in pixels.iter_mut() {
// saturating_add si ferma a 255 invece di andare in overflow
*p = p.saturating_add(amount);
}
}
fn main() {
let mut pixels = [10, 100, 250];
brighten(&mut pixels, 20);
println!("{pixels:?}");
}

Output:

[30, 120, 255]
▶ Provalo tu

Nota che 250 + 20 è diventato 255, non un crash né un giro dei valori: saturating_add si ferma al massimo. È esattamente quello che vuoi per i colori.

Ogni byte del tuo file .wasm va scaricato prima che la pagina possa usarlo. Qualche consiglio:

  • Distribuisci sempre la build release. Per il nostro piccolo esempio, wasm-pack build --dev ha prodotto un file .wasm di 58.591 byte; la build release predefinita era di 21.530 byte. (Sono numeri della nostra macchina con wasm-opt disattivato: i tuoi saranno diversi.)
  • Lascia che wasm-pack esegua wasm-opt. In modalità release, wasm-pack esegue di default l’ottimizzatore wasm-opt di Binaryen sull’output, e di solito lo riduce ancora.
  • Attiva la compressione sul tuo server. Il nostro file da 21.530 byte pesava 8.464 byte dopo gzip.
  • Prova le impostazioni pensate per la dimensione, poi misura:
[profile.release]
opt-level = "z" # ottimizza per la dimensione ("s" è una versione più blanda)
lto = true
codegen-units = 1

Sii onesto con te stesso e misura. Per il nostro esempio con due funzioni, queste impostazioni non hanno aiutato: il file è venuto di 22.817 byte, un po’ più grande. Di solito ripagano nei crate più grandi. Anche cose come format! e i messaggi di panic si portano dietro codice, quindi un crate che li evita può essere molto più piccolo.

Alcuni esempi, ciascuno con il resoconto scritto dall’azienda stessa (in inglese):

Dimenticare crate-type = ["cdylib"]. Senza, wasm-pack si ferma subito. Per fortuna l’errore ti dice esattamente cosa aggiungere:

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

Il messaggio dice che il tipo di crate deve essere cdylib per compilare in WebAssembly, e ti mostra le righe da aggiungere a Cargo.toml.

Aprire index.html con un doppio clic. La pagina resta su «Loading…», e la console del browser (in Chrome) dice:

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.

Cioè: la policy CORS ha bloccato lo script, perché le richieste cross-origin funzionano solo con protocolli come http e https. La soluzione: servi la cartella con un server web locale, come python3 -m http.server, e apri invece l’indirizzo http://localhost.

Chiamare una funzione prima di await init(). Il modulo Wasm non è ancora caricato, quindi il codice di collegamento non ha niente da chiamare. Quando abbiamo tolto la riga init(), Chrome ha mostrato Cannot read properties of undefined (reading '__wbindgen_free'). Fai sempre prima await init().

Chiamare qualche funzione Rust da JavaScript è un ottimo inizio. Se vuoi costruire l’intera app web in Rust, dai un’occhiata a questi framework. Entrambi compilano in Wasm e ti permettono di scrivere componenti in Rust, un po’ come con React:

  • Leptos (in inglese): un framework moderno e veloce che può anche generare le pagine sul server.
  • Yew (in inglese): un framework maturo basato su componenti.
  • WebAssembly è un formato binario veloce e sicuro che i browser (e i server) possono eseguire; Rust ci si compila bene perché non ha un garbage collector.
  • Aggiungi il target con rustup target add wasm32-unknown-unknown e installa wasm-pack.
  • Marca le funzioni con #[wasm_bindgen], imposta crate-type = ["cdylib", "rlib"] e compila con wasm-pack build --target web.
  • In JavaScript, prima await init(), poi chiama le tue funzioni; servi la pagina via http://, non file://.
  • I numeri attraversano il confine a basso costo; stringhe e slice vengono copiate, quindi fai poche chiamate corpose.
  • Distribuisci build release con compressione, e misura prima di fidarti dei ritocchi sulla dimensione.

Verifica veloce

1. Quale comando aggiunge il target Rust per WebAssembly nel browser?

2. Cosa fa l'attributo #[wasm_bindgen]?

3. La tua pagina resta su «Loading…» e la console mostra un errore CORS per un indirizzo file:///. Come lo risolvi?

4. Quale di questi è il più economico da passare da JavaScript a Rust?

Punteggio: 0 / 4