Rust + WebAssembly – Jak to działa ?

W poprzednich artykułach skupiłem się na podstawowych aspektach języka RUST.

W tym artykule pokażę jego bardziej praktyczną stronę w zastosowaniach webowych. Do wizualizacji wykorzystam możliwości przeglądarki oraz języka Javascript. Kod napisany w języku RUST zbuduję do formatu WASM zwanego popularnie WebAssembly, a następnie wyświetlę go w htmlu wykorzystując dobrodziejstwa języka JS.

Czym jest WebAssembly?

WebAssembly jest sposobem na uruchomienie aplikacji napisanych w językach takich, jak C/C++/Rust w nowoczesnych przeglądarkach. WebAssembly jest “językiem” niższego poziomu niż sam Javascript. Bardziej precyzyjne byłoby określenie, że WebAssembly to format instrukcji binarnych dla maszyny wirtualnej opartej na stosie, czyli takiej, jak maszyna wirtualna Javascriptu, który się ma charakteryzować zbliżonymi możliwościami, jeśli chodzi o wydajność co do języków, w których zostały napisane. Webassembly z założenia ma być uzupełnieniem Javascriptu. Możemy budować rozszerzenia możliwości JS w języku Rust.

W skrócie – JS może zaimportować moduły WA oraz współdzielić funkcjonalność. Możemy tworzyć obiekty/struktury stworzone po stronie Rust, możemy wywoływać funkcje. Są oczywiście pewne ograniczenia, ale o tym potem.

To tak w skrócie. Jeśli ciekawi Cię bardziej szczegółowy opis, to zachęcam do zapoznania się z artykułem w naszym serwisie.

Najprościej będzie to pokazać na przykładzie projektu:

Moim celem jest stworzenie prostej aplikacji w języku Rust, której celem jest:

  • wygenerowanie planszy 2D o wymiarach 80 na 40
  • napisać algorytm, który wyznacza ścieżkę pomiędzy punktem Startu oraz Końca
  • każde pole może być ścianą (WALL), pustym polem, wodą lub lasem
  • pola różnią się kosztem poruszania przez różne rodzaje terenów:
    • Nie jest możliwe przejście przez ścianę,
    • Możemy poruszać się w 8 kierunkach z danego pola (tak, poruszanie się po skosie jest także dozwolone).
    • Koszt przejścia przez las to 3, a przez wodę to 5.

Algorytm generacji planszy oraz algorytm znajdowania optymalnej ścieżki napiszę w języku Rust. Następnie skompiluję go do WASM. Wizualizację samej planszy oprę o Canvas i wykorzystam możlwości języka JS do renderowania obrazu.

Postaram się przybliżyć proces budowania aplikacji krok po kroku wraz z problemami i decyzjami, jakie podjąłem.

Jakie narzędzia?

Zakładam, że masz już zainstalowany toolchain RUST. Jeśli nie, to odsyłam Cię do strony, gdzie jest to bardzo fajnie opisane.

Jako IDE korzystam z VS Code z dodatkami. Bardzo przyjemnie się pracuje z debuggerem, który w przypadku problemów może się okazać nieocenioną pomocą. Wolę takie podejście, niż spamowanie println! i console.log’ami.

Alternatywą jest Intellij. Niestety nie jest jeszcze dostępny tam debugger, więc w przypadku tego narzędzia można wybrać podejście sterowane testami.

Budowanie projektu do formatu docelowego będzie wykorzystywać tool wasm-pack, który można pobrać tutaj i jest praktycznie gotowy do użycia.

Jak zacząć?

Scaffolding:

By ułatwić sobie start, wykorzystamy gotowy projekt szablonowy dostępny na githubie. By z niego skorzystać, potrzebujemy także zainstalować generator.

cargo install cargo-generate

Następnie uruchamiamy z linii komend polecenie

cargo generate --git https://github.com/rustwasm/wasm-pack-template 

