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 với C/C++ và C/C++ làm ví dụ.

WebAssembly (wasm) thường được đóng khung là hiệu suất gốc hoặc cách chạy C++ hiện có của bạn cơ sở mã trên web. Với squoosh.app, chúng tôi muốn cho thấy có ít nhất một góc nhìn thứ ba về wasm: tận dụng việc khai thác hệ sinh thái của các ngôn ngữ lập trình khác. Bằng Emscripten, bạn có thể sử dụng mã C/C++, Rust có hỗ trợ wasm được tích hợp và Go nhóm cũng đang xử lý vấn đề này. Tôi và 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 câu đố mảnh: 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 thế 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 tôi sẽ giải thích lấy 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. C/C++ các thư viện thường được viết để tương thích với hệ điều hành mà chúng được xây dựng. Việc có được một môi trường nhất quán sẽ cực kỳ hữu ích. Với Docker, bạn sẽ có được hệ thống Linux ảo hóa đã được thiết lập để hoạt động với Emscripten và mọi công cụ và phần phụ thuộc đã cài đặt. Nếu thiếu nội dung nào đó, bạn có thể cài đặt mà không phải lo lắng về việc ứng dụng sẽ ảnh hưởng như thế nào đến máy tính của bạn hoặc các dự án khác. Nếu xảy ra sự cố, hãy vứt bỏ vùng chứa và bắt đầu qua. 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à sẽ cho ra kết quả giống hệt nhau.

Sổ đăng ký Docker có một Bản mô tả hình ảnh của trzeci mà tôi đang sử dụng rộng rãi.

Tích hợp với npm

Trong phần lớn các trường hợp, điểm truy cập vào dự án web là npm package.json. Theo quy ước, bạn có thể xây dựng hầu hết các dự án 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 (một .js và một .wasm tệp) nên được coi là một mô-đun JavaScript khác và chỉ nội dung. Tệp JavaScript có thể do một gói như webpack hoặc cuộn lên xử lý, và tệp wasm nên đượ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 đó, các cấu phần phần mềm bản dựng Emscripten cần được tạo trước khi chuyển sang trạng thái "bình thường" quá trình xây dựng sẽ khởi động:

{
    "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 vì đề cập ở trên, 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 khởi động một thiết bị mới vùng chứa bằng hình ảnh trzeci/emscripten rồi chạy lệnh ./build.sh. build.sh là tập lệnh shell mà bạn sẽ viết tiếp theo! --rm cho biết Docker để xoá vùng chứa sau khi chạy xong. Bằng cách này, bạn không phải tạo tạo một tập hợ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 trở thành "phản chiếu" thư mục hiện tại ($(pwd)) sang /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ủa bạn. Các thư mục được đồng bộ hoá hai chiều này được gọi là "liên kết gắn kết".

Hãy cùng tìm hiểu về 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 trạng thái "không thành công nhanh chóng" . Nếu có bất kỳ lệnh nào trong tập lệnh trả về một lỗi, thì toàn bộ tập lệnh sẽ bị huỷ ngay lập tức. Thông tin này có thể là vô cùng hữu ích vì kết quả cuối cùng của tập lệnh sẽ luôn 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 môi trường biến. Chúng cho phép bạn truyền các tham số dòng lệnh bổ sung đến C (CFLAGS), trình biên dịch C++ (CXXFLAGS) và trình liên kết (LDFLAGS). Tất cả họ đều nhận được chế độ cài đặt trình tối ưu hoá qua OPTIMIZE để đảm bảo rằng mọi thứ đều được tối ưu hoá theo cách tương tự. Có một vài giá trị có thể cho biến OPTIMIZE:

  • -O0: Không thực hiện bất kỳ tối ưu hoá nào. Không loại bỏ mã chết nào và Emscripten cũng không thu nhỏ 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á tích cực để nâng cao hiệu suất và quy mô dưới dạng chiến dịch phụ tiêu chí.
  • -Oz: Tối ưu hoá tích cực để tăng quy mô, 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 là là tính năng "thay thế cho các trình biên dịch như GCC hoặc clang". Vậy tất cả cờ mà bạn có thể biết từ GCC rất có thể sẽ được emcc triển khai dưới dạng tốt. Cờ -s đặc biệt ở chỗ nó cho phép chúng ta định cấu hình Emscripten cụ thể. Tất cả các lựa chọn có sẵn trong trình đơn Emscripten settings.js! nhưng tệp đó có thể gây quá tải. Dưới đây là danh sách các cờ Emscripten mà tôi nghĩ là quan trọng nhất đối với nhà phát triển web:

  • --bind bật liên kết.
  • -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 giúp đảm bảo mà mã của bạn tạo theo cách tương thích chuyển tiếp.
  • -s ALLOW_MEMORY_GROWTH=1 cho phép tự động tăng bộ nhớ nếu nếu cần. Tại thời điểm viết, Emscripten sẽ phân bổ 16MB bộ nhớ ban đầu. Khi mã của bạn phân bổ các đoạn bộ nhớ, tuỳ chọn này sẽ quyết định xem các thao tác này sẽ làm cho toàn bộ mô-đun wasm không thành công khi bộ nhớ hoặc nếu mã kết nối được phép mở rộng tổng bộ nhớ lên phù hợp với sự phân bổ.
  • -s MALLOC=... chọn cách triển khai malloc() để sử dụng. emmalloc là một cách triển khai malloc() nhỏ và nhanh chóng dành riêng cho Emscripten. Chiến lược phát hành đĩa đơn thay thế là dlmalloc, một phương thức triển khai malloc() chính thức. Chỉ bạn cần chuyển sang dlmalloc nếu bạn đang phân bổ nhiều đối tượng nhỏ thường xuyên 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ô-đun ES6 có xuất dữ liệu mặc định hoạt động với bất kỳ gói nào. Cũng cần -s MODULARIZE=1 để thiết lập.

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

  • -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 thao tác hệ thống tệp. Công cụ này thực hiện một số phân tích trên mã mà nó biên dịch để quyết định xem có bao gồm mô phỏng hệ thống tệp trong mã keo. Tuy nhiên, đôi khi thì bản phân tích có thể sai và bạn phải trả 70kB khá nhiều để có thêm công cụ phân tích để mô phỏng hệ thống tệp mà bạn có thể không cần đến. Với -s FILESYSTEM=0, bạn có thể buộc Emscripten không thêm mã này vào.
  • -g4 sẽ làm cho Emscripten bao gồm thông tin gỡ lỗi trong .wasm và cũng phát ra tệp bản đồ nguồn cho mô-đun wasm. Bạn có thể đọc thêm trên gỡ lỗi bằng Emscripten trong quy trình 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);
    }

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>

