如何通过 JavaScript 运行用 Go 编写的 WebAssembly 模块?

2,748 阅读11分钟

最近在着手重构 presence.js 2.0,其中编解码模块通过 WebAssembly 实现。但代码是一年多之前写的,期间一直没有碰过,现在已经比较陌生了,借着这次机会,我打算重新梳理一下相关的知识,带大家完成一个 Go 的 WebAssembly 程序。

什么是 WebAssembly?

JavaScript 一直是浏览器能够直接运行的唯一编程语言。

JavaScript 经受住了时间的考验,它能够提供大多数 Web 应用所需的性能。但是当涉及到 3D 游戏、VR、AR 和图像编辑之类的应用时,JavaScript 并不完全提供很好的性能,因为它是一门解释性语言。尽管 Gecko 和 V8 等 JavaScript 引擎具有即时编译(JIT)功能,但 JavaScript 仍然无法提供现代 Web 应用所需要的更高性能。

WebAssembly 简称 wasm,它的目标是解决性能这个问题。WebAssembly 是一种浏览器可以运行的虚拟汇编语言。当我们说到虚拟的时候,就意味着它不能在底层硬件上本地运行。

由于浏览器可以在任何架构上运行,所以浏览器不可能直接在底层硬件上运行 WebAssembly。但是这种高度优化的虚拟程序集格式在现代浏览器中的处理速度比普通 JavaScript 快得多,因为它经过编译并且比 JavaScript 更接近硬件架构。

下图显示了与 Javascript 相比,WebAssembly 在堆栈中的位置。它比 JavaScript 更接近硬件。

现在所有的 JavaScript 引擎都支持运行 WebAssembly 的虚拟程序集代码。

WebAssembly 会取代 JavaScript 吗?

WebAssembly 并不是要取代 JavaScript。它会和 JavaScript 一起运行,并且专注处理 Web 应用的性能关键组件。可以从 JavaScript 调用 WebAssembly,反之亦然。

WebAssembly 通常不是人去写出来的,而是从其他高级编程语言交叉编译得到的。例如可以将 Go、C、C++ 和 Rust 代码交叉编译为 WebAssembly 代码。因此,已经用其他编程语言编码的模块可以交叉编译成 WebAssembly 并直接在浏览器中使用。

我们要开发什么应用?

在本文中,我将交叉编译一个 Go 应用程序到 WebAssembly 并在浏览器上运行它。

我们会创建一个用于格式化 JSON 的简单应用程序。我们把合规的 JSON 字符串作为参数传入,它会将 JSON 格式化并打印出来。

例如,如果输入 JSON 是这样的:

{"user_id": "571401777717031","user_name": "代码与野兽","description": "关注real-time web、低代码、数据可视化、web3等领域。"}

它会被格式化,并且显示在浏览器中。

{
  "user_id": "571401777717031",
  "user_name": "代码与野兽",
  "description": "关注real-time web、低代码、数据可视化、web3等领域。"
}

这个程序的名字我们叫做 prettyJson。

这个程序需要确保 Go Versions >= 1.13。

Hello World WebAssembly 程序

在开始编写 prettyJson 之前,我们先实现一个用 Go 编写的 hello world 程序,通过交叉编译为 WebAssembly,然后在浏览器上运行。

然后基于这个简单的程序,逐渐转换成我们的 prettyJson 程序。

我们首选在 gopath/bin 目录中创建以下目录结构。

bin
└── webassembly
    ├── assets
    └── cmd
        ├── server
        └── wasm

然后运行 go mod init v2 创建模块。

go mod init v2

在 /webassembly/cmd/wasm 中创建 main.go 文件,并打印一句话。

package main

import (
	"fmt"
)

func main() {
	fmt.Println("Go Web Assembly")
}

将上面的 Go 程序交叉编译成 WebAssembly。

下面的命令可以交叉编译这个 Go 程序并将输出二进制文件到 assets 文件夹中。

