30.12.20219 min

Mikhail MedvedevSenior Software Engineer

Poznaj WebAssembly z pomocą Rust

Stwórz swój pierwszy projekt w WebAssembly i przekonaj się, dlaczego jest taki uniwersalny.

Poznaj WebAssembly z pomocą Rust

„Jeśli chcesz coś zrozumieć, napisz o tym”. Postanowiłem posłuchać słynnej rady i opowiedzieć krótką historię mojej podróży do krainy WebAssembly.



Czym jest WebAssembly?

WebAssembly (znany także jako Wasm) jest otwartym standardem, który zawiera specyfikację kodu bajtowego, jego reprezentację tekstową oraz bezpieczne środowisko hosta, które wykonuje kod. Początkowym celem było uruchomienie kodu C na stronach internetowych, jednak finalnie opracowano szereg kompilatorów i systemów uruchamiania. Tak więc teraz możemy uruchomić WebAssembly bez przeglądarki internetowej lub JavaScriptu.

Nie jest to pierwsza próba stworzenia wieloplatformowego systemu uruchamiania. Czym więc WebAssembly różni się od poprzednich technologii typu „napisz raz, uruchom wszędzie”?

  • Jest przystępny i prosty: bez ciężkich maszyn wirtualnych, bez platform ze skomplikowanymi interfejsami API.
  • Jest to otwarty standard: nic nie jest zastrzeżone, nic nie jest na sprzedaż.
  • Nie ma jednego “głównego” języka, jak to jest w przypadku Javy czy .NET.
  • Brak specjalizacji: ponieważ WebAssembly nie jest platformą, może być zastosowany do wszystkiego.


Teraz gdy na to patrzę, to dziwniej wyglądają cztery „nie ma tego i tamtego” niż, że „ma to i tamto”. Myślę, że to jest ważna rzecz w WebAssembly: nie jest to Java. Jest tak prosty, otwarty i uniwersalny, że może działać wszędzie.

Nie zliczę, ile razy widziałem zdanie, że WebAssembly nie jest zaprojektowany, by zastąpić JavaScript, ale raczej, by go uzupełnić. Cóż, bądźmy szczerzy: pracowałem trochę jako web developer i nie jestem wielkim fanem JavaScriptu. Kiedy patrzę na WebAssembly, tak naprawdę myślę: „czy mogę tym zastąpić JavaScript?”. Wiele osób skupia się na wydajności, i pewnie, jest to ważne, ale co jeśli po prostu nie chcę już pisać w JavaScript? Kiedy skończy się jego nienaturalny monopol?

WebAssembly może być odpowiedzią na to pytanie.


Czym jest Rust?

Chciałem zacząć od stosunkowo niskiego poziomu — bez skomplikowanych frameworków, bez narzędzi. Lubię wiedzieć, jak działają pewne rzeczy, zanim zacznę używać wysokopoziomowego frameworku. Nie byłem jednak na tyle zdeterminowany, żeby pisać WebAssembly ręcznie. Nie chciałem też pisać ani trochę kodu w C.

Na szczęście mamy Rust. Charakteryzują go dwie ważne rzeczy: nie pozwala na złe zarządzanie pamięcią (poprzez zastosowanie nowatorskiego mechanizmu Ownership), oraz nie używa garbage collectorów — a to oznacza, że prawie nie ma runtime’u. Te cechy sprawiają, że jest to idealny język do kompilacji kodu bajtowego i uruchamiania na lekkiej maszynie wirtualnej.

Takiej jak WebAssembly.


Warunki wstępne

Do naszego małego eksperymentu będziemy musieli zainstalować kilka elementów:

  • Zainstaluj Rust
  • Zainstaluj wasm-gc : cargo install wasm-gc
  • Pakiet wasm-pack: cargo install wasm-pack
  • Miniserve, prosty serwer WWW: cargo install miniserve
  • Wasmer, środowisko uruchomieniowe, które pozwoli nam uruchomić WebAssembly poza przeglądarką: https://wasmer.io/


