Emscripten và npm

Làm cách nào để bạn tích hợp WebAssembly vào quy trình thiết lập này? Trong bài viết này, chúng ta sẽ xử lý vấn đề này với 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 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 chỉ ra rằng ít nhất có một góc nhìn thứ ba cho wasm, đó là 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 đã tích hợp tính năng hỗ trợ wasmnhóm Go cũng đang nghiên cứu việc này. Tôi chắc rằng nhiều ngôn ngữ khác cũng sẽ làm theo.

Trong các trường hợp này, wasm không phải là trọng tâm của ứng dụng mà là một phần giải đố: lại là một mô-đun khác. Ứng dụng của bạn đã có JavaScript, CSS, thành phần hình ảnh, một 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ẽ xử lý vấn đề này với C/C++ và Emscripten làm ví dụ.

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 để tương thích với hệ điều hành mà chúng tích hợp sẵn. Sẽ vô cùng hữu ích nếu bạn có một môi trường nhất quán. Với Docker, bạn sẽ có một hệ thống Linux ảo đã đượ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ề việc nó ảnh hưởng như thế nào đến máy của bạn hoặc các dự án khác của bạn. Nếu xảy ra sự cố, hãy ném vùng chứa đi và bắt đầu lại. Nếu phương thức này hoạt động một lần, bạn có thể chắc chắn rằng chương trình đó 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 mô phỏng của trzeci mà tôi đã và đang 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 cho một dự án web là package.json của npm. Theo quy ước, hầu hết các dự án đều có thể được xây dựng bằng npm install && npm run build.

Nói 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 xem là một mô-đun JavaScript khác và chỉ là một tài sản khác. Tệp JavaScript có thể được một trình gói như webpack hoặc tổng hợp xử lý và tệp wasm nên được xử lý giống như mọi thành phầ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 quy trình xây dựng "bình thường" của bạn 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 tạo bản dựng nhất quán.

docker run ... trzeci/emscripten ./build.sh yêu cầu Docker tạo một vùng chứa mới bằng cách sử dụ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 không tạo một bộ sưu tập các 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. Những thư mục được đồng bộ hoá hai chiều này gọi là "bind Taggings".

Hãy cùng xem xét 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 để mổ xẻ ở đây!

set -e đặt shell ở chế độ "không 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à một thông báo thành công hoặc lỗi khiến bản dựng không hoạt động.

Với các 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 API này cho phép bạn truyền thêm 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ả đều nhận chế độ cài đặt trình tối ưu hoá thông qua OPTIMIZE để đảm bảo mọi thứ được tối ưu hoá theo cùng một cách. Biến OPTIMIZE có thể có một số giá trị:

  • -O0: Không tối ưu hoá. Không loại bỏ được đoạn mã chết nào và Emscripten cũng không giảm kích thước 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á linh hoạt để đạt được hiệu suất và kích thước làm tiêu chí phụ.
  • -Oz: Tối ưu hoá linh hoạt về kích thước, giảm 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 dùng để thay thế 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ể 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 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ể khiến bạn cảm thấy quá tải. Sau đâ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 cho phép liên kết.
  • -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 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 trưở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 phần bộ nhớ, tuỳ chọn này sẽ quyết định xem các hoạt động này có khiến toàn bộ mô-đun wasm bị lỗi khi bộ nhớ bị hết hay không, hoặc liệu mã keo được phép mở rộng tổng bộ nhớ để đáp ứng việc phân bổ.
  • -s MALLOC=... chọn phương thức triển khai malloc() sẽ sử dụng. emmalloc là một cách triển khai malloc() nhỏ và nhanh chóng dành riêng cho Emscripten. Lựa chọ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 muốn sử dụng tính năng tạo luồng.
  • -s EXPORT_ES6=1 sẽ chuyển mã JavaScript thành mô-đun ES6 với dữ liệu xuất mặc định hoạt động với mọi trình đóng gói. Ngoài ra, 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ờ liên quan đến Emscripten và có khả năng mô phỏng một 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ã kết nối hay không. Tuy nhiên, đôi khi, bản phân tích này có thể gây ra sai sót và bạn phải trả 70kB mã kết nối bổ sung khá lớn để mô phỏng hệ thống tệp mà có thể bạn không cần đến. Với -s FILESYSTEM=0, bạn có thể buộc Emscripten không đưa vào mã này.
  • -g4 sẽ khiến Emscripten bao gồm thông tin gỡ lỗi trong .wasm và cũng phát hành 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 của dịch vụ này.

