使用 Docker 和 Golang 快速上手 WebAssembly

本文將聊聊,如何使用 Docker 和 Golang 快速上手 WebAssembly。我會分別從瀏覽器場景和 “通用應用” 場景來進行敘述,如果你還徘徊在 WebAssembly 的門前,或許這篇文章會對你所有幫助。

寫在前面

如果從 2017 年瀏覽器紛紛開始以實驗性的方式,支持 Web WebAssembly 功能來看,在瀏覽器使用非 JavaScript 來完成計算的風已經吹了五年了。不過,感受到 Wasm 生態真正發力的是近三年。

大環境的變化,讓行業生態中音視頻、雲計算、物聯網有了更廣闊的市場,以及在降本提效上更高的追求,此爲天時。如果說 Wasm 生態中的 C 位是 Mozilla,那麼去年在 Mozilla 裁員事件出現後,他們迅速成立 Rust 的基金會,以保障 Rust 開發團隊能夠獨立、穩定地運行,保護 Rust 以及周邊項目的持續發展,爲生態提供土壤,此可謂地利。

天時地利,只待人和。

國內外經濟環境均有了前所未有的變化,在少了不少外部資本誘惑之後,能夠感受到這幾年來,基礎技術設施的蓬勃發展,這裏面少不了各種優秀的工程師正在將注意力從 “業務”,逐步轉移到“技術” 上。目前 Wasm 王國在它的一等公民 Rust 高速發展和推動下,已經吸引了不少其他語言生態、知名商業公司的注意力。至於何時爆發,我個人認爲,只是時間問題。

不過需要注意的是,** 沒有技術會是銀彈,只有把技術放在適用的場景下才能達到事半功倍的效果。** 那麼哪些場景適合 WebAssembly 呢?

爲了行文方便,接下來 WebAssembly 會簡稱爲 Wasm

適用場景 & 優勢

先來看看,近三年業界公開表明已使用它的場景:

如果將上面的場景進行歸納,我們可以看到,在瀏覽器端、雲計算、嵌入式方向,WebAssembly 的優勢還是比較大的:

簡單起步:瀏覽器中的 WebAssembly

循序漸進,我們先從最簡單的場景開始:瀏覽器。

環境準備

如果你不想折騰 golang 的本地開發環境,我們可以使用 Docker 來快速創建一個運行環境:

docker run --rm -it -v `pwd`/code:/app -p 8012:8012 golang:1.17.3-buster bash

這裏,我們將本地的 code 目錄,映射到容器內的 /app 目錄中,並將本地和容器中的 8012 端口打通,以備後續使用。

接着,在命令執行完畢後的容器的終端控制檯中進行項目的初始化:

cd /app
go mod init soulteary.com/wasm-demo/v2

然後,使用你喜歡的方式(在容器內或者在本地 IDE 中),創建一個 golang 的程序文件,比如 main.go

package main

import "fmt"

func main() {
 fmt.Println("一切都將從這裏開始")
}

完成之後,在容器控制檯內執行 go run main.go,不出意外,將看到 “一切都將從這裏開始” 的文本輸出結果。

因爲我們要演示的場景包含前端,所以還需要有一個簡單的 Web 服務器,繼續使用 golang 寫一個簡單的 Web 服務器吧。

package main

import (
 "log"
 "net/http"
)

func main() {
 log.Fatal(http.ListenAndServe(":8012", http.FileServer(http.Dir("."))))
}

將上面的內存保存爲 server.go,當我們執行它的時候,它會將本地作爲服務器根目錄,對訪問者提供 Web 服務。

在這個場景下,工程師們一般會有幾個問題:

在 “Show You The Code” 的過程中,我們將依次解答上面的問題。

從 Golang 創建 WebAssembly 程序

將 Golang 程序 “變成” WebAssembly 一般會採取兩種方案:

Golang “原生編譯器方案” 適用性非常好,適合項目初期開發、或者不太介意編譯產物尺寸、程序首次分發時間的 B 端產品使用,如果你願意投入時間做產物體積裁剪,也能夠獲得不錯的結果。構建命令一般會類似 GOOS=js GOARCH=wasm go build -o YOUR_MODULE_NAME.wasm .,構建產物需要配合 Golang wasm_exec.js 使用。

