編寫與 npm

如何將 WebAssembly 整合至這項設定中?在本文中,我們會以 C/C++ 和 Emscripten 做為範例。

WebAssembly (wasm) 通常是以效能原始為基礎,或在網路上執行現有 C++ 程式碼集的方法。我們希望透過 squoosh.app 證明 WASM 至少還有第三種觀點:利用其他程式設計語言的龐大生態系統。Emscripten 可讓您使用 C/C++ 程式碼,Rust 內建了 WASM 支援Go 團隊也正在進行相關工作。我相信許多其他語言也會跟進。

在這些情況下,WASM 並非應用程式的重點,而是拼圖的一部分:另一個模組。您的應用程式已具備 JavaScript、CSS、圖片素材資源、以網頁為中心的建構系統,甚至是 React 這類架構。您要如何將 WebAssembly 整合至這項設定中?在本文中,我們會以 C/C++ 和 Emscripten 做為範例。

Docker

我發現在使用 Emscripten 時,Docker 非常實用。您通常會編寫 C/C++ 程式庫,以與建構的作業系統搭配使用。能有一致的環境也非常有幫助。使用 Docker 可取得虛擬化 Linux 系統,該系統已設定為與 Emscripten 搭配運作,並已安裝所有工具和依附元件。如果缺少某些內容,您可以直接安裝,不必擔心會對自己的機器或其他專案造成影響。如果發生錯誤,請丟棄容器並重新開始。如果一次運作成功,您可以確定它會繼續運作並產生相同的結果。

Docker Registrytrzeci 廣泛使用的 Emscripten 映像檔

與 npm 整合

在大多數情況下,網路專案的進入點為 npm 的 package.json。按照慣例,大多數專案都能使用 npm install && npm run build 建構。

一般來說,Emscripten 產生的建構成果 (.js.wasm 檔案) 應視為另一個 JavaScript 模組,只應視為另一個資產。JavaScript 檔案可由 webpack 或匯總等整合工具處理,且 wasm 檔案應視為圖片等其他大型二進位資產。

因此,您需要在啟用「正常」建構程序之前建構 Emscripten 建構成果:

