WebAssembly + Go 系列(1)什么是 WebAssembly 和 Go 语言示例

337 阅读9分钟

WebAssembly 简介

当 JavaScript 这种动态语言在某些场景下性能很难再压榨时,WebAssembly 慢慢走向人们的视野,并成为一个突破口。那么,什么是 WebAssembly?

WebAssembly(Wasm)是基于堆栈式虚拟机的二进制指令集,它被设计为编程语言的可移植编译目标,从而可以部署于客户端和服务端的 Web 应用程序。

具体一点地说,WebAssembly 是一种可以在现代 Web 浏览器(Web 环境)中运行的类似于汇编的低级语言(编译为二进制格式),可以以接近本机的性能运行,并提供为诸如 C/C++,C# 和 Rust 等高级语言的可移植编译目标,以便它们可以在 Web 上运行。

其实,关于 WebAssembly 是否会替代 JavaScript 的讨论还蛮多的。实际上,Wasm 还被设计为与 JavaScript 一起运行,从而允许两者一起工作,Wasm 可以被 JavaScript 调用,进入 JavaScript 上下文,也可以像 Web API 一样调用浏览器的功能。在 Web 环境中,WebAssembly 将会严格遵守同源策略以及浏览器安全策略。说了这么多,我们可以发现,WebAssembly 的主要目的之一以及它的初衷是在 Web 环境上运行,不然也不会叫 WebAssembly 了。

Docker 的创始人 Solomon Hykes 曾说:

If WASM+WASI existed in 2008, we wouldn't have needed to created Docker. That's how important it is. Webassembly on the server is the future of computing. A standardized system interface was the missing link. Let's hope WASI is up to the task!

什么哦?咋 WebAssembly 跟 Docker 还能扯上关系?Docker 不是容器技术么?如果你不是很熟悉 WebAssembly,可能会有这样的疑问,实际上 WASM + WASI 是挺底层的技术了,我们不能仅仅想着 WebAssembly 就是用后端语言写前端。“小了嚯,格局小了”。WebAssembly 将是未来非常重要的一门技术,目前仍在发展。

WebAssembly 不仅可以运行在浏览器上,它也可以运行在非 Web 环境下,即便 WebAssembly 一开始就是面向 Web 设计的。非Web 环境,即服务端、物联网(IoT)设备、移动端程序、桌面应用程序等,甚至嵌入到一个大型程序中。Wasm 可以运行在 JavaScript 虚拟机(比如 Node.js)中的,当然也可以不运行在 JavaScript 虚拟机中(一些 WASI 的 Runtime 实现,以后介绍)。总的来说,Wasm 运行在一个沙箱化的执行环境中(是不是开始听起来跟 Docker 有点类似了?)。

目前,除了 Wasm 的核心规范,Wasm 的嵌入接口规范有这 3 种:

  • JavaScript API:定义用于从 JavaScript 中访问 WebAssembly 的 JavaScript 类和对象
  • Web API:定义了 JavaScript API 的扩展,专门用于浏览器的 Web API 调用
  • WASI API:定义了一个模块化的系统接口,以在 Web 外部运行 WebAssembly

这里 WASI 即是用于非 Web 环境的标准,WASI 就是 WebAssembly System Interface,不走浏览器,而直接走系统调用,这也是为什么 WebAssembly 如此强大的原因。

WebAssembly 指令集

之前说了,WebAssembly(Wasm)是基于堆栈式虚拟机的二进制指令集。Wasm 在使用时,编译为二进制,包含的是指令集,因为基于沙盒式的虚拟环境,所以它是一种虚拟指令集架构(V-ISA),一般 V-ISA 都会基于堆栈机模型, Wasm 也选择了基于堆栈式虚拟机的模型设计。

编写 Wasm 指令集,就好比你写汇编一样,会有对应的助记符,在 Wasm 我们有 wat 可读文本格式;这些指令助记符文本最终被编译到 Wasm 二进制模块中,对应 wasm 二进制字节码格式的文件。