Pierwszy projekt w Rust

Na początek napiszemy trochę kodu w Rust i stworzymy prosty projekt przy pomocy Cargo, niesamowitego narzędzia wiersza poleceń Rusta:

cargo init --lib


Tworzymy dynamiczną bibliotekę, więc będziemy potrzebowali poprawnego crate-type w Cargo.toml  — głównym pliku metadanych projektu:

[package]
name = "wasm-example"
version = "0.1.0"
authors = ["Mikhail Medvedev <mmedvedev@tenable.com>"]
edition = "2018"

[lib]
name = "wasm_example"
crate-type = ["cdylib"]


Teraz dodajmy dwie małe funkcje: jedną, która oblicza liczbę Fibonacciego, i drugą, która po prostu zwraca string:

#[no_mangle]
pub fn fibonacci(n: u32) -> u32 {
    match n {
        0 | 1 => n,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

#[no_mangle]
pub fn will_return_string() -> String {
    String::from("Hello from Rust")
}


Nawet jeśli nie masz doświadczenia z Rustem, to zakładam, że wszystko w kodzie jest dla Ciebie zrozumiałe. Dwie rzeczy, na które warto zwrócić uwagę: pierwsza to tagi #[no_mangle] — potrzebujemy ich, aby funkcje były dostępne w dynamicznie linkowanym module, a drugą rzeczą jest fakt, że pierwsza funkcja posiada zwracany typ String. String jest typem dynamicznym, alokowanym na stercie ciągiem znaków UTF-8 — będzie to wymagało szczególnej uwagi, gdy zajmiemy się bundlowaniem WebAssembly.

Możemy zbudować go do natywnego kodu używając cargo build, ale naszym celem jest WebAssembly, więc musimy zainstalować cel kompilacji:

rustup target add wasm32-unknown-unknown


Teraz możemy zrobić coś takiego:

cargo build --target wasm32-unknown-unknown --release


Po zakończeniu kompilacji, plik WebAssembly można znaleźć w target/wasm32-unknown-unknown/release/wasm_example.wasm. Plik ten możemy załadować na stronę internetową lub uruchomić na dowolnym runtime’ie Wasm.

Ale jeśli spojrzymy na rozmiar, to jest on dość duży — szczególnie dla stron internetowych, a my potrzebujemy trochę mniejszy plik. Nie ma żadnego problemu, użyjmy wasm-gc, aby to zoptymalizować:

wasm-gc ./target/wasm32-unknown-unknown/release/wasm_example.wasm ./wasm_example_rust.wasm


Zoptymalizowany plik ma tylko 17 Kb (spadek z 1,4M).


Biegnąc z Wasmerem

Co możemy teraz zrobić z tym plikiem? Dobrym początkiem byłoby przetestowanie go lokalnie. Możemy użyć Wasmera do wywołania funkcji z pliku wasm:

wasmer wasm_example_rust.wasm -i fibonacci 10
89


Działa! Ale co z funkcją, która zwraca string?

wasmer wasm_example_rust.wasm -i will_return_string
error: failed to run `wasm_example_rust.wasm`
╰─> 1: Function expected 1 arguments, but received 0: “”


Chwila, nasza funkcja nie wymaga żadnych parametrów! Co to oznacza? Zbudujmy trochę napięcia — dojdziemy do tego później.


Biegając w sieci

Z definicji kod WebAssembly może być wykonywany w dowolnej przeglądarce internetowej — co w dzisiejszych czasach oznacza prawie każdą przeglądarkę. Niestety, plik nadal musi zostać załadowany przez JavaScript. Kod jest jednak bardzo prosty.

<!doctype html>
<html lang="en-us">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>WebAssembly Example</title>
  </head>
  <body>
   <script>
     WebAssembly.instantiateStreaming(fetch('wasm_example_rust.wasm'), {})
      .then(wa => {
        console.log(wa.instance.exports.fibonacci(10));
        console.log(wa.instance.exports.will_return_string());
      });
   </script>
  </body>
</html>


Po prostu pobieramy plik, instancjonujemy obiekt, a następnie uruchamiamy obie funkcje. Aby to wykonać, możemy użyć miniserve lub innego serwera WWW.

miniserve . --index index.html


Otwórzmy przeglądarkę, przejdźmy na stronę localhost:8080 i zajrzyjmy do konsoli:

Hmm, znowu to samo — fibonacci działa zgodnie z planem, ale nadal nie możemy zwrócić stringa.


Zajrzyjmy do środka

Plik Wasm jest kodem bajtowym w jego binarnym formacie, ale WebAssembly również dostarcza reprezentację tekstową, zwaną WAT. Możemy go przywrócić z naszego pliku binarnego, używając narzędzia wasm2wat lub jego internetowego demo: https://webassembly.github.io/wabt/demo/wasm2wat/. Istnieje też fajne rozszerzenie VSCode.

Po załadowaniu nasz plik zamienia się w zaskakująco dużą masę kodu, przypominającą nieco Lisp — ale przy odrobinie wysiłku jest to bardziej zrozumiałe. Możemy na przykład znaleźć definicję funkcji fibonacci

(func $fibonacci (export “fibonacci”) (type $t5) (param $p0 i32) (result i32)


Oczywiste jest, że przyjmuje ona parametr integer i również go zwraca. A co z naszą drugą funkcją, która zachowuje się nieco dziwnie?

(func $will_return_string (export “will_return_string”) (type $t4) (param $p0 i32)


Jak być może zauważyłeś, kod został przerobiony w taki sposób, aby akceptował parametr, jednak nic nie zwraca. Dlaczego tak? Kopiąc głębiej, odkryłem, że $p0 jest w rzeczywistości adresem pamięci — wskaźnikiem — pod którym funkcja może umieścić wynik. Najwyraźniej jest to jedyny sposób, w jaki możemy zwrócić dynamiczną encję, taką jak String w WebAssembly.

Spowodowane jest to faktem, że WebAssembly operuje na bardzo ograniczonej liczbie typów prymitywnych (pamiętasz cztery „nie ma tego i tamtego”?) — koncept Stringów po prostu nie istnieje w tym wymiarze.

Co ciekawe, jeśli pominiemy funkcję will_return_string z naszego kodu Rust, skompilujemy do wasm i przekonwertujemy do WAT, wynik stanie się krótki i zrozumiały:

(module
  (type $t0 (func (param i32) (result i32)))
  (func $fibonacci (type $t0) (param $p0 i32) (result i32)
    (local $l1 i32)
    i32.const 1
    local.set $l1
    block $B0
      local.get $p0
      i32.const 2
      i32.lt_u
      br_if $B0
      i32.const 0
      local.set $l1
      loop $L1
        local.get $p0
        i32.const -1
        i32.add
        call $fibonacci
        local.get $l1
        i32.add
        local.set $l1
        local.get $p0
        i32.const -2
        i32.add
        local.tee $p0
        i32.const 1
        i32.gt_u
        br_if $L1
      end
      local.get $l1
      i32.const 1
      i32.add
      local.set $l1
    end
    local.get $l1)
  (table $T0 1 1 funcref)
  (memory $memory 16)
  (global $__data_end i32 (i32.const 1048576))
  (global $__heap_base i32 (i32.const 1048576))
  (export "memory" (memory 0))
  (export "fibonacci" (func $fibonacci))
  (export "__data_end" (global 0))
  (export "__heap_base" (global 1)))


Możemy wywnioskować, że reszta była tylko boilerplate’em do przenoszenia pamięci.


Klej

Aby przezwyciężyć te trudności, musimy zrobić zwrot w tył i zacząć wszystko od nowa. Tym razem użyjemy wrappera, narzędzia, które zajmie się boilerplatem zarówno po stronie Rusta, jak i JavaScript. Nie zmienia się kod Rusta, zmienia się, tylko jego „rusztowanie”.

W pierwszej kolejności musimy dodać zależność do Cargo.toml. Część wasm-opt jest obejściem, które musiałem dodać z powodu błędu w tym wydaniu wasm-bindgen (nie pytaj dlaczego).

[package]
name = "wasm-example-bindgen"
version = "0.1.0"
authors = ["Mikhail Medvedev <mmedvedev@tenable.com>"]
edition = "2018"

[lib]
name = "wasm_example_bindgen"
crate-type = ["cdylib"]

[dependencies]
wasm-bindgen = "0.2.68"

[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-O2", "--enable-mutable-globals"]


Następnie należy lekko zmodyfikować kod:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn fibonacci(n: u32) -> u32 {
    match n {
        0 | 1 => n,
        _ => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

#[wasm_bindgen]
pub fn will_return_string() -> String {
    String::from("Hello from Rust")
}


Tutaj informujemy Rusta, że chcemy użyć wasm-bindgen i zastępujemy tagi #[no_mangle] tagami #[wasm_bindgen].

Ponieważ rezultatem będzie pakiet Javascript, musimy użyć wasm-pack do jego zbudowania:

wasm-pack build --target web


Tym razem pomijamy wasm-gc, wasm-pack wykonuje optymalizację rozmiaru za nas.

W przeciwieństwie do artefaktów tworzonych przez Cargo wynik jest generowany w katalogu pkg. Jest to właściwy pakiet NPM z javascriptowym boilerplate’em. Aby jednak nieco uprościć, stworzymy po prostu kolejny mały plik HTML do jego uruchomienia:

<!doctype html>
<html lang="en-us">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <title>WebAssembly Example</title>
  </head>
  <body>
    <script type="module">
      import init, { fibonacci, will_return_string } from './pkg/wasm_example_bindgen.js';

      async function run() {
        await init();
        
        console.log(fibonacci(10));

        console.log(will_return_string());
      }

      run();
    </script>
  </body>
</html>


Wasm-pack zadbał o wszystko za nas. W zasadzie jedyne, co musimy zrobić, to zaimportować moduł JavaScriptu — załaduje on nasz kod WebAssembly i pokaże obie funkcje w swojej przestrzeni nazw.

Co więc zobaczymy, jeśli wykonamy to w sieci? Przekonajmy się.

Obie funkcje już działają! Nawet ta, która zwraca String.


Na koniec

WebAssembly jest nieco ograniczony, ale to dobrze, ponieważ w tym przypadku jego siła tkwi w prostocie.

Rust nie jest prosty, ale jest potężnym językiem, który pozostaje stosunkowo niskopoziomowy.

Nie można po prostu przejść do WebAssembly z Rusta, ale z pomocą tych narzędzi to zadanie będzie ułatwione:

  • wasm-bindgen dostarcza więcej kleju pomiędzy Rust a Javascript. Działa to w obie strony: możemy używać API JavaScriptu z Rusta i kodu Rusta z JavaScriptu.
  • wasm-pack, pakiet, który pakuje nasz kod w taki sposób, aby mógł być wykonany w przeglądarce internetowej.


A co z Rustem połączonym z WebAssembly, zastępującym Javascript? Cóż, pozostaje to na razie w sferze marzeń. Wierzę, że pewnego dnia odejdziemy od HTML i przeglądarka będzie po prostu wykonywała aplikacje Wasm. Niestety, nie jesteśmy jeszcze na tym etapie.

Mimo to WebAssembly jest potężną technologią i staje się coraz bardziej popularny. Jestem pewien, że WebAssembly ma przed sobą świetlaną przyszłość, gdy tylko trafi do produkcji w dużych korporacjach.

Bądźcie czujni! Może pojawi się więcej artykułów o WebAssembly — Go i Python są następne w kolejce!


Oryginał tekstu w języku angielskim przeczytasz tutaj.

<p>Loading...</p>