編寫與 npm

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

WebAssembly (wasm) 通常是效能原始的影格,或是網路執行現有 C++ 程式碼集的方式。我們希望透過 squoosh.app,讓我們可以看到至少還有第三個觀點:使用其他程式設計語言的大型生態系統。透過 Emscripten,您可以使用 C/C++ 程式碼、內建 Rust 支援,而且 Go 團隊也會著手處理。我保證日後會支援多種語言。

在這些情況下,他們不是應用程式的中心,而是另一個模組。您的應用程式已有 JavaScript、CSS、圖片素材資源、以網頁為中心的建構系統,甚至是 React 這類架構。如何將 WebAssembly 整合至此設定?在本文中,我們將以 C/C++ 和 Emscripten 為例進行說明。

Docker

我發現 Docker 在使用 Emscripten 時非常寶貴。C/C++ 程式庫通常會加以編寫,以便與建構該程式庫的作業系統搭配使用。使用一致的環境十分有幫助。Docker 會為您提供已設定為可與 Emscripten 搭配使用的虛擬化 Linux 系統,並已安裝所有工具和依附元件。如果缺少任何項目,則只須安裝即可,無須擔心它會對您自己的機器或其他專案造成任何影響。如果發生問題,請捨棄容器並重新開始。如果執行一次,您可以確定它會繼續運作並產生相同的結果。

Docker RegistryEmscripten 映像檔,這些都是我很常使用的 trzeci

與 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:不做任何最佳化。移除無效程式碼,Escripten 也不會壓縮其發出的 JavaScript 程式碼。適合用於偵錯。
  • -O3:積極最佳化效能。
  • -Os:以次要條件對效能和大小主動進行最佳化調整。
  • -Oz:針對大小主動最佳化,在必要時犧牲效能。

針對網頁版,我強烈建議使用 -Os

emcc 指令有各種選項。請注意,emcc 會被設為「直接取代 GCC 或 clang 等編譯器」。因此,您可能知道從 GCC 知道的所有標記,很可能也會以 emcc 實作。-s 旗標十分特殊,可讓我們特別設定 Emscripten。所有可用選項都可在 Emscripten 的 settings.js 中找到,但這個檔案可能會令人不知所措。以下為網頁程式開發人員最重視的 Emscripten 標記清單:

  • --bind 可啟用組合功能。
  • -s STRICT=1 停止支援所有已淘汰的建構選項。這可確保您的程式碼以前瞻相容性的方式建構。
  • -s ALLOW_MEMORY_GROWTH=1 可在必要時自動增加記憶體。在寫入時,Emmscripten 一開始會分配 16 MB 的記憶體。當程式碼配置記憶體區塊時,這個選項會決定這些作業會在記憶體耗盡時導致整個 wasm 模組失敗,或者是否允許 glue 程式碼擴充記憶體總量以配合配置。
  • -s MALLOC=... 會選擇要使用的 malloc() 實作方式。emmalloc 是專門用於 Emscripten 的小型 malloc() 實作項目。替代方法是 dlmalloc,這是完善的 malloc() 實作。只有在經常配置大量小型物件或想要使用執行緒時,才需要切換為 dlmalloc
  • -s EXPORT_ES6=1 會將 JavaScript 程式碼轉換為 ES6 模組,其中包含與任何 Bundler 搭配使用的預設匯出項目。也需要設定 -s MODULARIZE=1

下列標記並非一律必要,也不一定有助於偵錯:

  • -s FILESYSTEM=0 是與 Emscripten 相關的標記,可在 C/C++ 程式碼使用檔案系統作業時模擬檔案系統。會對編譯的程式碼進行分析,以判斷是否要在 glue 程式碼中加入檔案系統模擬。然而,有時這項分析可能會出錯,而對於您可能不需要的檔案系統模擬,您需要支付大量 70kB 的額外 glue 程式碼。您可以使用 -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>

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

如要建構一切,請執行

$ npm install
$ npm run build
$ npm run serve

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

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

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

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

此難題可以使用 napa。napa 可讓您將任何 Git 存放區網址做為依附元件安裝到 node_modules 資料夾。

安裝 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 進行建構。幸好,Escripten 可以協助確保 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 ...

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

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

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

請注意,您需要手動安裝 Git 和本機副本,因為執行 docker build 時沒有繫結掛接。作為副作用,就不再需要 Napa。