橫練金剛!將 Go 程式碼編譯成 WebAssembly — 以縮圖程式為例
Posted on Apr 26, 2024 in Go 程式設計 - 高階 by Amo Chen ‐ 9 min read
近年來不斷地看到一些公司或服務採用 WebAssembly 這項技術,包含 Adobe, Microsoft, Google, Figma 等等,使我也對 WebAssembly 產生一點興趣,不過一直都沒有特別花時間研究,只知道 WebAssembly 是 1 種類似組合語言而且可以在瀏覽器中執行的低階語言,相較於 JavaScript 而言,更接近硬體層級,因此效率也更好一些。
直到最近 side project 有個很實際的需求作為契機,我才藉機會研究 WebAssembly 並體驗一下用 WebAssembly 打造應用的感覺。
現代很多語言都支援將程式碼編譯為 WebAssembly ,其中比較知名的是 Rust 程式語言,不過本文將以 Go / Golang 作為教學示範。
本文環境
p.s. 本文適合對 JavaScript, Go 有一定程度了解的讀者
WebAssembly / WASM 簡介
WebAssembly 或俗稱 WASM, 是 1 種低階語言,可以在現代瀏覽器內執行,Chrome, Edge, Safari, FireFox 等主流瀏覽器都已經支援,至於 IE 的話,你知道的。
其實看到它的名字有個 Assembly 就知道它跟組合語言ㄧ樣不好寫,以下是以 WebAssembly 實作呼叫瀏覽器內的 console.log("Hello World from WebAssembly!")
的範例,相當不友善啊⋯⋯:
(module
;; Imports from JavaScript namespace
(import "console" "log" (func $log (param i32 i32))) ;; Import log function
(import "js" "mem" (memory 1)) ;; Import 1 page of memory (54kb)
;; Data section of our module
(data (i32.const 0) "Hello World from WebAssembly!")
;; Function declaration: Exported as helloWorld(), no arguments
(func (export "helloWorld")
i32.const 0 ;; pass offset 0 to log
i32.const 29 ;; pass length 29 to log (strlen of sample text)
call $log
)
)
不過 WebAssembly 本身也不是要設計給人類寫的,而是設計成能讓高階語言編譯成 WebAssembly 的,並藉此讓瀏覽器具有接近原生的效能執行多種程式語言的能力。
換句話說, WebAssembly 賦予開發者:
- 跨語言開發 Web 應用的能力,也就是能用各種語言編譯成 WebAssembly 之後,並且在瀏覽器甚至是在 Node.js 執行的能力,譬如知名的 Figma 其實就是編譯成 WebAssembly 後在瀏覽器執行的。
- 讓 Web 應用能夠執行對效能要求更高的計算型應用,譬如 3D 遊戲、物理模擬計算、電腦視覺、圖片編輯等等,傳統這些領域若單純使用 JavaScript 很容易就會遇到效能瓶頸,顯見 Figma 使用 WebAssembly 這項技術是一項很好的決策。
- Write once run anywhere! 只要裝置/平台/系統有支援執行 WebAssembly 的能力,就可以寫一次程式,到處都能使用!
不過 WebAssembly 這項技術仍在持續發展中,所以使用上並不如 JavaScript 來得直覺,但即使如此, WebAssembly 確實已經能夠做出像 Figma 般成熟的產品。
更多關於 WebAssembly 的介紹詳見 MDN - WebAssembly 。
專案結構
接下來,我們將以 Go / Golang 將程式編譯成 WebAssembly 並在瀏覽器中執行。
以下是本文的專案資料夾結構:
.
├── go.mod
├── go.sum
├── index.html
├── main.go
├── original.jpg
└── wasm_exec.js
go.mod
與 go.sum
是 Go modules 需要的檔案,可以用以下指令建立:
$ go mod init example.com/demo
main.go
裡存放我們要編譯成 WebAssembly 的程式碼。
wasm_exec.js
是安裝 Go 時會一併安裝的 JavaScript 程式碼,用途為專門執行 Go 編譯而成 WebAssembly 檔案,可以用以下指令查看 Go 的 wasm
資料夾:
$ ls $(go env GOROOT)/misc/wasm/
可以看到裡面有 wasm_exec.js
與 wasm_exec_node.js
2 個檔案,一個是在瀏覽器中使用,另一個則是在 Node.js 使用:
go_js_wasm_exec wasm_exec.html wasm_exec.js wasm_exec_node.js
本文為純前端應用,因此只需要 wasm_exec.js
, 可以使用以下指令複製 wasm_exec.js
到你的專案資料夾:
cp $(go env GOROOT)/misc/wasm/wasm_exec.js <PATH>
p.s. 請將 <PATH>
修改為你的專案資料夾路徑
index.html
為本專案的前端網頁,裡面存放執行編譯好的 WebAssembly 檔案的 JavaScript 程式碼。
original.jpg
是最後的範例需要的圖檔,可以到此連結下載 Photo by Tom Winckels on Unsplash 。
以 Hello, WebAssembly! 認識編譯指令
以下是極其簡單的 Go 程式碼,該程式只會列印 Hello, WebAssembly!
:
// main.go
package main
import "fmt"
func main() {
fmt.Println("Hello, WebAssembly!")
}
要將 Go 程式碼編譯為 WebAssembly 的指令很簡單,只要設定環境變數 GOARCH=wasm GOOS=js
即可,剩下的都是 go build
指令的事,例如將本文的 main.go
編譯為 main.wasm
指令:
$ GOARCH=wasm GOOS=js go build -o main.wasm
p.s. Go 編譯 WebAssembly 只能編譯 main packages
執行成功之後,就可以看到當前資料夾增加 1 個名稱為 main.wasm
的檔案。
如果想執行編譯之後的 .wasm
檔,可以用以下指令:
$ GOOS=js GOARCH=wasm go run -exec="$(go env GOROOT)/misc/wasm/go_js_wasm_exec" .
或者以下指令:
$ export PATH="$PATH:$(go env GOROOT)/misc/wasm"
$ GOOS=js GOARCH=wasm go run .
執行成功之後,將會看到螢幕上出現 Hello, WebAssembly!
字串。
如果有寫測試的話,則同樣設定環境變數 GOARCH=wasm GOOS=js
並執行 go test
指令:
$ export PATH="$PATH:$(go env GOROOT)/misc/wasm"
$ GOOS=js GOARCH=wasm go test
至此,我們已學會如何將 Go 程式碼編譯為 WebAssembly 。
之所以使用 Go 能夠這麼方便地編譯 WebAssembly, 是因為 Go 原生就支援 cross-compiling 功能,而且 WebAssembly 也是 Go 官方支援的一個架構。
syscall/js 模組
目前為止還沒牽扯到與瀏覽器互動的部分,就算前述程式編譯成 WebAssembly, 它也不會在瀏覽器的 console
上印出任何字串,真正要與瀏覽器進行互動,就得靠 Go 的 syscall/js package 。
syscall/js 是專門負責介接 Go 與 JavaScript 的 package, 它的工作包含:
- 轉換 JavaScript 到 Go 的資料型態
- 轉換 Go 到 JavaScript 的資料型態
- export Go 定義的函式給 JavaScript 使用
- 存取前端 JavaScript 物件/函式
- 其他
不過 syscall/js 目前還是 experimental 階段,如果要用到 production 環境還是要多加注意未來可能衍伸的版本問題。
如何與 DOM 互動
前文提到 syscall/js 能夠存取前端的 JavaScript 物件/函式,以下 Go 程式碼會呼叫 JavaScript 的 document.createElement() 函式,創造 1 個 <h1>Hello, WebAssembly!</h1>
element, 並且將該 element 加到網頁裡的 <body>
底下:
// main.go
package main
import "syscall/js"
func main() {
/*
h1 = document.createElement("h1");
h1.innterText = "Hello, WebAssembly";
document.appendChild(h1);
*/
document := js.Global().Get("document")
h1 := document.Call("createElement", "h1")
h1.Set("innerText", "Hello, WebAssembly!")
document.Get("body").Call("appendChild", h1)
}
上述程式碼中 js.Global()
是取得 JavaScript global object 的方法,可以幫助我們拿到前端 JavaScript 會用到的 window
或 global
物件,剩下的其實屬於 JavaScript 的相關操作。
前述程式碼等同於執行以下 JavaScript 程式碼,不過只是用 Go 語言透過 syscall/js 模組達到:
h1 = document.createElement("h1");
h1.innterText = "Hello, WebAssembly";
document.appendChild(h1);
從這個範例可以發現用 Go 語言操作 DOM 確實不如直接使用 JavaScript 來得直覺,建議可以把前端相關的功能、元件交給 JavaScript 即可,讓 WebAssembly 專注在需要高效運算的部分就好。
接著,將 main.go
編譯為 main.wasm
:
$ GOARCH=wasm GOOS=js go build -o main.wasm
再來打開 index.html
輸入以下 HTML 與 JavaScript 程式,讓網頁可以執行我們編譯好的 main.wasm
:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Go + WebAssembly</title>
</head>
<body>
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch('main.wasm'), go.importObject).then((source) => {
go.run(source.instance);
});
</script>
</body>
</html>
上述最重要的部分是以 JavaScript 呼叫 WebAssembly API 執行 Go 編譯而成的 WebAssembly 檔案:
const go = new Go();
WebAssembly.instantiateStreaming(fetch('main.wasm'), go.importObject).then((source) => {
go.run(source.instance);
});
const go = new Go();
建立 1 個名為 go 的物件,它是 1 個為 Go 準備的 WebAssembly 環境,詳細可以閱讀原始碼。
WebAssembly.instantiateStreaming(fetch('main.wasm'), go.importObject)
是以串流(streaming)方式下載 'main.wasm'
檔案,並且 import JavaScript 與 WebAssembly 相關 interfaces, 也就是 go.importObject
的部分。
上述準備工作都做完之後,就會透過 go.run(source.instance)
執行用 Go 編譯的 WebAssembly 。
基本上,執行 Go 編譯的 WebAssembly 最少就只需要這幾行。
最後用 VS Code Live Sever 擴充瀏覽 index.html
, 執行順利的話,可以看到以下畫面,成功透過 WebAssembly 在網頁放入文字:
如何定義與呼叫 Go 的函式
前文提到用 Go 語言操作 DOM 不如直接使用 JavaScript 來得直覺,因此可以把前端相關的功能、元件交給 JavaScript ,讓 WebAssembly 專注在需要高效運算的部分。
為了達到此目的,最簡單的方式是:
- 將關鍵運算部分以 Go 函式實作
- 透過 syscall/js package 將該函式掛到 JavaScript global object
不過 Go 與 JavaScript 都有各自的資料型別,因此當 JavaScript 呼叫以 Go 實作的函式時,不免需要型別轉換。
為此, syscall/js pacakge 定義 1 個 type 稱為 js.Value ,該型別代表 JavaScript 的值,可以透呼叫 Int()
, String()
等函式,將 JavaScript 的值轉成 Go 的資料型別。
定義 JavaScript 可以呼叫的函式樣式如下:
func <name>(this js.Value, args []js.Value) any {
// implement
}
假如我們想用 JavaScript 呼叫 Go 的 Sum(a, b int) int
,我們需要定義 Go 函式:
func Sum(this js.Value, args []js.Value) any {
return args[0].Int() + args[1].Int()
}
Sum(a, b int) int
中的 a, b
對應的是上述 Go 函式的 args []js.Value
,也就是 args[0]
與 args[1]
,而 this js.Value
對應的其實是先前提到的 const go = new Go();
裡的變數 go
, 之所以 a, b
的型別不是 int
而是 js.Value
, 是因為 Sum()
是要給 JavaScript 端呼叫的,傳進來的自然是 JavaScript 的值,因此型別為 js.Value
。
定義完成之後,我們可以用 js.Global().Set()
函式把我們寫好的 Sum
函式放到 JavaScript global object, 完整程式碼如下所示:
package main
import "syscall/js"
func Sum(this js.Value, args []js.Value) any {
a, b := args[0].Int(), args[1].Int()
return a + b
}
func main() {
js.Global().Set("Sum", js.FuncOf(Sum))
select {}
}
上述把 Sum
函式放到 JavaScript global object 的程式碼為 js.Global().Set("Sum", js.FuncOf(Sum))
,其中我們需要額外以 js.FuncOf(Sum)
把 Sum 函式再包裝一層,它會幫我們把呼叫函式所需要的 this
自動傳進去(詳情請看原始碼)。
而 select {}
的部分是確保 Go 程式會持續執行,如果沒有這一行的話,會造成 Go 程式結束執行,造成我們無法呼叫 Go 實作的函式,因此顯示 Go program has already exited :
接著,同樣用以下指令編譯成 WebAssembly:
$ GOARCH=wasm GOOS=js go build -o main.wasm
再來,同樣以 VS Code Live Server 擴充瀏覽 index.html
, 並打開發者工具,切換到 console
輸入 Sum(1, 2)
就可以看到結果:
這代表我們能透過 JavaScript 呼叫 Go 實作的函式!
做個 Image Resizer 驗收成果
至此,我們已經學會:
- 將 Go 程式碼編譯成 WebAssembly
- 透過 Go 程式碼與網頁 DOM 互動
- 以 Go 實作 JavaScript 能夠呼叫的函式
如此一來,我們已初步具備將需要高效運算的功能移植到 WebAssembly 的能力。
最後 1 個範例將會製作 1 個縮圖程式,縮圖程式有一些理由很適合在前端頁面處理,譬如每次縮圖都要將圖片送到後端進行的話,將會:
- 消耗頻寬
- 消耗後端伺服器運算資源
- 使用者需要等待網路傳輸與後端伺服器處理的時間
使用 WebAssembly 將可以有效解決上述 3 個問題,所有的運算都在瀏覽器內執行,使用者既不需等待網路傳輸與伺服器處理的時間,也不會額外消耗後端相關資源,一舉多得!
以下是範例完成的畫面:
main.go
以下是 main.go
的程式碼:
package main
import (
"bytes"
"image"
"syscall/js"
"github.com/disintegration/imaging"
)
func Resize(this js.Value, args []js.Value) any {
// retrieve args
w, h := args[0].Int(), args[1].Int()
imgBytesLen := args[2].Get("length").Int()
imgBytes := make([]byte, imgBytesLen)
js.CopyBytesToGo(imgBytes, args[2])
// process
src, _, _ := image.Decode(bytes.NewReader(imgBytes))
newImg := imaging.Resize(src, w, h, imaging.Lanczos)
// output
buffer := new(bytes.Buffer)
imaging.Encode(buffer, newImg, imaging.JPEG)
jsArray := js.Global().Get("Uint8Array").New(args[2].Get("length").Int())
js.CopyBytesToJS(jsArray, buffer.Bytes())
return jsArray
}
func main() {
js.Global().Set("Resize", js.FuncOf(Resize))
select {}
}
裡面主要定義 1 個名稱為 Resize()
的函式,該函式接受 3 個參數,分別是寬度、高度與圖片 bytes, 如果以 Go 函式表示的話,其實是:
Resize(w, h int, b byte[]) byte[]
main.go
程式碼主要透過 Go package imaging 完成縮圖功能,縮圖的程式碼為以下 2 行:
src, _, _ := image.Decode(bytes.NewReader(imgBytes))
newImg := imaging.Resize(src, w, h, imaging.Lanczos)
除此之外, main.go
最重要部分有 2 個!
第 1 個是將前端網頁裡的圖片 uint8 array 轉成 Go 的 bytes[]
, 這部分需要用到 js.CopyBytesToGo() 函式,使用這個函式前需要先按照傳進來的 array 長度,做一個相對應長度的 bytes[]
,才能順利將資料轉成 bytes[]
:
// array.length in Js
imgBytesLen := args[2].Get("length").Int()
imgBytes := make([]byte, imgBytesLen)
js.CopyBytesToGo(imgBytes, args[2])
第 2 個是將縮圖後的 buffer
轉回 JavaScript uint8 array 再回傳,所以我們用 jsArray := js.Global().Get("Uint8Array").New(len(buffer.Bytes()))
在 JavaScript 那端建立 1 個相同長度 uint8 array, 接著呼叫 js.CopyBytesToJS() 把 buffer
資料複製到 JavaScript 那端:
// new Uint8Array(length) in Js
jsArray := js.Global().Get("Uint8Array").New(len(buffer.Bytes()))
js.CopyBytesToJS(jsArray, buffer.Bytes())
經歷這過程之後,將更能體會 Go 與 JavaScript 溝通的方式。
index.html
接著,是前端頁面 index.html
的程式碼:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Go + WebAssembly - Image Resizer</title>
<link
href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous" />
</head>
<body>
<div class="container mt-3">
<h1>Image Resizer</h1>
<div class="row mb-3">
<div class="col hstack gap-3 align-items-end">
<div>
<label for="width">Width</label>
<input class="form-control" type="number" id="width" value="640" min="1" />
</div>
<div>
<label for="height">Height</label>
<input type="number" class="form-control" id="height" value="426" min="1" />
</div>
<div>
<button class="btn btn-primary" onclick="callResize()">Resize Image</button>
</div>
</div>
</div>
<div class="row">
<h2>Before</h2>
<div class="col">
<img id="srcImage" src="/original.jpg" alt="Original Image" />
</div>
</div>
<div class="row">
<h2>After</h2>
<div class="col">
<img id="newImage" src="/original.jpg" alt="Resized Image" />
</div>
</div>
</div>
<script src="wasm_exec.js"></script>
<script>
var srcImgBytes;
var image = document.getElementById('srcImage');
var xhr = new XMLHttpRequest();
xhr.open('GET', image.src, true);
xhr.responseType = 'arraybuffer';
xhr.onload = function () {
if (xhr.status === 200) {
srcImgBytes = new Uint8Array(xhr.response);
}
};
xhr.send();
const go = new Go();
WebAssembly.instantiateStreaming(fetch('main.wasm'), go.importObject).then((source) => {
go.run(source.instance);
document.callResize = () => {
var width = parseInt(document.getElementById('width').value);
var height = parseInt(document.getElementById('height').value);
var newImgBytes = Resize(width, height, srcImgBytes);
var baseb4 = btoa(String.fromCharCode.apply(null, newImgBytes));
document.getElementById('newImage').src = `data:image/jpg;base64,${baseb4}`;
};
});
</script>
</body>
</html>
上述程式碼重點在於將 original.jpg
轉成 uint8 array:
srcImgBytes = new Uint8Array(xhr.response);
以及在 document
object 加入 1 個名稱為 callResize
的函式,該函式最關鍵的部分在於呼叫我們用 Go 實作的 Resize()
函式,並且將結果轉成 base64 後放到網頁上:
document.callResize = () => {
var width = parseInt(document.getElementById('width').value);
var height = parseInt(document.getElementById('height').value);
var newImgBytes = Resize(width, height, srcImgBytes);
var baseb4 = btoa(String.fromCharCode.apply(null, newImgBytes));
document.getElementById('newImage').src = `data:image/jpg;base64,${baseb4}`;
};
以上!就是用 Go + WebAssembly 實作前端縮圖程式的範例啦!
範例程式碼可以在 spitfire-sidra/go-webassembly-demo 找到,歡迎使用。
延伸議題
編譯成 WebAssembly 之後,如果有特別注意它的檔案大小的話,舉本文縮圖程式為例,可以發現它約 3.1M 左右,比起一般網頁資源都還大!想必會對 Web 載入速度、下載頻寬都有影響,要解決這個問題的話,可以試試使用特別針對檔案大小最佳化過的編譯器(compiler),例如 TinyGo 。
總結
大家應該都聽過諸葛孔明草船借箭的故事吧?WebAssembly 技術頗有將運算資源的範疇延伸到使用者端,相當於借使用者的裝置運算能力一用,既能節省後端相關資源,還能讓前端做到更多以往做不到的高效運算,是一個雙贏局面!
期待 WebAssembly 未來持續進步發展!
如果對 Go + WebAssembly 有興趣的話,也可以翻閱 Go 官方關於 WebAssembly 的 Wiki 喔!
以上!
Enjoy!
References
WebAssembly 的概念 - WebAssembly | MDN
https://github.com/golang/go/wiki/WebAssembly
js package - syscall/js - Go Packages
How to write ‘Hello World’ in WebAssembly
WebAssembly/wabt: The WebAssembly Binary Toolkit