WABT 是一个 WebAssembly 二进制工具集,里面有很多工具可以使用,我们借助 wat2wasm 这个工具来举例看看。

下面是一个 WAT 格式的文件:

(module
  (func (export "addTwo") (param i32 i32) (result i32)
    local.get 0
    local.get 1
    i32.add))

我们通过这个代码,猜也能猜出来实现了什么功能吧?这里定义了一个模块,暴漏了一个 addTwo 方法,它接收了两个整型参数,然后把这两个数加起来,然后返回。就这么简单。

接下来,我们会把它编译成 wasm 二进制格式。编译过程日志如下:

0000000: 0061 736d                                 ; WASM_BINARY_MAGIC
0000004: 0100 0000                                 ; WASM_BINARY_VERSION
; section "Type" (1)
0000008: 01                                        ; section code
0000009: 00                                        ; section size (guess)
000000a: 01                                        ; num types
; func type 0
000000b: 60                                        ; func
000000c: 02                                        ; num params
000000d: 7f                                        ; i32
000000e: 7f                                        ; i32
000000f: 01                                        ; num results
0000010: 7f                                        ; i32
0000009: 07                                        ; FIXUP section size
; section "Function" (3)
0000011: 03                                        ; section code
0000012: 00                                        ; section size (guess)
0000013: 01                                        ; num functions
0000014: 00                                        ; function 0 signature index
0000012: 02                                        ; FIXUP section size
; section "Export" (7)
0000015: 07                                        ; section code
0000016: 00                                        ; section size (guess)
0000017: 01                                        ; num exports
0000018: 06                                        ; string length
0000019: 6164 6454 776f                           addTwo  ; export name
000001f: 00                                        ; export kind
0000020: 00                                        ; export func index
0000016: 0a                                        ; FIXUP section size
; section "Code" (10)
0000021: 0a                                        ; section code
0000022: 00                                        ; section size (guess)
0000023: 01                                        ; num functions
; function body 0
0000024: 00                                        ; func body size (guess)
0000025: 00                                        ; local decl count
0000026: 20                                        ; local.get
0000027: 00                                        ; local index
0000028: 20                                        ; local.get
0000029: 01                                        ; local index
000002a: 6a                                        ; i32.add
000002b: 0b                                        ; end
0000024: 07                                        ; FIXUP func body size
0000022: 09                                        ; FIXUP section size
; section "name"
000002c: 00                                        ; section code
000002d: 00                                        ; section size (guess)
000002e: 04                                        ; string length
000002f: 6e61 6d65                                name  ; custom section name
0000033: 02                                        ; local name type
0000034: 00                                        ; subsection size (guess)
0000035: 01                                        ; num functions
0000036: 00                                        ; function index
0000037: 02                                        ; num locals
0000038: 00                                        ; local index
0000039: 00                                        ; string length
000003a: 01                                        ; local index
000003b: 00                                        ; string length
0000034: 07                                        ; FIXUP subsection size
000002d: 0e                                        ; FIXUP section size

哦豁?头大哦,为什么要来看类似汇编的这种东西哦,不如先粗略瞄一眼,我们先来看看,编译后的二进制,我们怎么用的?

我们看看这段 JS 代码:

const wasmInstance = new WebAssembly.Instance(wasmModule, {});
const { addTwo } = wasmInstance.exports;

for (let i = 0; i < 10; i++) {
  console.log(addTwo(i, i));
}

首先第一行,我们从 Wasm 中获取模块实例,第二行我们获取到之前编写的 addTwo 方法。然后下面的 for 循环直接就用上这个方法了,简单易懂。这段代码会输出:

0
2
4
6
8
10
12
14
16
18

这就是一个简单的循环输出。

好了,我们大概知道 Wasm 指令集是怎么在 Web 环境下交互使用的了。那么,我们使用 Wasm 还得编写上面的 WAT 么?(别吧,学不动了。)跟编写汇编语言不同的是,我们在使用 Wasm 时,通常只需要编写 C/C++,Rust,Go 等这类高级编程语言的代码就可以了,最终编译器会帮你自动转换为 wasm 二进制指令。

