Sytuacja kobiet w IT w 2024 roku
27.12.20198 min
Krzysztof Zawisła

Krzysztof Zawisła

Wprowadzenie do WebAssembly w Go

Sprawdź, jak stworzyć prostą aplikację WebAssembly w Go.

Wprowadzenie do WebAssembly w Go

Pewnie słyszałeś/aś kiedyś o WebAssembly i zadałeś/aś sobie pytanie: jak się za to zabrać? Nawet jeśli nie, to teraz możesz dowiedzieć się, jak na podstawie Golang'a kompilować ten język do plików .wasm (binarny format WebAssembly) oraz jak pisać kod, tak żeby działał on w przeglądarce.

"Must have" do pracy oraz konfiguracja środowiska

Aby rozpocząć naszą pracę z WebAssembly w Go, będziemy potrzebować kilku rzeczy:

  • Znajomości Go w stopniu pozwalającym na zrozumienie kodu bez objaśniania znaczenia słów kluczowych oraz podstaw języka.
  • Go w wersji 1.11.x lub wyższej. Na tę chwilę najlepiej korzystać z wersji 1.12.14. Działa z nią najwięcej bibliotek.
  • Pliku wasm_exec.js który przychodzi wraz z Golang'iem. Znajdziemy go w: "Go/misc/wasm/wasm_exec.js".
  • Przeglądarki, która obsługuje WebAssembly
    • Jeśli korzystamy z przeglądarki bazującej na chromium, najlepiej jest przełączyć flagę #enable-webassembly-baseline na true pod adresem chrome://flags, żeby trochę przyśpieszyć działanie naszego kodu.
  • IDE do pracy

Hello, World! Here we go again

Zacznijmy od czegoś prostego:

package main

import "fmt"

func main() {
  fmt.Println(", WebAssembly!")
}


Powyżej przedstawiłem standardowy program wypisujący tekst w stylu "Hello, World!" do konsoli. Doskonale wiemy, jak ten program zachowa się pod: Windowsem, Linuxem, MacOS-em. Ale co się stanie, jeśli będzie uruchamiany w obrębie przeglądarki? W tym celu ustawimy pod zmienną środowiskową o nazwie GOOS wartość js, co sprawi, że nasz program nie będzie wykorzystywał syscall'i Windowsa czy innego popularnego systemu operacyjnego. Zamiast tego wykorzysta callbacki silnika JavaScript, takiego jak V8, SpiderMonkey itd. Poza tym pod zmienną środowiskową określającą architekturę przypiszemy wartość wasm.

set GOOS=js
set GOARCH=wasm
go build -o main.wasm


Aby sprawdzić, czy nasz kod działa, przejdziemy do następnego kroku, jakim jest ładowanie kodu WebAssembly na stronie.

Ładowanie kodu WebAssembly na stronie

Aby załadować nasz program w WebAssembly na stronę, będziemy potrzebować wcześniej wspomnianego pliku pomocniczego w JavaScript.

<script src="wasm_exec.js"></script>


Po jego dołączeniu, możemy utworzyć instancję naszego pliku WASM oraz otworzyć strumień między nim, a silnikiem JavaScriptu, przy czym musimy pamiętać, by resetować naszą instację, gdy nasz program wychodzi, aby uniknąć wycieku do pamięci. Na temat tego, jak napisać program, który nigdy nie wychodzi, napomnę w dalszej części tego artykułu. Zacznijmy od obsługi przeglądarek, które nie zaimplementowały funkcji WebAssembly.instantiateStreaming. W tym celu sprawdzimy, czy funkcja jest dostępna w przeglądarce. Jeśli nie, to obejdziemy problem, tworząc własną implementację funkcji.

if(!WebAssembly.instantiateStreaming) {
  WebAssembly.instantiateStreaming = async (resp, importObject) => {
    const source = await (await resp).arrayBuffer();
    return await WebAssembly.instantiate(source, importObject);
  };
}