Wpisujemy nazwę biblioteki. Ja nazwałem swoją PathFinder.

Nawet nie będę ukrywał, że pierwsze uruchomienie zrobiłem na bazie ogólnodostępnego tutoriala

Struktura projektu

Jest to zwyczajna struktura, jaką charakteryzują się projekty Rustowe, które można zbudować przez cargo.

W katalogu src mamy plik lib.rs, który jest naszym plikiem głównym w bibliotece. Znajdują się w nim deklaracje funkcji, które będą dostępne po stronie JSa.

Testy wyjątkowo są w katalogu tests. Do definiowania i uruchamiania testów potrzebny jest dodatkowy crate (paczka) wasm_bindgen_test, który udostępnia atrybut #[wasm_bindgen_test].Funkcje opisane tym atrybutem będą funkcjami testowymi.

Plik cargo.toml zawiera informacje potrzebne przy procesie budowania, takie jak biblioteki, jak wasm-bindgen oraz informacje o budowaniu cdylib wersji naszego pakietu.

Udostępnianie na zewnątrz

Do tego celu będzie nam potrzebny atrybut #[wasm_bindgen], którego celem jest właśnie udostępnienie na zewnątrz funkcji, struktur.

Funkcje zadeklarowane w module, które nie będą posiadały tego atrybutu są traktowane jako wewnętrzne, czyli można z nich korzystać wewnątrz projektu.

Daje nam to możliwość decydowania, które byty chcemy uwspólnić.

Jak wejdziemy do pliku lib.rs zobaczymy, że zadeklarowana jest jedna funkcja greet, która zostanie wyeksportowana. Jej celem jest wyświetlenie prostego alertu po stronie przeglądarki.

#[wasm_bindgen]
pub fn greet() {
   alert("Hello, path-finder!");
}

Budowanie i uruchamianie

Kolejnym krokiem jest zbudowanie naszej paczki.

Jeśli jesteśmy zainteresowani odpaleniem testów to możemy to zrobić w trybie headlless wybierając docelowy typ przeglądarki:

wasm-pack test --headless --firefox

Nas bardziej pewnie interesuje zbudowanie paczki do docelowego formatu. Służy do tego polecenie

wasm-pack build

,w wyniku którego wewnątrz katalogu projektu zostanie utworzony nowy katalog pkg.

Co dzieje się pod spodem:

  • Nasz kod kompilowany jest do WebAssembly
  • Wasm-bindgen generuje kod na podstawie binarki WA, który owrapowuje kod z binarki do postaci modułu zrozumiałego przez npm
  • Tworzy plik package.json na podstawie pliku cargo.toml

Polecenie wasm-pack build ma na celu zbudowanie gotowej paczki/modułu pobierając zależności, kompilując je, kompilując nasz pod kątem odpowiedniego targetu.

Co znajdziemy w środku folderu

  • Plik package.json, który zawiera opis biblioteki wraz z potrzebnymi informacjami.
  • Plik path-finder.d.ts zawierający wszystkie potrzebne informacje o typach.
  • Plik path-finder.js opis modułu wraz z bindigami do wasm. Ogólnie rzecz biorąc, jest tu owrapowanie dwustronnej komunikacji z js do webassembly i z webassembly do js.
  • Plik path-finder_bg.wasm, czyli nasza binarka.

Aplikacja www

W aplikacji www potrzebujemy dostęp do zbudowanego modułu, który jest aktualnie w katalogu.

Wcześniej zbudowany moduł, napisany w Rust a skompilowany do WebAssembly jest gotowy do użycia w JavasSripcie. Możemy go spushować do repozytorium npm i stamtąd pobrać lub linkować lub importować go bezpośrednio z katalogu './pkg/path_finder'.

