原文:https://golangbot.com/webassembly-using-go/
欢迎来到WebAssembly教程系列的第一篇。
JavaScript已成为浏览器可以理解的唯一语言。它经历了时间的考验,可以满足大多数web应用的性能需求。但是,当遇到3D游戏、VR、AR以及图像编辑等应用的时候,JavaScript就不那么好用了,其原因是它是一种解释性的语言。虽然像Gecko和V8这样的JavaScript引擎已具备JIT特性,但JavaScript还是不能完全满足现代web应用所需的高性能。
WebAssembly(又称wasm)的目标就是解决这个问题。它是一种专为浏览器设计的虚拟汇编语言。所谓虚拟,意思就是它不能直接运行于底层的硬件之上。因为浏览器可能运行在任意体系的硬件上,所以浏览器不可能让WebAssembly直接运行于底层硬件之上。但是,WebAssembly采用了高度优化的虚拟汇编格式,它在浏览器中运行时要比普通的JavaScript快得多,这是由于它是编译型的而且比JavaScript更靠近硬件体系。下图显示了WebAssembly与JavaScript在栈中的位置。它比JavaScript更靠近硬件一些。
现有的JavaScript引擎基本上都可以支持WebAssembly虚拟汇编代码的运行。
WebAssembly的目标并不是替代JavaScript。它的目标是与JavaScript一起配合,以实现web应用中性能敏感的部分。可以从JavaScript调用WebAssembly,反之亦然。
WebAssembly通常并不需要手工编写汇编代码,而是从其它高级语言编译得到。例如,可以从Go、C、C++或Rust等代码编译得到WebAssembly。因此,在其它语言中已实现的模块也可以被编译成WebAssembly,从而在浏览器中直接使用。
在本教程中,我们将把一个Go程序编译为WebAssembly并在浏览器中运行它。
我们将创建一个对JSON进行格式化的简单程序。如果输入的是一个未格式化的JSON串,我们把它格式化后再打印出来。
例如,输入的JSON如下:
{"website":"golangbot.com", "tutorials": {"string":"https://golangbot.com/strings/", "maps":"https://golangbot.com/maps/", "goroutine":"https://golangbot.com/goroutines/", "channels":"https://golangbot.com/channels/"}}
它会被格式化并在浏览器中显示以下内容:
{
"tutorials": {
"channels": "https://golangbot.com/channels/",
"goroutine": "https://golangbot.com/goroutines/",
"maps": "https://golangbot.com/maps/",
"string": "https://golangbot.com/strings/"
},
"website": "golangbot.com"
}
我们还会为这个应用创建一个UI,并在Go语言中操纵浏览器的DOM,不过这个要留到下一个教程。
本教程代码在Go 1.13以上版本中测试通过。
我们从编写一个Go的最简单的hello world程序开始,把它编译成WebAssembly并在浏览器上运行。然后我们再修改这个程序,把它变成我们的JSON格式化应用。
我们先来创建以下目录结构,比如在Documents目录之下:
Documents/
└── webassembly
├── assets
└── cmd
├── server
└── wasm
后面将逐步明晰各个文件夹的用途。
在~/Documents/webassembly/cmd/wasm目录下创建一个main.go文件,文件内容如下:
package main
import (
"fmt"
)
func main() {
fmt.Println("Go Web Assembly")
}
我们来把它编译成WebAssembly。以下命令将对这个Go程序进行编译并将输入的二进制文件存放在assets文件夹中:
cd ~/Documents/webassembly/cmd/wasm/
GOOS=js GOARCH=wasm go build -o ../../assets/json.wasm
以上命令使用js作为GOOS,wasm作为GOARCH,wasm是WebAssembly的缩写。执行这一命令将在assets目录下创建一个名为json.wasm的WebAssembly模块。恭喜!我们已经成功将第一个Go程序编译成WebAssembly了。
有一个重要的提示就是,只能将main包编译成WebAssembly。所以我们必须把所有代码都写在main包中。
如果你试图在终端上运行这个二进制文件,像这样:
$]~/Documents/webassembly/assets/json.wasm
-bash: json.wasm: cannot execute binary file: Exec format error
你将收到一个错误提示。这是因为这个二进制文件是一个wasm二进制文件,只能在浏览器沙盒中运行。Linux或Mac操作系统无法理解这种格式,所以提示错误。
前面已提到过,WebAssembly是需要和JavaScript一起配合运行的。因些我们需要一些JavaScript胶水代码来引入我们刚才得到的WebAssembly模块,以便在浏览器中运行。这些胶水代码已存在于Go的安装路径下。我们只需要把它复制到我们的assets目录下即可:
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ~/Documents/webassembly/assets/
以上命令将wasm_exec.js复制到assets目录下,这个js文件中包含了用于运行WebAssembly的胶水代码。
现在你应该已知道,assets文件夹的用途就是用于存放我们的web服务器所要使用的所有HTML、JavaScript和wasm代码。
现在我们已经准备好wasm二进制文件以及胶水代码了。下一步就是创建一个index.html文件用于导入我们的wasm文件。
我们创建一下index.html文件,还是在assets目录下,文件内容如下。文件中包含了运行WebAssembly模块所需的样板代码,参看WebAssembly Wiki。
创建index.html之后的目录结构如下:
Documents/
└── webassembly
├── assets
│ ├── index.html
│ ├── json.wasm
│ └── wasm_exec.js
└── cmd
├── server
└── wasm
└── main.go
虽然index.html只是一个标准的样板,但是稍微了解一下也无妨。我们来尝试了解一下index.html中的代码。instantiateStreaming函数是用来初始化我们的json.wasm模块。这个函数返回一个WebAssembly实例,实例中包含一个可以被JavaScript调用的WebAssembly函数列表。要从JavaScript中调用我们的wasm函数,这一步是必须的。随着教程的继续,它的用法会越来越清晰。
现在我们已经准备好了JavaScript胶水、index.html以及我们的wasm二进制文件。最后还缺少的一块就是,我们需要一个web服务器来提供assets文件夹中的这些内容。开干吧!
在server目录下创建一个main.go文件,目录结构将变成:
Documents/
└── webassembly
├── assets
│ ├── index.html
│ ├── json.wasm
│ └── wasm_exec.js
└── cmd
├── server
| └── main.go
└── wasm
└── main.go
将以下代码输入~/Documents/webassembly/cmd/server/main.go:
package main
import (
"fmt"
"net/http"
)
func main() {
err := http.ListenAndServe(":9090", http.FileServer(http.Dir("../../assets")))
if err != nil {
fmt.Println("Failed to start server", err)
return
}
}
以上程序创建一个文件服务器,在9090端口监听,以我们的assets文件夹作为根目录。这就是我们想要的。我们来运行这个服务器,看看我们第一个WebAssembly程序开始运行。
cd ~/Documents/webassembly/cmd/server/
go run main.go
服务器监听于9090端口。启动你喜欢的浏览器,敲入http://localhost:9090。你会看到页面是空的。不要紧张,后面我们会创建UI的。
现在我们要关注的是JavaScript控制台。在浏览器中右击鼠标并选择"检查"。
开发者控制台将被打开。选择"控制台"页。
你将看到控制台中有一段"Go Web Assembly"文字。漂亮!我们已经成功运行了我们的第一个用Go编写的WebAssembly程序。从Go编译生成的WebAssembly模块已被我们的服务器提供给到浏览器,并被浏览器的JavaScript引擎正确执行。
下面我们再进一步,开始编写我们的JSON格式化器。
我们的JSON格式化器以未格式化的JSON作为输入,对其进行格式化,并返回格式化后的JSON字符串作为输出。我们可以用MarshalIndent函数来实现。
把以下函数加入到~/Documents/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
}
MarshalIndent函数有3个输入参数。第一个是未格式化的JSON串,第二个是要加到每行JSON前的前缀。这里我们不需要加前缀。第三个参数是每行JSON的缩入要增加的字符串。这里我们给定两个空格。这样,格式化时每次JSON行要缩入,就会多增加两个空格。
如果把字符串 {"website":"golangbot.com", "tutorials": {"string":"https://golangbot.com/strings/"}} 作为输入传给以上函数,它将返回以下格式化JSON字符串:
{
"tutorials": {
"string": "https://golangbot.com/strings/"
},
"website": "golangbot.com"
}
我们的函数已经准备好了,但是我们还没有把这个函数暴露给JavaScript以使得该函数可以从前端调用。
Go提供了syscall/js包,可以帮助我们从Go把函数暴露给JavaScript。
暴露函数给JavaScript的第一步是创建一个Func类型。Func是一个被包裹的Go函数,它可以被JavaScript调用。可以用FuncOf函数来创建Func类型。
把以下函数加入到~/Documents/webassembly/cmd/wasm/main.go:
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
}
FuncOf函数接受一个函数类型的参数,该类型的函数应带有两个参数以及一个interface{}返回类型。传递给FuncOf的函数将会被JavaScript以同步方式调用。这个函数的第一个参数是JavaScript的this关键字。this指向JavaScript的global对象。第二个参数是一个[]js.Value切片,表示被传入到这个JavaScript函数调用的所有参数。在这个例子中,应该只传入一个参数,即未格式化的JSON字符串。如果你觉得不太好理解也不要紧。程序完成的时候你会搞清楚的:)。
我们首先检查传入的参数数量是否为1(第3行)。这个检查是需要的,因为我们希望只有一个JSON串参数。如果不是这样,我们就返回一个字符串信息Invalid no of arguments passwd。我们不能从Go直接返回一个error类型给JavaScript。下一节教程会讨论如何进行错误处理。
我们用args[0].String()来获取输入的JSON。这表示从JavaScript传入的第一个参数。获取输入的JSON后,我们就调用prettyJson函数(第8行),将将结果返回。
从Go返回一个值给JavaScript时,编译器将自动使用ValueOf函数将Go值转换为JavaScript值。在这个例子中,我们从Go返回的是一个string,它会被编译器用js.ValueOf()函数转换为相应的JavaScript字符串类型。
我们将FuncOf的返回值赋值给jsonFunc。这样jsonFunc就是一个可以从JavaScript调用的函数了。最后我们返回jsonFunc(第15行)。
现在我们有了一个可以从JavaScript调用的函数了。最后还差一步。
我们需要把刚刚创建的这个函数暴露出去,以使得可以从JavaScript调用。方法是,把JavaScript的global对象的formatJSON属性设置为jsonWrapper()返回的js.Func。
以下这行代码就是干这个的:
js.Global().Set("formatJSON", jsonWrapper())
把这行代码加入到main()函数的最后。在这行代码中,我们将JavaScript的global对象的formatJSON属性设置为jsonWrapper()函数的返回值。现在负责对JSON串进行格式化的jsonFunc函数可以从JavaScript以函数名formatJSON来调用了。
完整的程序代码如下:
package main
import (
"fmt"
"encoding/json"
"syscall/js"
)
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 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 main() {
fmt.Println("Go Web Assembly")
js.Global().Set("formatJSON", jsonWrapper())
}
我们来编译并测试一下这个程序。
cd ~/Documents/webassembly/cmd/wasm/
GOOS=js GOARCH=wasm go build -o ../../assets/json.wasm
cd ~/Documents/webassembly/cmd/server/
go run main.go
以上命令将编译wasm二进制并启动我们的web服务器。
我们已经成功地将Go函数暴露给了JavaScript。我们来检查一下它能不能用。
再次从浏览器打开http://localhost:9090,并打开JavaScript控制台。
在控制台中输入以下命令:
formatJSON('{"website":"golangbot.com", "tutorials": {"string":"https://golangbot.com/strings/"}}')
以上命令调用了我们从Go暴露出去的formatJSON函数,传入一个JSON参数。敲回车,能否成功?
对不起:),你得到的是一个错误提示:Error: Go program has already exited
原因是,当从JavaScript调用的时候,我们的Go程序已经退出了。怎么办?很简单,我们必须确保在JavaScript调用的时候,我们的Go程序还在运行。最简单的办法就是在Go程序中持续等待一个channel。
func main() {
fmt.Println("Go Web Assembly")
js.Global().Set("formatJSON", jsonWrapper())
<-make(chan bool)
}
在这段代码中,我们在一个channel上持续等待输入。把最后一行代码加入到~/Documents/webassembly/cmd/wasm/main.go中,编译并重新运行。然后在浏览器中再次尝试以下命令:
formatJSON('{"website":"golangbot.com", "tutorials": {"string":"https://golangbot.com/strings/"}}')
这一次可以正确打印出格式化的JSON了:
如果我们不传入参数:
formatJSON()
则会得到以下提示:
"Invalid no of arguments passed"
漂亮!我们已经成功地从JavaScript调用了用Go编写的函数。
本教程的源代码可以从https://github.com/golangbot/webassembly/tree/tutorial1/处获取。
在下一节教程中,我们将为这个应用创建UI,进行错误处理,并从Go修改浏览器的DOM。