1. WebAssembly 的应用场景

下面通过示例说明 WebAssembly 的应用场景:

  1. Google Earth(3D 地图,https://earth.google.com/web/)使用 WebAssembly 将高性能的任务带入浏览器环境,并且可以在各种主流浏览器中运行,而且流畅
  2. Istio(开源的服务网格,https://istio.io/latest/zh/docs/overview/what-is-istio/)提供使用 WebAssembly 扩展代理功能的能力。使用 WebAssembly 的关键优势之一是可以在运行时动态加载扩展

2. WebAssembly 是什么?

下面是官网(https://webassembly.org/)的定义:

WebAssembly(缩写为 Wasm)是一种用于基于栈的虚拟机的二进制指令格式(bytecode)。Wasm 被设计为编程语言的可移植编译目标,支持在 Web 上部署客户端和服务端应用程序。

WebAssembly 旨在维护 Web 的无版本、经过功能测试和向后兼容的本性(Web Embedding)。WebAssembly 模块能够调用 JavaScript 上下文,并且通过能够从 JavaScript 访问的相同 Web API 访问浏览器功能。WebAssembly 还支持非 Web 嵌入(Non-Web Embedding)。

wasm-1.png

WebAssembly 分为两个版本:

wasm-2.webp

比较流行的 WASI Runtime 包括 Wasmtime(https://docs.wasmtime.dev/)、Wasmer、Wazero、WasmEdge 等。后面将使用 Wasmtime。


3. TinyGo

Go 标准库 syscall/js 只支持 WebAssembly 1.0。TinyGo(https://tinygo.org/)既支持编译在浏览器中使用的程序(WASM),也支持编译在服务端及其它边缘设备上使用的程序(WASI)。

3.1. 简短介绍 TinyGo

TinyGo 是 Go 语言的新编译器。TinyGo 聚焦于编译 Go 编写的代码,但适用于较小类型的系统:

比如,下面这段代码在两个编译器中做完全相同的事情:

package main

import "fmt"

func main() {
    fmt.Println("Hello world!")
}

可以使用相似的命令编译这段代码:

go build -o hello ./hello.go

tinygo build -o hello ./hello.go

编程语言是相同的。标准库(包括 fmt 包)是相同的。唯一的不同是使用的编译器及相关的运行时。

显著不同的是输出二进制大小。以下是每个编译器生成的可执行文件:

1937340 may 12 12:42 helloworld-with-fmt.go1.16.3
 251376 may 12 12:42 helloworld-with-fmt.tinygo0.18

在每个可执行文件上执行 strip 命令移除符号和调试信息后:

837624 may 12 20:03 helloworld-with-fmt.go1.16.3-stripped
  10392 may 12 20:05 helloworld-with-fmt.tinygo0.18-stripped

在这种情况下,Go 编译的二进制文件大小为 837k(strip 前为 1.9MB),而 TinyGo 生成的二进制文件大小仅为 10k(strip 前为 251k)!

这是原始二进制文件大小的 1%,这使得这样的二进制文件可以在以前因二进制文件大小而不支持的更小的系统上使用。

通过使用 TinyGo,可以在各种裸机硬件平台上编译和运行二进制文件。例如,可以直接在 BBC micro:bit 上运行该程序:

tinygo flash -target=microbit ./hello.go

3.2. 使用 WASM

如何在浏览器中从 JavaScript 调用 WebAssembly。

可以从 Go 中调用 JavaScript 函数,以及从 WebAssembly 中调用 Go 函数。

package main

// This calls a JS function from Go.
func main() {
    println("adding two numbers:", add(2, 3)) // expecting 5
}

// This function is imported from JavaScript, as it doesn't define a body.
// You should define a function named 'add' in the WebAssembly 'env'
// module from JavaScript.
//
//export add
func add(x, y int) int

// This function is exported to JavaScript, so can be called using
// exports.multiply() in JavaScript.
//
//export multiply
func multiply(x, y int) int {
    return x * y;
}

相关的 JavaScript 类似:

// Providing the environment object, used in WebAssembly.instantiateStreaming.
// This part goes after "const go = new Go();" declaration.
go.importObject.env = {
    'add': function(x, y) {
        return x + y
    }
    // ... other functions
}

// Calling the multiply function:
console.log('multiplied two numbers:', wasm.exports.multiply(5, 3));

也可以简单地在 func main() 中执行代码,就像在 WebAssembly 的标准库实现中一样。

3.2.1. 构建

如果已安装 tinygo,那么只需提供正确的目标:

GOOS=js GOARCH=wasm tinygo build -o wasm.wasm ./main.go

查看 wasm examples,获取更多完整示例。

3.2.2. 它是如何运行的

内容的执行需要一些从 WebAssembly 中调用的 JS 辅助函数。tinygotinygo/targets/wasm_exec.js 中定义这些辅助函数。它基于标准库中的 $GOROOT/misc/wasm/wasm_exec.js,但略有不同。确保 tinygo 的版本与 wasm_exec.js 的版本相同。

在浏览器中运行 WebAssembly 文件所需的步骤包括使用 WebAssembly.instantiateStreamingWebAssembly.instantiate 将其加载到 JavaScript 中:

const go = new Go(); // Defined in wasm_exec.js
const WASM_URL = 'wasm.wasm';

var wasm;

if ('instantiateStreaming' in WebAssembly) {
    WebAssembly.instantiateStreaming(fetch(WASM_URL), go.importObject).then(function (obj) {
        wasm = obj.instance;
        go.run(wasm);
    })
} else {
    fetch(WASM_URL).then(resp =>
        resp.arrayBuffer()
    ).then(bytes =>
        WebAssembly.instantiate(bytes, go.importObject).then(function (obj) {
            wasm = obj.instance;
            go.run(wasm);
        })
    )
}

如果使用显式导出,那么可以在 wasm.exports 名称空间下调用它们。请参阅示例中的 export 目录,以获取此示例。

除 JavaScript 之外,需要将 wasm 文件的 Content-Type 头设置为 application/wasm。否则,大多数浏览器都不会运行它。

package main

import (
    "log"
    "net/http"
    "strings"
)

const dir = "./html"

func main() {
    fs := http.FileServer(http.Dir(dir))
    log.Print("Serving " + dir + " on http://localhost:8080")
    http.ListenAndServe(":8080", http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
        resp.Header().Add("Cache-Control", "no-cache")
        if strings.HasSuffix(req.URL.Path, ".wasm") {
            resp.Header().Set("content-type", "application/wasm")
        }
        fs.ServeHTTP(resp, req)
    }))
}

这个简单的服务器在端口 8080 上为 ./html 目录提供服务,并且适当地设置任何 *.wasm 文件的 Content-Type 头。

3.3. 使用 WASI

TinyGo 如何使用 WebAssembly System Interface(WASI)。

TinyGo 可以编译在服务端及其它边缘设备上使用的程序(WASI)。

TinyGo 程序可以运行在 Extism、Fastly Compute@Edge、Fermyon Spin、tau、wazero 及许多其它 WebAssembly 运行时中。

TinyGo 当前同时支持 WASI Preview 1(wasip1)及 WASI Preview 2(wasip2)。

下面是一个在 WASI 主机应用程序中使用的 TinyGo 程序:

package main

//export add
func add(x, y uint32) uint32 {
    return x + y
}

// main is required for the `wasip1` target, even if it isn't used.
func main() {}

编译上述 TinyGo 程序,使其可以在任何 WASI 运行时上使用:

GOOS=wasip1 GOARCH=wasm tinygo build -o main.wasm main.go

4. 示例

4.1. 使用 py2wasm 将 Python 代码编译为 WebAssembly

说明:

  1. py2wasm 版本是 2.6.3

  2. Python 版本是 3.11

  3. 暂未找到导出特定函数的方法

源代码 test.py 的内容如下:

def multiply(a: int, b: int) -> int:
    return a * b


def main() -> None:
    print(multiply(3, 4))


if __name__ == "__main__":
    main()

执行如下命令将 Python 代码编译为 WebAssembly:

py2wasm -o test.wasm test.py

执行如下命令运行上一步生成 .wasm 文件:

wasmtime test.wasm

4.2. 在 Go 中运行上一步生成 .wasm 文件

源代码 main.go 的内容如下:

package main

import (
    "errors"
    "flag"
    "github.com/bytecodealliance/wasmtime-go/v24"
    "log"
    "os"
)

func main() {
    flag.Parse()
    wasmFilename := flag.Arg(0)
    if wasmFilename == "" {
        wasmFilename = "main.wasm"
    }

    // 读取 Wasm 文件
    wasm, err := os.ReadFile(wasmFilename)
    if err != nil {
        log.Fatal(err)
    }

    // 创建新 Engine
    engine := wasmtime.NewEngine()
    // 使用 Engine 中的配置,从提供的 WASM 编译新 Module
    module, err := wasmtime.NewModule(engine, wasm)
    if err != nil {
        log.Fatal(err)
    }

    // Linker 实现 wasmtime Linking 模块,可以将实例化的模块链接在一起
    linker := wasmtime.NewLinker(engine)
    // DefineWasi 将 WASI 模块链接到该链接器中,确保所有导出函数都可用
    err = linker.DefineWasi()
    if err != nil {
        log.Fatal(err)
    }

    // 配置 WASI,然后使用该 wasi 配置创建 Store
    wasiConfig := wasmtime.NewWasiConfig()
    wasiConfig.InheritEnv()
    wasiConfig.InheritArgv()
    wasiConfig.InheritStdout()
    wasiConfig.InheritStderr()
    wasiConfig.InheritStdin()
    store := wasmtime.NewStore(engine)
    store.SetWasi(wasiConfig)
    // 使用该链接器中定义的所有导入实例化模块
    instance, err := linker.Instantiate(store, module)
    if err != nil {
        log.Fatal(err)
    }

    // 运行启动函数
    start := instance.GetFunc(store, "_start")
    // 使用提供的参数调用函数
    _, err = start.Call(store)
    if err != nil {
        var wError *wasmtime.Error
        ok := errors.As(err, &wError)
        if !ok {
            log.Fatal(err)
        }
        exitCode, isDefined := wError.ExitStatus()
        if !isDefined || exitCode != 0 {
            log.Panic(err)
        }
    }
}

执行如下命令在 Go 中运行 .wasm 文件:

go run main.go test.wasm

4.3. 在 TinyGo 中使用 WASI

源代码 add.go 的内容如下:

package main

//export add
func add(x, y uint32) uint32 {
    return x + y
}

// main is required for the `wasip1` target, even if it isn't used.
func main() {
    println("Hello, I'm TinyGo~")
}

执行如下命令将上述源代码编译为 .wasm 文件:

GOOS=wasip1 GOARCH=wasm tinygo build -o add.wasm add.go

使用上一节的 Go 程序运行 add.wasm

go run main.go add.wasm

也使用 wasmtime 运行该文件:

wasmtime add.wasm
wasmtime --invoke add add.wasm 2 3

修改上一节的 Go 程序调用 add.wasm 中导出的 add 函数,在 main 函数的尾部添加:

addFunc := instance.GetFunc(store, "add")
if addFunc == nil {
    log.Println("no exported function named add")
    return
}
result, err := addFunc.Call(store, 3, 4)
if err != nil {
    log.Fatal(err)
}
log.Println("3 + 4 =", result)

执行如下命令运行 add.wasm

go run main.go add.wasm

参考文档