Następnie utworzymy obiekt go, który będzie runtimem dla naszego programu. Utworzymy go na podstawie klasy Go, która pochodzi z wasm_exec.js.

const go = new Go();


Następnie utworzymy dwie zmienne: jedną, która będzie przechowywać naszą instancję z programem oraz zmienną przechowującą ArrayBuffer, z naszym kodem binarnym. W przypadku gdy korzystamy tylko z funkcji WebAssembly.instantiateStreaming, zmienna przechowująca ArrayBuffer będzie zbędna. Sytuacją, w której nigdy nie będziemy wywoływać WebAssembly.instantiate, jest moment, gdy tworzymy program, który nigdy nie wychodzi, ale do tego przejdziemy w dalszej części artykułu.

let mod; // ArrayBuffer z kodem binarnym
let inst; // Instancja programu


Po utworzeniu zmiennych fetchujemy nasz plik .wasm i podamy go funkcji WebAssembly.instantiateStreaming. W tym miejscu wykorzystujemy tę funkcję tylko z pobudek optymalizacyjnych. Równie dobrze moglibyśmy wykorzystać WebAssembly.instantiate. Ten kod jest dość standardowym borderplatem. Można znaleźć go z małymi różnicami w "Go/misc/wasm/" w zależności od wersji Golanga lub na oficjalnej wiki. Aktualny zapis pozwala nam na wywołanie całego naszego programu pod funkcją run. Aktualnie program wykonuje się przy załadowaniu.

WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then(async (result) => {
  mod = result.module;
  inst = result.instance;
  await run();
}).catch((err) => {
  console.error(err);
});

async function run() {
  await go.run(inst);
  // Przypisujemy nową instancję za każdym razem gdy wykonujemy nasz program, żeby uniknąć "out of memory".
  inst = await WebAssembly.instantiate(mod, go.importObject);
}


Dodatkowo trzeba zwrócić uwagę, że nasz plik .wasm musi być serwowany po HTTP. Nie ma możliwości ładowania go z dysku.

Stworzenie serwera HTTP pozostawiam Ci we własnym zakresie albo polecam skopiowanie gotowego rozwiązania stąd.

Jeśli postępowaliśmy zgodnie krok po kroku z instrukcją, to w naszej konsoli powinien ukazać się piękny napis "Witaj, WebAssembly!".

Syscall/js pokaż mi swoje towary

Syscall/js jest pakietem domyślnie wbudowanym w Golanga, pozwalającym na komunikowanie się z silnikiem JavaScriptu. Udostępnia on nam kilka podstawowych funkcjonalności do zintegrowania pracy z JavaScriptem w Golangu.

Żadną tajemnicą nie jest to, że JavaScript posiada globalny obiekt, w którym trzymane jest prawie wszystko. Ukryty jest on pod nazwą globalThis lub global i zazwyczaj wskazuje on na window. Na potwierdzenie tych słów napiszemy krótki skrypt w JavaScript'cie.

Google Chrome:

console.log(globalThis === window); // true
console.log(globalThis instanceof Window); // true


Na szczęście jedną z udostępnionych funkcji przez syscall/js jest funkcja dająca nam dostęp do globalnego obiektu JavaScriptu z poziomu Golanga.

js.Global() // Zwraca js.Value, które jest naszym obiektem globalThis/global/window.


Mając dostęp do globalnego obiektu JavaScriptu, mamy już z górki. Możemy odwoływać się do zagnieżdżonych wartości poprzez funkcję .Get(value string) na js.Value, czyli również na wyniku funkcji js.Global().

Dla przykładu, jeśli chcemy wykorzystać właściwość window.innerWidth lub window.innerHeight w Golangu, to pobierzemy ją w ten sposób:

innerWidth := js.Global().Get("innerWidth") // Typ js.Value
innerHeight := js.Global().Get("innerHeight") // Typ js.Value


ale również możemy ją od razu przekonwertować na golangowy typ float64 lub int32, aby móc na niej operować właśnie w tym języku.