cd /go/bin/webassembly/cmd/wasm/  
GOOS=js GOARCH=wasm go build -o  ../../assets/json.wasm  

上面的命令中 wasm 是 WebAssembly 架构的缩写形式。

运行上述命令会在 assets 目录中创建 WebAssembly 模块,文件名是 json.wasm。

现在我们已经成功地把第一个 Go 程序交叉编译到 WebAssembly。

如果你尝试在终端中运行编译后的二进制文件,你会得到错误:exec format error: ./json.wasm。

这是因为这个二进制文件是一个 wasm 二进制文件,应该在浏览器沙箱中运行。Linux/Mac 操作系统不理解这个二进制文件的格式。

Javascript 胶水代码

我之前提到,WebAssembly 是和 JavaScript 并存的。所以我们要想运行这段代码,需要一些 JavaScript 代码来导入我们刚刚创建的 WebAssembly 模块,这种 JavaScript 代码的作用我们可以称为 JavaScript 胶水。

这个 JavaScript 代码已在安装 Go 时下载到了本地,我们只需要把它复制到我们的 assets 目录就可以了。

cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ~/go/bin/webassembly/assets/  

上面的命令会把包含运行 WebAssembly 的胶水代码 wasm_exec.js 复制到 assets 目录中。

assets 文件夹将会包含所有 HTML、JavaScript 和 wasm 代码,最后我们会使用 Web 服务器来运行这些代码。

index.html

现在我们已经准备好 wasm 二进制文件和胶水代码。下一步是创建 index.html 文件并导入我们的 wasm 二进制文件。

在 assets 目录中创建一个 index.html 文件,内容如下。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>hello wasm</title>
    <script src="wasm_exec.js"></script>
    <script>
      const go = new Go();
      WebAssembly.instantiateStreaming(
        fetch("json.wasm"),
        go.importObject
      ).then((result) => {
        go.run(result.instance);
      });
    </script>
  </head>
  <body></body>
</html>

文件中运行 WebAssembly 模块的代码可以在 WebAssembly Wiki 中找到。

创建 index.html 后的当前目录结构如下所示。

bin
└── webassembly
    ├── assets
    │   ├── index.html
    │   ├── json.wasm
    │   └── wasm_exec.js
    └── cmd
        ├── server
        └── wasm
            └── main.go

虽然 index.html 中的代码是模板代码,但我们可以稍微理解一下。

instantiateStreaming 函数用来初始化 json.wasm 模块。这个函数返回一个 WebAssembly 实例,其中包含了可以从 JavaScript 调用的 WebAssembly 函数列表。这是从 JavaScript 调用 wasm 函数所必需的列表。

Server

现在我们已经准备好了 JavaScript 胶水代码、index.html 和 wasm 二进制文件。唯一缺少的部分就是一个 Web 服务器来托管 assets 文件夹中的内容。

在 server 目录中创建一个 main.go文件。

创建 main.go 之后的目录结构如下。

bin  
└── webassembly
    ├── assets
    │   ├── index.html
    │   ├── json.wasm
    │   └── wasm_exec.js
    └── cmd
        ├── server
        |   └── main.go
        └── wasm
            └── main.go

然后在 /webassembly/cmd/server/main.go 中实现一个 Web 服务器。

package main

import (
	"fmt"
	"net/http"
)

func main() {
	fs := http.FileServer(http.Dir("assets"))
	err := http.ListenAndServe(":9090", http.StripPrefix("/", fs))
	if err != nil {
		fmt.Println("Failed to start server", err)
		return
	}
}

上面的代码创建了一个文件服务器。它监听 9090 端口,根目录是 assets 文件夹。

现在启动服务器,看看第一个 WebAssembly 程序能否正常运行。

cd ~/go/bin/webassembly/cmd/server/  
go run main.go

现在服务器正在监听 9090 端口。

