Emscripten và npm

Làm cách nào để tích hợp WebAssembly vào chế độ thiết lập này? Trong bài viết này, chúng ta sẽ giải quyết vấn đề này bằng C/C++ và Emscripten làm ví dụ.

WebAssembly (wasm) thường được coi là một nguyên hàm hiệu suất hoặc một cách để chạy cơ sở mã C++ hiện có trên web. Với squoosh.app, chúng tôi muốn cho thấy rằng ít nhất có một quan điểm thứ ba về wasm: tận dụng hệ sinh thái khổng lồ của các ngôn ngữ lập trình khác. Với Emscripten, bạn có thể sử dụng mã C/C++, Rust có hỗ trợ wasm tích hợp sẵn và nhóm Go cũng đang làm việc trên đó. Tôi chắc chắn rằng nhiều ngôn ngữ khác cũng sẽ được hỗ trợ.

Trong những trường hợp này, wasm không phải là trung tâm của ứng dụng mà là một mảnh ghép: một mô-đun khác. Ứng dụng của bạn đã có JavaScript, CSS, thành phần hình ảnh, hệ thống xây dựng tập trung vào web và thậm chí có thể là một khung như React. Làm cách nào để tích hợp WebAssembly vào chế độ thiết lập này? Trong bài viết này, chúng ta sẽ giải quyết vấn đề này bằng ví dụ về C/C++ và Emscripten.

Docker

Tôi nhận thấy Docker rất hữu ích khi làm việc với Emscripten. Thư viện C/C++ thường được viết để hoạt động với hệ điều hành mà thư viện đó được xây dựng. Việc có một môi trường nhất quán là vô cùng hữu ích. Với Docker, bạn sẽ có một hệ thống Linux ảo hoá đã được thiết lập để hoạt động với Emscripten và đã cài đặt tất cả các công cụ và phần phụ thuộc. Nếu thiếu một phần nào đó, bạn chỉ cần cài đặt phần đó mà không phải lo lắng về việc phần đó ảnh hưởng như thế nào đến máy của bạn hoặc các dự án khác. Nếu có lỗi xảy ra, hãy loại bỏ vùng chứa và bắt đầu lại. Nếu công thức hoạt động một lần, bạn có thể chắc chắn rằng công thức đó sẽ tiếp tục hoạt động và tạo ra kết quả giống hệt.

Docker Registry có một hình ảnh Emscripten của trzeci mà tôi đã sử dụng rộng rãi.

Tích hợp với npm

Trong hầu hết các trường hợp, điểm truy cập vào dự án web là package.json của npm. Theo quy ước, hầu hết các dự án đều có thể được tạo bằng npm install && npm run build.

Nhìn chung, các cấu phần phần mềm bản dựng do Emscripten tạo ra (tệp .js.wasm) chỉ được coi là một mô-đun JavaScript và một tài sản khác. Tệp JavaScript có thể được xử lý bằng trình đóng gói như webpack hoặc rollup, và tệp wasm phải được coi như mọi tài sản nhị phân lớn hơn khác, chẳng hạn như hình ảnh.

Do đó, bạn cần tạo các cấu phần phần mềm bản dựng Emscripten trước khi quá trình tạo bản dựng "bình thường" bắt đầu:

{
    "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",
    // ...
    },
    // ...
}

Tác vụ build:emscripten mới có thể gọi trực tiếp Emscripten, nhưng như đã đề cập trước đó, bạn nên sử dụng Docker để đảm bảo môi trường xây dựng nhất quán.

docker run ... trzeci/emscripten ./build.sh yêu cầu Docker khởi động một vùng chứa mới bằng hình ảnh trzeci/emscripten và chạy lệnh ./build.sh. build.sh là một tập lệnh shell mà bạn sẽ viết tiếp theo! --rm yêu cầu Docker xoá vùng chứa sau khi chạy xong. Bằng cách này, bạn sẽ không tạo ra một bộ sưu tập hình ảnh máy cũ theo thời gian. -v $(pwd):/src có nghĩa là bạn muốn Docker "phản chiếu" thư mục hiện tại ($(pwd)) thành /src bên trong vùng chứa. Mọi thay đổi bạn thực hiện đối với các tệp trong thư mục /src bên trong vùng chứa sẽ được phản ánh vào dự án thực tế của bạn. Các thư mục được phản chiếu này được gọi là "điểm gắn liên kết".

Hãy cùng xem 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 "============================================="

Có rất nhiều điều cần phân tích ở đây!

