用Go来开发WebAssembly入门(1)

原文:https://golangbot.com/webassembly-using-go/

欢迎来到WebAssembly教程系列的第一篇。

WebAssembly是什么?

JavaScript已成为浏览器可以理解的唯一语言。它经历了时间的考验,可以满足大多数web应用的性能需求。但是,当遇到3D游戏、VR、AR以及图像编辑等应用的时候,JavaScript就不那么好用了,其原因是它是一种解释性的语言。虽然像Gecko和V8这样的JavaScript引擎已具备JIT特性,但JavaScript还是不能完全满足现代web应用所需的高性能。

WebAssembly(又称wasm)的目标就是解决这个问题。它是一种专为浏览器设计的虚拟汇编语言。所谓虚拟,意思就是它不能直接运行于底层的硬件之上。因为浏览器可能运行在任意体系的硬件上,所以浏览器不可能让WebAssembly直接运行于底层硬件之上。但是,WebAssembly采用了高度优化的虚拟汇编格式,它在浏览器中运行时要比普通的JavaScript快得多,这是由于它是编译型的而且比JavaScript更靠近硬件体系。下图显示了WebAssembly与JavaScript在栈中的位置。它比JavaScript更靠近硬件一些。

用Go来开发WebAssembly入门(1)_第1张图片

现有的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编译WebAssembly的Hello World程序

我们从编写一个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操作系统无法理解这种格式,所以提示错误。

JavaScript胶水

前面已提到过,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代码。

Index.html

现在我们已经准备好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函数,这一步是必须的。随着教程的继续,它的用法会越来越清晰。

Web服务器

现在我们已经准备好了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来开发WebAssembly入门(1)_第2张图片

 

开发者控制台将被打开。选择"控制台"页。

用Go来开发WebAssembly入门(1)_第3张图片 你将看到控制台中有一段"Go Web Assembly"文字。漂亮!我们已经成功运行了我们的第一个用Go编写的WebAssembly程序。从Go编译生成的WebAssembly模块已被我们的服务器提供给到浏览器,并被浏览器的JavaScript引擎正确执行。

下面我们再进一步,开始编写我们的JSON格式化器。

编写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"
}

从Go暴露函数给JavaScript

我们的函数已经准备好了,但是我们还没有把这个函数暴露给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服务器。

从JavaScript调用Go函数

我们已经成功地将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

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了:

用Go来开发WebAssembly入门(1)_第4张图片

如果我们不传入参数:

formatJSON()  

则会得到以下提示:

"Invalid no of arguments passed"

漂亮!我们已经成功地从JavaScript调用了用Go编写的函数。

本教程的源代码可以从https://github.com/golangbot/webassembly/tree/tutorial1/处获取。

在下一节教程中,我们将为这个应用创建UI,进行错误处理,并从Go修改浏览器的DOM。

 

 

 

 

你可能感兴趣的:(golang,webassembly)