我们在浏览器并输入 http://localhost:9090/,可以看到页面是空的。

我们现在来查看 JavaScript 控制台。


我们可以看到,在控制台中看到打印了 Go Web Assembly。

现在我们已经成功运行了第一个使用 Go 编写的 Web Assembly 程序。我们从 Go 交叉编译的 Web Assembly 程序已经由服务器提供给了浏览器,并且被浏览器的 Javascript 引擎成功执行。

prettyJson 程序

接下来我们来实现 prettyJson 程序。

实现 prettyJson 函数

prettyJson 程序会将未格式化的 JSON 字符串作为输入参数,对其进行格式化,然后将格式化的 JSON 字符串作为输出返回。我们使用 MarshalIndent 函数来完成这个操作。

将以下代码添加到 ~/go/bin/webassembly/cmd/wasm/main.go 文件中。

func prettyJson(input string) (string, error) {  
    var raw interface{}
    if err := json.Unmarshal([]byte(input), &raw); err != nil {
        return "", err
    }
    pretty, err := json.MarshalIndent(raw, "", "  ")
    if err != nil {
        return "", err
    }
    return string(pretty), nil
}

同时需要导一下包。

import (
    "encoding/json"
    "fmt"
)

MarshalIndent 函数有 3 个参数。

第一个是未格式化的原始 JSON。

第二个是添加到 JSON 的每个新行的前缀。我们不添加前缀,所以是空字符串。

第三个参数是为 JSON 的每个缩进添加的字符串。我们传递两个空格字符串。

总之这段代码的含义是,对 JSON 每个新的缩进,都会添加两个空格,通过这种方式来完成 JSON 的格式化。

如果传递的 JSON 字符串是这样的:

{"user_id": "571401777717031","user_name": "代码与野兽","description": "关注real-time web、低代码、数据可视化、web3等领域。"}

那么它的输出下面这种被格式化的 JSON 字符串。\

{
  "user_id": "571401777717031",
  "user_name": "代码与野兽",
  "description": "关注real-time web、低代码、数据可视化、web3等领域。"
}

将 Go 中的函数暴露给 Javascript

现在我们已经实现了 prettyJSON 函数,但是我们还没有将这个函数公开给 Javascript,这样 JavaScript 没办法调用它。

Go 提供了syscall/js 包,它可以将函数从 Go 公开到 Javascript。

将 Go 的函数公开到 JavaScript 的第一步是创建一个 Func 类型的变量。

Func 是一个封装好的 Go 函数,可以被 JavaScript 调用。FuncOf 函数可以创建 Func 类型变量。

在 ~/go/bin/webassembly/cmd/wasm/main.go 中添加 jsonWrapper 函数。

func jsonWrapper() js.Func {
	jsonFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		if len(args) != 1 {
			return "Invalid no of arguments passed"
		}
		inputJSON := args[0].String()
		fmt.Printf("input %s\n", inputJSON)
		pretty, err := prettyJson(inputJSON)
		if err != nil {
			fmt.Printf("unable to convert to json %s\n", err)
			return err.Error()
		}
		return pretty
	})
	return jsonFunc
}

注意需要导包。

import (
    "encoding/json"
    "fmt"
    "syscall/js"
)

FuncOf 函数具有两个参数。

第一个参数是 Javascript 的 this 关键字。this 指向的是 JavaScript 的 global 对象,在浏览器中就是 window 对象。

第二个参数是一个切片,[]js.Value 表示在调用 Javascript 函数时传递的参数。在我们的例子中,它是未格式化的 JSON 输入字符串。

我们首先检查在 Javascript 中是否只传递了一个参数。这个检查是必要的,因为我们只需要一个 JSON 字符串参数。如果不是,我们返回一个字符串消息。

我们使用 args[0].String() 来将参数转换为字符串类型。

获取到输入的 JSON 后,我在第 8 行调用 prettyJson 函数完成美化工作,并返回输出结果。

