如何將 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 Registry 有我經常使用的 trzeci 提供的 Emscripten 映像檔。
與 npm 整合
在大多數情況下,網站專案的進入點是 npm 的 package.json
。依照慣例,大多數專案都可以使用 npm install &&
npm run build
建構。
一般來說,Emscripten 產生的建構成果 (.js
和 .wasm
檔案) 應視為另一個 JavaScript 模組和另一個資產。JavaScript 檔案可由 webpack 或 rollup 等套件處理器處理,而 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
是您接下來要編寫的 Shell 指令碼!--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++ 程式碼使用檔案系統作業時,它可以模擬檔案系統。它會對編譯的程式碼進行一些分析,決定是否要在黏合程式碼中加入檔案系統模擬功能。不過,有時這項分析可能會出錯,您可能會為不需要的檔案系統模擬作業付出相當龐大的 70 KB 額外黏合程式碼。您可以使用-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 後,DevTools 控制台中應會顯示以下輸出內容:
將 C/C++ 程式碼新增為依附元件
如果您想為網頁應用程式建構 C/C++ 程式庫,則需要將其程式碼納入專案。您可以手動將程式碼新增至專案的存放區,也可以使用 npm 來管理這類依附元件。假設我想在我的網路應用程式中使用 libvpx。libvpx 是用於以 VP8 編碼圖片的 C++ 程式庫,這是 .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 會使用 configure
和 make
進行建構。幸好,Emscripten 可確保 configure
和 make
使用 Emscripten 的編譯器。為此,我們提供包裝函式指令 emconfigure
和 emmake
:
# ... 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 為例,每次執行建構指令時,即使原始檔案未變更,它也會為 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 的說明文件。Doxygen 無法在 Docker 容器中使用,但您可以使用 apt
安裝。
如果您在 build.sh
中執行此操作,每次要建構程式庫時,都必須重新下載及安裝 Doxygen。這不僅會造成浪費,也會讓您無法在離線時處理專案。
因此,建議您自行建構 Docker 映像檔。建構 Docker 映像檔時,您必須編寫 Dockerfile
來說明建構步驟。Dockerfile 相當強大,且有許多指令,但大多數情況下,您只需使用 FROM
、RUN
和 ADD
即可。在這種情況下:
FROM trzeci/emscripten
RUN apt-get update && \
apt-get install -qqy doxygen
您可以使用 FROM
宣告要用來做為起點的 Docker 映像檔。我選擇 trzeci/emscripten
做為基礎,也就是您一直以來使用的圖片。使用 RUN
時,您會指示 Docker 在容器內執行殼層指令。這些指令對容器所做的任何變更,現在都會成為 Docker 映像檔的一部分。為確保 Docker 映像檔已建構,並且在執行 build.sh
前可供使用,您必須稍微調整 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 並複製 libvpx,因為執行 docker build
時沒有綁定掛載點。因此,您不再需要使用 napa。