相比較前者,TinyGo 的編譯結果更小巧,可以用於嵌入式場景(官方目前支持 60 多種單片機)、支持 WASI 接口的雲計算場景,以及本文本小節提到的 Web 場景。經過 GZip 壓縮後,你的程序甚至不如一張圖片大。構建命令和原生類似 tinygo build --no-debug -o YOUR_MODULE_NAME.wasm -target wasi .,不同的是,除了支持構建結果爲 wasm 之外,支持溝通通用的 wasi ,方便你進行多端功能複用。(這個能力在分發模式上類似 Docker、在應用角度來看,則有些類似 Node 剛出現時,我在淘寶團隊實踐的前後端代碼複用。)

我們先以原生方式爲例,基於 “環境準備” 小節中的內容,使用下面的命令就能夠完成對 wasm 的編譯啦:

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

接着,將 Golang 提供的 “JS Bridge” 複製到項目根目錄。

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

然後,編寫一個落地頁,讓它能夠加載上面的 JS Bridge,自動下載我們編譯好的 wasm 程序,在程序下載完成後自動執行:

<html>
<head>
  <meta charset="utf-8" />
  <title>Go wasm</title>
</head>
<body>
  <script src="wasm_exec.js"></script>

  <script>
    if (!WebAssembly.instantiateStreaming) {
      // polyfill
      WebAssembly.instantiateStreaming = async (resp, importObject) ={
        const source = await (await resp).arrayBuffer();
        return await WebAssembly.instantiate(source, importObject);
      };
    }

    const go = new Go();

    let mod, inst;

    WebAssembly.instantiateStreaming(fetch("module.wasm"), go.importObject).then(
      async (result) ={
        mod = result.module;
        inst = result.instance;

        await go.run(inst);
        inst = await WebAssembly.instantiate(mod, go.importObject); // reset instance
      }
    );

  </script>
</body>
</html>

一切就緒後,我們執行 go run server.go。在瀏覽器中訪問 localhost:8012 就能夠看到控制檯中輸出了上文中久違的字符串:“一切都將從這裏開始”。

瀏覽器中出現了 Wasm 輸出的內容

創建可與 JS 交互的 API 接口

我們以一個基礎的 MD5 計算爲例展開本小節的故事:假設我們需要讓瀏覽器中的 JavaScript 調用 Golang 中的 MD5 計算函數。

先對 “環境準備” 小節中的“main.go” 文件進行調整,完成基礎計算部分。

package main

import (
 "crypto/md5"
 "fmt"
)

func main() {
 fmt.Println("一切都將從這裏開始")
 fmt.Println(CalcMd5("想要計算的結果"))
}

func CalcMd5(src string) string {
 return fmt.Sprintf("%x", md5.Sum([]byte(src)))
}

使用 go run main.go 運行程序,可以看到類似下面的結果:

一切都將從這裏開始
849d1b972ec01975a9d1e16f804fec94

接着,將上面的程序進行語法調整,將新增的函數 CalcMd5 聲明爲 JS 可訪問的 Wasm 導出函數。

package main

import (
 "crypto/md5"
 "fmt"
 "syscall/js"
)

func main() {
 fmt.Println("一切都將從這裏開始")

 wait := make(chan struct{}, 0)
 js.Global().Set("CalcMd5", js.FuncOf(CalcMd5))
 <-wait
}

func CalcMd5(this js.Value, p []js.Value) interface{} {
 ret := fmt.Sprintf("%x", md5.Sum([]byte(p[0].String())))
 return js.ValueOf(ret)
}

執行 GOOS=js GOARCH=wasm go build -o module.wasm main.go 對模塊進行編譯構建。然後再次執行 go run server.go,在瀏覽器中的控制檯中,我們就能夠通過 JS 調用剛剛在 Golang 中創建的計算 MD5 的函數 CalcMd5 了。