(Đây là ý chính 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 điều hướng đến localhost:8080, bạn sẽ thấy kết quả sau trong Bảng điều khiển Công cụ cho nhà phát triển:

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 thư viện C/C++ cho ứng dụng web, bạn cần có mã của thư viện đó trong dự án của bạn. Bạn có thể tự thêm mã nguồn vào kho lưu trữ của dự án hoặc bạn cũng có thể 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 tôi. 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 có trên npm và không có package.json, vì vậy tôi không thể bằng cách sử dụng npm trực tiếp.

Để thoát khỏi câu hỏi hóc búa này, có napa. napa cho phép bạn cài đặt bất kỳ git URL kho lưu trữ 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ẽ xử lý việc sao chép libvpx GitHub kho lưu trữ vào node_modules của bạn 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 để xây dựng. 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ụ mục đích này, có trình bao bọc các lệnh 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 (theo truyền thống là .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à các tệp .so hoặc .a). Người nhận sử dụng hằng số VPX_CODEC_ABI_VERSION của thư viện trong mã, bạn có để đư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 ở đâu để tìm vpxenc.h. Đây là mục đích của cờ -I. Phương thức này cho trình biên dịch biết cần chọn thư mục nào hãy 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 rằng quá trình này sẽ tạo một .js mới và một tệp .wasm mới và 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 xây dựng mất nhiều thời gian. Lý do 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 mất nhiều thời gian vì nó biên dịch một bộ mã hoá và một bộ giải mã cho cả VP8 và VP9 mỗi khi bạn chạy lệnh tạo bản dựng của bạn, mặc dù các tệp nguồn không thay đổi. Ngay cả khi một quy mô nhỏ thì việc thay đổi đối với my-module.cpp sẽ mất nhiều thời gian. Sẽ rất hữu ích sẽ giúp ích cho việc lưu giữ các cấu phần phần mềm bản dựng của libvpx sau khi chúng được tạo được lần đầu tiên.

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

(Sau đây là ý chính 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 $SKIP_LIBVPX được đặt (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 tạo bản dựng

Đôi khi, thư việ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 chúng. Ví dụ: giả sử bạn cũng muốn tạo tài liệu về libvpx bằng doxygen. Doxygen thì không có sẵn bê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 cần tải xuống rồi cài đặt lại doxygen mỗi khi bạn muốn xây dựng thư viện. Điều đó không chỉ nhưng điều này cũng sẽ cản trở bạn thực hiện dự án khi không có kết nối Internet.

Ở đây, bạn nên tạo hình ảnh Docker của riêng mình. Docker hình ảnh được tạo bởi viết một Dockerfile mô tả các bước tạo. Dockerfile khá mạnh mẽ và có rất nhiều nhưng hầu hết thời gian bạn có thể bỏ qua chỉ bằ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 khi bắt đầu điểm. Tôi đã chọn trzeci/emscripten làm cơ sở — hình ảnh bạn đang sử dụng tất cả đều ổn. 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 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à đang có sẵn trước khi chạy build.sh, bạn phải điều chỉnh package.json của mình 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",
    // ...
    },
    // ...
}

(Sau đây là ý 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ứ chạy như trước, nhưng giờ đây, môi trường tạo bản dựng có doxygen Lệnh này sẽ khiến tài liệu của libvpx được tạo dưới dạng tốt.

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ể giúp ứng dụng hoạt động khá thoải mái với một số công cụ bổ sung và khả năng tách biệt mà Docker cung cấp. Chế độ thiết lập này không áp dụng cho mọi dự án, nhưng một điểm khởi đầu phù hợp 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ó cải tiến, vui lòng chia sẻ.

Phụ lục: Tận dụng các lớp hình ảnh của 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 Dockerfiles 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 bước đó. 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 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 cuối cùng hình ảnh được tạo.

Trước đây, bạn phải nỗ lực rất nhiều để không tạo lại libvpx mỗi lần bạn xây dựng ứng dụng của mình. 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 tính năng lưu vào bộ nhớ đệm của Docker cơ chế:

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

(Sau đây là ý 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 gắn kết khi chạy docker build. Như là hiệu ứng phụ, không cần napa nữa.