Và bạn đã hoàn tất! Để kiểm tra 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);
    }

Và một 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à gist chứa tất cả các tệp.)

Để xây dựng mọi thứ, hãy chạy

$ npm install
$ npm run build
$ npm run serve

Việc chuyển đến localhost:8080 sẽ cho bạn 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ị 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 có mã của thư viện đó 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à thư viện C++ để mã hoá hình ảnh bằng VP8, bộ mã hoá và giải mã được dùng trong tệp .webm. Tuy nhiên, libvpx không nằm 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 đáp thắc mắc này, mời bạn tham khảo napa. napa cho phép bạn cài đặt mọi URL kho lưu trữ git làm 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à đảm bảo 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ẽ xử lý việc sao chép kho lưu trữ GitHub libvpx vào node_modules dướ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. Thật may là Emscripten có thể giúp đảm bảo rằng configuremake sử dụng trình biên dịch của Emscripten. Vì 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 2 phần: tiêu đề (theo truyền 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à một thư viện hiển thị và thư viện thực tế (theo truyền 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 phải tìm vpxenc.h ở đâu. Đây là mục đích của cờ -I. Phương thức này cho trình biên dịch biết thư mục nào cần kiểm tra 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 quy 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ự cho ra hằng số:

Công cụ cho nhà phát triển cho thấy phiên bản ABI của libvpx được in thông qua bản mô tả.

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. Lý do dẫn đến thời gian xây dựng kéo dài có thể khác nhau. Đối với libvpx, quá trình này mất nhiều thời gian vì công cụ này biên dịch một 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 chưa 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 để xây dựng. Sẽ rất có lợi nếu bạ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 các cấu phần phần mềm đó trong lần đầu tiên.

Một cách để làm việc 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 ...

(Đây là gist chứa tất cả các tệp.)

Lệnh eval cho phép chúng ta đặt các 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 giá trị bất kỳ).

Giờ đây, 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 tạo bản dựng

Đôi khi, các thư viện cần phải dựa vào các công cụ bổ sung để xây dựng. Nếu thiếu các phần phụ thuộc này trong môi trường tạo bản dựng do hình ảnh Docker cung cấp, bạn cần phải 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 của 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 qua apt.

Nếu phải làm 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. Việc đó 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.

Ở đây, bạn nên xây dựng hình ảnh Docker của riêng mình. Hình ảnh Docker được tạo bằng cách viết Dockerfile mô tả các bước tạo bản dựng. Dockerfile khá mạnh mẽ và có rất nhiều lệnh, nhưng hầu hết trường hợp bạn có thể thoát chỉ bằng cách 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 nào 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à các bạn đã và đang sử dụng. 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 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à 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",
    // ...
    },
    // ...
}

(Đây là gist chứa tất cả các tệp.)

Thao tác này sẽ tạo hình ảnh Docker của bạn, nhưng chỉ khi hình ảnh này chưa được tạo. Sau đó, mọi thứ sẽ chạy như trước, nhưng giờ đây, môi trường tạo bản dựng có sẵn lệnh doxygen. Thao tác này sẽ khiến cả tài liệu về libvpx cũng đượ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ã 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ách thiết lập này không phù hợp với mọi dự án, nhưng 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. Vui lòng chia sẻ nếu bạn có điểm cải thiện nào.

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 thêm những vấn đề này bằng phương pháp thông minh của Docker và Docker để lưu vào bộ nhớ đệm. Docker thực thi Dockerfile từng bước và gán kết quả của mỗi bước một hình ảnh của riêng nó. Những hình ảnh trung gian này thường được gọi là "lớp". Nếu lệnh trong Dockerfile không thay đổi, thì Docker sẽ không thực sự chạy lại bước đó khi bạn tạo lại Dockerfile. Thay vào đó, hệ thống 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 xây dựng ứ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

(Đây là gist chứa tất cả các tệp.)

Xin 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ó giá đỡ liên kết khi chạy docker build. Là hiệu ứng phụ, bạn không cần dùng napa nữa.