Linkowanie możemy to zrealizować linkując moduł za pomocą polecenia npm link. Możemy osiągnąć ten efekt uruchamiając polecenie npm link w katalogu pkg. Następnie przechodzimy do folderu, gdzie jest nasza aplikacja (powinien znajdować się tam folder node_modules) i odpalamy polecenie npm link path-finder. Od tego momentu mamy dostęp do modułu wewnątrz naszej aplikacji.

Następnie importujemy bibliotekę po stronie js.

import * as wasm from "path-finder";

wasm.greet();

Odwołanie się do modułu path-finder umożliwia komunikacje z naszą biblioteką w formacie WebAssembly.

Gdy uruchomicie stronę, powinniście zobaczyć alert ze stosowną informacją.

Kolejny krok – wyświetlanie mapy

Jako, że mamy działającą już podstawową aplikację, napiszmy własną funkcję w języku Rust, która umożliwi nam generowanie planszy 2D z odpowiednimi polami.

Przesyłanie typów prostych pomiędzy Rustem (WA) a JS jest proste. Jeśli chcemy przesłać coś bardziej skomplikowanego, to należy to przemyśleć.

Dlaczego to jest istotne?

Pomimo, że JS i WA umożliwiają uruchamianie kodu wewnątrz tej samej maszyny wirtualnej, to jednak wymagają nieco innego podejścia do rozwiązywania problemów. Charakteryzują się przy tym także innym modelem pamięci: WA ma bardziej liniowy model, a JS wykorzystuje stos, zarządzany przez mechanizm garbage collectora.

Przy komunikacji JS/WA chcemy uniknąć kopiowania danych, serializacji i deserializacji. Możemy wtedy doprowadzić min: do spadków wydajności.

Idealnie byłoby zlecić WA wykonanie przetwarzania i odebrać wynik.

Duże struktury danych będą przechowywane po stronie WA, natomiast rezultaty przeliczeń (jeśli to możliwe) będą udostępniane JS do wizualizacji.

Struktury w Rust

Typy zdefiniowane po stronie Rusta mogą być udostępnione do wykorzystania po stronie JS, ale nie muszą być w całości eksponowane. My decydujemy, które fragmenty są widoczne poprzez bindingi a co za tym idzie eksportowane.

Plansza składać się będzie z 320 pól różnego typu.

Do reprezentacji typu służyć będzie nam enum:

#[wasm_bindgen]
#[repr(u8)]
pub enum Cell {
   Wall = 0,
   Free = 1,
   Tree = 2,
   Water = 3,
   Start = 4,
   End = 5
}

Oznaczenie za pomocą atrubutu #[wasm_bindgen] oznacza, że typ zostanie udostępniony do JS. Dodatkowo za pomocą atrybutu repr zdefiniowaliśmy mu strukturę reprezentacji, dzięki czemu po stronie JS będziemy odwoływać do się pamięci WA i zczytywać wektor pól składający się 8 bitowych wartości.

Definujemy też planszę jako strukturę:

#[wasm_bindgen]
pub struct Board {
   width: u32,
   height: u32,
   cells: Vec,
}

Po zbudowanu pakietu po stronie JS możmy uzyskać dostęp do obiektów za pomocą prostego importa

import { Board, Cell, Point} from "path-finder";

Tworzenie struktur

By móc stworzyć obiekt po stronie JS dobrą praktyką jest stworzenie konstruktora po stronie Rust.

pub fn new(width: u32, height: u32, start_point: &Point, end_point: &Point) -> Board {...}

Po stronie JS będzie można się do niego odwołać za pomocą Board::new() która zwróci nam gotowy do użycia obiekt.

Używamy w nim takich parametrów jak width, height oraz dwóch punktów określonych jako start i koniec ścieżki, której szukamy.

Dostęp do zmiennych

Jak pewnie widzisz, wszystkie pola struktur są prywatne. Pomijając aspekt enkapsulacji, gdybyśmy chcieli upublicznić ostatnie pole, zakończyłoby się to błędem.