我猜你大概明白了,WebAssembly 理论上不是一种新的语言,它是一种编译目标格式,是一种标准规范,而 WAT(WebAssembly Text Format)是一种用来在文本编辑器、浏览器开发者工具等工具中显示的中间形式,意思是 WAT 只是为了能够让人类阅读和编辑 WebAssembly 而产生的,因为 WebAssembly 最终是二进制格式。比如,如果我现在想写一个 Go 编译器,将 Go 代码编译为 Wasm 格式,那么 WAT 这种中间格式对于你一个底层开发人员来说,就很有帮助。所以,WebAssembly 不是语言,没有 WebAssembly 自己的编译器,你不需要装一个 Wasm 自身的编译器之类的。

例如,C/C++程序通过 Emscripten(基于 LLVM)工具编译为 .wasm 模块,在使用 wasm 模块时,我们需要一个 HTML 文件,一个 JS 胶水文件把 wasm 模块引入。同理,我们用 Go 开发 Wasm 模块,也是通过 Go 编译器(官方已支持)转化为 wasm 模块,然后一个 HTML 文件,一个 JS 胶水文件,读取 wasm 模块,这种形式来实现。WebAssembly 目前不能直接存取 DOM。所以为了使用 Web API,WebAssembly 需要调用 JavaScript,然后由 JavaScript 调用 Web API。

wasm-1.png

目前只有 Wasm 核心规范是发布了 1.0 版本,其它的规范还都是在草案状态中,Wasm 还是比较年轻,还有很长的路要走,我们可以持续关注以后的变化。

Go 语言编写 WebAssembly 应用

现在我们尝试用 Go 语言来编写一个简单的 WebAssembly 应用,我们可以看 Go 官方 Wiki 的指引,实际上在 Go 中编写 Wasm 非常简单。

首先我们编写 wasm/main.go 文件:

package main

import "fmt"

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

一个简单的打印内容,接下来编译为 .wasm 文件,通过下面的命令:

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

Copy go 官方给我们准备好的胶水 JS 文件:

cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" .

创建一个 HTML 文件:

<html>
<head>
    <meta charset="utf-8"/>
    <script src="wasm_exec.js"></script>
    <script>
        const go = new Go();
        WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
            go.run(result.instance);
        });
    </script>
</head>
<body>
<h1>WebAssembly Demo</h1>
</body>
</html>

在 HTML 文件中我们引入了编译的 .wasm 文件和固定的 wasm_exec.js 文件。

接下来编写一个简单 HTTP 服务来给我们的静态文件提供支持:

// A basic HTTP server.
package main

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

var (
    listen = flag.String("listen", ":2345", "listen address")
    dir    = flag.String("dir", "../../assets", "directory to serve")
)

// go run cmd/server/main.go -dir assets/
func main() {
    flag.Parse()
    log.Printf("listening on %q...", *listen)
    err := http.ListenAndServe(*listen, http.FileServer(http.Dir(*dir)))
    log.Fatalln(err)
}

我将静态文件放到 assets 中,main 文件放到了 cmd 目录中,具体的路径根据你的具体情况修改就可以。

运行:

$ go run cmd/server/main.go -dir assets/
2021/03/08 23:46:36 listening on ":2345"...

浏览器打开 ​127.0.0.1:2345​ 可以看到 HTML 正常,打开控制台,可以看输出 ​Hello, WebAssembly!

好了,一个简单的 Demo 展示完成,代码我放到了 go-language-plus/code-example。通过这个例子,你大概能知道 WebAssembly 是如何工作的,以及我们如何来编写 WebAssembly 应用了。下一篇,准备介绍 WASI,WebAssembly out of Web!

-------

感兴趣的可以关注公众号:写行代码摸条鱼,接收最新推文。