如何將 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 或匯總等整合工具處理,且 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/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 會使用 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 ...
(這個其中包含下列所有檔案)。
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 相當強大,且有許多指令,但大多數情況下,您只需使用 FROM
、RUN
和 ADD
即可。在這種情況下:
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
時沒有綁定掛載點。但還有一個副作用,因此不再需要小睡片刻。