在瀏覽器中調用 Wasm 函數

如果你的項目沒有使用 cgo ,那麼可以考慮直接使用 TinyGo 進行編譯器替換,編譯的默認產物將縮小到一個讓你驚訝的尺寸。(TinyGo 的代碼示例,關於 TinyGo 的討論,下文中有詳細展開,再次不做更多描述)

想要使用 TinyGo,需要先調整之前的 JS Bridge 爲 TinyGo 的版本。

cp "$(tinygo env TINYGOROOT)/targets/wasm_exec.js" .

繼續使用 tinygo build --no-debug -o module.tiny.wasm -target wasm main.go 構建小巧的 wasm 程序即可。程序構建完畢,我們對照一下原生構建的文件的尺寸變化,可以看到優化結果非常明顯,甚至進一步壓縮之後,文件尺寸大小隻有 64kb 左右:

du -hs *
148K module.tiny.wasm
2.0M module.wasm
// 64K module.tiny.wasm.gz

當然,使用 Go 創建的程序,並不單單是創建讓 JS 調用的接口,還能夠在 Go 中調用瀏覽器環境中的 JS API,或者在 Go 中直接操作瀏覽器 BOM API,來改變整個瀏覽器中頁面的呈現和行爲。

TinyGo 異常報錯修復

在瀏覽器控制檯中使用 TinyGo 版本的程序,可能會出現一些異常報錯,比如會收到:“syscall/js.finalizeRef not implemented” 這類報錯,解決方案可以參考 GitHub 中的方案,對 wasm_exec.js 文件打個補丁。

進階操作:拿 WebAssembly 當容器使用,構建通用 WASI 程序

在最近十年裏,不少語言都曾提出了 “write once,run anywhere” 的宏偉目標,其中 Node.js 更是使用語言同構的思路進行了踐行。然而在容器時代,我們發現,異構的技術棧也很香啊,只要你的應用接口能夠標準化、通訊效率足夠高、計算過程中損失成本小就行了。

我們可以將現有的容器技術視作帶有 OS 、程序運行依賴的,高緯度的輕量應用運行環境。而 WASI 應用,則是粒度更細的 “靈活容器”:它可以被任何環境中、非常多的語言集成使用,在執行過程中被以更 low-level 的方式解析執行,或者作爲輕量的沙盒使用。 如果說以前我們將程序扔在容器裏,用顯示聲明的方式來實現 “code as infrastructure”,那麼 Wasm/ WASI 的到來,則讓我們的程序本身具備了組件容器化的能力,尤其是針對跨棧、異構場景的能力擴展。

環境準備

爲了體驗 Wasm 程序的 “通用性”,我們將編寫一個 Wasm 程序,並使用 瀏覽器、Node、Golang 三種不同的運行環境來對其進行調用。爲了能夠快速的開發和驗證,我這裏準備了一個簡單的容器環境:

FROM golang:1.17.3-buster
# 前置準備
RUN sed -i -e "s/deb\.debian\.org/mirrors\.tuna\.tsinghua\.edu\.cn/" /etc/apt/sources.list && \
    sed -i -e "s/security\.debian\.org/mirrors\.tuna\.tsinghua\.edu\.cn/" /etc/apt/sources.list && \
    apt-get update && apt-get install -y && \
    rm /bin/sh && ln -s /bin/bash /bin/sh && \
    echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
# 準備 Go 環境
RUN go env -w GO111MODULE=on
RUN go env -w  GOPROXY=https://goproxy.cn,direct
RUN curl -L https://github.com/tinygo-org/tinygo/releases/download/v0.21.0/tinygo_0.21.0_amd64.deb -o tinygo_0.21.0_amd64.deb && \
    dpkg -i tinygo_0.21.0_amd64.deb
# 安裝 wasmer
RUN curl https://get.wasmer.io -sSfL | sh
# 準備 Node 環境
RUN curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
ENV NVM_DIR /root/.nvm
ENV NVM_NODEJS_ORG_MIRROR "https://npm.taobao.org/mirrors/node"
RUN . $NVM_DIR/nvm.sh && nvm install node