set -e đặt shell vào chế độ "fail fast" (lỗi nhanh). Nếu bất kỳ lệnh nào trong tập lệnh trả về lỗi, toàn bộ tập lệnh sẽ bị huỷ ngay lập tức. Điều này có thể cực kỳ hữu ích vì kết quả cuối cùng của tập lệnh sẽ luôn là thông báo thành công hoặc lỗi khiến bản dựng không thành công.

Với câu lệnh export, bạn xác định giá trị của một vài biến môi trường. Các lớp này cho phép bạn truyền các tham số dòng lệnh bổ sung đến trình biên dịch C (CFLAGS), trình biên dịch C++ (CXXFLAGS) và trình liên kết (LDFLAGS). Tất cả các lớp này đều nhận được chế độ cài đặt trình tối ưu hoá thông qua OPTIMIZE để đảm bảo mọi thứ đều được tối ưu hoá theo cùng một cách. Có một số giá trị có thể có cho biến OPTIMIZE:

  • -O0: Không tối ưu hoá. Không có mã chết nào bị loại bỏ và Emscripten cũng không rút gọn mã JavaScript mà nó phát ra. Thích hợp để gỡ lỗi.
  • -O3: Tối ưu hoá hiệu quả.
  • -Os: Tối ưu hoá hiệu suất và kích thước một cách mạnh mẽ dưới dạng tiêu chí phụ.
  • -Oz: Tối ưu hoá mạnh mẽ về kích thước, hy sinh hiệu suất nếu cần.

Đối với web, bạn nên sử dụng -Os.

Lệnh emcc có vô số tuỳ chọn riêng. Xin lưu ý rằng emcc được coi là "trình thay thế trực tiếp cho các trình biên dịch như GCC hoặc clang". Vì vậy, tất cả các cờ mà bạn có thể biết từ GCC rất có thể cũng sẽ được emcc triển khai. Cờ -s đặc biệt ở chỗ cho phép chúng ta định cấu hình Emscripten một cách cụ thể. Bạn có thể tìm thấy tất cả các tuỳ chọn có sẵn trong settings.js của Emscripten, nhưng tệp đó có thể khá khó hiểu. Dưới đây là danh sách các cờ Emscripten mà tôi cho là quan trọng nhất đối với các nhà phát triển web:

  • --bind cho phép embind.
  • -s STRICT=1 ngừng hỗ trợ tất cả các tuỳ chọn bản dựng không dùng nữa. Điều này đảm bảo rằng mã của bạn được tạo theo cách tương thích chuyển tiếp.
  • -s ALLOW_MEMORY_GROWTH=1 cho phép bộ nhớ tự động tăng nếu cần. Tại thời điểm viết, Emscripten sẽ phân bổ 16 MB bộ nhớ ban đầu. Khi mã của bạn phân bổ các khối bộ nhớ, tuỳ chọn này sẽ quyết định xem các thao tác này có khiến toàn bộ mô-đun wasm không hoạt động khi bộ nhớ đã hết hay không, hoặc liệu mã keo có được phép mở rộng tổng bộ nhớ để phù hợp với việc phân bổ hay không.
  • -s MALLOC=... chọn cách triển khai malloc() nào sẽ sử dụng. emmalloc là một cách triển khai malloc() nhỏ và nhanh dành riêng cho Emscripten. Phương án thay thế là dlmalloc, một phương thức triển khai malloc() hoàn chỉnh. Bạn chỉ cần chuyển sang dlmalloc nếu thường xuyên phân bổ nhiều đối tượng nhỏ hoặc nếu bạn muốn sử dụng luồng.
  • -s EXPORT_ES6=1 sẽ biến mã JavaScript thành một mô-đun ES6 với một lệnh xuất mặc định hoạt động với mọi trình kết hợp. Ngoài ra, bạn cũng cần đặt -s MODULARIZE=1.

Các cờ sau đây không phải lúc nào cũng cần thiết hoặc chỉ hữu ích cho mục đích gỡ lỗi:

  • -s FILESYSTEM=0 là một cờ liên quan đến Emscripten và khả năng mô phỏng hệ thống tệp cho bạn khi mã C/C++ sử dụng các thao tác hệ thống tệp. Công cụ này thực hiện một số phân tích về mã được biên dịch để quyết định có đưa tính năng mô phỏng hệ thống tệp vào mã keo hay không. Tuy nhiên, đôi khi, phân tích này có thể không chính xác và bạn phải trả thêm 70 kB mã keo cho một hoạt động mô phỏng hệ thống tệp mà bạn có thể không cần. Với -s FILESYSTEM=0, bạn có thể buộc Emscripten không đưa mã này vào.
  • -g4 sẽ khiến Emscripten đưa thông tin gỡ lỗi vào .wasm và cũng phát một tệp bản đồ nguồn cho mô-đun wasm. Bạn có thể đọc thêm về cách gỡ lỗi bằng Emscripten trong phần gỡ lỗi.