Width i Height są typami prostymi, numerycznymi, które bez problemu są rozpoznawalne po stronie JS.

W przypadku udostępniania wektora musimy postąpić nieco inaczej. Udostępnimy pointer do niego, dzięki któremu będzie można odczytać zawartość pamięci, bajt po bajcie do odpowiedniego obiektu po stronie JS. Ile jest tych bajtów? 320.

By dostać dostęp do wskaźnika deklarujemy metodę, która z wektora zwraca jego pointer za pomocą as_ptr().

#[wasm_bindgen]
impl Board {

    pub fn all_cells(&self) -> *const Cell {
       self.cells.as_ptr()
    }
}

Po stronie JS tworzymy Uint8Array wykorzystując do tego wskaźnik udostępniony po stronie Rusta.

const cellsPtr = board.all_cells();
const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);

Dodatkowo potrzebujemy dostęp do modelu pamięci, który także jest udostępniony w pliku TS. Wystarczy go importować i użyć.

import { memory } from "path_finder/path_finder_bg";

Dzięki powyższym 3 linijkom dostajemy dostęp do wektora Cell, z których każda wartość jest reprezentowana przez u8 czyli bajt bez znaku.

Przekazywanie parametrów

Z przekazywaniem zmiennych należy postępować ostrożnie. Pamiętając o mechanizmach przenoszenia własności oraz pożyczania.

Załóżmy, że stworzyliśmy obiekt

const startPoint = Point.new(5,5);  

po stronie JSa.

Defacto mamy do dyspozycji tylko referencję, wskaźnik do obiektu. Obiekt ma przypisane deklaracje metod, których ciało znajduje się po stronie WA.

Gdy wyświetlimy obiekt w console.logu zobaczymy:

Gdy postanowimy go przekazać do funkcji, np. do konstruktora Board, to w zależności od deklaracji po stronie Rust możemy zaobserwować dwojakie zachowanie.

  • Gdy w RUST użyliśmy pożyczenia, to nie zauważymy żadnej różnicy po stronie JS.
  • Gdy w RUST z jakiegoś powodu użyliśmy przekazania własności, to obiekt Point zostanie zwolniony z pamięci w momencie wyjścia z metody. Efekt po stronie JS mamy dostęp do null.

Gdy spróbujemy go przekazać dalej do kolejnej funkcji siedzącej w WA dostaniemy zapewne informacje o null pointerze.

W stronie JS nie widać różnicy w wywołaniu parametru jako referencji i jako przeniesienia własności.

To jest pierwsza zasadnicza różnica, o której należy pamiętać, projektując kod po stronie RUST. Przeniesienie własności po stronie RUST spowoduje, że po stronie JS będziemy mieli pusty obiekt i będziemy musieli to odpowiednio obsłużyć.

Dostępne biblioteki

Drugim zauważalnym problemem, na jaki natrafiłem jest fakt, że nie wszystkie biblioteki po stronie RUST chcą współpracować z trybem kompilacji do WA.

Potrzebowałem wygenerować pola planszy według następujących zasad:

  • Chcemy, by obrys planszy był ścianami.
  • Chcemy, by start i koniec był podany.
  • Chcemy, by pozostałe pola były przypisane randomowo.

I tu pojawia się wcześniej wspomniany problem. Nie mogę użyć biblioteki rand, gdyż tak nie została zbudowana z odpowiednimi targetami. Mógłbym pokombinować, ale jest dostępna analogiczna biblioteka js_sys, która umożliwia dostęp do funkcji zdefiniowanych po stronie JS. Dzięki czemu js_sys::Math::random() daje nam dostęp do JSowego randoma.

Dzięki temu crate’owi mam dostęp w prosty i przystępny sposób do standardowych elementów języka JS. Mówimy tu o standardowych, wbudowanych elementach języka JavaScript, w tym ich metod i właściwości.

Co nam to daje?