WORKDIR /app

將上面的內容保存爲 Dockerfile,然後使用 docker build -it wasm-dev-env . 執行構建。考慮到使用時的簡單,我們還可以編寫一個容器編排配置:

version: '3'

services:

  wasm-dev-env:
    image: wasm-dev-env
    volumes:
      - ./app:/app
    command: tail -f /etc/hosts
    ports: 
      - 8081:8081
      - 8082:8082
      - 8083:8083

將上面的內容保存爲 docker-compose.yml ,然後使用 docker-compose up -d 啓動容器,容器啓動後,使用 docker-compose exec wasm-dev-env bash 就能夠進入我們的開發環境了,先來看一下各個組件的版本。

# go version
go version go1.17.3 linux/amd64

# node --version
v17.1.0

# wasmer --version
wasmer 2.0.0

#tinygo version
tinygo version 0.21.0 linux/amd64 (using go version go1.17.3 and LLVM version 11.0.0)

編寫通用的 WASI 程序

考慮到實用性和趣味性,這裏我將會把一個開源的 Go 軟件編譯成 WASI  程序,來讓其他的語言的程序進行調用。我選擇的開源項目是能夠將普通的文本轉換爲 ASCII ART 的 https://github.com/common-nighthawk/go-figure

先來初始化項目目錄。

mkdir /app/wasm
cd /app/wasm
go mod init soulteary.com/wasm-demo/v2

接着,創建一個名爲 funny.go 的程序:

package main

import (
 "github.com/common-nighthawk/go-figure"
)

func main() {}

//export HelloWorld
func HelloWorld() {
 myFigure := figure.NewFigure("Hello World"""true)
 myFigure.Print()
}

然後使用 tinygo build --no-debug -o module.wasm -wasm-abi=generic -target=wasi funny.go 進行程序編譯。不過由於 TinyGo 目前的 fs 模塊的兼容性問題,我們的編譯會失敗:

# github.com/common-nighthawk/go-figure
../../go/pkg/mod/github.com/common-nighthawk/go-figure@v0.0.0-20210622060536-734e95fb86be/bindata.go:3606:11: MkdirAll not declared by package os
../../go/pkg/mod/github.com/common-nighthawk/go-figure@v0.0.0-20210622060536-734e95fb86be/bindata.go:3614:11: Chtimes not declared by package os

考慮到我們可以不需要原始項目中自定義外部資源的能力,所以可以直接針對報錯的依賴文件進行調整,刪除 TinyGo 中不支持的 API 方法。在完成調整之後,再次進行編譯,會看到很快就能夠得到我們所需要的 WASI 程序了。

du -hs *
4.0K funny.go
4.0K go.mod
4.0K go.sum
704K module.wasm

在 Node 中運行 WASI 標準的 WebAssembly 程序

在 Node.js 中運行 Wasm 有兩種方案,一種是使用 Node 中的 WebAssembly 對象,直接運行傳統的 Wasm 程序,另外一種則是使用 WASI 接口運行 Wasm 程序。

雖然第二種方案目前在 Node 中還處於實驗狀態,需要使用參數啓用,但是畢竟是未來的標準,這裏依舊推薦採用第二種方式。

const { readFileSync } = require('fs');
const { WASI } = require('wasi');
const { argv, env } = require('process');

(async function () {
    const wasi = new WASI({ args: argv, env });
    const importObject = { wasi_snapshot_preview1: wasi.wasiImport, /** or: wasi_unstable: wasi.wasiImport **/ };
    const wasm = await WebAssembly.compile(readFileSync("./module.wasm"));
    const instance = await WebAssembly.instantiate(wasm, importObject);
    wasi.start(instance);

    const { HelloWorld } = instance.exports;
    HelloWorld();
}());

將上面的內容保存爲 index.js,接着使用 node --experimental-wasi-unstable-preview1 index.js 執行程序,可以看到我們成功的在 Node.js 中調用了使用 Go 編譯的 Wasm 程序,輸出了 “藝術字”。