当从 Go 向 Javascript 返回值时,编译器会自动使用 ValueOf 函数将 Go 中的值转换为 JavaScript 中的值。我们从 Go 中返回一个 string,它被 js.ValueOf() 编译器转换为相应的 JavaScript 字符串类型的值。

我们将 FuncOf 的返回值赋值给 jsonFunc。

现在 jsonFunc 是一个可以被 Javascript 调用的函数。

在最后我们把 jsonFunc 函数返回了出去。

现在我们已经准备好可以从 Javascript 调用的函数了,还差最后一步。

我们需要公开我们刚刚创建的函数,来让 Javascript 调用它。

将 Go 函数暴露给 Javascript 的方法是将 JavaScript 的全局对象的 prettyJSON 属性设置为 jsonWrapper()。

对应的代码如下:

js.Global().Set("prettyJSON", jsonWrapper())  

把这行代码添加到 main() 函数的末尾。

在上面的代码中,我们将 Javascript 的 Global 对象的 formatJSON 属性设置为 jsonWrapper 函数的返回值。

现在 jsonFunc 可以使用函数名 prettyJSON 从 JavaScript 调用。

下面是完整的代码:

package main

import (
	"encoding/json"
	"fmt"
	"syscall/js"
)

func jsonWrapper() js.Func {
	jsonFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		if len(args) != 1 {
			return "Invalid no of arguments passed"
		}
		inputJSON := args[0].String()
		fmt.Printf("input %s\n", inputJSON)
		pretty, err := prettyJson(inputJSON)
		if err != nil {
			fmt.Printf("unable to convert to json %s\n", err)
			return err.Error()
		}
		return pretty
	})
	return jsonFunc
}

func prettyJson(input string) (string, error) {
	var raw interface{}
	if err := json.Unmarshal([]byte(input), &raw); err != nil {
		return "", err
	}
	pretty, err := json.MarshalIndent(raw, "", "  ")
	if err != nil {
		return "", err
	}
	return string(pretty), nil
}

func main() {
	fmt.Println("Go Web Assembly")
	js.Global().Set("prettyJSON", jsonWrapper())
}

现在我们来编译和测试刚刚完成的程序。

cd ~/go/bin/webassembly/cmd/wasm/  
GOOS=js GOARCH=wasm go build -o  ../../assets/json.wasm  
cd ~/go/bin/webassembly/cmd/server/  
go run main.go  

上面的命令会编译 wasm 二进制文件并启动 Web 服务器。

从 JavaScript 调用 Go 函数

我们已经成功地将 Go 函数暴露给 JavaScript,现在我们来检查它是否有效。

在浏览器中打开 http://localhost:9090/,然后打开 Javascript 控制台。

在 Javascript 控制台中调用 prettyJSON 函数:

prettyJSON('{"user_id": "571401777717031","user_name": "代码与野兽","description": "关注real-time web、低代码、数据可视化、web3等领域。"}')

这时我们得到了一个错误:Error: Go program has already exited


错误的原因是我们的 Go 程序在当 Javascript 调用时已经退出了。

该如何解决这个问题呢?

其实很简单,我们只需要保证 Go 程序在 JavaScript 调用它时仍然处于运行状态。

在 Go 中实现这个需求最简单的方法就是通过 channel 来让程序一直等待。

func main() {  
        fmt.Println("Go Web Assembly")
        js.Global().Set("formatJSON", jsonWrapper())
        <-make(chan bool)
}

重新编译程序,然后再次尝试在浏览器中调用 prettyJSON 函数。

prettyJSON('{"user_id": "571401777717031","user_name": "代码与野兽","description": "关注real-time web、低代码、数据可视化、web3等领域。"}')

现在 JSON 将会被美化。

当我们没有传入参数时,也会有正常的错误输出:

OK,通过本文的讲解,相信你已经成功地通过 JavaScript 调用了一个使用 Go 编写的函数。