Vậy là xong! Để kiểm thử chế độ thiết lập này, hãy tạo một my-module.cpp nhỏ:

    #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>

(Dưới đây là một tinh thần chứa tất cả các tệp.)

Để tạo mọi thứ, hãy chạy

$ npm install
$ npm run build
$ npm run serve

Khi chuyển đến localhost:8080, bạn sẽ thấy kết quả sau trong bảng điều khiển DevTools:

Công cụ cho nhà phát triển hiển thị một thông báo được in qua C++ và Emscripten.

Thêm mã C/C++ làm phần phụ thuộc

Nếu muốn tạo thư viện C/C++ cho ứng dụng web, bạn cần đưa mã của thư viện đó vào dự án. Bạn có thể thêm mã vào kho lưu trữ của dự án theo cách thủ công hoặc sử dụng npm để quản lý các loại phần phụ thuộc này. Giả sử tôi muốn sử dụng libvpx trong ứng dụng web của mình. libvpx là một thư viện C++ để mã hoá hình ảnh bằng VP8, bộ mã hoá và giải mã được dùng trong các tệp .webm. Tuy nhiên, libvpx không có trên npm và không có package.json, vì vậy, tôi không thể cài đặt trực tiếp bằng npm.

Để giải quyết vấn đề này, bạn có thể sử dụng napa. Napa cho phép bạn cài đặt bất kỳ URL kho lưu trữ git nào dưới dạng phần phụ thuộc vào thư mục node_modules.

Cài đặt napa làm phần phụ thuộc:

$ npm install --save napa

và nhớ chạy napa dưới dạng tập lệnh cài đặt:

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

Khi bạn chạy npm install, napa sẽ sao chép kho lưu trữ GitHub libvpx vào node_modules của bạn với tên libvpx.

Giờ đây, bạn có thể mở rộng tập lệnh bản dựng để tạo libvpx. libvpx sử dụng configuremake để tạo. May mắn thay, Emscripten có thể giúp đảm bảo rằng configuremake sử dụng trình biên dịch của Emscripten. Để phục vụ cho mục đích này, có các lệnh trình bao bọc 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 ...

Thư viện C/C++ được chia thành hai phần: tiêu đề (thông thường là tệp .h hoặc .hpp) xác định cấu trúc dữ liệu, lớp, hằng số, v.v. mà thư viện hiển thị và thư viện thực tế (thông thường là tệp .so hoặc .a). Để sử dụng hằng số VPX_CODEC_ABI_VERSION của thư viện trong mã, bạn phải đưa các tệp tiêu đề của thư viện vào bằng câu lệnh #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;
}

Vấn đề là trình biên dịch không biết nơi để tìm vpxenc.h. Đây là mục đích của cờ -I. Tệp này cho trình biên dịch biết cần kiểm tra thư mục nào để tìm tệp tiêu đề. Ngoài ra, bạn cũng cần cung cấp cho trình biên dịch tệp thư viện thực tế:

# ... 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 ...

Nếu chạy npm run build ngay bây giờ, bạn sẽ thấy quá trình này tạo một .js mới và một tệp .wasm mới, đồng thời trang minh hoạ sẽ thực sự xuất ra hằng số:

DevTools cho thấy phiên bản ABI của libvpx được in qua emscripten.

Bạn cũng sẽ nhận thấy rằng quá trình tạo bản dựng mất nhiều thời gian. Lý do dẫn đến thời gian tạo bản dựng lâu có thể khác nhau. Trong trường hợp libvpx, quá trình này sẽ mất nhiều thời gian vì thư viện này biên dịch bộ mã hoá và bộ giải mã cho cả VP8 và VP9 mỗi khi bạn chạy lệnh bản dựng, mặc dù các tệp nguồn không thay đổi. Ngay cả một thay đổi nhỏ đối với my-module.cpp cũng sẽ mất nhiều thời gian để tạo. Bạn nên giữ lại các cấu phần phần mềm bản dựng của libvpx sau khi tạo lần đầu tiên.

Một cách để đạt được điều này là sử dụng biến môi trường.

# ... 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 ...

(Dưới đây là tinh thần chứa tất cả các tệp.)