(node:15307) ExperimentalWarning: WASI is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
  _   _          _   _            __        __                 _       _
 | | | |   ___  | | | |   ___     \ \      / /   ___    _ __  | |   __| |
 | |_| |  / _ | | | |  / _     \ \ // /   / _  | '__| | |  / _` |
 |  _  | |  __/ | | | | | (_) |     V  V /   | (_) | | |    | | | (_| |
 |_| |_|  \___| |_| |_|  \___/       \_/\_/     \___/  |_|    |_|  \__,_|

在瀏覽器中運行 WASI 標準的 WebAssembly 程序

前文中已經提到了一種方案,接下來我們來嘗試第二種運行方案。首先準備項目目錄,以及進行項目初始化。

mkdir /app/js-app
cd /app/js-app
npm init-y
npm i parcel parcel-bundler @wasmer/wasi @wasmer/wasmfs @wasmer/wasm-transformer --registry=https://registry.npmmirror.com
mkdir dist
cp module.wasm dist/

上面的命令執行完畢後,我們在項目目錄的 package.json 中添加一個字段內容,儘可能的減少不必要的兼容性轉換(你可以根據你的實際情況調整):

  "browserslist"[
    "last 1 Chrome versions"
  ],

創建一個用於展示的落地頁面,index.html

<html>
  <body>
    <script src="./index.js" type="module"></script>
  </body>
</html>

然後,創建我們的核心腳本程序,index.js

import { WASI } from '@wasmer/wasi/lib'
import browserBindings from '@wasmer/wasi/lib/bindings/browser'
import { WasmFs } from '@wasmer/wasmfs'

const wasmFilePath = '/module.wasm'
const wasmFs = new WasmFs()

let wasi = new WASI({
  args: [wasmFilePath],
  env: {},
  bindings: {
    ...browserBindings,
    fs: wasmFs.fs
  }
})

const startWasiTask =
  async pathToWasmFile ={
    let response = await fetch(pathToWasmFile)
    let wasmBytes = new Uint8Array(await response.arrayBuffer())

    let wasmModule = await WebAssembly.compile(wasmBytes);
    let instance = await WebAssembly.instantiate(wasmModule, {
      ...wasi.getImports(wasmModule)
    });

    wasi.start(instance)
    instance.exports.HelloWorld()

    let stdout = await wasmFs.getStdOut()
    document.write(`<p>Standard Output:</p><pre>${stdout}</pre>`)
  }

startWasiTask(wasmFilePath)

文件都準備繼續之後,使用 ./node_modules/.bin/parcel index.html --port=8081 啓動服務,在瀏覽器中訪問 localhost:8081,你將會看到調用 Wasm 程序輸出的內容:

Standard Output:

  _   _          _   _            __        __                 _       _
 | | | |   ___  | | | |   ___     \ \      / /   ___    _ __  | |   __| |
 | |_| |  / _ | | | |  / _     \ \ // /   / _  | '__| | |  / _` |
 |  _  | |  __/ | | | | | (_) |     V  V /   | (_) | | |    | | | (_| |
 |_| |_|  \___| |_| |_|  \___/       \_/\_/     \___/  |_|    |_|  \__,_|

瀏覽器中 Go Wasm 的程序輸出

在 Go 程序中運行 WASI 標準的 WebAssembly

想在 Golang 中運行由 Golang 編寫的具備 WASI 標準接口的 Wasm,其實還是有一點挑戰的。一般情況下,你可能會遇到下面這些問題:

關於上面的這些問題,在 wasmer-go 維護者的回答中曾提到,關於生成 WASI 標準的程序的方式,wasmer-go 項目的維護者們也不止一次的建議我們使用 TinyGo 替代默認的 Golang 編譯器。如果你想使用 wasmer-go 來完成這件事,會遇到一些問題,維護者目前並不考慮朝着這個方向完善,並推薦我們使用 https://github.com/go-wasm-adapter/go-wasm 這個項目,來將上文中在瀏覽器中起到橋接作用的 JS Bridge 代碼,在 Go 的代碼中 “運行一次”,將運行環境“墊平”。或考慮使用 https://github.com/mattn/gowasmer 的項目,針對 TinyGo 的產物進行“墊平” 操作。

回想起文章一開始提到的,各種雲服務網關都陸陸續續開始支持 WASM 的方式來擴展能力,而我們之前熟悉的 Traefik 卻採用了類似 Nginx 的方案,則使用了另外一種更笨重的方案,官方團隊提供基於 Golang 的 SDK,然後使用基於約定的方式動態從本地或遠程加載這些同構的應用。採取這個技術路線的原因裏,或許有一大部分正是出於上面的種種現實問題。

不過,2021 即將結束,這個問題還會是問題嗎?

其實,早在今年年中的時候,wasmer-go 就可以通過 WASI 的方式來運行 Wasm 了,不過官方的項目缺少一個可以使用的示例。在經過一些嘗試之後,我解決了這個問題,下面跟着我一起來玩吧。

先創建項目目錄,進行一些初始化操作:

mkdir /app/go-app
cd /app/go-app/
go mod init soulteary.com/go-app/v2
cp /app/wasm/module.wasm .

接着,安裝最新版本的 wasmer-go 項目運行時:

go get github.com/mattn/gowasmer

然後,編寫一個簡單的 Golang 程序,來加載 Wasm 程序,並執行它:

package main

import (
 "fmt"
 "io/ioutil"

 wasmer "github.com/wasmerio/wasmer-go/wasmer"
)

func main() {
 wasmBytes, _ := ioutil.ReadFile("module.wasm")

 store := wasmer.NewStore(wasmer.NewEngine())
 module, _ := wasmer.NewModule(store, wasmBytes)

 wasiEnv, _ := wasmer.NewWasiStateBuilder("wasi-program").
  // Choose according to your actual situation
  // Argument("--foo").
  // Environment("ABC""DEF").
  // MapDirectory("./"".").
  Finalize()
 importObject, err := wasiEnv.GenerateImportObject(store, module)
 check(err)

 instance, err := wasmer.NewInstance(module, importObject)
 check(err)

 start, err := instance.Exports.GetWasiStartFunction()
 check(err)
 start()

 HelloWorld, err := instance.Exports.GetFunction("HelloWorld")
 check(err)
 result, _ := HelloWorld()
 fmt.Println(result)
}

func check(e error) {
 if e != nil {
  panic(e)
 }
}

將上面的內容保存爲 main.go,然後執行 go run main.go。不出意外,你將會看到類似下面的結果:

  _   _          _   _            __        __                 _       _
 | | | |   ___  | | | |   ___     \ \      / /   ___    _ __  | |   __| |
 | |_| |  / _ | | | |  / _     \ \ // /   / _  | '__| | |  / _` |
 |  _  | |  __/ | | | | | (_) |     V  V /   | (_) | | |    | | | (_| |
 |_| |_|  \___| |_| |_|  \___/       \_/\_/     \___/  |_|    |_|  \__,_|
<nil>

在 Go 中調用 Wasm 程序

在其他語言中運行 WASI 標準的程序

如果你對其他語言中運行 WASI 程序有需求,可以關注 https://github.com/wasmerio 這個項目。或者參考前文中提到的商業化公司或團隊的實踐,從他們的開源項目中剝離所需要的代碼。

考慮到具體場景問題需要具體分析,這裏就不做展開了,如果有必要,我會再寫一篇文章,聊聊其他技術棧、應用生態中集成和使用 Wasm 程序。

最後

目前的 Wasm 領域的生態還有待完善,像極了十年前的前端生態。Wasm 和前端技術一樣,出現和發展不是爲了取代誰,而是爲了讓解決事情的路徑多一條,讓已有多技術產品效能更高。 我始終相信會出現那麼一批人,和十年前的老前端們一樣,將它的生態完善起來的。

希望本文能夠幫助到徘徊在這項技術前無從下手的你,併爲你打開一扇新的大門。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/j58_DU8Dir0qun8Lh7uzUw