{
    "name": "my-worldchanging-project",
    "scripts": {
    "build:emscripten": "docker run --rm -v $(pwd):/src trzeci/emscripten
./build.sh",
    "build:app": "<the old build command>",
    "build": "npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

新的 build:emscripten 工作可以直接叫用 Emscripten,但如前所述,我們建議您使用 Docker 來確保建構環境一致。

docker run ... trzeci/emscripten ./build.sh 會指示 Docker 使用 trzeci/emscripten 映像檔啟動新的容器,並執行 ./build.sh 指令。build.sh 是您接下來要編寫的殼層指令碼!--rm 會指示 Docker 在容器執行完畢後刪除容器。這樣一來,您就不必隨時間建構過時的機器映像檔集合。-v $(pwd):/src 表示您希望 Docker 將當前目錄 ($(pwd))「鏡像」到容器內的 /src。您對容器中 /src 目錄內檔案所做的任何變更,都會鏡射到實際專案。這些鏡像的目錄稱為「繫結掛接」

讓我們看看 build.sh

#!/bin/bash

set -e

export OPTIMIZE="-Os"
export LDFLAGS="${OPTIMIZE}"
export CFLAGS="${OPTIMIZE}"
export CXXFLAGS="${OPTIMIZE}"

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    src/my-module.cpp

    # Create output folder
    mkdir -p dist
    # Move artifacts
    mv my-module.{js,wasm} dist
)
echo "============================================="
echo "Compiling wasm bindings done"
echo "============================================="

這裡有很多內容要分析!

set -e 可讓殼層進入「失敗快速」模式。如果指令碼中的任何指令傳回錯誤,系統就會立即取消整個指令碼。這點非常實用,因為指令碼的最後輸出內容一律會是成功訊息,或是導致建構失敗的錯誤。

使用 export 陳述式,您可以定義幾個環境變數的值。這些參數可讓您將額外的命令列參數傳遞至 C 編譯器 (CFLAGS)、C++ 編譯器 (CXXFLAGS) 和連結器 (LDFLAGS)。這些參數都會透過 OPTIMIZE 接收最佳化工具設定,確保所有項目都以相同方式進行最佳化。OPTIMIZE 變數有幾個可能的值:

  • -O0:不進行任何最佳化作業。不會移除任何無效程式碼,Emscripten 也不會壓縮所產生的 JavaScript 程式碼。適合偵錯。
  • -O3:積極進行最佳化以提升成效。
  • -Os:以次要條件為效能和大小積極最佳化。
  • -Oz:積極縮減大小,視需要犧牲效能。

關於網頁版,我通常會建議使用 -Os

emcc 指令有好幾種選項。請注意,emcc 取代了「GCC 或 clang」等編譯器的內建取代功能。因此,您可能是在 GCC 中知道的所有標記,很可能也會透過 emcc 實作。-s 標記的特別之處,在於我們能特別設定 Emscripten。您可以在 Emscripten 的 settings.js 中找到所有可用選項,但這個檔案可能會相當吃力。以下是我認為對網頁程式開發人員來說最重要的 Emscripten 標記清單:

  • --bind 會啟用embind
  • -s STRICT=1 停止支援所有已淘汰的建構選項。這樣可確保程式碼以向前相容的方式建構。
  • -s ALLOW_MEMORY_GROWTH=1 可在必要時自動增加記憶體。在撰寫本文時,Emscripten 會一開始分配 16 MB 的記憶體。當程式碼分配記憶體區塊時,這個選項會決定這些作業是否會在記憶體用盡時導致整個 WASM 模組失敗,或是允許黏合程式碼擴充總記憶體,以便配合分配作業。
  • -s MALLOC=... 會選擇要使用的 malloc() 實作項目。emmalloc 是專為 Emscripten 且快速且快速的 malloc() 實作項目。替代方案是 dlmalloc,這是完整的 malloc() 實作。只有在經常分配大量小型物件,或想要使用執行緒時,才需要切換至 dlmalloc
  • -s EXPORT_ES6=1 會將 JavaScript 程式碼轉換為 ES6 模組,並具備可與任何組合工具搭配使用的預設匯出功能。此外,您還必須設定 -s MODULARIZE=1

下列旗標不一定必要,或僅供偵錯使用:

  • -s FILESYSTEM=0 是與 Emscripten 相關的標記,可在 C/C++ 程式碼使用檔案系統作業時,為您模擬檔案系統。它會對編譯的程式碼進行一些分析,決定是否要在黏合程式碼中加入檔案系統模擬功能。不過,這類分析有時可能會出錯,而您可以額外支付 70kB 的額外黏合程式碼,處理您不需要的檔案系統模擬作業。您可以使用 -s FILESYSTEM=0 強制 Emscripten 不納入此程式碼。
  • -g4 可讓 Emscripten 在 .wasm 中加入偵錯資訊,並發出 wasm 模組的來源對應檔案。如要進一步瞭解如何使用 Emscripten 偵錯,請參閱偵錯章節

就是這樣!為測試這項設定,讓我們建立新的 my-module.cpp

    #include <emscripten/bind.h>

    using namespace emscripten;

    int say_hello() {
      printf("Hello from your wasm module\n");
      return 0;
    }

    EMSCRIPTEN_BINDINGS(my_module) {
      function("sayHello", &say_hello);
    }

以及 index.html

    <!doctype html>
    <title>Emscripten + npm example</title>
    Open the console to see the output from the wasm module.
    <script type="module">
    import wasmModule from "./my-module.js";

    const instance = wasmModule({
      onRuntimeInitialized() {
        instance.sayHello();
      }
    });
    </script>

(這是包含所有檔案的來源)。

如要建構所有內容,請執行

$ npm install
$ npm run build
$ npm run serve

前往 localhost:8080 後,開發人員工具控制台中應會顯示下列輸出內容:

開發人員工具顯示透過 C++ 和 Emscripten 列印的訊息。

將 C/C++ 程式碼新增為依附元件

如要為網頁應用程式建構 C/C++ 程式庫,您需要將其程式碼加入專案中。您可以將程式碼手動新增至專案的存放區,也可以使用 npm 管理這些類型的依附元件。假設我想在我的網路應用程式中使用 libvpx。libvpx 是用於以 VP8 編碼圖片的 C++ 程式庫,也是 .webm 檔案中使用的轉碼器。不過,libvpx 不在 npm 上,也沒有 package.json,因此我無法直接使用 npm 安裝它。

napa 就是其中之一。napa 可讓您在 node_modules 資料夾中,將任何 Git 存放區網址做為依附元件安裝。

安裝 napa 做為依附元件:

$ npm install --save napa

並確認將 napa 做為安裝指令碼執行:

{
// ...
"scripts": {
    "install": "napa",
    // ...
},
"napa": {
    "libvpx": "git+https://github.com/webmproject/libvpx"
}
// ...
}

執行 npm install 時,napa 會負責將 libvpx GitHub 存放區複製到 node_modules 名稱下的 libvpx

您現在可以擴充建構指令碼來建構 libvpx。libvpx 會使用 configuremake 進行建構。幸運的是,Emscripten 可確保 configuremake 使用 Emscripten 的編譯器。為此,您可以使用包裝函式指令 emconfigureemmake

# ... above is unchanged ...
echo "============================================="
echo "Compiling libvpx"
echo "============================================="
(
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="

echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
# ... below is unchanged ...

C/C++ 程式庫會分成兩個部分:定義程式庫公開的資料結構、類別、常數等的標頭 (傳統上為 .h.hpp 檔案),以及實際的程式庫 (傳統上為 .so.a 檔案)。如要在程式碼中使用程式庫的 VPX_CODEC_ABI_VERSION 常數,您必須使用 #include 陳述式加入程式庫的標頭檔案:

#include "vpxenc.h"
#include <emscripten/bind.h>

int say_hello() {
    printf("Hello from your wasm module with libvpx %d\n", VPX_CODEC_ABI_VERSION);
    return 0;
}

問題是編譯器不知道要在哪裡尋找 vpxenc.h。這就是 -I 標記的用途。這會告知編譯器要檢查哪些目錄檔案。此外,您還需要將實際的程式庫檔案提供給編譯器:

# ... above is unchanged ...
echo "============================================="
echo "Compiling wasm bindings"
echo "============================================="
(
    # Compile C/C++ code
    emcc \
    ${OPTIMIZE} \
    --bind \
    -s STRICT=1 \
    -s ALLOW_MEMORY_GROWTH=1 \
    -s ASSERTIONS=0 \
    -s MALLOC=emmalloc \
    -s MODULARIZE=1 \
    -s EXPORT_ES6=1 \
    -o ./my-module.js \
    -I ./node_modules/libvpx \
    src/my-module.cpp \
    build-vpx/libvpx.a

# ... below is unchanged ...

如果現在執行 npm run build,您會發現該程序會建構新的 .js 和新的 .wasm 檔案,且示範頁面確實會輸出常數:

開發人員工具顯示 libvpx 的 ABI 版本,而該版本是以轉錄稿顯示。

您也會發現建構程序花費的時間較長。導致建構時間過長的原因可能不盡相同。以 libvpx 來說,由於來源檔案沒有變更,因此每次執行建構指令時,這個程式庫都會針對 VP8 和 VP9 編譯編碼器和解碼器編譯編碼器與解碼器。即使對 my-module.cpp 進行小幅變更,建構時間也會很長。在首次建構 libvpx 的建構構件後,將有助於保留其建構成果。

其中一種做法就是使用環境變數。

# ... above is unchanged ...
eval $@

echo "============================================="
echo "Compiling libvpx"
echo "============================================="
test -n "$SKIP_LIBVPX" || (
    rm -rf build-vpx || true
    mkdir build-vpx
    cd build-vpx
    emconfigure ../node_modules/libvpx/configure \
    --target=generic-gnu
    emmake make
)
echo "============================================="
echo "Compiling libvpx done"
echo "============================================="
# ... below is unchanged ...

(這個其中包含下列所有檔案)。

eval 指令可將參數傳遞至建構指令碼,藉此設定環境變數。如果將 $SKIP_LIBVPX 設為任何值,test 指令會略過建構 libvpx。

您現在可以編譯模組,但略過重新建構 libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

自訂建構環境

有時程式庫必須仰賴額外工具進行建構。如果 Docker 映像檔提供的建構環境中缺少這些依附元件,您必須自行新增。舉例來說,假設您要使用 doxygen 建立 libvpx 的說明文件,您的 Docker 容器不提供 Doxygen,但您可以使用 apt 安裝。

如果您在 build.sh 中執行此操作,每次要建構程式庫時,都必須重新下載及安裝 Doxygen。這不僅會造成浪費,也會讓您無法在離線時處理專案。

在這裡建構自己的 Docker 映像檔是很合理的。建構 Docker 映像檔時,您必須編寫 Dockerfile 來說明建構步驟。Dockerfile 相當強大,且有許多指令,但大多數情況下,您只需使用 FROMRUNADD 即可。在這種情況下:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen

您可以使用 FROM 宣告要用來做為起點的 Docker 映像檔。我選擇 trzeci/emscripten 做為基礎,也就是您用到的映像檔。使用 RUN 即可指示 Docker 在容器內執行殼層指令。無論這些指令對容器所做的任何變更,現在都屬於 Docker 映像檔的一部分。為了確保已在執行 build.sh 前建構 Docker 映像檔,且可供使用,您必須調整 package.json 位元:

{
    // ...
    "scripts": {
    "build:dockerimage": "docker image inspect -f '.' mydockerimage || docker build -t mydockerimage .",
    "build:emscripten": "docker run --rm -v $(pwd):/src mydockerimage ./build.sh",
    "build": "npm run build:dockerimage && npm run build:emscripten && npm run build:app",
    // ...
    },
    // ...
}

(以下是包含所有檔案的 Gist)。

這會建構 Docker 映像檔,但僅限尚未建構的映像檔。接著系統會照常執行各項作業,但建構環境現已提供 doxygen 指令,因此也會建構 libvpx 的說明文件。

結論

C/C++ 程式碼和 npm 並不意外,但您可以使用一些額外的工具和 Docker 提供的隔離機制,讓此函式更加舒適。這項設定並非適用於所有專案,但可做為起點,您可以視需求進行調整。如果您有所改進,歡迎與我們分享。

附錄:使用 Docker 映像檔層

另一個解決方法是使用 Docker 和 Docker 的智慧快取方法,封裝更多這類問題。Docker 會逐一執行 Dockerfile,並將每個步驟的結果指派給各自的映像檔。這些中間圖片通常稱為「圖層」。如果 Dockerfile 中的指令未變更,在重新建構 Dockerfile 時,Docker 實際上不會重新執行該步驟。而是重複使用上次建構圖片時的圖層。

過去,您須花費一些心力,不要在每次建構應用程式時重新建構 libvpx。您可以改為將 libvpx 的建構操作說明從 build.sh 移至 Dockerfile,以便使用 Docker 的快取機制:

FROM trzeci/emscripten

RUN apt-get update && \
    apt-get install -qqy doxygen git && \
    mkdir -p /opt/libvpx/build && \
    git clone https://github.com/webmproject/libvpx /opt/libvpx/src
RUN cd /opt/libvpx/build && \
    emconfigure ../src/configure --target=generic-gnu && \
    emmake make

(這個其中包含下列所有檔案)。

請注意,您需要手動安裝 git 並複製 libvpx,因為執行 docker build 時沒有綁定掛載點。但還有一個副作用,因此不再需要小睡片刻。