Używając tej biblioteki mamy gwarancję, że użyjemy tylko tych elementów, które wystąpią niezależnie od silnika przeglądarki.

Wyświetlanie mapy

Po stronie JS wystarczy zdefinować wyświetlanie. Skorzystamy z htmlowego canvas do tego celu.

Zrobię to najprościej jak, tylko potrafię:

const canvas = document.getElementById("the_canvas");

Definiujemy potrzebne zmienne:

const startPoint = Point.new(5,5);
const endPoint = Point.new(77, 37);
const board = Board.new(80, 40, startPoint, endPoint);

const ctx = canvas.getContext('2d');

I rysujemy zawartość:

const cellsPtr = board.all_cells();
const cells = new Uint8Array(memory.buffer, cellsPtr, width * height);

ctx.beginPath();

Iterowanie po każdej komórce 1 wymiarowego wektora odbywać się będzie za pomocą 2 pętli dla osi x i y.

for (let row = 0; row < height; row++) {
 for (let col = 0; col < width; col++) {
 ..
 }
}

Przekształcenie 1 wymiaru na 2 jest za pomocą funkcji matematycznej:

return row * width + column;

Sprawdzenie typu pola i dobór odpowiedniego wypełnienia- pokaże tylko dla ściany:

if (cell  === Cell.Wall) {
 fillStyle = WALL_COLOR
}
...
ctx.fillStyle = fillStyle

A rysowanie pola to po prostu narysowanie wypełnionego prostokąta:

ctx.fillRect(
        col * (CELL_SIZE + 1) + 1,
        row * (CELL_SIZE + 1) + 1,
        CELL_SIZE,
        CELL_SIZE
      );

ctx.stroke();

Efekt jest następujący:

  • Czarne - ściany
  • Białe - puste pola
  • Niebieskie - rzeki
  • Zielone - lasy
  • Czerwony - początek drogi
  • Jasny niebieski - koniec drogi

Podsumowanie

W części pierwszej pokazałem, jak zbudować paczkę, którą potem można używać jako modułu w aplikacji JS.

Dzięki temu można budować aplikacje po stronie przeglądarki, korzystając z dobrodziejstw obu światów: świata JS i RUST.

Trzeba zdawać sobie sprawę z ograniczeń i różnic wynikających z obu światów. Niewidoczny po stronie JS model zarządzania zmiennymi w RUST (ownership) może przysporzyć nie lada kłopotów, gdy okaże się, że zmienna została z jakiegoś powodu wyzerowana i nie wskazuje na nic. Warto też pamiętać o tym, kiedy warto użyć przeniesienia własności, a kiedy pożyczenia.

W przypadku bardziej skomplikowanych struktur danych warto rozważyć przetwarzanie po stronie WA i wysyłanie tylko efektów do JS w celu np wizualizacji.

Kod źródłowy na odpowiednim branchu: https://github.com/bartlomiejmichalski/PathFinder/tree/P1-Board-visualization

W dzień Senior Big Data Architect | Lead Developer | Software Developer w firmie Future Processing, w nocy śpi. Ponad 10 lat doświadczenia w zakresie wytwarzania oprogramowania w różnych technologiach oraz domenach, również w takich, w których nikt nie chciał pracować. Jak trzeba usunąć problem w dowolnej dziedzinie to wiesz do kogo dzwonić :) Zafascynowany rozwojem technologii związanej z przetwarzaniem danych a w szczególności tworzeniem rozwiązań z rodziny Big Data. Prelegent oraz organizator licznych wydarzeń, których głównym celem jest dzielenie się wiedzą oraz krzewienie potrzeby stosowania dobrych praktyk, w celu maksymalizacji jakości wytwarzanego produktu. Współorganizator Wakacyjnych Praktyk w Future Processing oraz prowadzący przedmiot na Politechnice Śląskiej „Tworzenie Oprogramowania w Zmiennym Środowisku Biznesowym”.
PODZIEL SIĘ