innerWidthAsFloat := js.Global().Get("innerWidth").Float() // Typ float64
innerHeightAsFloat := js.Global().Get("innerWidth").Float() // Typ float64


Dla przykładu nie będziemy mogli wykonać Golangowej funkcji math.Max z wartościami typu js.Value jako argumenty, ponieważ ta funkcja nie przyjmuje takich typów.

maxOfFloats := math.Max(innerWidthAsFloat, innerHeightAsFloat) // Jest ok
maxOfJsValues := math.Max(innerWidth, innerHeight) // Program się nie skompiluje


W drugą stronę to tak nie działa, co może być dla nas ułatwieniem, bo jak podamy funkcji Math.max z JavaScriptu typy golangowe, to wasm_exec.js pomoże nam je przekonwertować w locie. Np.:

mathMax := js.Global().Get("Math").Get("max")
maxOfFloatsButWithJsMathMax := mathMax.Invoke(innerWidthAsFloat, innerHeightAsFloat) // Jest ok
maxOfJsValuesButWithJsMathMax := mathMax.Invoke(innerWidth, innerHeight) // Jest ok


Wraz z wyżej prezentowanym przykładem, poznajemy kolejną funkcje, którą udostępnia nam syscall/js. Na zaciągniętej funkcji z JavaScriptu możemy wywołać .Invoke(args ...interface{}) i przekazać tylko argumenty. Alternatywną metodą jest wywołanie na obiekcie rodzicu .Call(functionName string, args ...interface{}). Np.:

isNaNResult := js.Global().Call("isNaN", 0)


Jeśli nie jesteśmy pewni, jakiego typu jest nasza wartość, możemy sprawdzić to dzięki instrukcjom warunkowym. Np.: Mamy teoretyczną sytuację, gdzie konwertujemy daną wartość na boolean, ale ta sama wartość w JavaScript jest typu string. Konwersja czegoś, co nie jest booleanem w JavaScript na boolean w Go, spowoduje panic.

const exampleNotBoolean = "example text";


Zły kod:

exampleNotBooleanInGo := js.Global().Get("exampleNotBoolean").Bool() // Panic!


Prawidłowy kod:

exampleNotBooleanInGo := js.Global().Get("exampleNotBoolean")
if exampleNotBooleanInGo.Type() == js.TypeBoolean {
  exampleNotBooleanInGo = exampleNotBooleanInGo.Bool() // Jest ok
  // Kod
} else if exampleNotBooleanInGo.Type() == js.TypeString {
  // Kod obsługujący sytuację gdy exampleNotBooleanInGo jest stringiem
} else {
  // Kod
}


Cała lista typów jest dostępna w oficjalnej dokumentacji.

Jeśli chcemy dokonać ręcznej konwersji typu golangowego na jego odpowiednik w JavaScript, możemy przekazać tę wartość do funkcji js.ValueOf(value interface{}), która zwróci nam js.Value. Pozwoli nam to na wykonywanie funkcji z JavaScript na tej wartości. Np.:

object := make(map[string]interface{})
object["exampleString"] = "?";
jsObject := js.ValueOf(object)
jsObject.Set("exampleNumber", 12)
js.Global().Set("$", jsObject)
js.Global().Get("Object").Call("freeze", jsObject)
console.log($.exampleString); // ?
console.log($.exampleNumber); // 12


Tabelkę objaśniającą, jakie typy golangowe są konwertowane na konkretne typy JavaScript, znajdziesz tutaj.

Jeśli chcemy wykorzystać null, nie musimy konwertować nil tą funkcją. Można zamiennie wykorzystać:

js.Null()


Jeśli chodzi o undefined, dostajemy również do dyspozycji funkcję js.Undefined().

A jak to wygląda z funkcjami? W Go mamy narzucony wygląd funkcji, aby mogła ona być przekonwertowana na funkcję JavaScript. Przykładowa funkcja musi wyglądać tak:

func functionName(this js.Value, args []js.Value) interface{} {
  // Kod
  return js.Null() // Mogą być zwracane js.Value i typy, które można przekonwertować przez js.ValueOf
}


Konwersja takiej funkcji wygląda dość standardowo. Jedyne, o czym musimy pamiętać, to aby zwolnić zaalokowaną pod nią pamięć na wyjściu z programu.

ourFunction := js.ValueOf(functionName)
defer ourFunction.Release()


W momencie, gdy mamy naszą funkcję jako typ js.Func, możemy ją ustawić pod globalny obiekt, aby była dostępna z poziomu JavaScriptu w globalnym scoopie.

js.Global().Set("ourFunction", ourFunction)


Przykład wykorzystania takiej przekonwertowanej funkcji będzie w dalszej części artykułu.

W przypadku tworzenia obiektów mamy dostęp do funkcji .New(args ...interface{}) na typie js.Value. Oraz funkcji na porównywanie czy wartość x jest instancją klasy y po przez .InstaceOf(y js.Value).

Dodatkowo otrzymujemy kilka innych przydatnych funkcji, których w tym wprowadzeniu nie omówię. Potestujcie sami. Resztę funkcji znajdziecie pod tym linkiem.

Wychodzenie z programu? A po co to komu? A do czego?

Tak jak wspominałem wcześniej, jest możliwość nie opuszczania naszego programu poprzez tworzenie kanału, który nigdy nie wychodzi. Udostępnia nam to możliwość wywoływania funkcji zadeklarowanych i przekonwertowanych w Golangu z poziomu JavaScriptu w dowolnym momencie. W tym przykładzie użyjemy zewnętrznej biblioteki, aby mieć dostęp do funkcji haszującej SHA3-512 oraz zaimportujemy encoding/hex, żeby przekonwertować nasz hasz na stringa z haszem zapisanym heksadecymalnie.

package main

import (
  "encoding/hex"
  "syscall/js"

  "golang.org/x/crypto/sha3"
)

func main() {
  done := make(chan struct{}, 0)
  defer close(done)
  sha3_512Func := js.FuncOf(sha3_512)
  defer sha3_512Func.Release()
  js.Global().Set("sha3_512", sha3_512Func)
  <-done
}

func sha3_512(this js.Value, args []js.Value) interface{} {
  if len(args) > 0 {
    value := args[0]
    if value.Type() == js.TypeString {
      crypto := sha3.New512()
      crypto.Write([]byte(value.String()))
      value := hex.EncodeToString(crypto.Sum(nil))
      return js.ValueOf(value)
    }
    panic("Wrongly passed argument. Value have the wrong type: " + value.Type().String() + ". Expected: " + js.TypeString.String() + ".")
  }
  return js.Null()
}


Taka konstrukcja programu jednak rodzi zbędny kod w obsłudze WebAssembly z poziomu JavaScriptu. W tym celu wyeliminujemy zbędną zmienną mod, ponieważ nigdy nie będziemy resetować instancji, a co za tym idzie, nigdy jawnie nie skorzystamy z funkcji WebAssembly.instantiate.

let inst; // Instancja programu


Tak samo możemy usunąć słówka kluczowe async/await oraz kod odpowiedzialny za reset instancji.

WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
  mod = result.module;
  inst = result.instance;
  go.run(inst);
  /*
    W tym miejscu mamy dostęp do funkcji utworzonych w Golangu.
    np.:
  */
  console.log(sha3_512("https://bulldogjob.pl/")); // e85c2be2358ed74f5c06e421de404d777506863ecbde41d16794aab89443e302e79cbf46b0dbb6fdb76b2785af3018b6d7b22db6f3013412b834b0d1989b6ad3
}).catch((err) => {
  console.error(err);
});

Ta-da! Prawda, że proste? Mając tak solidne podstawy, kompilowanie następnych programów na WebAssembly nie będzie dla Ciebie żadną zagadką.

<p>Loading...</p>