Emscripten và npm

Bạn tích hợp WebAssembly vào chế độ thiết lập này như thế nào? 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 đóng khung là một hiệu suất gốc hoặc là một cách để chạy cơ sở mã C++ hiện có của bạn trên web. Với squoosh.app, chúng tôi muốn cho thấy rằng có ít nhất một góc nhìn thứ ba dành cho wasm, đó là việc tận dụng các 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 để bạn 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 thấy Docker là vô giá 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 bạn 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 được ảo hoá, vốn đã được thiết lập để hoạt động với Emscripten, đồng thời đã cài đặt tất cả các công cụ và phần phụ thuộc. Nếu thiếu tính năng nào đó, bạn có thể chỉ cần cài đặt mà không phải lo lắng về mức độ ảnh hưởng của nó đến máy tính hoặc các dự án khác của bạn. 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 mã này hoạt động một lần, bạn có thể chắc chắn rằng mã đó sẽ tiếp tục hoạt động và cho 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 của bản dựng do Emscripten tạo ra (một tệp .js.wasm) chỉ nên được coi là một mô-đun JavaScript khác và chỉ là một tài sản khác. Có thể xử lý tệp JavaScript bằng một gói webpack hoặc thông tin tổng hợp, và tệp wasm phải được xử lý giống 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 xoay vòng một vùng chứa mới bằng hình ảnh trzeci/emscripten rồi 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 ở thư mục /src bên trong vùng chứa sẽ được đồng bộ hoá với dự án thực tế. 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 để phân tích ở đây!

set -e đặt shell vào chế độ "fail fast" (khắc phục lỗi nhanh). Nếu bất kỳ lệnh nào trong tập lệnh trả về lỗi, thì 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 công cụ này cho phép bạn truyền thêm các tham số dòng lệnh đế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 trình này đều nhận được chế độ cài đặt trình tối ưu hoá qua OPTIMIZE để đảm bảo mọi thứ được tối ưu hoá như nhau. Có một số giá trị có thể có cho biến OPTIMIZE:

  • -O0: Không thực hiện bất kỳ tối ưu hoá nào. 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. Phù hợp để gỡ lỗi.
  • -O3: Tối ưu hoá tích cực để đạt hiệu suất.
  • -Os: Tối ưu hoá một cách linh hoạt để nâng cao hiệu suất và quy 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, tôi chủ yếu đề xuất -Os.

Lệnh emcc có vô số tuỳ chọn riêng. Xin lưu ý rằng emcc được cho là một "tính năng thay thế thả xuống 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 triển khai bằng emcc. 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 nhà phát triển web:

  • --bind bật tính năng embind.
  • -s STRICT=1 ngừng hỗ trợ mọi 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ẽ chuyển mã JavaScript thành một mô-đun ES6 với tệp xuất mặc định hoạt động với bất kỳ trình đóng gói nào. Bạn cũng phải đặt -s MODULARIZE=1.

Các cờ sau 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ờ có liên quan đến Emscripten và có khả năng mô phỏng hệ thống tệp cho bạn khi mã C/C++ của bạn 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ã mà nó biên dịch để quyết định xem có nên đưa quy trình 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 tra chế độ thiết lập này, hãy tạo một my-module.cpp nhỏ nhắn:

    #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 cho thấy 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 xây dựng một thư viện C/C++ cho ứng dụng web, bạn cần có mã của thư viện đó để sử dụng trong 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.

Để thoát khỏi câu đố này, có 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 dưới dạng 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.

Bây giờ, 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, hã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: phần đầu (tệp .h hoặc .hpp theo cách truyền thống) 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ế (tệp .so hoặc .a theo cách truyền thống). Để 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 những 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 rằng quy trình này sẽ tạo một .js mới và một tệp .wasm mới, đồng thời trang minh hoạ thực sự 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 xây dựng mất nhiều thời gian. Thời gian xây dựng dài 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. Việc giữ lại các cấu phần phần mềm bản dựng của libvpx sau khi được tạo lần đầu sẽ mang lại nhiều lợi ích.

Một cách để đạt được điều này là sử dụng các 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à nội dung chính 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 của mình 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 cần phụ thuộc vào các công cụ bổ sung để xây dựng. Nếu các phần phụ thuộc này bị thiếu 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 bằng apt.

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

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 ghi Dockerfile mô tả các bước tạo. Dockerfiles khá mạnh mẽ và có rất nhiều lệnh, nhưng hầu hết thời gian bạn có thể thoát khỏi 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. Bất kể thay đổi nào mà những lệnh này thực hiện đối với vùng chứa, giờ đây đều là một phần của hình ảnh Docker. Để đảm bảo hình ảnh Docker của bạn đã được tạo và xuất hiện trước khi chạy build.sh, bạn phải điều chỉnh package.json một bit:

{
    // ...
    "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à nội dung chính 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 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 một cách tự nhiên, 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 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ó tiến bộ, hãy chia sẻ với mọi người.

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 phương pháp lưu vào bộ nhớ đệm thông minh của Docker và Docker. Docker thực thi từng bước của Dockerfiles và gán kết quả của mỗi bước cho một hình ảnh của riêng Docker. 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 tốn rất nhiều công sứ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 sang Dockerfile để tận 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à nội dung chính chứa tất cả các tệp.)

Lưu ý rằng bạn cần cài đặt git và sao chép libvpx theo cách thủ công vì bạn không có liên kết liên kết khi chạy docker build. Không cần dùngnapa nữa là hiệu ứng phụ.