橫練金剛!將 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 賦予開發者:

  1. 跨語言開發 Web 應用的能力,也就是能用各種語言編譯成 WebAssembly 之後,並且在瀏覽器甚至是在 Node.js 執行的能力,譬如知名的 Figma 其實就是編譯成 WebAssembly 後在瀏覽器執行的。
  2. 讓 Web 應用能夠執行對效能要求更高的計算型應用,譬如 3D 遊戲、物理模擬計算、電腦視覺、圖片編輯等等,傳統這些領域若單純使用 JavaScript 很容易就會遇到效能瓶頸,顯見 Figma 使用 WebAssembly 這項技術是一項很好的決策。
  3. 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.modgo.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.jswasm_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, 它的工作包含:

  1. 轉換 JavaScript 到 Go 的資料型態
  2. 轉換 Go 到 JavaScript 的資料型態
  3. export Go 定義的函式給 JavaScript 使用
  4. 存取前端 JavaScript 物件/函式
  5. 其他

不過 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 會用到的 windowglobal 物件,剩下的其實屬於 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-run.png

如何定義與呼叫 Go 的函式

前文提到用 Go 語言操作 DOM 不如直接使用 JavaScript 來得直覺,因此可以把前端相關的功能、元件交給 JavaScript ,讓 WebAssembly 專注在需要高效運算的部分。

為了達到此目的,最簡單的方式是:

  1. 將關鍵運算部分以 Go 函式實作
  2. 透過 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

go-exited.png

接著,同樣用以下指令編譯成 WebAssembly:

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

再來,同樣以 VS Code Live Server 擴充瀏覽 index.html , 並打開發者工具,切換到 console 輸入 Sum(1, 2) 就可以看到結果:

go-sum.png

這代表我們能透過 JavaScript 呼叫 Go 實作的函式!

做個 Image Resizer 驗收成果

至此,我們已經學會:

  1. 將 Go 程式碼編譯成 WebAssembly
  2. 透過 Go 程式碼與網頁 DOM 互動
  3. 以 Go 實作 JavaScript 能夠呼叫的函式

如此一來,我們已初步具備將需要高效運算的功能移植到 WebAssembly 的能力。

最後 1 個範例將會製作 1 個縮圖程式,縮圖程式有一些理由很適合在前端頁面處理,譬如每次縮圖都要將圖片送到後端進行的話,將會:

  1. 消耗頻寬
  2. 消耗後端伺服器運算資源
  3. 使用者需要等待網路傳輸與後端伺服器處理的時間

使用 WebAssembly 將可以有效解決上述 3 個問題,所有的運算都在瀏覽器內執行,使用者既不需等待網路傳輸與伺服器處理的時間,也不會額外消耗後端相關資源,一舉多得!

以下是範例完成的畫面:

go-webassembly-demo.png

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

對抗久坐職業傷害

研究指出每天增加 2 小時坐著的時間,會增加大腸癌、心臟疾病、肺癌的風險,也造成肩頸、腰背疼痛等常見問題。

然而對抗這些問題,卻只需要工作時定期休息跟伸展身體即可!

你想輕鬆改變現狀嗎?試試看我們的 PomodoRoll 番茄鐘吧! PomodoRoll 番茄鐘會根據你所設定的專注時間,定期建議你 1 項辦公族適用的伸展運動,幫助你打敗久坐所帶來的傷害!

贊助我們的創作

看完這篇文章了嗎? 休息一下,喝杯咖啡吧!

如果你覺得 MyApollo 有讓你獲得實用的資訊,希望能看到更多的技術分享,邀請你贊助我們一杯咖啡,讓我們有更多的動力與精力繼續提供高品質的文章,感謝你的支持!