Lệnh eval cho phép chúng ta đặt biến môi trường bằng cách truyền các tham số vào tập lệnh bản dựng. Lệnh test sẽ bỏ qua việc tạo libvpx nếu bạn đặt $SKIP_LIBVPX (thành bất kỳ giá trị nào).

Bây giờ, bạn có thể biên dịch mô-đun nhưng bỏ qua việc tạo lại libvpx:

$ npm run build:emscripten -- SKIP_LIBVPX=1

Tuỳ chỉnh môi trường xây dựng

Đôi khi, thư viện phụ thuộc vào các công cụ bổ sung để tạo. Nếu thiếu các phần phụ thuộc này trong môi trường xây dựng do hình ảnh Docker cung cấp, bạn cần tự thêm các phần phụ thuộc đó. Ví dụ: giả sử bạn cũng muốn tạo tài liệu về libvpx bằng doxygen. Doxygen không có sẵn trong vùng chứa Docker, nhưng bạn có thể cài đặt Doxygen bằng apt.

Nếu thực hiện việc đó trong build.sh, bạn sẽ phải tải xuống và cài đặt lại doxygen mỗi khi muốn tạo thư viện. Điều này không chỉ lãng phí mà còn khiến bạn không thể làm việc trên dự án khi không có kết nối Internet.

Do đó, bạn nên tạo hình ảnh Docker của riêng mình. Hình ảnh Docker được tạo bằng cách viết một Dockerfile mô tả các bước xây dựng. Tệp Docker khá mạnh mẽ và có nhiều lệnh, nhưng hầu hết thời gian bạn chỉ cần sử dụng FROM, RUNADD. Trong trường hợp này:

FROM trzeci/emscripten

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

Với FROM, bạn có thể khai báo hình ảnh Docker mà bạn muốn sử dụng làm điểm xuất phát. Tôi chọn trzeci/emscripten làm cơ sở – hình ảnh mà bạn đã sử dụng từ đầu. Với RUN, bạn hướng dẫn Docker chạy các lệnh shell bên trong vùng chứa. Mọi thay đổi mà các lệnh này thực hiện đối với vùng chứa hiện là một phần của hình ảnh Docker. Để đảm bảo hình ảnh Docker đã được tạo và có sẵn trước khi chạy build.sh, bạn phải điều chỉnh package.json một chút:

{
    // ...
    "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",
    // ...
    },
    // ...
}

(Dưới đây là tinh thần chứa tất cả các tệp.)

Thao tác này sẽ tạo hình ảnh Docker, nhưng chỉ khi hình ảnh đó chưa được tạo. Sau đó, mọi thứ sẽ chạy như trước, nhưng giờ đây, môi trường xây dựng có sẵn lệnh doxygen, điều này sẽ giúp tài liệu về libvpx được tạo.

Kết luận

Không có gì đáng ngạc nhiên khi mã C/C++ và npm không phù hợp với nhau, nhưng bạn có thể làm cho mã này hoạt động khá thoải mái với một số công cụ bổ sung và tính năng tách biệt mà Docker cung cấp. Cấu hình này sẽ không phù hợp với mọi dự án, nhưng đây là một điểm xuất phát hợp lý mà bạn có thể điều chỉnh cho phù hợp với nhu cầu của mình. Nếu bạn có ý kiến cải tiến, vui lòng chia sẻ.

Phụ lục: Sử dụng các lớp hình ảnh Docker

Một giải pháp thay thế là đóng gói nhiều vấn đề hơn trong số này bằng Docker và phương pháp lưu vào bộ nhớ đệm thông minh của Docker. Docker thực thi tệp Docker theo từng bước và chỉ định kết quả của mỗi bước là một hình ảnh riêng. Những hình ảnh trung gian này thường được gọi là "lớp". Nếu một lệnh trong Dockerfile không thay đổi, Docker sẽ không thực sự chạy lại bước đó khi bạn tạo lại Dockerfile. Thay vào đó, lớp này sẽ sử dụng lại lớp từ lần tạo hình ảnh gần đây nhất.

Trước đây, bạn phải nỗ lực để không tạo lại libvpx mỗi khi tạo ứng dụng. Thay vào đó, bạn có thể di chuyển hướng dẫn tạo cho libvpx từ build.sh vào Dockerfile để sử dụng cơ chế lưu vào bộ nhớ đệm của 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

(Dưới đây là tinh thần chứa tất cả các tệp.)

Xin lưu ý rằng bạn cần cài đặt git và nhân bản libvpx theo cách thủ công vì bạn không có các điểm gắn liên kết khi chạy docker build. Do đó, bạn không cần sử dụng napa nữa.